feat(ssh): allow explicit forwarding host fingerprint bypass

Keep strict verification by default, expose an opt-in warning in the connection editor, and report the observed host fingerprint. Preserve password authentication and cover the behavior with model, SSH fixture and browser tests. Include compatibility and validation documentation.
This commit is contained in:
曾志威
2026-09-22 14:01:45 +08:00
parent 408f346ab1
commit 4d34f94e75
11 changed files with 139 additions and 9 deletions
+8
View File
@@ -20,6 +20,14 @@
验证:模型/API 专项覆盖协议限制、空密码、显式清除、草稿不落库及不放宽网关认证;Mongo/HTTP 使用本机协议 fixture 验证不发送上游认证、仍拒绝错误网关密码及目标拒绝时返回失败,Redis 无认证覆盖沿用现有 fixture。浏览器使用新构建程序、隔离数据库及模拟远程结果,不代表真实服务验证。实际构建和回归结果见 [测试记录](testing.md)。
## SSH 转发允许显式跳过主机指纹(2026-09-22)
原因:用户要求 SSH 转发主机指纹可以跳过。新增 outbound.skipHostKeyVerify 布尔开关,默认 false,前端显示不安全提示;显式开启才放宽指纹必填和比对,不放宽密码认证。关闭开关恢复原有严格校验并保留原指纹。测试返回实际观察到的服务器指纹,不把跳过模式当成可信身份确认。
范围与兼容:统一 Outbound 配置、API 校验、SSH 连接测试和实际转发、连接资产编辑器;SSH/SFTP 目标自身的身份策略不变。配置通过现有 JSON 存储,无表迁移,未修改真实数据或部署程序。旧版本忽略新字段,回退前需补齐合法指纹。本仓库无 multilingual-ui 所指定的 Shopee i18n 框架,检查后沿用现有中文文案,不新增翻译框架。
验证:Go 1.25.0 全量测试、go vet、前端构建通过;新增本机 SSH fixture 覆盖空/错误指纹显式放行、转发回显、仍拒绝错误密码、关闭后严格拒绝,非真实远端服务器验证。新程序 Playwright 专项 3 项通过,见 testing.md。
## 合并 origin/main 943ea75(2026-09-22)
原因:将远端最新提交合入本地 main,同时保留已提交的 TCP 映射、MongoDB 非 admin 认证库和单 SQLite 存储等功能。
+9 -1
View File
@@ -1,6 +1,14 @@
# SSH 连接测试
独立「代理」菜单的 SSH 转发项支持“测试连接”,验证跳板机连通性、已保存的主机指纹和账号密码,显示服务器版本、指纹与耗时。测试结束会关闭 SSH 连接,不执行远程命令。
「资产管理 → 连接资产」的 SSH 转发项支持“测试连接”,验证跳板机连通性、已保存的主机指纹和账号密码,显示服务器版本、指纹与耗时。测试结束会关闭 SSH 连接,不执行远程命令。
## 可选跳过跳板机指纹校验
编辑 SSH 连接资产时,可以勾选“跳过主机指纹校验(不安全)”。默认关闭:必须填写并匹配 SHA256 指纹。开启后指纹输入禁用且可以留空,已有指纹不删除;保存后连接测试及实际 SSH 转发都跳过跳板机身份比对,但仍校验账号密码。连接测试返回服务器实际出示的指纹,这不表示其身份已验证。
API 对应 `outbound.skipHostKeyVerify: true`,未提供或为 false 时仍严格校验。无需数据库迁移;旧程序不支持此选项,回退前应补齐正确指纹。此选项只影响 SSH 转发跳板机,不改变目标 SSH/SFTP 的独立主机校验设置。共享连接资产的修改影响后续使用该资产建立的连接。
跳过校验存在中间人攻击风险,仅用于可信网络或临时排查。恢复严格模式前,通过可信渠道核对并填写指纹。
此测试不打开目标通道。验证转发权限、目标可达性和目标认证,请在「目标服务」中测试连接。SSH 目标测试前仍须先探测并确认目标主机指纹。
+7
View File
@@ -18,6 +18,13 @@
- 随后与 WebDAV 专项一起运行 target-auth.spec.ts 全部 7 项通过(组合共 11 项,artifacts/webdav-sync/focused.log),包括新建空密码、无认证表单/草稿、旧账号默认模式、显式清除、协议限制及手机布局。浏览器部分远程结果使用模拟,不代表真实目标兼容验证。
- 未部署或修改实际目标数据,保留工作区已有 SSH 跳过指纹改动;本项无需数据库迁移。
## 2026-09-22 SSH 转发可选跳过指纹
- Go 1.25.0 `go test ./... -count=1 -timeout=120s` 全部通过(本轮 cluster 亦通过),日志 artifacts/ssh-skip-go-test.log;`go vet ./...` 通过。
- `npm.cmd --prefix web run build` 通过,仅大 chunk 提示;恢复 web/dist/.gitkeep 后构建新程序 artifacts/serviceport-ssh-skip.exe。未替换正式程序或访问真实数据库。
- 使用 SERVICEPORT_E2E_BINARY 指定新程序,在隔离端口/SQLite 运行 ssh-forward-skip.spec.ts、host-identity.spec.ts:3 项通过(13.5 秒)。新增用例首次因 Element Plus 隐藏 checkbox 的操作方式及校验文本定位失败,修正测试定位后重跑通过。本次未重跑全量浏览器套件。
- 新增模型测试验证仅显式开关允许空指纹、仍要求密码。SSH fixture 验证空/错误指纹跳过、实际转发字节回显、返回实际服务器指纹、错误密码拒绝及关闭跳过后的严格校验;不声称真实 SSH 跳板机验证。
## 2026-09-22 合并 origin/main 943ea75
- 前端 `npm.cmd --prefix web run build` 通过(仅大 chunk 提示);Windows Go 1.25.0 新程序构建通过,恢复并保留 web/dist/.gitkeep。
+1
View File
@@ -115,6 +115,7 @@ type Outbound struct {
Namespace string `json:"namespace,omitempty"`
Resource string `json:"resource,omitempty"`
HostFingerprint string `json:"hostFingerprint,omitempty"`
SkipHostKeyVerify bool `json:"skipHostKeyVerify,omitempty"`
Mode string `json:"mode"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
+21
View File
@@ -0,0 +1,21 @@
package model
import "testing"
func TestSSHForwardSkipRequiresExplicitOptIn(t *testing.T) {
target := TargetDefaults()
target.ID, target.Name, target.Type = "target", "TCP target", "tcp"
target.TargetHost, target.TargetPort, target.TargetTLS = "target.internal", 1234, false
target.Outbound = Outbound{Mode: "ssh", Host: "jump.internal", Port: 22, Auth: "password", Username: "jump", Password: "fixture"}
if target.Validate() == nil {
t.Fatal("missing fingerprint accepted by default")
}
target.Outbound.SkipHostKeyVerify = true
if err := target.Validate(); err != nil {
t.Fatal(err)
}
target.Outbound.Password = ""
if target.Validate() == nil {
t.Fatal("missing password accepted")
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ func (p *Proxy) Validate() error {
return errors.New("SSH forwarding requires username and password")
}
fingerprint, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(o.HostFingerprint, "SHA256:"))
if !strings.HasPrefix(o.HostFingerprint, "SHA256:") || err != nil || len(fingerprint) != 32 {
if !o.SkipHostKeyVerify && (!strings.HasPrefix(o.HostFingerprint, "SHA256:") || err != nil || len(fingerprint) != 32) {
return errors.New("SSH forwarding requires a valid SHA256 host fingerprint")
}
}
+13 -4
View File
@@ -57,16 +57,21 @@ func dialSSH(ctx context.Context, network, address string, o model.Outbound, kee
func TestSSH(ctx context.Context, o model.Outbound) (model.TestResult, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
client, _, err := connectSSH(ctx, o)
var fingerprint string
client, _, err := connectSSHObserved(ctx, o, &fingerprint)
if err != nil {
return model.TestResult{}, err
}
defer client.Close()
return model.TestResult{Version: string(client.ServerVersion()), Fingerprint: o.HostFingerprint}, nil
return model.TestResult{Version: string(client.ServerVersion()), Fingerprint: fingerprint}, nil
}
func connectSSH(ctx context.Context, o model.Outbound, keepAlive ...time.Duration) (*ssh.Client, net.Conn, error) {
if o.Auth != "password" || o.HostFingerprint == "" {
return connectSSHObserved(ctx, o, nil, keepAlive...)
}
func connectSSHObserved(ctx context.Context, o model.Outbound, observed *string, keepAlive ...time.Duration) (*ssh.Client, net.Conn, error) {
if o.Auth != "password" || (!o.SkipHostKeyVerify && o.HostFingerprint == "") {
return nil, nil, errors.New("SSH forwarding authentication or host fingerprint missing")
}
endpoint := net.JoinHostPort(o.Host, strconv.Itoa(o.Port))
@@ -89,7 +94,11 @@ func connectSSH(ctx context.Context, o model.Outbound, keepAlive ...time.Duratio
deadline, _ := ctx.Deadline()
raw.SetDeadline(deadline)
config := &ssh.ClientConfig{User: o.Username, Auth: []ssh.AuthMethod{ssh.Password(o.Password)}, HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
if ssh.FingerprintSHA256(key) != o.HostFingerprint {
fingerprint := ssh.FingerprintSHA256(key)
if observed != nil {
*observed = fingerprint
}
if !o.SkipHostKeyVerify && fingerprint != o.HostFingerprint {
return errors.New("SSH forwarding host fingerprint mismatch")
}
return nil
+35 -1
View File
@@ -1,7 +1,6 @@
package outbound
import (
"serviceport/internal/model"
"context"
"crypto/ed25519"
"crypto/rand"
@@ -9,6 +8,7 @@ import (
"golang.org/x/crypto/ssh"
"io"
"net"
"serviceport/internal/model"
"strconv"
"testing"
"time"
@@ -157,4 +157,38 @@ func TestSSHForwarding(t *testing.T) {
}
})
}
t.Run("explicit skip still authenticates and forwards", func(t *testing.T) {
for _, fingerprint := range []string{"", "SHA256:wrong"} {
o := p.Outbound
o.SkipHostKeyVerify, o.HostFingerprint = true, fingerprint
result, err := TestSSH(context.Background(), o)
if err != nil || result.Fingerprint != ssh.FingerprintSHA256(signer.PublicKey()) {
t.Fatalf("observed host key: %+v %v", result, err)
}
copy := p
copy.Outbound = o
c, err := Dialer(copy)(context.Background(), "tcp", "database.internal:3306")
if err != nil {
t.Fatal(err)
}
c.SetDeadline(time.Now().Add(time.Second))
_, err = c.Write([]byte("skip"))
data := make([]byte, 4)
if err == nil {
_, err = io.ReadFull(c, data)
}
c.Close()
if err != nil || string(data) != "skip" {
t.Fatalf("forward: %q %v", data, err)
}
o.Password = "wrong"
if _, err := TestSSH(context.Background(), o); err == nil {
t.Fatal("skip bypassed authentication")
}
o.Password, o.SkipHostKeyVerify = p.Outbound.Password, false
if _, err := TestSSH(context.Background(), o); err == nil {
t.Fatal("strict accepted missing or wrong fingerprint")
}
}
})
}
+7 -1
View File
@@ -33,7 +33,13 @@ function changeMode() {
<el-form-item :label="`${outboundName(value.mode)} 用户名`" data-field="outbound.username"><el-input v-model="value.username" /></el-form-item>
<el-form-item :label="`${outboundName(value.mode)} 密码`" data-field="outbound.password"><el-input v-model="value.password" type="password" show-password :placeholder="editing ? '留空保留原值' : ''" /></el-form-item>
</template>
<el-form-item v-if="value.mode === 'ssh'" label="SSH 转发主机指纹(SHA256)" data-field="outbound.hostFingerprint"><el-input v-model="value.hostFingerprint" placeholder="SHA256:…" /></el-form-item>
<template v-if="value.mode === 'ssh'">
<el-form-item label="SSH 转发主机校验" data-field="outbound.skipHostKeyVerify">
<el-checkbox v-model="value.skipHostKeyVerify">跳过主机指纹校验(不安全)</el-checkbox>
</el-form-item>
<el-alert v-if="value.skipHostKeyVerify" type="warning" :closable="false" title="已跳过 SSH 转发主机身份校验,存在中间人攻击风险;仅用于可信网络或临时排查,账号密码仍需验证。" />
<el-form-item label="SSH 转发主机指纹(SHA256)" data-field="outbound.hostFingerprint"><el-input v-model="value.hostFingerprint" :disabled="value.skipHostKeyVerify" placeholder="SHA256:…" /></el-form-item>
</template>
</template>
</template>
+1 -1
View File
@@ -136,7 +136,7 @@ export function validateOutbound(draft: any, options: Options = {}): FieldIssue[
if (!o.username) add('outbound.username','用户名','请填写代理用户名');
if (!o.password && !options.secretStatus?.password) add('outbound.password','密码','请填写代理密码');
}
if (o.mode === 'ssh' && !/^SHA256:[A-Za-z0-9+/]{43}$/.test(o.hostFingerprint || '')) add('outbound.hostFingerprint','主机指纹','请输入 SHA256 主机指纹');
if (o.mode === 'ssh' && !o.skipHostKeyVerify && !/^SHA256:[A-Za-z0-9+/]{43}$/.test(o.hostFingerprint || '')) add('outbound.hostFingerprint','主机指纹','请输入 SHA256 主机指纹');
}
return issues;
}
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
test('SSH 连接资产显式跳过指纹并持久化,关闭后恢复必填校验', async ({ page }) => {
await page.goto('/');
const session = await (await page.request.get('/api/v1/session')).json();
const headers = { 'X-CSRF-Token': session.csrfToken };
const response = await page.request.post('/api/v1/outbounds', { headers, data: {
name: 'SSH skip fixture', outbound: { mode: 'ssh', host: '127.0.0.1', port: 22, auth: 'password', username: 'fixture', password: 'fixture', skipHostKeyVerify: true },
} });
expect(response.ok()).toBeTruthy();
const { config } = await response.json();
try {
await page.reload();
await page.getByRole('navigation').getByRole('button', { name: /资产管理/ }).click();
await page.getByRole('radio', { name: '连接资产', exact: true }).locator('..').click();
await page.getByRole('row').filter({ hasText: config.name }).getByRole('button', { name: '编辑连接资产', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '编辑连接资产', exact: true });
const skip = dialog.getByRole('checkbox', { name: '跳过主机指纹校验(不安全)' });
await expect(skip).toBeChecked();
await expect(dialog.locator('[data-field="outbound.hostFingerprint"] input')).toBeDisabled();
await expect(dialog.getByText(/存在中间人攻击风险/)).toBeVisible();
await dialog.locator('[data-field="outbound.skipHostKeyVerify"] .el-checkbox').click();
await expect(skip).not.toBeChecked();
await dialog.getByRole('button', { name: '保存连接资产', exact: true }).click();
await expect(dialog.getByRole('button', { name: '主机指纹:请输入 SHA256 主机指纹', exact: true })).toBeVisible();
await dialog.locator('[data-field="outbound.skipHostKeyVerify"] .el-checkbox').click();
await expect(skip).toBeChecked();
const saving = page.waitForResponse(r => r.url().endsWith(`/api/v1/outbounds/${config.id}`) && r.request().method() === 'PUT');
await dialog.getByRole('button', { name: '保存连接资产', exact: true }).click();
expect((await saving).ok()).toBeTruthy();
const saved = await (await page.request.get(`/api/v1/outbounds/${config.id}`)).json();
expect(saved.config.outbound.skipHostKeyVerify).toBe(true);
} finally {
await page.request.delete(`/api/v1/outbounds/${config.id}`, { headers });
}
});