From 4d34f94e75b6f6da7aca30d320a95825bade0dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BF=97=E5=A8=81?= Date: Tue, 22 Sep 2026 14:01:45 +0800 Subject: [PATCH] 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. --- docs/change-rationale.md | 8 ++++++ docs/ssh-connection-test.md | 10 ++++++- docs/testing.md | 7 +++++ internal/model/proxy.go | 1 + internal/model/ssh_skip_verify_test.go | 21 +++++++++++++++ internal/model/validation.go | 2 +- internal/outbound/ssh.go | 17 +++++++++--- internal/outbound/ssh_test.go | 36 +++++++++++++++++++++++++- web/src/components/OutboundFields.vue | 8 +++++- web/src/domain/formValidation.ts | 2 +- web/tests/ssh-forward-skip.spec.ts | 36 ++++++++++++++++++++++++++ 11 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 internal/model/ssh_skip_verify_test.go create mode 100644 web/tests/ssh-forward-skip.spec.ts diff --git a/docs/change-rationale.md b/docs/change-rationale.md index 9417cc2..97ed163 100644 --- a/docs/change-rationale.md +++ b/docs/change-rationale.md @@ -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 存储等功能。 diff --git a/docs/ssh-connection-test.md b/docs/ssh-connection-test.md index 6d7f147..177ac82 100644 --- a/docs/ssh-connection-test.md +++ b/docs/ssh-connection-test.md @@ -1,6 +1,14 @@ # SSH 连接测试 -独立「代理」菜单的 SSH 转发项支持“测试连接”,验证跳板机连通性、已保存的主机指纹和账号密码,显示服务器版本、指纹与耗时。测试结束会关闭 SSH 连接,不执行远程命令。 +「资产管理 → 连接资产」的 SSH 转发项支持“测试连接”,验证跳板机连通性、已保存的主机指纹和账号密码,显示服务器版本、指纹与耗时。测试结束会关闭 SSH 连接,不执行远程命令。 + +## 可选跳过跳板机指纹校验 + +编辑 SSH 连接资产时,可以勾选“跳过主机指纹校验(不安全)”。默认关闭:必须填写并匹配 SHA256 指纹。开启后指纹输入禁用且可以留空,已有指纹不删除;保存后连接测试及实际 SSH 转发都跳过跳板机身份比对,但仍校验账号密码。连接测试返回服务器实际出示的指纹,这不表示其身份已验证。 + +API 对应 `outbound.skipHostKeyVerify: true`,未提供或为 false 时仍严格校验。无需数据库迁移;旧程序不支持此选项,回退前应补齐正确指纹。此选项只影响 SSH 转发跳板机,不改变目标 SSH/SFTP 的独立主机校验设置。共享连接资产的修改影响后续使用该资产建立的连接。 + +跳过校验存在中间人攻击风险,仅用于可信网络或临时排查。恢复严格模式前,通过可信渠道核对并填写指纹。 此测试不打开目标通道。验证转发权限、目标可达性和目标认证,请在「目标服务」中测试连接。SSH 目标测试前仍须先探测并确认目标主机指纹。 diff --git a/docs/testing.md b/docs/testing.md index a0a9f51..c310c96 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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。 diff --git a/internal/model/proxy.go b/internal/model/proxy.go index 088310c..437ba0f 100644 --- a/internal/model/proxy.go +++ b/internal/model/proxy.go @@ -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"` diff --git a/internal/model/ssh_skip_verify_test.go b/internal/model/ssh_skip_verify_test.go new file mode 100644 index 0000000..42ffadb --- /dev/null +++ b/internal/model/ssh_skip_verify_test.go @@ -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") + } +} diff --git a/internal/model/validation.go b/internal/model/validation.go index cb14121..66b0b9f 100644 --- a/internal/model/validation.go +++ b/internal/model/validation.go @@ -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") } } diff --git a/internal/outbound/ssh.go b/internal/outbound/ssh.go index e97d067..dbc5b9c 100644 --- a/internal/outbound/ssh.go +++ b/internal/outbound/ssh.go @@ -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 diff --git a/internal/outbound/ssh_test.go b/internal/outbound/ssh_test.go index 1f6871e..0024ed9 100644 --- a/internal/outbound/ssh_test.go +++ b/internal/outbound/ssh_test.go @@ -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") + } + } + }) } diff --git a/web/src/components/OutboundFields.vue b/web/src/components/OutboundFields.vue index db6973c..051d9a4 100644 --- a/web/src/components/OutboundFields.vue +++ b/web/src/components/OutboundFields.vue @@ -33,7 +33,13 @@ function changeMode() { - + diff --git a/web/src/domain/formValidation.ts b/web/src/domain/formValidation.ts index f28c8b1..33529f0 100644 --- a/web/src/domain/formValidation.ts +++ b/web/src/domain/formValidation.ts @@ -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; } diff --git a/web/tests/ssh-forward-skip.spec.ts b/web/tests/ssh-forward-skip.spec.ts new file mode 100644 index 0000000..c76b7a5 --- /dev/null +++ b/web/tests/ssh-forward-skip.spec.ts @@ -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 }); + } +});