From d5334842f4632acdcb5c8dedf9fb8357dfd693f7 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 17 Jun 2026 22:17:02 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20SSH=20=E9=9A=A7=E9=81=93=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20ssh-agent=20=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SshAuthMethod 增 'agent',读取 SSH_AUTH_SOCK(win32 回退 OpenSSH 命名管道) - 抽出 tunnelCore 纯函数(buildTunnelOptions/resolveSshAgentSock), 收敛 connect()/test() 两处重复的隧道选项装配 - ConnectionForm 认证方式新增 SSH Agent(无需私钥/密码)+ 三套 i18n - 新增 11 个 tunnelCore 单测 --- src/main/mongo/sessionManager.ts | 35 +----- src/main/ssh/tunnel.ts | 14 +-- src/main/ssh/tunnelCore.ts | 83 +++++++++++++ .../src/components/sidebar/ConnectionForm.tsx | 13 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/zh-CN.json | 4 +- src/renderer/src/i18n/locales/zh-TW.json | 4 +- src/shared/types.ts | 3 +- test/unit/main/tunnelCore.test.ts | 111 ++++++++++++++++++ 9 files changed, 221 insertions(+), 50 deletions(-) create mode 100644 src/main/ssh/tunnelCore.ts create mode 100644 test/unit/main/tunnelCore.test.ts diff --git a/src/main/mongo/sessionManager.ts b/src/main/mongo/sessionManager.ts index ae7263d..318e55e 100644 --- a/src/main/mongo/sessionManager.ts +++ b/src/main/mongo/sessionManager.ts @@ -1,8 +1,8 @@ -import { readFileSync } from 'node:fs' import { MongoClient } from 'mongodb' import type { ConnectionStatus, TestResult } from '../../shared/types' import { connectionStore } from '../store/connectionStore' import { SshTunnel } from '../ssh/tunnel' +import { buildTunnelOptions } from '../ssh/tunnelCore' import { buildClientArgs, type DecryptedConnection } from './uri' interface Session { @@ -33,24 +33,8 @@ class SessionManager { } private async openTunnel(dec: DecryptedConnection): Promise { - const { config } = dec - if (config.useSrv) { - throw new Error('SSH tunnel with SRV/Atlas is not supported — use a direct host:port.') - } const tunnel = new SshTunnel() - const port = await tunnel.open({ - sshHost: config.ssh.host || '', - sshPort: config.ssh.port || 22, - username: config.ssh.username || '', - password: config.ssh.authMethod === 'password' ? dec.sshPassword : undefined, - privateKey: - config.ssh.authMethod === 'privateKey' && config.ssh.privateKeyPath - ? readFileSync(config.ssh.privateKeyPath) - : undefined, - passphrase: dec.sshPassphrase, - destHost: config.host, - destPort: config.port ?? 27017 - }) + const port = await tunnel.open(buildTunnelOptions(dec)) // stash tunnel so we can close it on disconnect this.pendingTunnel = tunnel return port @@ -135,21 +119,8 @@ class SessionManager { try { let tunnelPort: number | undefined if (dec.config.ssh.enabled) { - if (dec.config.useSrv) throw new Error('SSH tunnel with SRV/Atlas is not supported.') tunnel = new SshTunnel() - tunnelPort = await tunnel.open({ - sshHost: dec.config.ssh.host || '', - sshPort: dec.config.ssh.port || 22, - username: dec.config.ssh.username || '', - password: dec.config.ssh.authMethod === 'password' ? dec.sshPassword : undefined, - privateKey: - dec.config.ssh.authMethod === 'privateKey' && dec.config.ssh.privateKeyPath - ? readFileSync(dec.config.ssh.privateKeyPath) - : undefined, - passphrase: dec.sshPassphrase, - destHost: dec.config.host, - destPort: dec.config.port ?? 27017 - }) + tunnelPort = await tunnel.open(buildTunnelOptions(dec)) } const { uri, options } = buildClientArgs(dec, tunnelPort) client = new MongoClient(uri, options) diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index edb1fda..c9858b4 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,17 +1,8 @@ import net from 'node:net' import { Client, type ConnectConfig } from 'ssh2' +import type { TunnelOptions } from './tunnelCore' -export interface TunnelOptions { - sshHost: string - sshPort: number - username: string - password?: string - privateKey?: Buffer - passphrase?: string - /** Final MongoDB host/port to forward to (as seen from the SSH server). */ - destHost: string - destPort: number -} +export type { TunnelOptions } /** * A local TCP forwarder over SSH. We open an SSH connection, stand up a local @@ -37,6 +28,7 @@ export class SshTunnel { password: opts.password, privateKey: opts.privateKey, passphrase: opts.passphrase, + agent: opts.agent, readyTimeout: 20000, keepaliveInterval: 15000 } diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts new file mode 100644 index 0000000..30be368 --- /dev/null +++ b/src/main/ssh/tunnelCore.ts @@ -0,0 +1,83 @@ +/** + * Pure core for SSH tunnel option assembly (no ssh2 / electron deps), so the + * auth-method decision is unit-testable. The effectful wrapper ({@link SshTunnel} + * in `tunnel.ts`) only does the ssh2 Client + net.Server side effects. + * + * File reads (private key) are injected so the decision logic stays pure; the + * default reader does real disk IO at connect time. + */ +import { readFileSync } from 'node:fs' +import type { DecryptedConnection } from '../mongo/uri' + +export interface TunnelOptions { + sshHost: string + sshPort: number + username: string + password?: string + privateKey?: Buffer + passphrase?: string + /** ssh-agent socket (SSH_AUTH_SOCK) or Windows OpenSSH pipe — for agent auth. */ + agent?: string + /** Final MongoDB host/port to forward to (as seen from the SSH server). */ + destHost: string + destPort: number +} + +/** Windows 10/11 built-in OpenSSH agent named pipe (ssh2 accepts it as `agent`). */ +const WIN_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' + +/** + * Locate the local ssh-agent endpoint. Prefers `SSH_AUTH_SOCK` (set on + * macOS/Linux and by most Windows agents that bother); falls back to the + * Windows OpenSSH named pipe. Returns undefined when no agent can be located. + */ +export function resolveSshAgentSock( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string | undefined { + if (env.SSH_AUTH_SOCK) return env.SSH_AUTH_SOCK + if (platform === 'win32') return WIN_OPENSSH_AGENT_PIPE + return undefined +} + +/** + * Build {@link TunnelOptions} from a decrypted connection. Throws (rather than + * silently misconfiguring) when the chosen auth method lacks what it needs. + */ +export function buildTunnelOptions( + dec: DecryptedConnection, + readKey: (path: string) => Buffer = (p) => readFileSync(p), + resolveAgent: () => string | undefined = resolveSshAgentSock +): TunnelOptions { + const { config } = dec + if (config.useSrv) { + throw new Error('SSH tunnel with SRV/Atlas is not supported — use a direct host:port.') + } + + const base: TunnelOptions = { + sshHost: config.ssh.host || '', + sshPort: config.ssh.port || 22, + username: config.ssh.username || '', + destHost: config.host, + destPort: config.port ?? 27017 + } + + switch (config.ssh.authMethod ?? 'password') { + case 'agent': { + const agentSock = resolveAgent() + if (!agentSock) { + throw new Error( + 'SSH agent is unavailable: no SSH_AUTH_SOCK found. Start your ssh-agent and `ssh-add` your key, or use a private key file.' + ) + } + return { ...base, agent: agentSock } + } + case 'privateKey': + if (!config.ssh.privateKeyPath) { + throw new Error('SSH private-key auth requires a private key path.') + } + return { ...base, privateKey: readKey(config.ssh.privateKeyPath), passphrase: dec.sshPassphrase } + default: // 'password' + return { ...base, password: dec.sshPassword } + } +} diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index cd61fdf..50ee2a4 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -504,13 +504,14 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E onChange={setSshAuthMethod} options={[ { label: tFn('connection.ssh.methodPassword'), value: 'password' }, - { label: tFn('connection.ssh.methodPrivateKey'), value: 'privateKey' } + { label: tFn('connection.ssh.methodPrivateKey'), value: 'privateKey' }, + { label: tFn('connection.ssh.methodAgent'), value: 'agent' } ]} /> - {sshAuthMethod === 'password' ? ( + {sshAuthMethod === 'password' && ( - ) : ( + )} + + {sshAuthMethod === 'privateKey' && ( <> )} + + {sshAuthMethod === 'agent' && ( +
{tFn('connection.ssh.agentHint')}
+ )} )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 158b7fc..263305b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -115,10 +115,12 @@ "authMethod": "Auth Method", "methodPassword": "Password", "methodPrivateKey": "Private Key", + "methodAgent": "SSH Agent", "password": "SSH Password", "privateKeyPath": "Private Key Path", "passphrase": "Key Passphrase", - "passphraseHint": "Leave blank if the key has no passphrase." + "passphraseHint": "Leave blank if the key has no passphrase.", + "agentHint": "Authenticate with a key already loaded in your local ssh-agent (via SSH_AUTH_SOCK) — no key file or password needed here. Make sure ssh-agent is running and you've run ssh-add." }, "tls": { "enableLabel": "Enable TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index e6cadab..9e25154 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -115,10 +115,12 @@ "authMethod": "认证方式", "methodPassword": "密码", "methodPrivateKey": "私钥", + "methodAgent": "SSH Agent", "password": "SSH 密码", "privateKeyPath": "私钥路径", "passphrase": "私钥密码短语", - "passphraseHint": "如果私钥没有密码短语,请留空。" + "passphraseHint": "如果私钥没有密码短语,请留空。", + "agentHint": "使用本机 ssh-agent 中已加载的私钥认证(读取 SSH_AUTH_SOCK),无需在此填写私钥或密码。请确保 ssh-agent 正在运行并已通过 ssh-add 添加密钥。" }, "tls": { "enableLabel": "启用 TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index 6e37988..9b32845 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -115,10 +115,12 @@ "authMethod": "驗證方式", "methodPassword": "密碼", "methodPrivateKey": "私密金鑰", + "methodAgent": "SSH Agent", "password": "SSH 密碼", "privateKeyPath": "私密金鑰路徑", "passphrase": "私密金鑰密碼短語", - "passphraseHint": "若私密金鑰無密碼短語,請留空。" + "passphraseHint": "若私密金鑰無密碼短語,請留空。", + "agentHint": "使用本機 ssh-agent 中已載入的私密金鑰進行驗證(讀取 SSH_AUTH_SOCK),無需在此填寫私密金鑰或密碼。請確認 ssh-agent 正在執行並已透過 ssh-add 新增金鑰。" }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/src/shared/types.ts b/src/shared/types.ts index cb94f65..d4b7592 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -22,13 +22,14 @@ export interface AuthConfig { mechanism?: ScramMechanism } -export type SshAuthMethod = 'password' | 'privateKey' +export type SshAuthMethod = 'password' | 'privateKey' | 'agent' export interface SshConfig { enabled: boolean host?: string port?: number // default 22 username?: string + /** 'agent' authenticates via the local ssh-agent (SSH_AUTH_SOCK) — no key/password stored. */ authMethod?: SshAuthMethod /** Path to a private key file on disk (we read it at connect time). */ privateKeyPath?: string diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts new file mode 100644 index 0000000..72e10eb --- /dev/null +++ b/test/unit/main/tunnelCore.test.ts @@ -0,0 +1,111 @@ +/** + * SSH tunnel option assembly — auth-method decision + agent-socket resolution. + */ +import { describe, it, expect } from 'vitest' +import { buildTunnelOptions, resolveSshAgentSock } from '../../../src/main/ssh/tunnelCore' +import type { DecryptedConnection } from '../../../src/main/mongo/uri' +import type { ConnectionConfig, SshConfig } from '../../../src/shared/types' + +const cfg = (ssh: Partial = {}, over: Partial = {}): ConnectionConfig => ({ + id: 'c1', + name: 'conn', + useSrv: false, + host: 'db.internal', + port: 27017, + auth: { type: 'none' }, + ssh: { enabled: true, host: 'gw.example.com', port: 22, username: 'deploy', ...ssh }, + tls: { enabled: false }, + createdAt: 0, + updatedAt: 0, + ...over +}) + +const dec = (config: ConnectionConfig, secrets: Partial = {}): DecryptedConnection => ({ + config, + ...secrets +}) + +/** Stub reader so the privateKey branch never touches disk. */ +const stubKey = (): Buffer => Buffer.from('PRIV') + +describe('resolveSshAgentSock', () => { + it('prefers SSH_AUTH_SOCK when set', () => { + expect(resolveSshAgentSock({ SSH_AUTH_SOCK: '/tmp/agent.sock' }, 'darwin')).toBe('/tmp/agent.sock') + }) + it('falls back to the Windows OpenSSH named pipe on win32', () => { + expect(resolveSshAgentSock({}, 'win32')).toBe('\\\\.\\pipe\\openssh-ssh-agent') + }) + it('returns undefined on posix without SSH_AUTH_SOCK', () => { + expect(resolveSshAgentSock({}, 'linux')).toBeUndefined() + }) +}) + +describe('buildTunnelOptions — auth methods', () => { + it('password: carries sshPassword, no key/agent', () => { + const o = buildTunnelOptions(dec(cfg({ authMethod: 'password' }), { sshPassword: 'pw' }), stubKey) + expect(o).toMatchObject({ + sshHost: 'gw.example.com', + sshPort: 22, + username: 'deploy', + destHost: 'db.internal', + destPort: 27017, + password: 'pw' + }) + expect(o.privateKey).toBeUndefined() + expect(o.agent).toBeUndefined() + }) + + it('defaults to password when authMethod is unset', () => { + const o = buildTunnelOptions(dec(cfg({ authMethod: undefined }), { sshPassword: 'pw' }), stubKey) + expect(o.password).toBe('pw') + }) + + it('privateKey: reads the key path and carries the passphrase', () => { + const o = buildTunnelOptions( + dec(cfg({ authMethod: 'privateKey', privateKeyPath: '/keys/id_ed25519' }), { sshPassphrase: 'pp' }), + (p) => { + expect(p).toBe('/keys/id_ed25519') + return Buffer.from('PRIV') + } + ) + expect(o.privateKey?.toString()).toBe('PRIV') + expect(o.passphrase).toBe('pp') + expect(o.password).toBeUndefined() + expect(o.agent).toBeUndefined() + }) + + it('privateKey without a path throws', () => { + expect(() => buildTunnelOptions(dec(cfg({ authMethod: 'privateKey' })), stubKey)).toThrow(/private key/i) + }) + + it('agent: carries the resolved socket, no key/password', () => { + const o = buildTunnelOptions(dec(cfg({ authMethod: 'agent' })), stubKey, () => '/tmp/agent.sock') + expect(o.agent).toBe('/tmp/agent.sock') + expect(o.privateKey).toBeUndefined() + expect(o.password).toBeUndefined() + }) + + it('agent without a resolvable socket throws', () => { + expect(() => buildTunnelOptions(dec(cfg({ authMethod: 'agent' })), stubKey, () => undefined)).toThrow( + /agent/i + ) + }) +}) + +describe('buildTunnelOptions — guards & defaults', () => { + it('throws for SRV/Atlas (single forwarded socket only)', () => { + expect(() => + buildTunnelOptions(dec(cfg({ authMethod: 'password' }, { useSrv: true })), stubKey) + ).toThrow(/SRV/i) + }) + + it('defaults ssh port to 22 and dest port to 27017', () => { + const o = buildTunnelOptions( + dec(cfg({ authMethod: 'agent', port: undefined }, { port: undefined })), + stubKey, + () => '/s' + ) + expect(o.sshPort).toBe(22) + expect(o.destPort).toBe(27017) + }) +}) From 1d3f1a95c84b2788b6c2d106fb097691408f115b Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 17 Jun 2026 22:18:25 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20SSH=20=E9=9A=A7=E9=81=93=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E7=A7=81=E9=92=A5=20~=20=E8=B7=AF=E5=BE=84=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E4=B8=8E=E5=B9=B6=E5=8F=91=E9=9A=A7=E9=81=93=E5=8F=A5?= =?UTF-8?q?=E6=9F=84=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expandHome 展开私钥路径的前导 ~ / ~/(Node fs 不展开,照表单 占位符 ~/.ssh/id_ed25519 填会 ENOENT) - openTunnel 改为返回隧道句柄、connect() 用局部变量贯穿,删除共享 实例字段 pendingTunnel —— 并发连接不同 id 不再互相覆盖致隧道泄漏 - 新增 expandHome 单测 --- src/main/mongo/sessionManager.ts | 21 ++++++++------------- src/main/ssh/tunnelCore.ts | 16 +++++++++++++++- test/unit/main/tunnelCore.test.ts | 15 ++++++++++++++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/main/mongo/sessionManager.ts b/src/main/mongo/sessionManager.ts index 318e55e..1c0c9b6 100644 --- a/src/main/mongo/sessionManager.ts +++ b/src/main/mongo/sessionManager.ts @@ -32,16 +32,12 @@ class SessionManager { return this.sessions.get(id)?.tunnel?.localPort } - private async openTunnel(dec: DecryptedConnection): Promise { + private async openTunnel(dec: DecryptedConnection): Promise { const tunnel = new SshTunnel() - const port = await tunnel.open(buildTunnelOptions(dec)) - // stash tunnel so we can close it on disconnect - this.pendingTunnel = tunnel - return port + await tunnel.open(buildTunnelOptions(dec)) + return tunnel } - private pendingTunnel?: SshTunnel - private async probe(client: MongoClient): Promise<{ topology?: string; serverVersion?: string }> { try { const admin = client.db('admin') @@ -68,11 +64,12 @@ class SessionManager { return status } + let tunnel: SshTunnel | undefined try { - this.pendingTunnel = undefined let tunnelPort: number | undefined if (dec.config.ssh.enabled) { - tunnelPort = await this.openTunnel(dec) + tunnel = await this.openTunnel(dec) + tunnelPort = tunnel.localPort } const { uri, options } = buildClientArgs(dec, tunnelPort) @@ -86,12 +83,10 @@ class SessionManager { topology: info.topology, serverVersion: info.serverVersion } - this.sessions.set(id, { client, tunnel: this.pendingTunnel, status }) - this.pendingTunnel = undefined + this.sessions.set(id, { client, tunnel, status }) return status } catch (err) { - this.pendingTunnel?.close() - this.pendingTunnel = undefined + tunnel?.close() const status: ConnectionStatus = { id, state: 'error', diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index 30be368..7becf06 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -7,6 +7,8 @@ * default reader does real disk IO at connect time. */ import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' import type { DecryptedConnection } from '../mongo/uri' export interface TunnelOptions { @@ -23,6 +25,18 @@ export interface TunnelOptions { destPort: number } +/** + * Expand a leading `~` / `~/` to the user's home directory. Node's `fs` does + * NOT do this, so a privateKeyPath like `~/.ssh/id_ed25519` (which the form + * advertises) would otherwise ENOENT. Only the leading `~` is handled; `~user` + * is left untouched. + */ +export function expandHome(p: string, home: string = homedir()): string { + if (p === '~') return home + if (p.startsWith('~/') || p.startsWith('~\\')) return join(home, p.slice(2)) + return p +} + /** Windows 10/11 built-in OpenSSH agent named pipe (ssh2 accepts it as `agent`). */ const WIN_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' @@ -46,7 +60,7 @@ export function resolveSshAgentSock( */ export function buildTunnelOptions( dec: DecryptedConnection, - readKey: (path: string) => Buffer = (p) => readFileSync(p), + readKey: (path: string) => Buffer = (p) => readFileSync(expandHome(p)), resolveAgent: () => string | undefined = resolveSshAgentSock ): TunnelOptions { const { config } = dec diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index 72e10eb..b07a9b8 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -2,7 +2,7 @@ * SSH tunnel option assembly — auth-method decision + agent-socket resolution. */ import { describe, it, expect } from 'vitest' -import { buildTunnelOptions, resolveSshAgentSock } from '../../../src/main/ssh/tunnelCore' +import { buildTunnelOptions, expandHome, resolveSshAgentSock } from '../../../src/main/ssh/tunnelCore' import type { DecryptedConnection } from '../../../src/main/mongo/uri' import type { ConnectionConfig, SshConfig } from '../../../src/shared/types' @@ -28,6 +28,19 @@ const dec = (config: ConnectionConfig, secrets: Partial = { /** Stub reader so the privateKey branch never touches disk. */ const stubKey = (): Buffer => Buffer.from('PRIV') +describe('expandHome', () => { + it('expands a bare ~ to the home dir', () => { + expect(expandHome('~', '/home/u')).toBe('/home/u') + }) + it('expands a leading ~/ to home/...', () => { + expect(expandHome('~/.ssh/id_ed25519', '/home/u')).toBe('/home/u/.ssh/id_ed25519') + }) + it('leaves absolute and ~user paths untouched', () => { + expect(expandHome('/keys/id', '/home/u')).toBe('/keys/id') + expect(expandHome('~bob/id', '/home/u')).toBe('~bob/id') + }) +}) + describe('resolveSshAgentSock', () => { it('prefers SSH_AUTH_SOCK when set', () => { expect(resolveSshAgentSock({ SSH_AUTH_SOCK: '/tmp/agent.sock' }, 'darwin')).toBe('/tmp/agent.sock') From e320ec8114d4fd87948c41774e4f5cd14bc57343 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 17 Jun 2026 22:22:56 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20SSH=20=E9=9A=A7=E9=81=93=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=B8=BB=E6=9C=BA=E5=AF=86=E9=92=A5=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=88TOFU=20=E6=8C=87=E7=BA=B9=E5=9B=BA=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复审计标记的安全 blocker:此前 ssh2 无 hostVerifier,盲信任意 host key,可被中间人。 - evaluateHostKey 纯函数:首连无 pin 则信任并学习指纹(TOFU), 之后仅接受完全匹配,变更即拒连 - tunnel.ts 设 hostHash:'sha256' + hostVerifier,捕获不匹配并给出 清晰错误(区分服务器重建 vs MITM);暴露 learnedHostKey - SshConfig.pinnedHostKey(非密钥,明文存);connectionStore .recordSshHostKey 在首连成功后回写;test() 不回写 - ConnectionForm 保留 pin 不被编辑覆盖 + 提供重置入口(合法换机) + 三套 i18n - 新增 evaluateHostKey / pin 透传单测 --- src/main/mongo/sessionManager.ts | 4 +++ src/main/ssh/tunnel.ts | 25 +++++++++++++++++-- src/main/ssh/tunnelCore.ts | 21 ++++++++++++++++ src/main/store/connectionStore.ts | 12 +++++++++ .../src/components/sidebar/ConnectionForm.tsx | 23 ++++++++++++++++- src/renderer/src/i18n/locales/en.json | 4 ++- src/renderer/src/i18n/locales/zh-CN.json | 4 ++- src/renderer/src/i18n/locales/zh-TW.json | 4 ++- src/shared/types.ts | 5 ++++ test/unit/main/tunnelCore.test.ts | 24 +++++++++++++++++- 10 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/main/mongo/sessionManager.ts b/src/main/mongo/sessionManager.ts index 1c0c9b6..cef551b 100644 --- a/src/main/mongo/sessionManager.ts +++ b/src/main/mongo/sessionManager.ts @@ -84,6 +84,10 @@ class SessionManager { serverVersion: info.serverVersion } this.sessions.set(id, { client, tunnel, status }) + // TOFU: persist the host key learned on first connect so later connects verify it. + if (tunnel?.learnedHostKey) { + connectionStore.recordSshHostKey(id, tunnel.learnedHostKey) + } return status } catch (err) { tunnel?.close() diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index c9858b4..dcc09ee 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,6 +1,6 @@ import net from 'node:net' import { Client, type ConnectConfig } from 'ssh2' -import type { TunnelOptions } from './tunnelCore' +import { evaluateHostKey, type TunnelOptions } from './tunnelCore' export type { TunnelOptions } @@ -18,9 +18,15 @@ export class SshTunnel { private client = new Client() private server?: net.Server localPort = 0 + /** Set after a first-use connection (no prior pin) so the caller can persist it. */ + learnedHostKey?: string open(opts: TunnelOptions): Promise { return new Promise((resolve, reject) => { + // Capture host-key rejections so we can surface a clear message instead of + // ssh2's generic handshake error (the 'error' event fires right after). + let hostKeyError: Error | undefined + const connectConfig: ConnectConfig = { host: opts.sshHost, port: opts.sshPort, @@ -29,11 +35,26 @@ export class SshTunnel { privateKey: opts.privateKey, passphrase: opts.passphrase, agent: opts.agent, + // hostHash makes ssh2 pass the host key pre-hashed as a hex string. + hostHash: 'sha256', + hostVerifier: (fingerprint: string): boolean => { + const verdict = evaluateHostKey(opts.pinnedHostKey, fingerprint) + if (verdict.ok) { + if (verdict.learned) this.learnedHostKey = verdict.learned + return true + } + hostKeyError = new Error( + "Host key verification failed — the SSH server's key changed (possible MITM). " + + `Expected SHA256:${opts.pinnedHostKey}, got SHA256:${fingerprint}. ` + + 'If the server was legitimately rebuilt, reset the trusted host key in the connection’s SSH settings.' + ) + return false + }, readyTimeout: 20000, keepaliveInterval: 15000 } - this.client.on('error', (err) => reject(err)) + this.client.on('error', (err) => reject(hostKeyError ?? err)) this.client.on('ready', () => { this.server = net.createServer((sock) => { diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index 7becf06..8297282 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -20,11 +20,31 @@ export interface TunnelOptions { passphrase?: string /** ssh-agent socket (SSH_AUTH_SOCK) or Windows OpenSSH pipe — for agent auth. */ agent?: string + /** Previously-pinned SHA256 host-key fingerprint (TOFU); undefined on first use. */ + pinnedHostKey?: string /** Final MongoDB host/port to forward to (as seen from the SSH server). */ destHost: string destPort: number } +export interface HostKeyVerdict { + /** Whether to proceed with the connection. */ + ok: boolean + /** Set only on first use (no prior pin): the fingerprint the caller should persist. */ + learned?: string +} + +/** + * Trust-on-first-use host-key check. With no prior pin we accept and report the + * fingerprint to learn; with a pin we accept only an exact match and otherwise + * reject (a changed key means the server was rebuilt — or a MITM). + */ +export function evaluateHostKey(pinned: string | undefined, presented: string): HostKeyVerdict { + if (!pinned) return { ok: true, learned: presented } + if (pinned === presented) return { ok: true } + return { ok: false } +} + /** * Expand a leading `~` / `~/` to the user's home directory. Node's `fs` does * NOT do this, so a privateKeyPath like `~/.ssh/id_ed25519` (which the form @@ -72,6 +92,7 @@ export function buildTunnelOptions( sshHost: config.ssh.host || '', sshPort: config.ssh.port || 22, username: config.ssh.username || '', + pinnedHostKey: config.ssh.pinnedHostKey, destHost: config.host, destPort: config.port ?? 27017 } diff --git a/src/main/store/connectionStore.ts b/src/main/store/connectionStore.ts index d6db31d..ddd55ea 100644 --- a/src/main/store/connectionStore.ts +++ b/src/main/store/connectionStore.ts @@ -129,6 +129,18 @@ class ConnectionStore { this.persist() } + /** + * Pin the SSH host-key fingerprint learned on first connect (TOFU). No-op if + * the connection is gone or already pinned to the same value. The fingerprint + * is not a secret, so it lives in plaintext alongside the rest of the config. + */ + recordSshHostKey(id: string, fingerprint: string): void { + const stored = this.data.connections.find((c) => c.id === id) + if (!stored || stored.ssh.pinnedHostKey === fingerprint) return + stored.ssh = { ...stored.ssh, pinnedHostKey: fingerprint } + this.persist() + } + /** Internal-only: decrypted secrets for use at connect time. Never sent over IPC. */ getDecrypted(id: string): { config: ConnectionConfig diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index 50ee2a4..4199ce8 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -99,6 +99,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [sshPasswordTouched, setSshPasswordTouched] = useState(false) const [sshPassphrase, setSshPassphrase] = useState('') const [sshPassphraseTouched, setSshPassphraseTouched] = useState(false) + const [clearHostKey, setClearHostKey] = useState(false) // ---- TLS ---- const [tlsEnabled, setTlsEnabled] = useState(editing?.tls.enabled ?? false) @@ -208,7 +209,9 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E username: sshUser.trim() || undefined, authMethod: sshAuthMethod, privateKeyPath: - sshAuthMethod === 'privateKey' ? privateKeyPath.trim() || undefined : undefined + sshAuthMethod === 'privateKey' ? privateKeyPath.trim() || undefined : undefined, + // Preserve the TOFU-pinned host key across edits; clear it only on request. + pinnedHostKey: clearHostKey ? undefined : editing?.ssh.pinnedHostKey }, tls: { enabled: tlsEnabled, @@ -249,6 +252,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E sshPasswordTouched, sshPassphrase, sshPassphraseTouched, + clearHostKey, tlsEnabled, allowInvalidCertificates, caFile, @@ -554,6 +558,23 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E {sshAuthMethod === 'agent' && (
{tFn('connection.ssh.agentHint')}
)} + + {editing?.ssh.pinnedHostKey && ( + <> +
+ {tFn('connection.ssh.hostKeyTrusted', { + fp: editing.ssh.pinnedHostKey.slice(0, 24) + })} +
+
+ +
+ + )} )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 263305b..d639f9a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -120,7 +120,9 @@ "privateKeyPath": "Private Key Path", "passphrase": "Key Passphrase", "passphraseHint": "Leave blank if the key has no passphrase.", - "agentHint": "Authenticate with a key already loaded in your local ssh-agent (via SSH_AUTH_SOCK) — no key file or password needed here. Make sure ssh-agent is running and you've run ssh-add." + "agentHint": "Authenticate with a key already loaded in your local ssh-agent (via SSH_AUTH_SOCK) — no key file or password needed here. Make sure ssh-agent is running and you've run ssh-add.", + "hostKeyTrusted": "Trusted this server's host key fingerprint (SHA256:{{fp}}…); later connections verify it.", + "resetHostKey": "Reset the trusted host key (re-trust on next connect)" }, "tls": { "enableLabel": "Enable TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 9e25154..7d67ec7 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -120,7 +120,9 @@ "privateKeyPath": "私钥路径", "passphrase": "私钥密码短语", "passphraseHint": "如果私钥没有密码短语,请留空。", - "agentHint": "使用本机 ssh-agent 中已加载的私钥认证(读取 SSH_AUTH_SOCK),无需在此填写私钥或密码。请确保 ssh-agent 正在运行并已通过 ssh-add 添加密钥。" + "agentHint": "使用本机 ssh-agent 中已加载的私钥认证(读取 SSH_AUTH_SOCK),无需在此填写私钥或密码。请确保 ssh-agent 正在运行并已通过 ssh-add 添加密钥。", + "hostKeyTrusted": "已信任此服务器的主机密钥指纹(SHA256:{{fp}}…),后续连接将校验它。", + "resetHostKey": "重置已信任的主机密钥(下次连接重新信任)" }, "tls": { "enableLabel": "启用 TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index 9b32845..a42ef7a 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -120,7 +120,9 @@ "privateKeyPath": "私密金鑰路徑", "passphrase": "私密金鑰密碼短語", "passphraseHint": "若私密金鑰無密碼短語,請留空。", - "agentHint": "使用本機 ssh-agent 中已載入的私密金鑰進行驗證(讀取 SSH_AUTH_SOCK),無需在此填寫私密金鑰或密碼。請確認 ssh-agent 正在執行並已透過 ssh-add 新增金鑰。" + "agentHint": "使用本機 ssh-agent 中已載入的私密金鑰進行驗證(讀取 SSH_AUTH_SOCK),無需在此填寫私密金鑰或密碼。請確認 ssh-agent 正在執行並已透過 ssh-add 新增金鑰。", + "hostKeyTrusted": "已信任此伺服器的主機金鑰指紋(SHA256:{{fp}}…),後續連線將驗證它。", + "resetHostKey": "重設已信任的主機金鑰(下次連線重新信任)" }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/src/shared/types.ts b/src/shared/types.ts index d4b7592..539a4aa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -33,6 +33,11 @@ export interface SshConfig { authMethod?: SshAuthMethod /** Path to a private key file on disk (we read it at connect time). */ privateKeyPath?: string + /** + * Pinned SHA256 host-key fingerprint (hex). Learned on first connect (TOFU) + * and verified thereafter; a mismatch blocks the connection. Not a secret. + */ + pinnedHostKey?: string } export interface TlsConfig { diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index b07a9b8..db0a2da 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -2,7 +2,12 @@ * SSH tunnel option assembly — auth-method decision + agent-socket resolution. */ import { describe, it, expect } from 'vitest' -import { buildTunnelOptions, expandHome, resolveSshAgentSock } from '../../../src/main/ssh/tunnelCore' +import { + buildTunnelOptions, + evaluateHostKey, + expandHome, + resolveSshAgentSock +} from '../../../src/main/ssh/tunnelCore' import type { DecryptedConnection } from '../../../src/main/mongo/uri' import type { ConnectionConfig, SshConfig } from '../../../src/shared/types' @@ -103,6 +108,23 @@ describe('buildTunnelOptions — auth methods', () => { /agent/i ) }) + + it('threads the pinned host key through', () => { + const o = buildTunnelOptions(dec(cfg({ authMethod: 'agent', pinnedHostKey: 'abc123' })), stubKey, () => '/s') + expect(o.pinnedHostKey).toBe('abc123') + }) +}) + +describe('evaluateHostKey (TOFU)', () => { + it('first use (no pin) accepts and learns the fingerprint', () => { + expect(evaluateHostKey(undefined, 'fp1')).toEqual({ ok: true, learned: 'fp1' }) + }) + it('a matching pin accepts without re-learning', () => { + expect(evaluateHostKey('fp1', 'fp1')).toEqual({ ok: true }) + }) + it('a changed key is rejected', () => { + expect(evaluateHostKey('fp1', 'fp2')).toEqual({ ok: false }) + }) }) describe('buildTunnelOptions — guards & defaults', () => { From c91b98ae7f4dd042d380e5c8129e3981e84d189d Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 17 Jun 2026 22:31:49 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20SSH=20=E9=9A=A7=E9=81=93=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=20=E2=80=94=20=E8=A1=A8=E5=8D=95=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20/=20=E5=89=AF=E6=9C=AC=E9=9B=86=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=20/=20=E7=A7=81=E9=92=A5=E5=8F=8B=E5=A5=BD=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConnectionForm 启用 SSH 时前置校验 host/user 必填、端口 1–65535、 私钥模式必填路径,未过则禁用保存并在底栏给出原因(不再把空值 推迟到连接时才报错) - 启用 SSH 且填了副本集名时给出告警:隧道下按单节点 directConnection 连接、replicaSet 被忽略(对齐"缺失应报错绝不静默") - tunnelCore.readPrivateKey:私钥读取失败给出指明路径的可读错误 - 三套 i18n + readPrivateKey 单测 --- src/main/ssh/tunnelCore.ts | 15 +++++++++- .../src/components/sidebar/ConnectionForm.tsx | 28 ++++++++++++++++++- src/renderer/src/i18n/locales/en.json | 7 ++++- src/renderer/src/i18n/locales/zh-CN.json | 7 ++++- src/renderer/src/i18n/locales/zh-TW.json | 7 ++++- test/unit/main/tunnelCore.test.ts | 9 ++++++ 6 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index 8297282..06ea734 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -57,6 +57,19 @@ export function expandHome(p: string, home: string = homedir()): string { return p } +/** + * Read a private key file (expanding a leading `~`), turning the raw Node fs + * error into an actionable message that names the resolved path. + */ +export function readPrivateKey(path: string): Buffer { + const full = expandHome(path) + try { + return readFileSync(full) + } catch { + throw new Error(`Cannot read SSH private key at "${full}" — check the path and file permissions.`) + } +} + /** Windows 10/11 built-in OpenSSH agent named pipe (ssh2 accepts it as `agent`). */ const WIN_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' @@ -80,7 +93,7 @@ export function resolveSshAgentSock( */ export function buildTunnelOptions( dec: DecryptedConnection, - readKey: (path: string) => Buffer = (p) => readFileSync(expandHome(p)), + readKey: (path: string) => Buffer = readPrivateKey, resolveAgent: () => string | undefined = resolveSshAgentSock ): TunnelOptions { const { config } = dec diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index 4199ce8..f099b23 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -101,6 +101,18 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [sshPassphraseTouched, setSshPassphraseTouched] = useState(false) const [clearHostKey, setClearHostKey] = useState(false) + // Validate SSH fields up front so save fails fast (with a reason) rather than + // letting empty/invalid values surface as an opaque error at connect time. + const sshError = useMemo(() => { + if (!sshEnabled) return undefined + if (!sshHost.trim()) return tFn('connection.ssh.errHost') + if (!sshUser.trim()) return tFn('connection.ssh.errUser') + const p = Number(sshPort) + if (!Number.isInteger(p) || p < 1 || p > 65535) return tFn('connection.ssh.errPort') + if (sshAuthMethod === 'privateKey' && !privateKeyPath.trim()) return tFn('connection.ssh.errKey') + return undefined + }, [sshEnabled, sshHost, sshUser, sshPort, sshAuthMethod, privateKeyPath, tFn]) + // ---- TLS ---- const [tlsEnabled, setTlsEnabled] = useState(editing?.tls.enabled ?? false) const [allowInvalidCertificates, setAllowInvalid] = useState( @@ -301,11 +313,21 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E : tFn('connection.testResult.failed', { error: test.error ?? 'unknown' })} )} + {sshError && ( + + {sshError} + + )} - @@ -414,6 +436,10 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E
+ {sshEnabled && replicaSet.trim() && ( +
{tFn('connection.general.replicaSetSshIgnored')}
+ )} + {Object.keys(options).length > 0 && (
{tFn('connection.general.extraOptions', { opts: Object.entries(options).map(([k, v]) => `${k}=${v}`).join(' · ') })} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d639f9a..0474350 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -95,6 +95,7 @@ "srvHost": "SRV Host", "port": "Port", "replicaSet": "Replica Set", + "replicaSetSshIgnored": "The replica set name is ignored when the SSH tunnel is enabled (connects to a single node via directConnection).", "defaultDatabase": "Default Database", "extraOptions": "Extra options: {{opts}}" }, @@ -122,7 +123,11 @@ "passphraseHint": "Leave blank if the key has no passphrase.", "agentHint": "Authenticate with a key already loaded in your local ssh-agent (via SSH_AUTH_SOCK) — no key file or password needed here. Make sure ssh-agent is running and you've run ssh-add.", "hostKeyTrusted": "Trusted this server's host key fingerprint (SHA256:{{fp}}…); later connections verify it.", - "resetHostKey": "Reset the trusted host key (re-trust on next connect)" + "resetHostKey": "Reset the trusted host key (re-trust on next connect)", + "errHost": "SSH host is required", + "errUser": "SSH username is required", + "errPort": "SSH port must be an integer between 1 and 65535", + "errKey": "Private-key auth requires a key path" }, "tls": { "enableLabel": "Enable TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 7d67ec7..571618a 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -95,6 +95,7 @@ "srvHost": "SRV 主机", "port": "端口", "replicaSet": "副本集", + "replicaSetSshIgnored": "启用 SSH 隧道时副本集名称将被忽略(按单节点 directConnection 连接)。", "defaultDatabase": "默认数据库", "extraOptions": "额外选项:{{opts}}" }, @@ -122,7 +123,11 @@ "passphraseHint": "如果私钥没有密码短语,请留空。", "agentHint": "使用本机 ssh-agent 中已加载的私钥认证(读取 SSH_AUTH_SOCK),无需在此填写私钥或密码。请确保 ssh-agent 正在运行并已通过 ssh-add 添加密钥。", "hostKeyTrusted": "已信任此服务器的主机密钥指纹(SHA256:{{fp}}…),后续连接将校验它。", - "resetHostKey": "重置已信任的主机密钥(下次连接重新信任)" + "resetHostKey": "重置已信任的主机密钥(下次连接重新信任)", + "errHost": "请填写 SSH 主机", + "errUser": "请填写 SSH 用户名", + "errPort": "SSH 端口需为 1–65535 之间的整数", + "errKey": "私钥认证需填写私钥路径" }, "tls": { "enableLabel": "启用 TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index a42ef7a..bf796ae 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -95,6 +95,7 @@ "srvHost": "SRV 主機", "port": "連接埠", "replicaSet": "副本集", + "replicaSetSshIgnored": "啟用 SSH 通道時副本集名稱將被忽略(以單節點 directConnection 連線)。", "defaultDatabase": "預設資料庫", "extraOptions": "額外選項:{{opts}}" }, @@ -122,7 +123,11 @@ "passphraseHint": "若私密金鑰無密碼短語,請留空。", "agentHint": "使用本機 ssh-agent 中已載入的私密金鑰進行驗證(讀取 SSH_AUTH_SOCK),無需在此填寫私密金鑰或密碼。請確認 ssh-agent 正在執行並已透過 ssh-add 新增金鑰。", "hostKeyTrusted": "已信任此伺服器的主機金鑰指紋(SHA256:{{fp}}…),後續連線將驗證它。", - "resetHostKey": "重設已信任的主機金鑰(下次連線重新信任)" + "resetHostKey": "重設已信任的主機金鑰(下次連線重新信任)", + "errHost": "請填寫 SSH 主機", + "errUser": "請填寫 SSH 使用者名稱", + "errPort": "SSH 連接埠需為 1–65535 之間的整數", + "errKey": "私密金鑰驗證需填寫金鑰路徑" }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index db0a2da..fcfe7c8 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -6,6 +6,7 @@ import { buildTunnelOptions, evaluateHostKey, expandHome, + readPrivateKey, resolveSshAgentSock } from '../../../src/main/ssh/tunnelCore' import type { DecryptedConnection } from '../../../src/main/mongo/uri' @@ -46,6 +47,14 @@ describe('expandHome', () => { }) }) +describe('readPrivateKey', () => { + it('throws an actionable, path-naming error when the key is unreadable', () => { + expect(() => readPrivateKey('/no/such/ssh/key/_definitely_missing_')).toThrow( + /Cannot read SSH private key at ".*_definitely_missing_"/ + ) + }) +}) + describe('resolveSshAgentSock', () => { it('prefers SSH_AUTH_SOCK when set', () => { expect(resolveSshAgentSock({ SSH_AUTH_SOCK: '/tmp/agent.sock' }, 'darwin')).toBe('/tmp/agent.sock') From b130f2d2d76868939bbadfa8c97ff06c49b26444 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 17 Jun 2026 22:53:40 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20SSH=20=E9=9A=A7=E9=81=93=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=B7=B3=E6=9D=BF=E6=9C=BA=EF=BC=88ProxyJump=20/=20ss?= =?UTF-8?q?h2=20=E5=B5=8C=E5=A5=97=E8=BF=9E=E6=8E=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用现有 ssh2 实现支持"经跳板机连目标"(等价 ProxyJump / ProxyCommand -W):连堡垒机 → forwardOut 到目标 SSH 端口 → 在该 通道上再起一条 SSH 到目标 → 从目标 forwardOut 出 mongo。每一跳 独立做 host-key TOFU 校验,复用 agent 认证。 - tunnelCore:TunnelOptions 重构为 { target, jump?, dest },buildHop 统一装配;jump 仅 agent/私钥文件认证、不存任何密钥(带口令私钥 走 ssh-agent) - tunnel.ts:SshTunnel 支持可选 jump,connectHop 复用于两跳, learnedHostKey / learnedJumpHostKey 分别上报 - SshConfig.jump(SshHopConfig);connectionStore.recordSshJumpHostKey 回写跳板机指纹;sessionManager 首连后两跳指纹都持久化 - ConnectionForm 新增跳板机区块(开关/host/port/user/认证 agent|key/ host-key 重置)+ 校验 + 三套 i18n - tunnelCore 单测覆盖 jump 装配(target+jump、jump 不带存储密钥) --- src/main/mongo/sessionManager.ts | 5 +- src/main/ssh/tunnel.ts | 150 +++++++++++------- src/main/ssh/tunnelCore.ts | 93 +++++++---- src/main/store/connectionStore.ts | 8 + .../src/components/sidebar/ConnectionForm.tsx | 133 +++++++++++++++- src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/zh-CN.json | 11 +- src/renderer/src/i18n/locales/zh-TW.json | 11 +- src/shared/types.ts | 18 +++ test/unit/main/tunnelCore.test.ts | 85 +++++++--- 10 files changed, 411 insertions(+), 114 deletions(-) diff --git a/src/main/mongo/sessionManager.ts b/src/main/mongo/sessionManager.ts index cef551b..990bcf6 100644 --- a/src/main/mongo/sessionManager.ts +++ b/src/main/mongo/sessionManager.ts @@ -84,10 +84,13 @@ class SessionManager { serverVersion: info.serverVersion } this.sessions.set(id, { client, tunnel, status }) - // TOFU: persist the host key learned on first connect so later connects verify it. + // TOFU: persist the host key(s) learned on first connect so later connects verify them. if (tunnel?.learnedHostKey) { connectionStore.recordSshHostKey(id, tunnel.learnedHostKey) } + if (tunnel?.learnedJumpHostKey) { + connectionStore.recordSshJumpHostKey(id, tunnel.learnedJumpHostKey) + } return status } catch (err) { tunnel?.close() diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index dcc09ee..e3d4d67 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,51 +1,78 @@ import net from 'node:net' +import type { Duplex } from 'node:stream' import { Client, type ConnectConfig } from 'ssh2' -import { evaluateHostKey, type TunnelOptions } from './tunnelCore' +import { evaluateHostKey, type SshHopOptions, type TunnelOptions } from './tunnelCore' export type { TunnelOptions } /** - * A local TCP forwarder over SSH. We open an SSH connection, stand up a local - * server on 127.0.0.1:, and pipe each incoming socket through an - * `forwardOut` channel to the real MongoDB host. The driver then connects to - * the local port as if Mongo were on localhost. + * A local TCP forwarder over SSH, optionally through a single jump host. + * + * Without a jump: connect to the target, then pipe each inbound local socket + * through a `forwardOut` channel to MongoDB; the driver connects to the local + * port as if Mongo were on localhost. + * + * With a jump (ProxyJump / the old `ssh -W` ProxyCommand): connect to the + * bastion, `forwardOut` to the target's SSH port, run a *second* SSH session + * over that channel to the target, then forward MongoDB from there. Each hop's + * host key is verified (TOFU) independently. * * Limitation: a single forwarded node only — SRV/replica-set discovery (which - * resolves multiple real hostnames) is not supported through the tunnel; use a - * direct single-host connection with SSH. + * resolves multiple real hostnames) is not supported through the tunnel. */ export class SshTunnel { - private client = new Client() + private clients: Client[] = [] private server?: net.Server localPort = 0 - /** Set after a first-use connection (no prior pin) so the caller can persist it. */ + /** Target host key learned on first use (TOFU) so the caller can persist it. */ learnedHostKey?: string + /** Jump host key learned on first use (TOFU) so the caller can persist it. */ + learnedJumpHostKey?: string + + async open(opts: TunnelOptions): Promise { + let transport: Duplex | undefined + if (opts.jump) { + const jump = await this.connectHop(opts.jump) + this.learnedJumpHostKey = jump.learned + // Reach the target's SSH port *through* the bastion. + transport = await forwardOut(jump.client, opts.target.host, opts.target.port) + } + const target = await this.connectHop(opts.target, transport) + this.learnedHostKey = target.learned + this.localPort = await this.listenForward(target.client, opts.destHost, opts.destPort) + return this.localPort + } - open(opts: TunnelOptions): Promise { - return new Promise((resolve, reject) => { - // Capture host-key rejections so we can surface a clear message instead of + /** Open one SSH session, optionally tunnelled over an existing transport sock. */ + private connectHop(hop: SshHopOptions, sock?: Duplex): Promise<{ client: Client; learned?: string }> { + return new Promise((resolve, reject) => { + const client = new Client() + this.clients.push(client) + // Capture host-key rejections so we surface a clear message instead of // ssh2's generic handshake error (the 'error' event fires right after). let hostKeyError: Error | undefined + let learned: string | undefined - const connectConfig: ConnectConfig = { - host: opts.sshHost, - port: opts.sshPort, - username: opts.username, - password: opts.password, - privateKey: opts.privateKey, - passphrase: opts.passphrase, - agent: opts.agent, + const cfg: ConnectConfig = { + host: hop.host, + port: hop.port, + username: hop.username, + password: hop.password, + privateKey: hop.privateKey, + passphrase: hop.passphrase, + agent: hop.agent, + sock, // hostHash makes ssh2 pass the host key pre-hashed as a hex string. hostHash: 'sha256', hostVerifier: (fingerprint: string): boolean => { - const verdict = evaluateHostKey(opts.pinnedHostKey, fingerprint) + const verdict = evaluateHostKey(hop.pinnedHostKey, fingerprint) if (verdict.ok) { - if (verdict.learned) this.learnedHostKey = verdict.learned + if (verdict.learned) learned = verdict.learned return true } hostKeyError = new Error( - "Host key verification failed — the SSH server's key changed (possible MITM). " + - `Expected SHA256:${opts.pinnedHostKey}, got SHA256:${fingerprint}. ` + + `Host key verification failed for ${hop.host} — the SSH server's key changed (possible MITM). ` + + `Expected SHA256:${hop.pinnedHostKey}, got SHA256:${fingerprint}. ` + 'If the server was legitimately rebuilt, reset the trusted host key in the connection’s SSH settings.' ) return false @@ -54,41 +81,34 @@ export class SshTunnel { keepaliveInterval: 15000 } - this.client.on('error', (err) => reject(hostKeyError ?? err)) - - this.client.on('ready', () => { - this.server = net.createServer((sock) => { - this.client.forwardOut( - '127.0.0.1', - 0, - opts.destHost, - opts.destPort, - (err, stream) => { - if (err) { - sock.destroy() - return - } - sock.pipe(stream) - stream.pipe(sock) - stream.on('error', () => sock.destroy()) - sock.on('error', () => stream.destroy()) - } - ) - }) + client.on('error', (err) => reject(hostKeyError ?? err)) + client.on('ready', () => resolve({ client, learned })) + client.connect(cfg) + }) + } - this.server.on('error', (err) => reject(err)) - this.server.listen(0, '127.0.0.1', () => { - const addr = this.server!.address() - if (addr && typeof addr === 'object') { - this.localPort = addr.port - resolve(this.localPort) - } else { - reject(new Error('Failed to bind local tunnel port')) + /** Stand up the local listener that forwards each accepted socket to MongoDB. */ + private listenForward(client: Client, destHost: string, destPort: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer((sock) => { + client.forwardOut('127.0.0.1', 0, destHost, destPort, (err, stream) => { + if (err) { + sock.destroy() + return } + sock.pipe(stream) + stream.pipe(sock) + stream.on('error', () => sock.destroy()) + sock.on('error', () => stream.destroy()) }) }) - - this.client.connect(connectConfig) + this.server = server + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr && typeof addr === 'object') resolve(addr.port) + else reject(new Error('Failed to bind local tunnel port')) + }) }) } @@ -98,10 +118,22 @@ export class SshTunnel { } catch { /* ignore */ } - try { - this.client.end() - } catch { - /* ignore */ + for (const client of this.clients) { + try { + client.end() + } catch { + /* ignore */ + } } } } + +/** Promise wrapper around `forwardOut` to reach `host:port` from a connected client. */ +function forwardOut(client: Client, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + client.forwardOut('127.0.0.1', 0, host, port, (err, stream) => { + if (err) reject(err) + else resolve(stream) + }) + }) +} diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index 06ea734..d8c2322 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -9,11 +9,13 @@ import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import type { SshAuthMethod } from '../../shared/types' import type { DecryptedConnection } from '../mongo/uri' -export interface TunnelOptions { - sshHost: string - sshPort: number +/** Auth + identity for a single SSH hop (a bastion or the terminal host). */ +export interface SshHopOptions { + host: string + port: number username: string password?: string privateKey?: Buffer @@ -22,7 +24,14 @@ export interface TunnelOptions { agent?: string /** Previously-pinned SHA256 host-key fingerprint (TOFU); undefined on first use. */ pinnedHostKey?: string - /** Final MongoDB host/port to forward to (as seen from the SSH server). */ +} + +export interface TunnelOptions { + /** Terminal SSH host — the machine MongoDB runs on. */ + target: SshHopOptions + /** Optional single bastion in front of the target (ProxyJump / `ssh -W`). */ + jump?: SshHopOptions + /** Final MongoDB host/port to forward to (as seen from the target host). */ destHost: string destPort: number } @@ -87,30 +96,30 @@ export function resolveSshAgentSock( return undefined } -/** - * Build {@link TunnelOptions} from a decrypted connection. Throws (rather than - * silently misconfiguring) when the chosen auth method lacks what it needs. - */ -export function buildTunnelOptions( - dec: DecryptedConnection, - readKey: (path: string) => Buffer = readPrivateKey, - resolveAgent: () => string | undefined = resolveSshAgentSock -): TunnelOptions { - const { config } = dec - if (config.useSrv) { - throw new Error('SSH tunnel with SRV/Atlas is not supported — use a direct host:port.') - } +/** The hop-config fields shared by the target `SshConfig` and a jump hop. */ +interface HopConfigLike { + host?: string + port?: number + username?: string + authMethod?: SshAuthMethod + privateKeyPath?: string + pinnedHostKey?: string +} - const base: TunnelOptions = { - sshHost: config.ssh.host || '', - sshPort: config.ssh.port || 22, - username: config.ssh.username || '', - pinnedHostKey: config.ssh.pinnedHostKey, - destHost: config.host, - destPort: config.port ?? 27017 +/** Resolve one hop's auth into {@link SshHopOptions}, throwing on missing inputs. */ +function buildHop( + hop: HopConfigLike, + secrets: { password?: string; passphrase?: string }, + readKey: (path: string) => Buffer, + resolveAgent: () => string | undefined +): SshHopOptions { + const base: SshHopOptions = { + host: hop.host || '', + port: hop.port || 22, + username: hop.username || '', + pinnedHostKey: hop.pinnedHostKey } - - switch (config.ssh.authMethod ?? 'password') { + switch (hop.authMethod ?? 'password') { case 'agent': { const agentSock = resolveAgent() if (!agentSock) { @@ -121,11 +130,37 @@ export function buildTunnelOptions( return { ...base, agent: agentSock } } case 'privateKey': - if (!config.ssh.privateKeyPath) { + if (!hop.privateKeyPath) { throw new Error('SSH private-key auth requires a private key path.') } - return { ...base, privateKey: readKey(config.ssh.privateKeyPath), passphrase: dec.sshPassphrase } + return { ...base, privateKey: readKey(hop.privateKeyPath), passphrase: secrets.passphrase } default: // 'password' - return { ...base, password: dec.sshPassword } + return { ...base, password: secrets.password } } } + +/** + * Build {@link TunnelOptions} from a decrypted connection. Throws (rather than + * silently misconfiguring) when an auth method lacks what it needs. An optional + * jump hop authenticates via agent or a key file only — no stored secrets. + */ +export function buildTunnelOptions( + dec: DecryptedConnection, + readKey: (path: string) => Buffer = readPrivateKey, + resolveAgent: () => string | undefined = resolveSshAgentSock +): TunnelOptions { + const { config } = dec + if (config.useSrv) { + throw new Error('SSH tunnel with SRV/Atlas is not supported — use a direct host:port.') + } + + const target = buildHop( + config.ssh, + { password: dec.sshPassword, passphrase: dec.sshPassphrase }, + readKey, + resolveAgent + ) + const jump = config.ssh.jump ? buildHop(config.ssh.jump, {}, readKey, resolveAgent) : undefined + + return { target, jump, destHost: config.host, destPort: config.port ?? 27017 } +} diff --git a/src/main/store/connectionStore.ts b/src/main/store/connectionStore.ts index ddd55ea..8d0d61c 100644 --- a/src/main/store/connectionStore.ts +++ b/src/main/store/connectionStore.ts @@ -141,6 +141,14 @@ class ConnectionStore { this.persist() } + /** Pin the jump (bastion) host-key fingerprint learned on first connect (TOFU). */ + recordSshJumpHostKey(id: string, fingerprint: string): void { + const stored = this.data.connections.find((c) => c.id === id) + if (!stored || !stored.ssh.jump || stored.ssh.jump.pinnedHostKey === fingerprint) return + stored.ssh = { ...stored.ssh, jump: { ...stored.ssh.jump, pinnedHostKey: fingerprint } } + this.persist() + } + /** Internal-only: decrypted secrets for use at connect time. Never sent over IPC. */ getDecrypted(id: string): { config: ConnectionConfig diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index f099b23..60f4b8b 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -101,17 +101,51 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [sshPassphraseTouched, setSshPassphraseTouched] = useState(false) const [clearHostKey, setClearHostKey] = useState(false) + // ---- SSH jump host (bastion / ProxyJump) — agent or key file only ---- + const [jumpEnabled, setJumpEnabled] = useState(!!editing?.ssh.jump) + const [jumpHost, setJumpHost] = useState(editing?.ssh.jump?.host ?? '') + const [jumpPort, setJumpPort] = useState(String(editing?.ssh.jump?.port ?? 22)) + const [jumpUser, setJumpUser] = useState(editing?.ssh.jump?.username ?? '') + const [jumpAuthMethod, setJumpAuthMethod] = useState( + editing?.ssh.jump?.authMethod ?? 'agent' + ) + const [jumpKeyPath, setJumpKeyPath] = useState(editing?.ssh.jump?.privateKeyPath ?? '') + const [clearJumpHostKey, setClearJumpHostKey] = useState(false) + // Validate SSH fields up front so save fails fast (with a reason) rather than // letting empty/invalid values surface as an opaque error at connect time. const sshError = useMemo(() => { if (!sshEnabled) return undefined + const portOk = (s: string): boolean => { + const p = Number(s) + return Number.isInteger(p) && p >= 1 && p <= 65535 + } if (!sshHost.trim()) return tFn('connection.ssh.errHost') if (!sshUser.trim()) return tFn('connection.ssh.errUser') - const p = Number(sshPort) - if (!Number.isInteger(p) || p < 1 || p > 65535) return tFn('connection.ssh.errPort') + if (!portOk(sshPort)) return tFn('connection.ssh.errPort') if (sshAuthMethod === 'privateKey' && !privateKeyPath.trim()) return tFn('connection.ssh.errKey') + if (jumpEnabled) { + if (!jumpHost.trim()) return tFn('connection.ssh.errJumpHost') + if (!jumpUser.trim()) return tFn('connection.ssh.errJumpUser') + if (!portOk(jumpPort)) return tFn('connection.ssh.errJumpPort') + if (jumpAuthMethod === 'privateKey' && !jumpKeyPath.trim()) return tFn('connection.ssh.errJumpKey') + } return undefined - }, [sshEnabled, sshHost, sshUser, sshPort, sshAuthMethod, privateKeyPath, tFn]) + }, [ + sshEnabled, + sshHost, + sshUser, + sshPort, + sshAuthMethod, + privateKeyPath, + jumpEnabled, + jumpHost, + jumpUser, + jumpPort, + jumpAuthMethod, + jumpKeyPath, + tFn + ]) // ---- TLS ---- const [tlsEnabled, setTlsEnabled] = useState(editing?.tls.enabled ?? false) @@ -223,7 +257,18 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E privateKeyPath: sshAuthMethod === 'privateKey' ? privateKeyPath.trim() || undefined : undefined, // Preserve the TOFU-pinned host key across edits; clear it only on request. - pinnedHostKey: clearHostKey ? undefined : editing?.ssh.pinnedHostKey + pinnedHostKey: clearHostKey ? undefined : editing?.ssh.pinnedHostKey, + jump: jumpEnabled + ? { + host: jumpHost.trim() || undefined, + port: Number(jumpPort) || 22, + username: jumpUser.trim() || undefined, + authMethod: jumpAuthMethod, + privateKeyPath: + jumpAuthMethod === 'privateKey' ? jumpKeyPath.trim() || undefined : undefined, + pinnedHostKey: clearJumpHostKey ? undefined : editing?.ssh.jump?.pinnedHostKey + } + : undefined }, tls: { enabled: tlsEnabled, @@ -265,6 +310,13 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E sshPassphrase, sshPassphraseTouched, clearHostKey, + jumpEnabled, + jumpHost, + jumpPort, + jumpUser, + jumpAuthMethod, + jumpKeyPath, + clearJumpHostKey, tlsEnabled, allowInvalidCertificates, caFile, @@ -601,6 +653,79 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E
)} + + {/* Jump host (bastion / ProxyJump): reach the target through it. */} +
+ +
+ + {jumpEnabled && ( + <> +
{tFn('connection.ssh.jumpHint')}
+
+ + setJumpHost(e.target.value)} /> + + + setJumpPort(e.target.value)} + placeholder="22" + /> + +
+
+ + setJumpUser(e.target.value)} /> + + + + value={jumpAuthMethod} + onChange={setJumpAuthMethod} + options={[ + { label: tFn('connection.ssh.methodAgent'), value: 'agent' }, + { label: tFn('connection.ssh.methodPrivateKey'), value: 'privateKey' } + ]} + /> + +
+ + {jumpAuthMethod === 'privateKey' && ( + + setJumpKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + + )} + + {jumpAuthMethod === 'agent' && ( +
{tFn('connection.ssh.agentHint')}
+ )} + + {editing?.ssh.jump?.pinnedHostKey && ( + <> +
+ {tFn('connection.ssh.hostKeyTrusted', { + fp: editing.ssh.jump.pinnedHostKey.slice(0, 24) + })} +
+
+ +
+ + )} + + )} )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0474350..afde6f9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -127,7 +127,16 @@ "errHost": "SSH host is required", "errUser": "SSH username is required", "errPort": "SSH port must be an integer between 1 and 65535", - "errKey": "Private-key auth requires a key path" + "errKey": "Private-key auth requires a key path", + "jumpEnableLabel": "Connect through a jump host (bastion)", + "jumpHint": "For a target reachable only via a bastion/jump host (equivalent to ssh ProxyJump / ProxyCommand -W). The jump host supports agent or key-file auth only; load passphrase-protected keys into ssh-agent.", + "jumpHost": "Jump Host", + "jumpPort": "Jump Port", + "jumpUsername": "Jump Username", + "errJumpHost": "Jump host is required", + "errJumpUser": "Jump username is required", + "errJumpPort": "Jump port must be an integer between 1 and 65535", + "errJumpKey": "Jump private-key auth requires a key path" }, "tls": { "enableLabel": "Enable TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 571618a..29bbf70 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -127,7 +127,16 @@ "errHost": "请填写 SSH 主机", "errUser": "请填写 SSH 用户名", "errPort": "SSH 端口需为 1–65535 之间的整数", - "errKey": "私钥认证需填写私钥路径" + "errKey": "私钥认证需填写私钥路径", + "jumpEnableLabel": "通过跳板机连接(先连跳板机再到目标)", + "jumpHint": "适用于目标主机只能经堡垒机/跳板机访问的场景(等价于 ssh ProxyJump / ProxyCommand -W)。跳板机仅支持 agent 或私钥文件认证;带口令的私钥请先加入 ssh-agent。", + "jumpHost": "跳板机主机", + "jumpPort": "跳板机端口", + "jumpUsername": "跳板机用户名", + "errJumpHost": "请填写跳板机主机", + "errJumpUser": "请填写跳板机用户名", + "errJumpPort": "跳板机端口需为 1–65535 之间的整数", + "errJumpKey": "跳板机私钥认证需填写私钥路径" }, "tls": { "enableLabel": "启用 TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index bf796ae..058e37d 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -127,7 +127,16 @@ "errHost": "請填寫 SSH 主機", "errUser": "請填寫 SSH 使用者名稱", "errPort": "SSH 連接埠需為 1–65535 之間的整數", - "errKey": "私密金鑰驗證需填寫金鑰路徑" + "errKey": "私密金鑰驗證需填寫金鑰路徑", + "jumpEnableLabel": "透過跳板機連線(先連跳板機再到目標)", + "jumpHint": "適用於目標主機僅能經堡壘機/跳板機存取的情境(等同 ssh ProxyJump / ProxyCommand -W)。跳板機僅支援 agent 或私密金鑰檔驗證;含密碼的金鑰請先加入 ssh-agent。", + "jumpHost": "跳板機主機", + "jumpPort": "跳板機連接埠", + "jumpUsername": "跳板機使用者名稱", + "errJumpHost": "請填寫跳板機主機", + "errJumpUser": "請填寫跳板機使用者名稱", + "errJumpPort": "跳板機連接埠需為 1–65535 之間的整數", + "errJumpKey": "跳板機私密金鑰驗證需填寫金鑰路徑" }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/src/shared/types.ts b/src/shared/types.ts index 539a4aa..805d567 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,6 +24,22 @@ export interface AuthConfig { export type SshAuthMethod = 'password' | 'privateKey' | 'agent' +/** + * A single SSH hop in front of the target (a bastion / jump host, i.e. ProxyJump). + * Authenticates via agent or a key file only — no password/passphrase is stored + * for a jump hop, so passphrase-protected keys must be loaded into the agent. + */ +export interface SshHopConfig { + host?: string + port?: number // default 22 + username?: string + /** 'password' is intentionally unsupported for a jump hop (no stored secret). */ + authMethod?: SshAuthMethod + privateKeyPath?: string + /** Pinned SHA256 host-key fingerprint (hex), learned via TOFU. Not a secret. */ + pinnedHostKey?: string +} + export interface SshConfig { enabled: boolean host?: string @@ -38,6 +54,8 @@ export interface SshConfig { * and verified thereafter; a mismatch blocks the connection. Not a secret. */ pinnedHostKey?: string + /** Optional single bastion in front of the target (reach the target through it). */ + jump?: SshHopConfig } export interface TlsConfig { diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index fcfe7c8..1d3fd82 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -67,24 +67,25 @@ describe('resolveSshAgentSock', () => { }) }) -describe('buildTunnelOptions — auth methods', () => { +describe('buildTunnelOptions — target auth methods', () => { it('password: carries sshPassword, no key/agent', () => { const o = buildTunnelOptions(dec(cfg({ authMethod: 'password' }), { sshPassword: 'pw' }), stubKey) - expect(o).toMatchObject({ - sshHost: 'gw.example.com', - sshPort: 22, + expect(o.target).toMatchObject({ + host: 'gw.example.com', + port: 22, username: 'deploy', - destHost: 'db.internal', - destPort: 27017, password: 'pw' }) - expect(o.privateKey).toBeUndefined() - expect(o.agent).toBeUndefined() + expect(o.destHost).toBe('db.internal') + expect(o.destPort).toBe(27017) + expect(o.jump).toBeUndefined() + expect(o.target.privateKey).toBeUndefined() + expect(o.target.agent).toBeUndefined() }) it('defaults to password when authMethod is unset', () => { const o = buildTunnelOptions(dec(cfg({ authMethod: undefined }), { sshPassword: 'pw' }), stubKey) - expect(o.password).toBe('pw') + expect(o.target.password).toBe('pw') }) it('privateKey: reads the key path and carries the passphrase', () => { @@ -95,10 +96,10 @@ describe('buildTunnelOptions — auth methods', () => { return Buffer.from('PRIV') } ) - expect(o.privateKey?.toString()).toBe('PRIV') - expect(o.passphrase).toBe('pp') - expect(o.password).toBeUndefined() - expect(o.agent).toBeUndefined() + expect(o.target.privateKey?.toString()).toBe('PRIV') + expect(o.target.passphrase).toBe('pp') + expect(o.target.password).toBeUndefined() + expect(o.target.agent).toBeUndefined() }) it('privateKey without a path throws', () => { @@ -107,9 +108,9 @@ describe('buildTunnelOptions — auth methods', () => { it('agent: carries the resolved socket, no key/password', () => { const o = buildTunnelOptions(dec(cfg({ authMethod: 'agent' })), stubKey, () => '/tmp/agent.sock') - expect(o.agent).toBe('/tmp/agent.sock') - expect(o.privateKey).toBeUndefined() - expect(o.password).toBeUndefined() + expect(o.target.agent).toBe('/tmp/agent.sock') + expect(o.target.privateKey).toBeUndefined() + expect(o.target.password).toBeUndefined() }) it('agent without a resolvable socket throws', () => { @@ -120,7 +121,55 @@ describe('buildTunnelOptions — auth methods', () => { it('threads the pinned host key through', () => { const o = buildTunnelOptions(dec(cfg({ authMethod: 'agent', pinnedHostKey: 'abc123' })), stubKey, () => '/s') - expect(o.pinnedHostKey).toBe('abc123') + expect(o.target.pinnedHostKey).toBe('abc123') + }) +}) + +describe('buildTunnelOptions — jump host (ProxyJump)', () => { + it('builds a jump hop alongside the target, with its own auth + pin', () => { + const o = buildTunnelOptions( + dec( + cfg({ + authMethod: 'agent', + pinnedHostKey: 'target-fp', + jump: { + host: 'bastion.example.com', + port: 3522, + username: 'shawn', + authMethod: 'agent', + pinnedHostKey: 'jump-fp' + } + }), + {} + ), + stubKey, + () => '/tmp/agent.sock' + ) + expect(o.target).toMatchObject({ host: 'gw.example.com', agent: '/tmp/agent.sock', pinnedHostKey: 'target-fp' }) + expect(o.jump).toMatchObject({ + host: 'bastion.example.com', + port: 3522, + username: 'shawn', + agent: '/tmp/agent.sock', + pinnedHostKey: 'jump-fp' + }) + }) + + it('a jump hop never carries stored password/passphrase (agent/key only)', () => { + const o = buildTunnelOptions( + dec( + cfg({ + authMethod: 'agent', + jump: { host: 'b', username: 'u', authMethod: 'privateKey', privateKeyPath: '/k/jump' } + }), + { sshPassword: 'pw', sshPassphrase: 'pp' } + ), + () => Buffer.from('JUMPKEY'), + () => '/tmp/agent.sock' + ) + expect(o.jump?.privateKey?.toString()).toBe('JUMPKEY') + expect(o.jump?.password).toBeUndefined() + expect(o.jump?.passphrase).toBeUndefined() // jump secrets are not stored }) }) @@ -149,7 +198,7 @@ describe('buildTunnelOptions — guards & defaults', () => { stubKey, () => '/s' ) - expect(o.sshPort).toBe(22) + expect(o.target.port).toBe(22) expect(o.destPort).toBe(27017) }) }) From e84947c919f2b589d5c97825074a58476dd84a33 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 17 Jun 2026 23:58:29 +0800 Subject: [PATCH 06/13] =?UTF-8?q?docs:=20TODO.md=20=E8=AE=B0=E5=BD=95=20SS?= =?UTF-8?q?H=20=E9=9A=A7=E9=81=93=E5=90=8E=E7=BB=AD=20backlog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #14 之后尚未做的:运行期掉线上报(需新 IPC 推送通道,价值最高)、 多跳跳板链、跳板机密码认证、私钥粘贴/文件选择器、连接超时+取消、 自动重连。 --- TODO.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 858fe83..1fb7e26 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO — 待办 backlog(live) -> 更新于 2026-06-16。本文件**只列尚未做的事**;已交付功能见 git 历史 / PR / `SPEC.md`。 +> 更新于 2026-06-17。本文件**只列尚未做的事**;已交付功能见 git 历史 / PR / `SPEC.md`。 > 标签:`[难度]` = 编码与设计复杂度;`[风险]` = 回归影响面(尤其是否动到有测试的 shell 核心 / 主进程)。 **现状**:`SPEC.md` §2 的功能范围已基本交付完(连接管理 / 浏览 / Shell / 补全 / 保存查询+文件夹 / 导入导出含原生 BSON / 三视图 / explain 可视化 / 文档·单元格编辑 / 多 tab / i18n / 集中设置 / 聚合管道构建器)。剩下的是**性能加固与健壮性**两个缺口,都不紧急。 @@ -18,6 +18,17 @@ 导出已流式有界,但导入仍是「整个文件 `readFileSync` + 全部文档一次进内存再分批 insert」(`bsonFileCore` 及各格式导入路径)。.bson / JSON 上 GB 时会顶内存。理想形态:边解析 buffer 边按 1000 批量插(批量 insert 逻辑已有,缺的是流式读)。与既有 JSON/CSV/XLSX 导入同形状,非回归,优先级低。 +## 3. SSH 隧道增强(PR #14 之后的后续) `[难度: 中–高] [风险: 中]` + +PR #14(`feat/ssh-agent-auth`,待合并)已实现:ssh-agent 认证 / 主机密钥 TOFU 校验 / 跳板机(**单跳** ProxyJump,ssh2 嵌套连接)/ `~` 展开与并发隧道句柄竞态修复 / SSH 表单校验 / 副本集+隧道告警 / 私钥友好报错。隧道核心已拆为 `main/ssh/tunnelCore.ts`(纯函数,有单测)+ `tunnel.ts`(ssh2 副作用)。以下为**尚未做**的: + +- **运行期 SSH 掉线上报** `[难度: 中] [风险: 中–高]` —— 隧道在 `ready` 之后掉线不翻状态,`getStatus` 仍报 connected(“假在线”),查询被 30s `serverSelectionTimeout` 拖死。根因:`tunnel.ts` 仅 open 期监听 client error、resolve 后无 close/error 处理;`sessionManager` 不挂运行期监听。**卡点是架构**——本仓目前纯拉取式 IPC,全仓 0 处 `webContents.send`,要把“隧道断→状态 error”推给渲染层需新增一条**单向推送通道**(动 `shared/ipc.ts` + `preload` + `registerIpc` + store 订阅),这条通道也是未来一切实时状态推送的地基。**价值最高的下一步。** +- **多跳跳板链** `[难度: 中] [风险: 低]` —— 现仅支持单跳 `SshConfig.jump`。多跳需把 jump 改为链(数组或递归)并在 `tunnel.ts` 顺序嵌套 `connectHop`/`forwardOut`。 +- **跳板机密码 / 口令认证** `[难度: 中] [风险: 中]` —— 当前跳板机仅 agent / 私钥文件(不存任何密钥)。要支持跳板机密码或带口令私钥,需扩 `connectionStore` 的密钥模型(加 `encJumpSshPassword`/`encJumpSshPassphrase`)+ ConnectionInput/sanitize/表单。 +- **私钥粘贴内容 / 文件选择器** `[难度: 低–中] [风险: 低]` —— 私钥现只支持磁盘路径手输。可加 textarea 粘贴内容(走 safeStorage 加密)+ 一个通用 `dialog:openFile` IPC 供“浏览”。 +- **连接总超时 + 取消** `[难度: 低] [风险: 低]` —— 隧道 20s + mongo 30s 串行最坏 ~50s,renderer 停在 'connecting' 无取消入口。加统一 deadline(`Promise.race` + AbortSignal)+ 取消按钮(参考 `shellEngine` 的 abort 模式)。 +- **隧道 / 驱动自动重连** `[难度: 中] [风险: 中]` —— 断线后有限次自动重建隧道 + 重连(依赖上面的掉线事件机制)。 + --- ## 已交付(归档,详情见 git / PR) From 35c0837be49fd3ae42972cdb655487127cd84048 Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 00:23:18 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20SSH=20=E9=9A=A7=E9=81=93=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E8=BF=9E=E9=80=9A=E6=80=A7=E9=A2=84=E6=A3=80=20+=20?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E5=88=86=E8=B7=B3=E6=A0=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连接前对入口跳板/SSH 主机做 TCP 可达性预检(tcpProbe,8s 快失败), 把"网络不通"与"认证/host-key 失败"清楚区分开,且不必等 ssh2 的 20s readyTimeout - classifyConnError 纯函数:按 socket code / ssh2 level 把错误分为 network / timeout / dns / auth / hostkey / other 并给可读提示 - 每跳报错标注是 jump host 还是 target(host:port),多跳失败一眼 看出是哪一跳;跳板→目标 forwardOut 失败也单独标注 - 新增 diagnose.test(tcpProbe)+ classifyConnError 单测 --- src/main/ssh/diagnose.ts | 39 +++++++++++++++++++++++++++ src/main/ssh/tunnel.ts | 44 +++++++++++++++++++++++++------ src/main/ssh/tunnelCore.ts | 37 ++++++++++++++++++++++++++ test/unit/main/diagnose.test.ts | 31 ++++++++++++++++++++++ test/unit/main/tunnelCore.test.ts | 22 ++++++++++++++++ 5 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 src/main/ssh/diagnose.ts create mode 100644 test/unit/main/diagnose.test.ts diff --git a/src/main/ssh/diagnose.ts b/src/main/ssh/diagnose.ts new file mode 100644 index 0000000..be877de --- /dev/null +++ b/src/main/ssh/diagnose.ts @@ -0,0 +1,39 @@ +/** + * Network connectivity probe for the SSH tunnel path. A plain TCP dial that + * fails fast (and clearly) when the entry hop is unreachable, so a *network* + * problem is distinguishable from an SSH *auth* / *host-key* problem — and from + * the 20s ssh2 readyTimeout that a filtered host would otherwise incur. + */ +import net from 'node:net' +import { classifyConnError } from './tunnelCore' + +export const PROBE_TIMEOUT_MS = 8000 + +/** + * Resolve if a TCP connection to host:port succeeds; reject with a clear, + * classified message ("Cannot reach host:port — …") otherwise. + */ +export function tcpProbe(host: string, port: number, timeoutMs: number = PROBE_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const socket = new net.Socket() + let settled = false + const fail = (message: string): void => { + if (settled) return + settled = true + socket.destroy() + reject(new Error(`Cannot reach ${host}:${port} — ${message}`)) + } + socket.setTimeout(timeoutMs) + socket.once('connect', () => { + if (settled) return + settled = true + socket.destroy() + resolve() + }) + socket.once('timeout', () => + fail('connection timed out — the host is unreachable or the port is filtered') + ) + socket.once('error', (err) => fail(classifyConnError(err).message)) + socket.connect(port, host) + }) +} diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index e3d4d67..8e6bc1f 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,7 +1,8 @@ import net from 'node:net' import type { Duplex } from 'node:stream' import { Client, type ConnectConfig } from 'ssh2' -import { evaluateHostKey, type SshHopOptions, type TunnelOptions } from './tunnelCore' +import { classifyConnError, evaluateHostKey, type SshHopOptions, type TunnelOptions } from './tunnelCore' +import { tcpProbe } from './diagnose' export type { TunnelOptions } @@ -30,21 +31,41 @@ export class SshTunnel { learnedJumpHostKey?: string async open(opts: TunnelOptions): Promise { + // Network pre-check: fail fast & clearly if the entry hop is unreachable, + // so a connectivity problem is obvious (vs. an SSH auth / host-key failure). + const entry = opts.jump ?? opts.target + await tcpProbe(entry.host, entry.port) + let transport: Duplex | undefined if (opts.jump) { - const jump = await this.connectHop(opts.jump) + const jump = await this.connectHop(opts.jump, undefined, 'jump host') this.learnedJumpHostKey = jump.learned // Reach the target's SSH port *through* the bastion. - transport = await forwardOut(jump.client, opts.target.host, opts.target.port) + try { + transport = await forwardOut(jump.client, opts.target.host, opts.target.port) + } catch (err) { + throw new Error( + `Cannot reach the target ${opts.target.host}:${opts.target.port} through the jump host — ${classifyConnError(err).message}` + ) + } } - const target = await this.connectHop(opts.target, transport) + const target = await this.connectHop(opts.target, transport, 'target') this.learnedHostKey = target.learned this.localPort = await this.listenForward(target.client, opts.destHost, opts.destPort) return this.localPort } - /** Open one SSH session, optionally tunnelled over an existing transport sock. */ - private connectHop(hop: SshHopOptions, sock?: Duplex): Promise<{ client: Client; learned?: string }> { + /** + * Open one SSH session, optionally tunnelled over an existing transport sock. + * `role` ("jump host" / "target") labels errors so a multi-hop failure points + * at the hop that broke. + */ + private connectHop( + hop: SshHopOptions, + sock: Duplex | undefined, + role: string + ): Promise<{ client: Client; learned?: string }> { + const where = `${role} ${hop.host}:${hop.port}` return new Promise((resolve, reject) => { const client = new Client() this.clients.push(client) @@ -71,7 +92,7 @@ export class SshTunnel { return true } hostKeyError = new Error( - `Host key verification failed for ${hop.host} — the SSH server's key changed (possible MITM). ` + + `Host key verification failed for the ${where} — the SSH server's key changed (possible MITM). ` + `Expected SHA256:${hop.pinnedHostKey}, got SHA256:${fingerprint}. ` + 'If the server was legitimately rebuilt, reset the trusted host key in the connection’s SSH settings.' ) @@ -81,7 +102,14 @@ export class SshTunnel { keepaliveInterval: 15000 } - client.on('error', (err) => reject(hostKeyError ?? err)) + client.on('error', (err) => { + if (hostKeyError) { + reject(hostKeyError) + return + } + // Label which hop failed and whether it was network / auth / etc. + reject(new Error(`SSH ${where} — ${classifyConnError(err).message}`)) + }) client.on('ready', () => resolve({ client, learned })) client.connect(cfg) }) diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index d8c2322..cd38739 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -54,6 +54,43 @@ export function evaluateHostKey(pinned: string | undefined, presented: string): return { ok: false } } +export type ConnErrorKind = 'network' | 'timeout' | 'dns' | 'auth' | 'hostkey' | 'other' + +/** + * Classify a connection/SSH error so a network problem is distinguishable from + * an auth or host-key problem. Pure: maps Node socket `code`s and ssh2 + * `level`/message shapes to a kind + a short actionable message. + */ +export function classifyConnError(err: unknown): { kind: ConnErrorKind; message: string } { + const e = (err ?? {}) as { code?: string; level?: string; message?: string } + const code = e.code + const msg = e.message ?? String(err) + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return { kind: 'dns', message: 'host name could not be resolved (DNS)' } + } + if (code === 'ECONNREFUSED') { + return { + kind: 'network', + message: 'connection refused — nothing is listening on that port, or a firewall rejected it' + } + } + if (code === 'ETIMEDOUT' || code === 'ETIME' || e.level === 'client-timeout') { + return { kind: 'timeout', message: 'connection timed out — the host is unreachable or the port is filtered' } + } + if (code === 'EHOSTUNREACH' || code === 'ENETUNREACH' || code === 'EHOSTDOWN') { + return { kind: 'network', message: 'host or network is unreachable' } + } + if (e.level === 'client-authentication' || /authentication methods failed/i.test(msg)) { + return { + kind: 'auth', + message: + 'SSH authentication was rejected — check the username, and that your key is loaded in ssh-agent (ssh-add -l) or the key file/passphrase is correct' + } + } + if (/host key/i.test(msg)) return { kind: 'hostkey', message: msg } + return { kind: 'other', message: msg } +} + /** * Expand a leading `~` / `~/` to the user's home directory. Node's `fs` does * NOT do this, so a privateKeyPath like `~/.ssh/id_ed25519` (which the form diff --git a/test/unit/main/diagnose.test.ts b/test/unit/main/diagnose.test.ts new file mode 100644 index 0000000..a459610 --- /dev/null +++ b/test/unit/main/diagnose.test.ts @@ -0,0 +1,31 @@ +/** + * TCP reachability probe used as the SSH tunnel's network pre-check. + */ +import net from 'node:net' +import { describe, it, expect } from 'vitest' +import { tcpProbe } from '../../../src/main/ssh/diagnose' + +function listen(): Promise<{ port: number; close: () => void }> { + return new Promise((resolve) => { + const server = net.createServer() + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as net.AddressInfo + resolve({ port: addr.port, close: () => server.close() }) + }) + }) +} + +describe('tcpProbe', () => { + it('resolves when the port is open', async () => { + const s = await listen() + await expect(tcpProbe('127.0.0.1', s.port, 2000)).resolves.toBeUndefined() + s.close() + }) + + it('rejects with a clear "Cannot reach" message when the port is closed', async () => { + const s = await listen() + const port = s.port + s.close() // release the port → connection refused + await expect(tcpProbe('127.0.0.1', port, 2000)).rejects.toThrow(/Cannot reach 127\.0\.0\.1:\d+ —/) + }) +}) diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index 1d3fd82..33d5be8 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest' import { buildTunnelOptions, + classifyConnError, evaluateHostKey, expandHome, readPrivateKey, @@ -173,6 +174,27 @@ describe('buildTunnelOptions — jump host (ProxyJump)', () => { }) }) +describe('classifyConnError', () => { + it('maps socket codes to a network kind', () => { + expect(classifyConnError({ code: 'ECONNREFUSED' }).kind).toBe('network') + expect(classifyConnError({ code: 'EHOSTUNREACH' }).kind).toBe('network') + expect(classifyConnError({ code: 'ENETUNREACH' }).kind).toBe('network') + }) + it('maps timeouts and DNS failures', () => { + expect(classifyConnError({ code: 'ETIMEDOUT' }).kind).toBe('timeout') + expect(classifyConnError({ level: 'client-timeout' }).kind).toBe('timeout') + expect(classifyConnError({ code: 'ENOTFOUND' }).kind).toBe('dns') + }) + it('classifies ssh2 auth failures by level or message', () => { + expect(classifyConnError({ level: 'client-authentication' }).kind).toBe('auth') + expect(classifyConnError(new Error('All configured authentication methods failed')).kind).toBe('auth') + }) + it('recognizes host-key errors and falls back to other', () => { + expect(classifyConnError(new Error('Host key verification failed for x')).kind).toBe('hostkey') + expect(classifyConnError(new Error('something odd')).kind).toBe('other') + }) +}) + describe('evaluateHostKey (TOFU)', () => { it('first use (no pin) accepts and learns the fingerprint', () => { expect(evaluateHostKey(undefined, 'fp1')).toEqual({ ok: true, learned: 'fp1' }) From be89559ec3dd2d7046c70f545ce91951d53976be Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 00:48:48 +0800 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20SSH=20=E7=A7=81=E9=92=A5=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=85=A8=E7=A8=8B=20GUI=20=E5=8F=AF=E7=94=A8=EF=BC=88?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=80=89=E6=8B=A9=E5=99=A8=20+=20=E8=B7=B3?= =?UTF-8?q?=E6=9D=BF=E6=9C=BA=20passphrase=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向终端用户:不再需要开终端 ssh-add,私钥+口令在界面内即可完成。 - 新增通用 dialog:openFile IPC(Electron 原生文件选择器,main 侧展开 ~,接 ipc.ts/preload/registerIpc/useAppStore);私钥路径旁加“浏览…” - 跳板机那一跳支持带口令私钥:新增第 4 个加密密钥 encJumpSshPassphrase (safeStorage),沿 StoredSecrets/sanitize/getDecrypted/saveConnection/ ConnectionInput/DecryptedConnection/inputToDecrypted 对称扩展;表单加 “跳板机私钥密码短语”框 - tunnelCore 给 jump 传 dec.jumpSshPassphrase(此前硬编码空对象) - 软化 auth 报错:从“去终端 ssh-add”改为“在设置里改用私钥文件” - 三套 i18n(browse/pickKey/jumpPassphrase,改写 jumpHint)+ 单测 - agent 降为可选快路(保留),私钥文件成为不依赖终端的稳路 --- src/main/ipc/registerIpc.ts | 27 +++++++- src/main/mongo/uri.ts | 1 + src/main/ssh/tunnelCore.ts | 6 +- src/main/store/connectionStore.ts | 13 ++-- src/preload/index.ts | 3 + .../src/components/sidebar/ConnectionForm.tsx | 64 +++++++++++++++---- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/zh-CN.json | 7 +- src/renderer/src/i18n/locales/zh-TW.json | 7 +- src/renderer/src/store/useAppStore.ts | 10 +++ src/shared/ipc.ts | 9 ++- src/shared/types.ts | 20 +++++- test/unit/main/tunnelCore.test.ts | 8 +-- 13 files changed, 147 insertions(+), 35 deletions(-) diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 98cad89..370239e 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { BrowserWindow, dialog, ipcMain } from 'electron' +import { homedir } from 'node:os' import { IPC } from '../../shared/ipc' import { buildMongoUri } from '../../shared/connectionUri' import type { @@ -10,6 +11,7 @@ import type { DocUpdateRequest, ExportRequest, ImportRequest, + OpenFileOptions, SavedQueryInput, ShellRequest } from '../../shared/types' @@ -38,12 +40,13 @@ function historySummary(kind: string, count?: number, elapsedMs?: number, errorN * the stored values so "Test" works after editing without re-typing secrets. */ function inputToDecrypted(input: ConnectionInput): DecryptedConnection { - const { password, sshPassword, sshPassphrase, ...rest } = input + const { password, sshPassword, sshPassphrase, jumpSshPassphrase, ...rest } = input const config: ConnectionConfig = { ...rest, hasPassword: !!password, hasSshPassword: !!sshPassword, hasSshPassphrase: !!sshPassphrase, + hasJumpSshPassphrase: !!jumpSshPassphrase, createdAt: 0, updatedAt: 0 } @@ -51,15 +54,17 @@ function inputToDecrypted(input: ConnectionInput): DecryptedConnection { let pw = password let sshPw = sshPassword let sshPp = sshPassphrase + let jumpPp = jumpSshPassphrase if (input.id) { const stored = connectionStore.getDecrypted(input.id) if (stored) { if (!pw) pw = stored.password if (!sshPw) sshPw = stored.sshPassword if (!sshPp) sshPp = stored.sshPassphrase + if (!jumpPp) jumpPp = stored.jumpSshPassphrase } } - return { config, password: pw, sshPassword: sshPw, sshPassphrase: sshPp } + return { config, password: pw, sshPassword: sshPw, sshPassphrase: sshPp, jumpSshPassphrase: jumpPp } } const PASSWORD_PLACEHOLDER = '' @@ -124,6 +129,22 @@ export function registerIpc(): void { importConnections(BrowserWindow.getFocusedWindow()) ) + // Native file picker (e.g. choosing an SSH private key). Returns the absolute + // path, or null if cancelled. `~` in defaultPath is expanded here (the OS + // dialog does not expand it). + ipcMain.handle(IPC.dialogOpenFile, async (_e, opts?: OpenFileOptions) => { + const defaultPath = opts?.defaultPath?.replace(/^~(?=$|[/\\])/, homedir()) + const o: Electron.OpenDialogOptions = { + properties: ['openFile'], + title: opts?.title, + defaultPath, + filters: opts?.filters + } + const win = BrowserWindow.getFocusedWindow() + const r = win ? await dialog.showOpenDialog(win, o) : await dialog.showOpenDialog(o) + return r.canceled || !r.filePaths[0] ? null : r.filePaths[0] + }) + // session ipcMain.handle(IPC.sessionConnect, (_e, id: string) => sessionManager.connect(id)) ipcMain.handle(IPC.sessionDisconnect, (_e, id: string) => sessionManager.disconnect(id)) diff --git a/src/main/mongo/uri.ts b/src/main/mongo/uri.ts index 7589cd3..37be570 100644 --- a/src/main/mongo/uri.ts +++ b/src/main/mongo/uri.ts @@ -6,6 +6,7 @@ export interface DecryptedConnection { password?: string sshPassword?: string sshPassphrase?: string + jumpSshPassphrase?: string } export interface ClientArgs { diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index cd38739..b98a396 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -84,7 +84,7 @@ export function classifyConnError(err: unknown): { kind: ConnErrorKind; message: return { kind: 'auth', message: - 'SSH authentication was rejected — check the username, and that your key is loaded in ssh-agent (ssh-add -l) or the key file/passphrase is correct' + 'SSH authentication was rejected — check the username, or switch the auth method to “Private Key” and pick your key file (with its passphrase) in the connection settings' } } if (/host key/i.test(msg)) return { kind: 'hostkey', message: msg } @@ -197,7 +197,9 @@ export function buildTunnelOptions( readKey, resolveAgent ) - const jump = config.ssh.jump ? buildHop(config.ssh.jump, {}, readKey, resolveAgent) : undefined + const jump = config.ssh.jump + ? buildHop(config.ssh.jump, { passphrase: dec.jumpSshPassphrase }, readKey, resolveAgent) + : undefined return { target, jump, destHost: config.host, destPort: config.port ?? 27017 } } diff --git a/src/main/store/connectionStore.ts b/src/main/store/connectionStore.ts index 8d0d61c..426f06b 100644 --- a/src/main/store/connectionStore.ts +++ b/src/main/store/connectionStore.ts @@ -8,11 +8,12 @@ interface StoredSecrets { encPassword?: string encSshPassword?: string encSshPassphrase?: string + encJumpSshPassphrase?: string } type StoredConnection = Omit< ConnectionConfig, - 'hasPassword' | 'hasSshPassword' | 'hasSshPassphrase' + 'hasPassword' | 'hasSshPassword' | 'hasSshPassphrase' | 'hasJumpSshPassphrase' > & StoredSecrets @@ -78,12 +79,13 @@ class ConnectionStore { } private sanitize(c: StoredConnection): ConnectionConfig { - const { encPassword, encSshPassword, encSshPassphrase, ...rest } = c + const { encPassword, encSshPassword, encSshPassphrase, encJumpSshPassphrase, ...rest } = c return { ...rest, hasPassword: !!encPassword, hasSshPassword: !!encSshPassword, - hasSshPassphrase: !!encSshPassphrase + hasSshPassphrase: !!encSshPassphrase, + hasJumpSshPassphrase: !!encJumpSshPassphrase } } @@ -113,6 +115,7 @@ class ConnectionStore { encPassword: this.nextSecret(existing?.encPassword, input.password), encSshPassword: this.nextSecret(existing?.encSshPassword, input.sshPassword), encSshPassphrase: this.nextSecret(existing?.encSshPassphrase, input.sshPassphrase), + encJumpSshPassphrase: this.nextSecret(existing?.encJumpSshPassphrase, input.jumpSshPassphrase), createdAt: existing?.createdAt ?? now, updatedAt: now } @@ -155,6 +158,7 @@ class ConnectionStore { password?: string sshPassword?: string sshPassphrase?: string + jumpSshPassphrase?: string } | null { const stored = this.data.connections.find((c) => c.id === id) if (!stored) return null @@ -162,7 +166,8 @@ class ConnectionStore { config: this.sanitize(stored), password: this.decrypt(stored.encPassword), sshPassword: this.decrypt(stored.encSshPassword), - sshPassphrase: this.decrypt(stored.encSshPassphrase) + sshPassphrase: this.decrypt(stored.encSshPassphrase), + jumpSshPassphrase: this.decrypt(stored.encJumpSshPassphrase) } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index dacc939..0ece95b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -56,6 +56,9 @@ const api: Api = { settings: { get: () => ipcRenderer.invoke(IPC.settingsGet), update: (patch) => ipcRenderer.invoke(IPC.settingsUpdate, patch) + }, + dialog: { + openFile: (opts) => ipcRenderer.invoke(IPC.dialogOpenFile, opts) } } diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index 60f4b8b..23a9a47 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -46,6 +46,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const saveConnection = useAppStore((s) => s.saveConnection) const testConnection = useAppStore((s) => s.testConnection) const buildConnectionUri = useAppStore((s) => s.buildConnectionUri) + const pickFile = useAppStore((s) => s.pickFile) const updateSettings = useAppStore((s) => s.updateSettings) // Remembered "To URL" password choice (persisted in settings.json). const rememberedIncludePassword = useAppStore((s) => s.settings.exportIncludeRealPassword) @@ -110,8 +111,16 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E editing?.ssh.jump?.authMethod ?? 'agent' ) const [jumpKeyPath, setJumpKeyPath] = useState(editing?.ssh.jump?.privateKeyPath ?? '') + const [jumpSshPassphrase, setJumpSshPassphrase] = useState('') + const [jumpSshPassphraseTouched, setJumpSshPassphraseTouched] = useState(false) const [clearJumpHostKey, setClearJumpHostKey] = useState(false) + // Browse for a private key file via the native picker, default to ~/.ssh. + const browseKey = async (setter: (v: string) => void): Promise => { + const p = await pickFile({ title: tFn('connection.ssh.pickKey'), defaultPath: '~/.ssh' }) + if (p) setter(p) + } + // Validate SSH fields up front so save fails fast (with a reason) rather than // letting empty/invalid values surface as an opaque error at connect time. const sshError = useMemo(() => { @@ -281,6 +290,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E if (passwordTouched) input.password = password if (sshPasswordTouched) input.sshPassword = sshPassword if (sshPassphraseTouched) input.sshPassphrase = sshPassphrase + if (jumpSshPassphraseTouched) input.jumpSshPassphrase = jumpSshPassphrase return input }, [ @@ -316,6 +326,8 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E jumpUser, jumpAuthMethod, jumpKeyPath, + jumpSshPassphrase, + jumpSshPassphraseTouched, clearJumpHostKey, tlsEnabled, allowInvalidCertificates, @@ -610,11 +622,17 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E {sshAuthMethod === 'privateKey' && ( <> - setPrivateKeyPath(e.target.value)} - placeholder="~/.ssh/id_ed25519" - /> +
+ setPrivateKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + +
{jumpAuthMethod === 'privateKey' && ( - - setJumpKeyPath(e.target.value)} - placeholder="~/.ssh/id_ed25519" - /> - + <> + +
+ setJumpKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + +
+
+ + { + setJumpSshPassphrase(e.target.value) + setJumpSshPassphraseTouched(true) + }} + /> + + )} {jumpAuthMethod === 'agent' && ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index afde6f9..ed826d5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -129,14 +129,17 @@ "errPort": "SSH port must be an integer between 1 and 65535", "errKey": "Private-key auth requires a key path", "jumpEnableLabel": "Connect through a jump host (bastion)", - "jumpHint": "For a target reachable only via a bastion/jump host (equivalent to ssh ProxyJump / ProxyCommand -W). The jump host supports agent or key-file auth only; load passphrase-protected keys into ssh-agent.", + "jumpHint": "For a target reachable only via a bastion/jump host (equivalent to ssh ProxyJump / ProxyCommand -W). The jump host supports agent or a private key file; if the key has a passphrase, enter it below.", "jumpHost": "Jump Host", "jumpPort": "Jump Port", "jumpUsername": "Jump Username", "errJumpHost": "Jump host is required", "errJumpUser": "Jump username is required", "errJumpPort": "Jump port must be an integer between 1 and 65535", - "errJumpKey": "Jump private-key auth requires a key path" + "errJumpKey": "Jump private-key auth requires a key path", + "browse": "Browse…", + "pickKey": "Select private key file", + "jumpPassphrase": "Jump Key Passphrase" }, "tls": { "enableLabel": "Enable TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 29bbf70..c398d19 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -129,14 +129,17 @@ "errPort": "SSH 端口需为 1–65535 之间的整数", "errKey": "私钥认证需填写私钥路径", "jumpEnableLabel": "通过跳板机连接(先连跳板机再到目标)", - "jumpHint": "适用于目标主机只能经堡垒机/跳板机访问的场景(等价于 ssh ProxyJump / ProxyCommand -W)。跳板机仅支持 agent 或私钥文件认证;带口令的私钥请先加入 ssh-agent。", + "jumpHint": "适用于目标主机只能经堡垒机/跳板机访问的场景(等价于 ssh ProxyJump / ProxyCommand -W)。跳板机支持 agent 或私钥文件;私钥带口令可直接在下方填写。", "jumpHost": "跳板机主机", "jumpPort": "跳板机端口", "jumpUsername": "跳板机用户名", "errJumpHost": "请填写跳板机主机", "errJumpUser": "请填写跳板机用户名", "errJumpPort": "跳板机端口需为 1–65535 之间的整数", - "errJumpKey": "跳板机私钥认证需填写私钥路径" + "errJumpKey": "跳板机私钥认证需填写私钥路径", + "browse": "浏览…", + "pickKey": "选择私钥文件", + "jumpPassphrase": "跳板机私钥密码短语" }, "tls": { "enableLabel": "启用 TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index 058e37d..1b0d64b 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -129,14 +129,17 @@ "errPort": "SSH 連接埠需為 1–65535 之間的整數", "errKey": "私密金鑰驗證需填寫金鑰路徑", "jumpEnableLabel": "透過跳板機連線(先連跳板機再到目標)", - "jumpHint": "適用於目標主機僅能經堡壘機/跳板機存取的情境(等同 ssh ProxyJump / ProxyCommand -W)。跳板機僅支援 agent 或私密金鑰檔驗證;含密碼的金鑰請先加入 ssh-agent。", + "jumpHint": "適用於目標主機僅能經堡壘機/跳板機存取的情境(等同 ssh ProxyJump / ProxyCommand -W)。跳板機支援 agent 或私密金鑰檔;金鑰若有密碼,可直接在下方填寫。", "jumpHost": "跳板機主機", "jumpPort": "跳板機連接埠", "jumpUsername": "跳板機使用者名稱", "errJumpHost": "請填寫跳板機主機", "errJumpUser": "請填寫跳板機使用者名稱", "errJumpPort": "跳板機連接埠需為 1–65535 之間的整數", - "errJumpKey": "跳板機私密金鑰驗證需填寫金鑰路徑" + "errJumpKey": "跳板機私密金鑰驗證需填寫金鑰路徑", + "browse": "瀏覽…", + "pickKey": "選擇私密金鑰檔", + "jumpPassphrase": "跳板機私密金鑰密碼短語" }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/src/renderer/src/store/useAppStore.ts b/src/renderer/src/store/useAppStore.ts index a883f6a..52417bc 100644 --- a/src/renderer/src/store/useAppStore.ts +++ b/src/renderer/src/store/useAppStore.ts @@ -161,6 +161,8 @@ interface AppState { testConnection(input: ConnectionInput): Promise /** Build a connection string from the current form fields ("To URL"). */ buildConnectionUri(input: ConnectionInput, opts: { includePassword: boolean }): Promise + /** Open a native file picker (e.g. SSH private key); resolves the path or null. */ + pickFile(opts?: { title?: string; defaultPath?: string }): Promise /** Back up all connections to a JSON file (secrets excluded). */ exportConnections(): Promise /** Restore connections from a JSON backup (adds; secrets must be re-entered). */ @@ -478,6 +480,14 @@ export const useAppStore = create((set, get) => ({ } }, + async pickFile(opts) { + try { + return await window.api.dialog.openFile(opts) + } catch { + return null + } + }, + async exportConnections() { try { const res = await window.api.connections.export() diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3abb43c..d2b2ecf 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,6 +10,7 @@ import type { ConnectionInput, ConnectionStatus, DatabaseInfo, + OpenFileOptions, DataOpResult, DocMutateRequest, DocMutateResult, @@ -64,7 +65,9 @@ export const IPC = { ioImport: 'io:import', settingsGet: 'settings:get', - settingsUpdate: 'settings:update' + settingsUpdate: 'settings:update', + + dialogOpenFile: 'dialog:openFile' } as const /** The API shape exposed on `window.api` (see preload). */ @@ -131,4 +134,8 @@ export interface Api { /** Merge a partial patch and return the full updated settings. */ update(patch: Partial): Promise } + dialog: { + /** Native open-file picker; resolves the chosen absolute path, or null if cancelled. */ + openFile(opts?: OpenFileOptions): Promise + } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 805d567..3affbad 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -26,8 +26,9 @@ export type SshAuthMethod = 'password' | 'privateKey' | 'agent' /** * A single SSH hop in front of the target (a bastion / jump host, i.e. ProxyJump). - * Authenticates via agent or a key file only — no password/passphrase is stored - * for a jump hop, so passphrase-protected keys must be loaded into the agent. + * Authenticates via agent or a private key file; a passphrase for that key is + * carried out-of-band as `ConnectionInput.jumpSshPassphrase` (encrypted at rest, + * like the other secrets) rather than on this config object. */ export interface SshHopConfig { host?: string @@ -97,6 +98,7 @@ export interface ConnectionConfig { hasPassword?: boolean hasSshPassword?: boolean hasSshPassphrase?: boolean + hasJumpSshPassphrase?: boolean createdAt: number updatedAt: number @@ -108,10 +110,22 @@ export interface ConnectionConfig { * keep the previously stored value; pass empty string to clear it. */ export interface ConnectionInput - extends Omit { + extends Omit< + ConnectionConfig, + 'hasPassword' | 'hasSshPassword' | 'hasSshPassphrase' | 'hasJumpSshPassphrase' | 'createdAt' | 'updatedAt' + > { password?: string sshPassword?: string sshPassphrase?: string + /** Passphrase for the jump host's private key. */ + jumpSshPassphrase?: string +} + +/** Options for the native "open file" picker exposed over IPC. */ +export interface OpenFileOptions { + title?: string + defaultPath?: string + filters?: { name: string; extensions: string[] }[] } // --------------------------------------------------------------------------- diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index 33d5be8..3541d7e 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -156,21 +156,21 @@ describe('buildTunnelOptions — jump host (ProxyJump)', () => { }) }) - it('a jump hop never carries stored password/passphrase (agent/key only)', () => { + it('a jump hop carries its own passphrase but never a password', () => { const o = buildTunnelOptions( dec( cfg({ authMethod: 'agent', jump: { host: 'b', username: 'u', authMethod: 'privateKey', privateKeyPath: '/k/jump' } }), - { sshPassword: 'pw', sshPassphrase: 'pp' } + { sshPassword: 'pw', sshPassphrase: 'pp', jumpSshPassphrase: 'jpp' } ), () => Buffer.from('JUMPKEY'), () => '/tmp/agent.sock' ) expect(o.jump?.privateKey?.toString()).toBe('JUMPKEY') - expect(o.jump?.password).toBeUndefined() - expect(o.jump?.passphrase).toBeUndefined() // jump secrets are not stored + expect(o.jump?.passphrase).toBe('jpp') // its own jump passphrase, not the target's + expect(o.jump?.password).toBeUndefined() // jump never uses a stored password }) }) From ad63b635bb9e52388f3350fb513042f2ec03b204 Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 01:08:40 +0800 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20SSH=20=E9=9A=A7=E9=81=93=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E7=9A=84=E8=BF=9E=E9=80=9A=E6=80=A7=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=EF=BC=88=E9=80=90=E6=AE=B5=20=E2=9C=93/=E2=9C=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把之前隐形的网络预检做成 GUI 可见功能:SSH 标签页加「检测连通性」 按钮,点击逐段显示 TCP→跳板机 / 登录跳板机 / 经跳板机到目标SSH端口 / 登录目标 / 到达MongoDB端口 的 ✓/✗ + 耗时 + 失败原因,哪一段断了一眼 可见(非技术用户也能自查)。 - tunnel.ts:diagnoseConnection 逐段执行、首个失败即停(其余 skip)、 收尾销毁所有 client;connectHop 抽成模块级可复用(SshTunnel 与 诊断共用) - tunnelCore.planDiagnoseStages:纯函数规划阶段(可测,有无跳板两种) - IPC connections:diagnose 全链路(ipc/preload/registerIpc/useAppStore) - ConnectionForm:SSH 区「检测连通性」按钮 + 结果面板;三套 i18n - 新增 planDiagnoseStages 单测 --- src/main/ipc/registerIpc.ts | 4 + src/main/ssh/tunnel.ts | 214 ++++++++++++------ src/main/ssh/tunnelCore.ts | 23 ++ src/preload/index.ts | 1 + .../src/components/sidebar/ConnectionForm.tsx | 37 +++ src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/zh-CN.json | 16 +- src/renderer/src/i18n/locales/zh-TW.json | 16 +- src/renderer/src/store/useAppStore.ts | 12 + src/shared/ipc.ts | 4 + src/shared/types.ts | 13 ++ test/unit/main/tunnelCore.test.ts | 27 +++ 12 files changed, 314 insertions(+), 69 deletions(-) diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 370239e..9667d6b 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -19,6 +19,7 @@ import { connectionStore } from '../store/connectionStore' import { queryStore } from '../store/queryStore' import { settingsStore } from '../store/settingsStore' import { sessionManager } from '../mongo/sessionManager' +import { diagnoseConnection } from '../ssh/tunnel' import type { DecryptedConnection } from '../mongo/uri' import { listCollections, listDatabases, listIndexes, listUsers, sampleFields } from '../mongo/catalog' import { executeShell, abortShell } from '../mongo/shellEngine' @@ -117,6 +118,9 @@ export function registerIpc(): void { ipcMain.handle(IPC.connectionsTest, (_e, input: ConnectionInput) => sessionManager.test(inputToDecrypted(input)) ) + ipcMain.handle(IPC.connectionsDiagnose, (_e, input: ConnectionInput) => + diagnoseConnection(inputToDecrypted(input)) + ) ipcMain.handle( IPC.connectionsBuildUri, (_e, input: ConnectionInput, opts: { includePassword: boolean }) => diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index 8e6bc1f..7860df3 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,7 +1,16 @@ import net from 'node:net' import type { Duplex } from 'node:stream' import { Client, type ConnectConfig } from 'ssh2' -import { classifyConnError, evaluateHostKey, type SshHopOptions, type TunnelOptions } from './tunnelCore' +import type { DiagnoseStage } from '../../shared/types' +import { + buildTunnelOptions, + classifyConnError, + evaluateHostKey, + planDiagnoseStages, + type SshHopOptions, + type TunnelOptions +} from './tunnelCore' +import type { DecryptedConnection } from '../mongo/uri' import { tcpProbe } from './diagnose' export type { TunnelOptions } @@ -38,7 +47,7 @@ export class SshTunnel { let transport: Duplex | undefined if (opts.jump) { - const jump = await this.connectHop(opts.jump, undefined, 'jump host') + const jump = await connectHop(opts.jump, undefined, 'jump host', this.clients) this.learnedJumpHostKey = jump.learned // Reach the target's SSH port *through* the bastion. try { @@ -49,72 +58,12 @@ export class SshTunnel { ) } } - const target = await this.connectHop(opts.target, transport, 'target') + const target = await connectHop(opts.target, transport, 'target', this.clients) this.learnedHostKey = target.learned this.localPort = await this.listenForward(target.client, opts.destHost, opts.destPort) return this.localPort } - /** - * Open one SSH session, optionally tunnelled over an existing transport sock. - * `role` ("jump host" / "target") labels errors so a multi-hop failure points - * at the hop that broke. - */ - private connectHop( - hop: SshHopOptions, - sock: Duplex | undefined, - role: string - ): Promise<{ client: Client; learned?: string }> { - const where = `${role} ${hop.host}:${hop.port}` - return new Promise((resolve, reject) => { - const client = new Client() - this.clients.push(client) - // Capture host-key rejections so we surface a clear message instead of - // ssh2's generic handshake error (the 'error' event fires right after). - let hostKeyError: Error | undefined - let learned: string | undefined - - const cfg: ConnectConfig = { - host: hop.host, - port: hop.port, - username: hop.username, - password: hop.password, - privateKey: hop.privateKey, - passphrase: hop.passphrase, - agent: hop.agent, - sock, - // hostHash makes ssh2 pass the host key pre-hashed as a hex string. - hostHash: 'sha256', - hostVerifier: (fingerprint: string): boolean => { - const verdict = evaluateHostKey(hop.pinnedHostKey, fingerprint) - if (verdict.ok) { - if (verdict.learned) learned = verdict.learned - return true - } - hostKeyError = new Error( - `Host key verification failed for the ${where} — the SSH server's key changed (possible MITM). ` + - `Expected SHA256:${hop.pinnedHostKey}, got SHA256:${fingerprint}. ` + - 'If the server was legitimately rebuilt, reset the trusted host key in the connection’s SSH settings.' - ) - return false - }, - readyTimeout: 20000, - keepaliveInterval: 15000 - } - - client.on('error', (err) => { - if (hostKeyError) { - reject(hostKeyError) - return - } - // Label which hop failed and whether it was network / auth / etc. - reject(new Error(`SSH ${where} — ${classifyConnError(err).message}`)) - }) - client.on('ready', () => resolve({ client, learned })) - client.connect(cfg) - }) - } - /** Stand up the local listener that forwards each accepted socket to MongoDB. */ private listenForward(client: Client, destHost: string, destPort: number): Promise { return new Promise((resolve, reject) => { @@ -165,3 +114,142 @@ function forwardOut(client: Client, host: string, port: number): Promise }) }) } + +/** + * Open one SSH session, optionally tunnelled over an existing transport sock. + * The created client is pushed to `track` immediately so callers can always + * tear it down. `role` labels errors so a multi-hop failure points at the hop + * that broke. Shared by {@link SshTunnel} and {@link diagnoseConnection}. + */ +function connectHop( + hop: SshHopOptions, + sock: Duplex | undefined, + role: string, + track: Client[] +): Promise<{ client: Client; learned?: string }> { + const where = `${role} ${hop.host}:${hop.port}` + return new Promise((resolve, reject) => { + const client = new Client() + track.push(client) + // Capture host-key rejections so we surface a clear message instead of + // ssh2's generic handshake error (the 'error' event fires right after). + let hostKeyError: Error | undefined + let learned: string | undefined + + const cfg: ConnectConfig = { + host: hop.host, + port: hop.port, + username: hop.username, + password: hop.password, + privateKey: hop.privateKey, + passphrase: hop.passphrase, + agent: hop.agent, + sock, + // hostHash makes ssh2 pass the host key pre-hashed as a hex string. + hostHash: 'sha256', + hostVerifier: (fingerprint: string): boolean => { + const verdict = evaluateHostKey(hop.pinnedHostKey, fingerprint) + if (verdict.ok) { + if (verdict.learned) learned = verdict.learned + return true + } + hostKeyError = new Error( + `Host key verification failed for the ${where} — the SSH server's key changed (possible MITM). ` + + `Expected SHA256:${hop.pinnedHostKey}, got SHA256:${fingerprint}. ` + + 'If the server was legitimately rebuilt, reset the trusted host key in the connection’s SSH settings.' + ) + return false + }, + readyTimeout: 20000, + keepaliveInterval: 15000 + } + + client.on('error', (err) => { + if (hostKeyError) { + reject(hostKeyError) + return + } + // Label which hop failed and whether it was network / auth / etc. + reject(new Error(`SSH ${where} — ${classifyConnError(err).message}`)) + }) + client.on('ready', () => resolve({ client, learned })) + client.connect(cfg) + }) +} + +/** + * Run a staged connectivity check over the (would-be) tunnel and report each + * step's status — so a user sees exactly which hop is reachable and where it + * breaks, all from the GUI. Stops at the first failure (later steps → 'skip'). + * Opens no local listener and tears down every SSH client it created. + */ +export async function diagnoseConnection(dec: DecryptedConnection): Promise { + let opts: TunnelOptions + try { + opts = buildTunnelOptions(dec) + } catch (err) { + return [{ key: 'config', target: '', status: 'fail', detail: (err as Error).message }] + } + + const clients: Client[] = [] + let jumpClient: Client | undefined + let targetClient: Client | undefined + + const runStage = async (key: string): Promise => { + switch (key) { + case 'tcp-jump': + return tcpProbe(opts.jump!.host, opts.jump!.port) + case 'ssh-jump': + jumpClient = (await connectHop(opts.jump!, undefined, 'jump host', clients)).client + return + case 'tcp-target': { + const ch = await forwardOut(jumpClient!, opts.target.host, opts.target.port) + ch.destroy() + return + } + case 'ssh-target': { + const sock = await forwardOut(jumpClient!, opts.target.host, opts.target.port) + targetClient = (await connectHop(opts.target, sock, 'target', clients)).client + return + } + case 'tcp-ssh': + return tcpProbe(opts.target.host, opts.target.port) + case 'ssh': + targetClient = (await connectHop(opts.target, undefined, 'target', clients)).client + return + case 'tcp-mongo': { + const ch = await forwardOut(targetClient!, opts.destHost, opts.destPort) + ch.destroy() + return + } + } + } + + const results: DiagnoseStage[] = [] + let stopped = false + try { + for (const { key, target } of planDiagnoseStages(opts)) { + if (stopped) { + results.push({ key, target, status: 'skip' }) + continue + } + const t0 = Date.now() + try { + await runStage(key) + results.push({ key, target, status: 'ok', ms: Date.now() - t0 }) + } catch (err) { + results.push({ key, target, status: 'fail', ms: Date.now() - t0, detail: classifyConnError(err).message }) + stopped = true + } + } + } finally { + for (const c of clients) { + try { + c.end() + } catch { + /* ignore */ + } + } + } + return results +} diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index b98a396..29f2b7d 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -203,3 +203,26 @@ export function buildTunnelOptions( return { target, jump, destHost: config.host, destPort: config.port ?? 27017 } } + +/** + * The ordered connectivity-check steps for a tunnel — pure, so the stage list is + * unit-testable. `key` maps to a localized label in the renderer; `target` is the + * host:port that step checks. The effectful runner (diagnoseConnection) executes + * each in turn, stopping at the first failure. + */ +export function planDiagnoseStages(opts: TunnelOptions): { key: string; target: string }[] { + const t = opts.target + const stages: { key: string; target: string }[] = [] + if (opts.jump) { + const j = opts.jump + stages.push({ key: 'tcp-jump', target: `${j.host}:${j.port}` }) + stages.push({ key: 'ssh-jump', target: `${j.host}:${j.port}` }) + stages.push({ key: 'tcp-target', target: `${t.host}:${t.port}` }) + stages.push({ key: 'ssh-target', target: `${t.host}:${t.port}` }) + } else { + stages.push({ key: 'tcp-ssh', target: `${t.host}:${t.port}` }) + stages.push({ key: 'ssh', target: `${t.host}:${t.port}` }) + } + stages.push({ key: 'tcp-mongo', target: `${opts.destHost}:${opts.destPort}` }) + return stages +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0ece95b..14a1b53 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ const api: Api = { save: (input) => ipcRenderer.invoke(IPC.connectionsSave, input), delete: (id) => ipcRenderer.invoke(IPC.connectionsDelete, id), test: (input) => ipcRenderer.invoke(IPC.connectionsTest, input), + diagnose: (input) => ipcRenderer.invoke(IPC.connectionsDiagnose, input), buildUri: (input, opts) => ipcRenderer.invoke(IPC.connectionsBuildUri, input, opts), export: () => ipcRenderer.invoke(IPC.connectionsExport), import: () => ipcRenderer.invoke(IPC.connectionsImport) diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index 23a9a47..8f808d4 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -4,6 +4,7 @@ import { ClipboardPaste, Link } from 'lucide-react' import type { ConnectionConfig, ConnectionInput, + DiagnoseStage, ScramMechanism, SshAuthMethod, TestResult @@ -47,6 +48,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const testConnection = useAppStore((s) => s.testConnection) const buildConnectionUri = useAppStore((s) => s.buildConnectionUri) const pickFile = useAppStore((s) => s.pickFile) + const diagnoseConnection = useAppStore((s) => s.diagnoseConnection) const updateSettings = useAppStore((s) => s.updateSettings) // Remembered "To URL" password choice (persisted in settings.json). const rememberedIncludePassword = useAppStore((s) => s.settings.exportIncludeRealPassword) @@ -114,6 +116,8 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [jumpSshPassphrase, setJumpSshPassphrase] = useState('') const [jumpSshPassphraseTouched, setJumpSshPassphraseTouched] = useState(false) const [clearJumpHostKey, setClearJumpHostKey] = useState(false) + const [diagnosing, setDiagnosing] = useState(false) + const [diagnosis, setDiagnosis] = useState(null) // Browse for a private key file via the native picker, default to ~/.ssh. const browseKey = async (setter: (v: string) => void): Promise => { @@ -351,6 +355,14 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E setTesting(false) } + const runDiagnose = async (): Promise => { + setDiagnosing(true) + setDiagnosis(null) + const stages = await diagnoseConnection(buildInput()) + setDiagnosis(stages) + setDiagnosing(false) + } + const secretPlaceholder = (has?: boolean): string => has ? tFn('connection.secret.placeholder') : '' @@ -766,6 +778,31 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E )} )} + + {/* Visible connectivity check: per-hop ✓/✗ so the user sees where it breaks. */} +
+ +
+ {diagnosis && ( +
+ {diagnosis.map((s) => { + const color = + s.status === 'ok' ? '#4ade80' : s.status === 'fail' ? '#f87171' : '#9ca3af' + const icon = s.status === 'ok' ? '✓' : s.status === 'fail' ? '✗' : '○' + return ( +
+ {icon} + {tFn(`connection.ssh.stage.${s.key}`)} + {s.target && {s.target}} + {s.ms != null && {s.ms}ms} + {s.detail &&
{s.detail}
} +
+ ) + })} +
+ )} )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ed826d5..ea276d2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -62,7 +62,8 @@ "unknown": "unknown", "clipboardUnavailable": "Copy failed: clipboard unavailable", "copied": "Copied to clipboard", - "buildUriFailed": "Failed to build connection string: {{error}}" + "buildUriFailed": "Failed to build connection string: {{error}}", + "diagnoseFailed": "Connectivity check failed: {{error}}" }, "connection": { "title": { "edit": "Edit Connection", "new": "New Connection" }, @@ -139,7 +140,18 @@ "errJumpKey": "Jump private-key auth requires a key path", "browse": "Browse…", "pickKey": "Select private key file", - "jumpPassphrase": "Jump Key Passphrase" + "jumpPassphrase": "Jump Key Passphrase", + "diagnose": "Check connectivity", + "stage": { + "tcp-jump": "TCP reachable: jump host", + "ssh-jump": "SSH login: jump host", + "tcp-target": "Reach target SSH port via jump", + "ssh-target": "SSH login: target", + "tcp-ssh": "TCP reachable: SSH host", + "ssh": "SSH login", + "tcp-mongo": "Reach MongoDB port", + "config": "Configuration error" + } }, "tls": { "enableLabel": "Enable TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index c398d19..0c33e2b 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -62,7 +62,8 @@ "unknown": "未知", "clipboardUnavailable": "复制失败:剪贴板不可用", "copied": "已复制到剪贴板", - "buildUriFailed": "生成连接串失败:{{error}}" + "buildUriFailed": "生成连接串失败:{{error}}", + "diagnoseFailed": "连通性检测失败:{{error}}" }, "connection": { "title": { "edit": "编辑连接", "new": "新建连接" }, @@ -139,7 +140,18 @@ "errJumpKey": "跳板机私钥认证需填写私钥路径", "browse": "浏览…", "pickKey": "选择私钥文件", - "jumpPassphrase": "跳板机私钥密码短语" + "jumpPassphrase": "跳板机私钥密码短语", + "diagnose": "检测连通性", + "stage": { + "tcp-jump": "TCP 可达跳板机", + "ssh-jump": "登录跳板机", + "tcp-target": "经跳板机到达目标 SSH 端口", + "ssh-target": "登录目标主机", + "tcp-ssh": "TCP 可达 SSH 主机", + "ssh": "登录 SSH 主机", + "tcp-mongo": "到达 MongoDB 端口", + "config": "配置错误" + } }, "tls": { "enableLabel": "启用 TLS/SSL", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index 1b0d64b..4dd2cae 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -62,7 +62,8 @@ "unknown": "未知", "clipboardUnavailable": "複製失敗:剪貼簿不可用", "copied": "已複製到剪貼簿", - "buildUriFailed": "產生連線字串失敗:{{error}}" + "buildUriFailed": "產生連線字串失敗:{{error}}", + "diagnoseFailed": "連通性檢測失敗:{{error}}" }, "connection": { "title": { "edit": "編輯連線", "new": "新增連線" }, @@ -139,7 +140,18 @@ "errJumpKey": "跳板機私密金鑰驗證需填寫金鑰路徑", "browse": "瀏覽…", "pickKey": "選擇私密金鑰檔", - "jumpPassphrase": "跳板機私密金鑰密碼短語" + "jumpPassphrase": "跳板機私密金鑰密碼短語", + "diagnose": "檢測連通性", + "stage": { + "tcp-jump": "TCP 可達跳板機", + "ssh-jump": "登入跳板機", + "tcp-target": "經跳板機到達目標 SSH 連接埠", + "ssh-target": "登入目標主機", + "tcp-ssh": "TCP 可達 SSH 主機", + "ssh": "登入 SSH 主機", + "tcp-mongo": "到達 MongoDB 連接埠", + "config": "設定錯誤" + } }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/src/renderer/src/store/useAppStore.ts b/src/renderer/src/store/useAppStore.ts index 52417bc..6f5a079 100644 --- a/src/renderer/src/store/useAppStore.ts +++ b/src/renderer/src/store/useAppStore.ts @@ -19,6 +19,7 @@ import type { ConnectionStatus, DatabaseInfo, DataOpResult, + DiagnoseStage, DocMutateRequest, DocMutateResult, DocSetFieldRequest, @@ -163,6 +164,8 @@ interface AppState { buildConnectionUri(input: ConnectionInput, opts: { includePassword: boolean }): Promise /** Open a native file picker (e.g. SSH private key); resolves the path or null. */ pickFile(opts?: { title?: string; defaultPath?: string }): Promise + /** Run a staged SSH-tunnel connectivity check for the current form fields. */ + diagnoseConnection(input: ConnectionInput): Promise /** Back up all connections to a JSON file (secrets excluded). */ exportConnections(): Promise /** Restore connections from a JSON backup (adds; secrets must be re-entered). */ @@ -488,6 +491,15 @@ export const useAppStore = create((set, get) => ({ } }, + async diagnoseConnection(input) { + try { + return await window.api.connections.diagnose(input) + } catch (e) { + set({ lastError: tr('notify.diagnoseFailed', { error: errMessage(e) }) }) + return [] + } + }, + async exportConnections() { try { const res = await window.api.connections.export() diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d2b2ecf..e3519da 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,6 +10,7 @@ import type { ConnectionInput, ConnectionStatus, DatabaseInfo, + DiagnoseStage, OpenFileOptions, DataOpResult, DocMutateRequest, @@ -33,6 +34,7 @@ export const IPC = { connectionsSave: 'connections:save', connectionsDelete: 'connections:delete', connectionsTest: 'connections:test', + connectionsDiagnose: 'connections:diagnose', connectionsBuildUri: 'connections:buildUri', connectionsExport: 'connections:export', connectionsImport: 'connections:import', @@ -77,6 +79,8 @@ export interface Api { save(input: ConnectionInput): Promise delete(id: string): Promise test(input: ConnectionInput): Promise + /** Staged SSH-tunnel connectivity check; reports each hop's reachability. */ + diagnose(input: ConnectionInput): Promise /** * Build a connection string from the CURRENT form fields ("To URL" export) — * works while creating or editing. With `includePassword`, the plaintext diff --git a/src/shared/types.ts b/src/shared/types.ts index 3affbad..02f9bd4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -128,6 +128,19 @@ export interface OpenFileOptions { filters?: { name: string; extensions: string[] }[] } +/** One step of an SSH-tunnel connectivity diagnosis. `key` maps to a localized label. */ +export interface DiagnoseStage { + /** Stable stage id: tcp-jump | ssh-jump | tcp-target | ssh-target | tcp-ssh | ssh | tcp-mongo | config */ + key: string + /** The host:port this step checks (empty for a config error). */ + target: string + status: 'ok' | 'fail' | 'skip' + /** Elapsed milliseconds (omitted for skipped steps). */ + ms?: number + /** Failure reason (classified, human-readable) when status is 'fail'. */ + detail?: string +} + // --------------------------------------------------------------------------- // Live session / catalog // --------------------------------------------------------------------------- diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index 3541d7e..45a5778 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -7,6 +7,7 @@ import { classifyConnError, evaluateHostKey, expandHome, + planDiagnoseStages, readPrivateKey, resolveSshAgentSock } from '../../../src/main/ssh/tunnelCore' @@ -195,6 +196,32 @@ describe('classifyConnError', () => { }) }) +describe('planDiagnoseStages', () => { + const opts = (jump?: object) => ({ + target: { host: 'cai', port: 22, username: 'root' }, + jump: jump as never, + destHost: '127.0.0.1', + destPort: 27017 + }) + it('plans tcp→ssh→mongo without a jump', () => { + const keys = planDiagnoseStages(opts()).map((s) => s.key) + expect(keys).toEqual(['tcp-ssh', 'ssh', 'tcp-mongo']) + }) + it('plans both hops when a jump is present, with correct targets', () => { + const stages = planDiagnoseStages(opts({ host: 'cao', port: 3522, username: 'shawn' })) + expect(stages.map((s) => s.key)).toEqual([ + 'tcp-jump', + 'ssh-jump', + 'tcp-target', + 'ssh-target', + 'tcp-mongo' + ]) + expect(stages.find((s) => s.key === 'tcp-jump')?.target).toBe('cao:3522') + expect(stages.find((s) => s.key === 'tcp-target')?.target).toBe('cai:22') + expect(stages.find((s) => s.key === 'tcp-mongo')?.target).toBe('127.0.0.1:27017') + }) +}) + describe('evaluateHostKey (TOFU)', () => { it('first use (no pin) accepts and learns the fingerprint', () => { expect(evaluateHostKey(undefined, 'fp1')).toEqual({ ok: true, learned: 'fp1' }) From 8885529425b30cda5e8a9303916721bc1b2c4f6b Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 16:11:23 +0800 Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20SSH=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E4=B8=BA=E5=AF=86=E7=A0=81=20+=20=E7=A7=81?= =?UTF-8?q?=E9=92=A5,=E7=A7=BB=E9=99=A4=20ssh-agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 ssh-agent 认证方式(主连接下拉 + 跳板机),跳板机仅私钥 - 私钥密码短语提示改为输入框 placeholder(已存密钥仍优先显示掩码) - 移除主机密钥指纹展示与重置勾选;TOFU 校验机制保留(静默校验, 首连学习、变更即拒),变更拒连报错改为提示删除并重建连接 --- TODO.md | 4 +- src/main/ssh/tunnel.ts | 3 +- src/main/ssh/tunnelCore.ts | 45 +---- .../src/components/sidebar/ConnectionForm.tsx | 155 +++++------------- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/zh-CN.json | 6 +- src/renderer/src/i18n/locales/zh-TW.json | 6 +- src/shared/types.ts | 7 +- test/unit/main/tunnelCore.test.ts | 65 +++----- 9 files changed, 77 insertions(+), 220 deletions(-) diff --git a/TODO.md b/TODO.md index 1fb7e26..530e893 100644 --- a/TODO.md +++ b/TODO.md @@ -20,11 +20,11 @@ ## 3. SSH 隧道增强(PR #14 之后的后续) `[难度: 中–高] [风险: 中]` -PR #14(`feat/ssh-agent-auth`,待合并)已实现:ssh-agent 认证 / 主机密钥 TOFU 校验 / 跳板机(**单跳** ProxyJump,ssh2 嵌套连接)/ `~` 展开与并发隧道句柄竞态修复 / SSH 表单校验 / 副本集+隧道告警 / 私钥友好报错。隧道核心已拆为 `main/ssh/tunnelCore.ts`(纯函数,有单测)+ `tunnel.ts`(ssh2 副作用)。以下为**尚未做**的: +PR #14(`feat/ssh-agent-auth`,待合并)已实现:主机密钥 TOFU 校验(静默,无 UI,仅首连学习、变更即拒)/ 跳板机(**单跳** ProxyJump,ssh2 嵌套连接,私钥认证)/ `~` 展开与并发隧道句柄竞态修复 / SSH 表单校验 + 连通性预检 / 副本集+隧道告警 / 私钥友好报错。**SSH 认证仅密码 + 私钥两种(ssh-agent 已移除)。** 隧道核心已拆为 `main/ssh/tunnelCore.ts`(纯函数,有单测)+ `tunnel.ts`(ssh2 副作用)。以下为**尚未做**的: - **运行期 SSH 掉线上报** `[难度: 中] [风险: 中–高]` —— 隧道在 `ready` 之后掉线不翻状态,`getStatus` 仍报 connected(“假在线”),查询被 30s `serverSelectionTimeout` 拖死。根因:`tunnel.ts` 仅 open 期监听 client error、resolve 后无 close/error 处理;`sessionManager` 不挂运行期监听。**卡点是架构**——本仓目前纯拉取式 IPC,全仓 0 处 `webContents.send`,要把“隧道断→状态 error”推给渲染层需新增一条**单向推送通道**(动 `shared/ipc.ts` + `preload` + `registerIpc` + store 订阅),这条通道也是未来一切实时状态推送的地基。**价值最高的下一步。** - **多跳跳板链** `[难度: 中] [风险: 低]` —— 现仅支持单跳 `SshConfig.jump`。多跳需把 jump 改为链(数组或递归)并在 `tunnel.ts` 顺序嵌套 `connectHop`/`forwardOut`。 -- **跳板机密码 / 口令认证** `[难度: 中] [风险: 中]` —— 当前跳板机仅 agent / 私钥文件(不存任何密钥)。要支持跳板机密码或带口令私钥,需扩 `connectionStore` 的密钥模型(加 `encJumpSshPassword`/`encJumpSshPassphrase`)+ ConnectionInput/sanitize/表单。 +- **跳板机密码认证** `[难度: 中] [风险: 中]` —— 当前跳板机仅私钥文件认证(带口令私钥已支持,走 `encJumpSshPassphrase`)。要支持跳板机密码,需扩 `connectionStore` 的密钥模型(加 `encJumpSshPassword`)+ ConnectionInput/sanitize/表单。 - **私钥粘贴内容 / 文件选择器** `[难度: 低–中] [风险: 低]` —— 私钥现只支持磁盘路径手输。可加 textarea 粘贴内容(走 safeStorage 加密)+ 一个通用 `dialog:openFile` IPC 供“浏览”。 - **连接总超时 + 取消** `[难度: 低] [风险: 低]` —— 隧道 20s + mongo 30s 串行最坏 ~50s,renderer 停在 'connecting' 无取消入口。加统一 deadline(`Promise.race` + AbortSignal)+ 取消按钮(参考 `shellEngine` 的 abort 模式)。 - **隧道 / 驱动自动重连** `[难度: 中] [风险: 中]` —— 断线后有限次自动重建隧道 + 重连(依赖上面的掉线事件机制)。 diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index 7860df3..91e9c11 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -143,7 +143,6 @@ function connectHop( password: hop.password, privateKey: hop.privateKey, passphrase: hop.passphrase, - agent: hop.agent, sock, // hostHash makes ssh2 pass the host key pre-hashed as a hex string. hostHash: 'sha256', @@ -156,7 +155,7 @@ function connectHop( hostKeyError = new Error( `Host key verification failed for the ${where} — the SSH server's key changed (possible MITM). ` + `Expected SHA256:${hop.pinnedHostKey}, got SHA256:${fingerprint}. ` + - 'If the server was legitimately rebuilt, reset the trusted host key in the connection’s SSH settings.' + 'If the server was legitimately rebuilt, delete and re-create this connection to trust the new key.' ) return false }, diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index 29f2b7d..050967d 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -20,8 +20,6 @@ export interface SshHopOptions { password?: string privateKey?: Buffer passphrase?: string - /** ssh-agent socket (SSH_AUTH_SOCK) or Windows OpenSSH pipe — for agent auth. */ - agent?: string /** Previously-pinned SHA256 host-key fingerprint (TOFU); undefined on first use. */ pinnedHostKey?: string } @@ -116,23 +114,6 @@ export function readPrivateKey(path: string): Buffer { } } -/** Windows 10/11 built-in OpenSSH agent named pipe (ssh2 accepts it as `agent`). */ -const WIN_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' - -/** - * Locate the local ssh-agent endpoint. Prefers `SSH_AUTH_SOCK` (set on - * macOS/Linux and by most Windows agents that bother); falls back to the - * Windows OpenSSH named pipe. Returns undefined when no agent can be located. - */ -export function resolveSshAgentSock( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform -): string | undefined { - if (env.SSH_AUTH_SOCK) return env.SSH_AUTH_SOCK - if (platform === 'win32') return WIN_OPENSSH_AGENT_PIPE - return undefined -} - /** The hop-config fields shared by the target `SshConfig` and a jump hop. */ interface HopConfigLike { host?: string @@ -147,8 +128,7 @@ interface HopConfigLike { function buildHop( hop: HopConfigLike, secrets: { password?: string; passphrase?: string }, - readKey: (path: string) => Buffer, - resolveAgent: () => string | undefined + readKey: (path: string) => Buffer ): SshHopOptions { const base: SshHopOptions = { host: hop.host || '', @@ -157,15 +137,6 @@ function buildHop( pinnedHostKey: hop.pinnedHostKey } switch (hop.authMethod ?? 'password') { - case 'agent': { - const agentSock = resolveAgent() - if (!agentSock) { - throw new Error( - 'SSH agent is unavailable: no SSH_AUTH_SOCK found. Start your ssh-agent and `ssh-add` your key, or use a private key file.' - ) - } - return { ...base, agent: agentSock } - } case 'privateKey': if (!hop.privateKeyPath) { throw new Error('SSH private-key auth requires a private key path.') @@ -179,26 +150,20 @@ function buildHop( /** * Build {@link TunnelOptions} from a decrypted connection. Throws (rather than * silently misconfiguring) when an auth method lacks what it needs. An optional - * jump hop authenticates via agent or a key file only — no stored secrets. + * jump hop authenticates via a private key file only — no stored password. */ export function buildTunnelOptions( dec: DecryptedConnection, - readKey: (path: string) => Buffer = readPrivateKey, - resolveAgent: () => string | undefined = resolveSshAgentSock + readKey: (path: string) => Buffer = readPrivateKey ): TunnelOptions { const { config } = dec if (config.useSrv) { throw new Error('SSH tunnel with SRV/Atlas is not supported — use a direct host:port.') } - const target = buildHop( - config.ssh, - { password: dec.sshPassword, passphrase: dec.sshPassphrase }, - readKey, - resolveAgent - ) + const target = buildHop(config.ssh, { password: dec.sshPassword, passphrase: dec.sshPassphrase }, readKey) const jump = config.ssh.jump - ? buildHop(config.ssh.jump, { passphrase: dec.jumpSshPassphrase }, readKey, resolveAgent) + ? buildHop(config.ssh.jump, { passphrase: dec.jumpSshPassphrase }, readKey) : undefined return { target, jump, destHost: config.host, destPort: config.port ?? 27017 } diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index 8f808d4..aae3b46 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -102,20 +102,15 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [sshPasswordTouched, setSshPasswordTouched] = useState(false) const [sshPassphrase, setSshPassphrase] = useState('') const [sshPassphraseTouched, setSshPassphraseTouched] = useState(false) - const [clearHostKey, setClearHostKey] = useState(false) - // ---- SSH jump host (bastion / ProxyJump) — agent or key file only ---- + // ---- SSH jump host (bastion / ProxyJump) — private key file only ---- const [jumpEnabled, setJumpEnabled] = useState(!!editing?.ssh.jump) const [jumpHost, setJumpHost] = useState(editing?.ssh.jump?.host ?? '') const [jumpPort, setJumpPort] = useState(String(editing?.ssh.jump?.port ?? 22)) const [jumpUser, setJumpUser] = useState(editing?.ssh.jump?.username ?? '') - const [jumpAuthMethod, setJumpAuthMethod] = useState( - editing?.ssh.jump?.authMethod ?? 'agent' - ) const [jumpKeyPath, setJumpKeyPath] = useState(editing?.ssh.jump?.privateKeyPath ?? '') const [jumpSshPassphrase, setJumpSshPassphrase] = useState('') const [jumpSshPassphraseTouched, setJumpSshPassphraseTouched] = useState(false) - const [clearJumpHostKey, setClearJumpHostKey] = useState(false) const [diagnosing, setDiagnosing] = useState(false) const [diagnosis, setDiagnosis] = useState(null) @@ -141,7 +136,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E if (!jumpHost.trim()) return tFn('connection.ssh.errJumpHost') if (!jumpUser.trim()) return tFn('connection.ssh.errJumpUser') if (!portOk(jumpPort)) return tFn('connection.ssh.errJumpPort') - if (jumpAuthMethod === 'privateKey' && !jumpKeyPath.trim()) return tFn('connection.ssh.errJumpKey') + if (!jumpKeyPath.trim()) return tFn('connection.ssh.errJumpKey') } return undefined }, [ @@ -155,7 +150,6 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E jumpHost, jumpUser, jumpPort, - jumpAuthMethod, jumpKeyPath, tFn ]) @@ -269,17 +263,16 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E authMethod: sshAuthMethod, privateKeyPath: sshAuthMethod === 'privateKey' ? privateKeyPath.trim() || undefined : undefined, - // Preserve the TOFU-pinned host key across edits; clear it only on request. - pinnedHostKey: clearHostKey ? undefined : editing?.ssh.pinnedHostKey, + // Preserve the TOFU-pinned host key across edits (verified silently at connect). + pinnedHostKey: editing?.ssh.pinnedHostKey, jump: jumpEnabled ? { host: jumpHost.trim() || undefined, port: Number(jumpPort) || 22, username: jumpUser.trim() || undefined, - authMethod: jumpAuthMethod, - privateKeyPath: - jumpAuthMethod === 'privateKey' ? jumpKeyPath.trim() || undefined : undefined, - pinnedHostKey: clearJumpHostKey ? undefined : editing?.ssh.jump?.pinnedHostKey + authMethod: 'privateKey', + privateKeyPath: jumpKeyPath.trim() || undefined, + pinnedHostKey: editing?.ssh.jump?.pinnedHostKey } : undefined }, @@ -323,16 +316,13 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E sshPasswordTouched, sshPassphrase, sshPassphraseTouched, - clearHostKey, jumpEnabled, jumpHost, jumpPort, jumpUser, - jumpAuthMethod, jumpKeyPath, jumpSshPassphrase, jumpSshPassphraseTouched, - clearJumpHostKey, tlsEnabled, allowInvalidCertificates, caFile, @@ -610,8 +600,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E onChange={setSshAuthMethod} options={[ { label: tFn('connection.ssh.methodPassword'), value: 'password' }, - { label: tFn('connection.ssh.methodPrivateKey'), value: 'privateKey' }, - { label: tFn('connection.ssh.methodAgent'), value: 'agent' } + { label: tFn('connection.ssh.methodPrivateKey'), value: 'privateKey' } ]} />
@@ -646,14 +635,14 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E - + { setSshPassphrase(e.target.value) setSshPassphraseTouched(true) @@ -663,27 +652,6 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E )} - {sshAuthMethod === 'agent' && ( -
{tFn('connection.ssh.agentHint')}
- )} - - {editing?.ssh.pinnedHostKey && ( - <> -
- {tFn('connection.ssh.hostKeyTrusted', { - fp: editing.ssh.pinnedHostKey.slice(0, 24) - })} -
-
- -
- - )} - {/* Jump host (bastion / ProxyJump): reach the target through it. */}
-
- - setJumpUser(e.target.value)} /> - - - - value={jumpAuthMethod} - onChange={setJumpAuthMethod} - options={[ - { label: tFn('connection.ssh.methodAgent'), value: 'agent' }, - { label: tFn('connection.ssh.methodPrivateKey'), value: 'privateKey' } - ]} - /> - -
+ + setJumpUser(e.target.value)} /> + - {jumpAuthMethod === 'privateKey' && ( - <> - -
- setJumpKeyPath(e.target.value)} - placeholder="~/.ssh/id_ed25519" - /> - -
-
- - { - setJumpSshPassphrase(e.target.value) - setJumpSshPassphraseTouched(true) - }} - /> - - - )} - - {jumpAuthMethod === 'agent' && ( -
{tFn('connection.ssh.agentHint')}
- )} - - {editing?.ssh.jump?.pinnedHostKey && ( - <> -
- {tFn('connection.ssh.hostKeyTrusted', { - fp: editing.ssh.jump.pinnedHostKey.slice(0, 24) - })} -
-
- -
- - )} + +
+ setJumpKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + +
+
+ + { + setJumpSshPassphrase(e.target.value) + setJumpSshPassphraseTouched(true) + }} + /> + )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ea276d2..06a0421 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -117,20 +117,16 @@ "authMethod": "Auth Method", "methodPassword": "Password", "methodPrivateKey": "Private Key", - "methodAgent": "SSH Agent", "password": "SSH Password", "privateKeyPath": "Private Key Path", "passphrase": "Key Passphrase", "passphraseHint": "Leave blank if the key has no passphrase.", - "agentHint": "Authenticate with a key already loaded in your local ssh-agent (via SSH_AUTH_SOCK) — no key file or password needed here. Make sure ssh-agent is running and you've run ssh-add.", - "hostKeyTrusted": "Trusted this server's host key fingerprint (SHA256:{{fp}}…); later connections verify it.", - "resetHostKey": "Reset the trusted host key (re-trust on next connect)", "errHost": "SSH host is required", "errUser": "SSH username is required", "errPort": "SSH port must be an integer between 1 and 65535", "errKey": "Private-key auth requires a key path", "jumpEnableLabel": "Connect through a jump host (bastion)", - "jumpHint": "For a target reachable only via a bastion/jump host (equivalent to ssh ProxyJump / ProxyCommand -W). The jump host supports agent or a private key file; if the key has a passphrase, enter it below.", + "jumpHint": "For a target reachable only via a bastion/jump host (equivalent to ssh ProxyJump / ProxyCommand -W). The jump host uses a private key file; if the key has a passphrase, enter it below.", "jumpHost": "Jump Host", "jumpPort": "Jump Port", "jumpUsername": "Jump Username", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 0c33e2b..61e39f8 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -117,20 +117,16 @@ "authMethod": "认证方式", "methodPassword": "密码", "methodPrivateKey": "私钥", - "methodAgent": "SSH Agent", "password": "SSH 密码", "privateKeyPath": "私钥路径", "passphrase": "私钥密码短语", "passphraseHint": "如果私钥没有密码短语,请留空。", - "agentHint": "使用本机 ssh-agent 中已加载的私钥认证(读取 SSH_AUTH_SOCK),无需在此填写私钥或密码。请确保 ssh-agent 正在运行并已通过 ssh-add 添加密钥。", - "hostKeyTrusted": "已信任此服务器的主机密钥指纹(SHA256:{{fp}}…),后续连接将校验它。", - "resetHostKey": "重置已信任的主机密钥(下次连接重新信任)", "errHost": "请填写 SSH 主机", "errUser": "请填写 SSH 用户名", "errPort": "SSH 端口需为 1–65535 之间的整数", "errKey": "私钥认证需填写私钥路径", "jumpEnableLabel": "通过跳板机连接(先连跳板机再到目标)", - "jumpHint": "适用于目标主机只能经堡垒机/跳板机访问的场景(等价于 ssh ProxyJump / ProxyCommand -W)。跳板机支持 agent 或私钥文件;私钥带口令可直接在下方填写。", + "jumpHint": "适用于目标主机只能经堡垒机/跳板机访问的场景(等价于 ssh ProxyJump / ProxyCommand -W)。跳板机使用私钥文件;私钥带口令可直接在下方填写。", "jumpHost": "跳板机主机", "jumpPort": "跳板机端口", "jumpUsername": "跳板机用户名", diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index 4dd2cae..3fe4f66 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -117,20 +117,16 @@ "authMethod": "驗證方式", "methodPassword": "密碼", "methodPrivateKey": "私密金鑰", - "methodAgent": "SSH Agent", "password": "SSH 密碼", "privateKeyPath": "私密金鑰路徑", "passphrase": "私密金鑰密碼短語", "passphraseHint": "若私密金鑰無密碼短語,請留空。", - "agentHint": "使用本機 ssh-agent 中已載入的私密金鑰進行驗證(讀取 SSH_AUTH_SOCK),無需在此填寫私密金鑰或密碼。請確認 ssh-agent 正在執行並已透過 ssh-add 新增金鑰。", - "hostKeyTrusted": "已信任此伺服器的主機金鑰指紋(SHA256:{{fp}}…),後續連線將驗證它。", - "resetHostKey": "重設已信任的主機金鑰(下次連線重新信任)", "errHost": "請填寫 SSH 主機", "errUser": "請填寫 SSH 使用者名稱", "errPort": "SSH 連接埠需為 1–65535 之間的整數", "errKey": "私密金鑰驗證需填寫金鑰路徑", "jumpEnableLabel": "透過跳板機連線(先連跳板機再到目標)", - "jumpHint": "適用於目標主機僅能經堡壘機/跳板機存取的情境(等同 ssh ProxyJump / ProxyCommand -W)。跳板機支援 agent 或私密金鑰檔;金鑰若有密碼,可直接在下方填寫。", + "jumpHint": "適用於目標主機僅能經堡壘機/跳板機存取的情境(等同 ssh ProxyJump / ProxyCommand -W)。跳板機使用私密金鑰檔;金鑰若有密碼,可直接在下方填寫。", "jumpHost": "跳板機主機", "jumpPort": "跳板機連接埠", "jumpUsername": "跳板機使用者名稱", diff --git a/src/shared/types.ts b/src/shared/types.ts index 02f9bd4..3e44cd1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -22,11 +22,11 @@ export interface AuthConfig { mechanism?: ScramMechanism } -export type SshAuthMethod = 'password' | 'privateKey' | 'agent' +export type SshAuthMethod = 'password' | 'privateKey' /** * A single SSH hop in front of the target (a bastion / jump host, i.e. ProxyJump). - * Authenticates via agent or a private key file; a passphrase for that key is + * Authenticates via a private key file only; a passphrase for that key is * carried out-of-band as `ConnectionInput.jumpSshPassphrase` (encrypted at rest, * like the other secrets) rather than on this config object. */ @@ -34,7 +34,7 @@ export interface SshHopConfig { host?: string port?: number // default 22 username?: string - /** 'password' is intentionally unsupported for a jump hop (no stored secret). */ + /** A jump hop uses a private key file only (no stored password). */ authMethod?: SshAuthMethod privateKeyPath?: string /** Pinned SHA256 host-key fingerprint (hex), learned via TOFU. Not a secret. */ @@ -46,7 +46,6 @@ export interface SshConfig { host?: string port?: number // default 22 username?: string - /** 'agent' authenticates via the local ssh-agent (SSH_AUTH_SOCK) — no key/password stored. */ authMethod?: SshAuthMethod /** Path to a private key file on disk (we read it at connect time). */ privateKeyPath?: string diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index 45a5778..b749f65 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -1,5 +1,5 @@ /** - * SSH tunnel option assembly — auth-method decision + agent-socket resolution. + * SSH tunnel option assembly — auth-method decision + host-key TOFU + diagnosis plan. */ import { describe, it, expect } from 'vitest' import { @@ -8,8 +8,7 @@ import { evaluateHostKey, expandHome, planDiagnoseStages, - readPrivateKey, - resolveSshAgentSock + readPrivateKey } from '../../../src/main/ssh/tunnelCore' import type { DecryptedConnection } from '../../../src/main/mongo/uri' import type { ConnectionConfig, SshConfig } from '../../../src/shared/types' @@ -57,20 +56,8 @@ describe('readPrivateKey', () => { }) }) -describe('resolveSshAgentSock', () => { - it('prefers SSH_AUTH_SOCK when set', () => { - expect(resolveSshAgentSock({ SSH_AUTH_SOCK: '/tmp/agent.sock' }, 'darwin')).toBe('/tmp/agent.sock') - }) - it('falls back to the Windows OpenSSH named pipe on win32', () => { - expect(resolveSshAgentSock({}, 'win32')).toBe('\\\\.\\pipe\\openssh-ssh-agent') - }) - it('returns undefined on posix without SSH_AUTH_SOCK', () => { - expect(resolveSshAgentSock({}, 'linux')).toBeUndefined() - }) -}) - describe('buildTunnelOptions — target auth methods', () => { - it('password: carries sshPassword, no key/agent', () => { + it('password: carries sshPassword, no key', () => { const o = buildTunnelOptions(dec(cfg({ authMethod: 'password' }), { sshPassword: 'pw' }), stubKey) expect(o.target).toMatchObject({ host: 'gw.example.com', @@ -82,7 +69,6 @@ describe('buildTunnelOptions — target auth methods', () => { expect(o.destPort).toBe(27017) expect(o.jump).toBeUndefined() expect(o.target.privateKey).toBeUndefined() - expect(o.target.agent).toBeUndefined() }) it('defaults to password when authMethod is unset', () => { @@ -101,73 +87,63 @@ describe('buildTunnelOptions — target auth methods', () => { expect(o.target.privateKey?.toString()).toBe('PRIV') expect(o.target.passphrase).toBe('pp') expect(o.target.password).toBeUndefined() - expect(o.target.agent).toBeUndefined() }) it('privateKey without a path throws', () => { expect(() => buildTunnelOptions(dec(cfg({ authMethod: 'privateKey' })), stubKey)).toThrow(/private key/i) }) - it('agent: carries the resolved socket, no key/password', () => { - const o = buildTunnelOptions(dec(cfg({ authMethod: 'agent' })), stubKey, () => '/tmp/agent.sock') - expect(o.target.agent).toBe('/tmp/agent.sock') - expect(o.target.privateKey).toBeUndefined() - expect(o.target.password).toBeUndefined() - }) - - it('agent without a resolvable socket throws', () => { - expect(() => buildTunnelOptions(dec(cfg({ authMethod: 'agent' })), stubKey, () => undefined)).toThrow( - /agent/i - ) - }) - it('threads the pinned host key through', () => { - const o = buildTunnelOptions(dec(cfg({ authMethod: 'agent', pinnedHostKey: 'abc123' })), stubKey, () => '/s') + const o = buildTunnelOptions( + dec(cfg({ authMethod: 'privateKey', privateKeyPath: '/k', pinnedHostKey: 'abc123' })), + stubKey + ) expect(o.target.pinnedHostKey).toBe('abc123') }) }) describe('buildTunnelOptions — jump host (ProxyJump)', () => { - it('builds a jump hop alongside the target, with its own auth + pin', () => { + it('builds a jump hop alongside the target, with its own key + pin', () => { const o = buildTunnelOptions( dec( cfg({ - authMethod: 'agent', + authMethod: 'privateKey', + privateKeyPath: '/k/target', pinnedHostKey: 'target-fp', jump: { host: 'bastion.example.com', port: 3522, username: 'shawn', - authMethod: 'agent', + authMethod: 'privateKey', + privateKeyPath: '/k/jump', pinnedHostKey: 'jump-fp' } }), {} ), - stubKey, - () => '/tmp/agent.sock' + stubKey ) - expect(o.target).toMatchObject({ host: 'gw.example.com', agent: '/tmp/agent.sock', pinnedHostKey: 'target-fp' }) + expect(o.target).toMatchObject({ host: 'gw.example.com', pinnedHostKey: 'target-fp' }) + expect(o.target.privateKey?.toString()).toBe('PRIV') expect(o.jump).toMatchObject({ host: 'bastion.example.com', port: 3522, username: 'shawn', - agent: '/tmp/agent.sock', pinnedHostKey: 'jump-fp' }) + expect(o.jump?.privateKey?.toString()).toBe('PRIV') }) it('a jump hop carries its own passphrase but never a password', () => { const o = buildTunnelOptions( dec( cfg({ - authMethod: 'agent', + authMethod: 'password', jump: { host: 'b', username: 'u', authMethod: 'privateKey', privateKeyPath: '/k/jump' } }), { sshPassword: 'pw', sshPassphrase: 'pp', jumpSshPassphrase: 'jpp' } ), - () => Buffer.from('JUMPKEY'), - () => '/tmp/agent.sock' + () => Buffer.from('JUMPKEY') ) expect(o.jump?.privateKey?.toString()).toBe('JUMPKEY') expect(o.jump?.passphrase).toBe('jpp') // its own jump passphrase, not the target's @@ -243,9 +219,8 @@ describe('buildTunnelOptions — guards & defaults', () => { it('defaults ssh port to 22 and dest port to 27017', () => { const o = buildTunnelOptions( - dec(cfg({ authMethod: 'agent', port: undefined }, { port: undefined })), - stubKey, - () => '/s' + dec(cfg({ authMethod: 'password', port: undefined }, { port: undefined })), + stubKey ) expect(o.target.port).toBe(22) expect(o.destPort).toBe(27017) From 6c049af84f15935b5e739ebd703106811f9deec3 Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 16:54:57 +0800 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=E8=BF=9E=E9=80=9A=E6=80=A7?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=8B=86=E4=B8=BA=E9=80=90=E8=B7=B3=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=A3=80=E6=B5=8B,=E7=BB=93=E6=9E=9C=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=8A=98=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check connectivity 拆成「SSH 主机」「跳板机」两个按钮,各测自己那一跳 (去掉到 MongoDB 端口那步,避免与 Test connection 重合);SSH 主机检测 在配了跳板机时经跳板机测目标,否则直连 - 结果默认只显示总结 ✓/✗ + chevron,click 展开逐步日志(失败自动展开) - 移除跳板机区块冗余说明文字(与上方 SSH 段不对称) - diagnose IPC 增加 scope 参数(ssh|jump),贯穿 ipc/preload/registerIpc/store --- src/main/ipc/registerIpc.ts | 5 +- src/main/ssh/tunnel.ts | 49 ++++--- src/main/ssh/tunnelCore.ts | 48 ++++-- src/preload/index.ts | 2 +- .../src/components/sidebar/ConnectionForm.tsx | 138 +++++++++++++----- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/zh-CN.json | 4 +- src/renderer/src/i18n/locales/zh-TW.json | 4 +- src/renderer/src/store/useAppStore.ts | 9 +- src/shared/ipc.ts | 5 +- src/shared/types.ts | 5 +- test/unit/main/tunnelCore.test.ts | 30 ++-- 12 files changed, 198 insertions(+), 105 deletions(-) diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 9667d6b..1758ac5 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -6,6 +6,7 @@ import type { AppSettings, ConnectionConfig, ConnectionInput, + DiagnoseScope, DocMutateRequest, DocSetFieldRequest, DocUpdateRequest, @@ -118,8 +119,8 @@ export function registerIpc(): void { ipcMain.handle(IPC.connectionsTest, (_e, input: ConnectionInput) => sessionManager.test(inputToDecrypted(input)) ) - ipcMain.handle(IPC.connectionsDiagnose, (_e, input: ConnectionInput) => - diagnoseConnection(inputToDecrypted(input)) + ipcMain.handle(IPC.connectionsDiagnose, (_e, input: ConnectionInput, scope: DiagnoseScope) => + diagnoseConnection(inputToDecrypted(input), scope) ) ipcMain.handle( IPC.connectionsBuildUri, diff --git a/src/main/ssh/tunnel.ts b/src/main/ssh/tunnel.ts index 91e9c11..cf25b7b 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,7 +1,7 @@ import net from 'node:net' import type { Duplex } from 'node:stream' import { Client, type ConnectConfig } from 'ssh2' -import type { DiagnoseStage } from '../../shared/types' +import type { DiagnoseScope, DiagnoseStage } from '../../shared/types' import { buildTunnelOptions, classifyConnError, @@ -177,12 +177,15 @@ function connectHop( } /** - * Run a staged connectivity check over the (would-be) tunnel and report each - * step's status — so a user sees exactly which hop is reachable and where it - * breaks, all from the GUI. Stops at the first failure (later steps → 'skip'). - * Opens no local listener and tears down every SSH client it created. + * Run a single hop's connectivity check (see {@link planDiagnoseStages} for the + * `scope` semantics) and report each step's status — so a user sees exactly + * where it breaks, from the GUI. Stops at the first failure (later steps → + * 'skip'). Opens no local listener and tears down every SSH client it created. */ -export async function diagnoseConnection(dec: DecryptedConnection): Promise { +export async function diagnoseConnection( + dec: DecryptedConnection, + scope: DiagnoseScope +): Promise { let opts: TunnelOptions try { opts = buildTunnelOptions(dec) @@ -192,42 +195,44 @@ export async function diagnoseConnection(dec: DecryptedConnection): Promise => { + if (!jumpClient) jumpClient = (await connectHop(opts.jump!, undefined, 'jump host', clients)).client + return jumpClient + } const runStage = async (key: string): Promise => { switch (key) { case 'tcp-jump': return tcpProbe(opts.jump!.host, opts.jump!.port) case 'ssh-jump': - jumpClient = (await connectHop(opts.jump!, undefined, 'jump host', clients)).client + await connectHop(opts.jump!, undefined, 'jump host', clients) return - case 'tcp-target': { - const ch = await forwardOut(jumpClient!, opts.target.host, opts.target.port) - ch.destroy() - return - } - case 'ssh-target': { - const sock = await forwardOut(jumpClient!, opts.target.host, opts.target.port) - targetClient = (await connectHop(opts.target, sock, 'target', clients)).client - return - } case 'tcp-ssh': return tcpProbe(opts.target.host, opts.target.port) case 'ssh': - targetClient = (await connectHop(opts.target, undefined, 'target', clients)).client + await connectHop(opts.target, undefined, 'target', clients) return - case 'tcp-mongo': { - const ch = await forwardOut(targetClient!, opts.destHost, opts.destPort) + case 'tcp-target': { + const ch = await forwardOut(await ensureJump(), opts.target.host, opts.target.port) ch.destroy() return } + case 'ssh-target': { + const sock = await forwardOut(await ensureJump(), opts.target.host, opts.target.port) + await connectHop(opts.target, sock, 'target', clients) + return + } } } const results: DiagnoseStage[] = [] let stopped = false try { - for (const { key, target } of planDiagnoseStages(opts)) { + for (const { key, target } of planDiagnoseStages(opts, scope)) { if (stopped) { results.push({ key, target, status: 'skip' }) continue diff --git a/src/main/ssh/tunnelCore.ts b/src/main/ssh/tunnelCore.ts index 050967d..863b189 100644 --- a/src/main/ssh/tunnelCore.ts +++ b/src/main/ssh/tunnelCore.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import type { SshAuthMethod } from '../../shared/types' +import type { DiagnoseScope, SshAuthMethod } from '../../shared/types' import type { DecryptedConnection } from '../mongo/uri' /** Auth + identity for a single SSH hop (a bastion or the terminal host). */ @@ -170,24 +170,40 @@ export function buildTunnelOptions( } /** - * The ordered connectivity-check steps for a tunnel — pure, so the stage list is + * The ordered connectivity-check steps for one hop — pure, so the stage list is * unit-testable. `key` maps to a localized label in the renderer; `target` is the - * host:port that step checks. The effectful runner (diagnoseConnection) executes - * each in turn, stopping at the first failure. + * host:port that step checks. Each check is scoped to a single hop so it stays + * distinct from "Test connection" (which exercises the whole path to MongoDB): + * + * - `jump`: TCP + SSH login to the bastion (direct). + * - `ssh`: TCP + SSH login to the target — *through* the jump if one is set, + * else direct. Never probes the MongoDB port. + * + * The effectful runner (diagnoseConnection) executes each in turn, stopping at + * the first failure. */ -export function planDiagnoseStages(opts: TunnelOptions): { key: string; target: string }[] { +export function planDiagnoseStages( + opts: TunnelOptions, + scope: DiagnoseScope +): { key: string; target: string }[] { const t = opts.target - const stages: { key: string; target: string }[] = [] - if (opts.jump) { + if (scope === 'jump') { + if (!opts.jump) return [] const j = opts.jump - stages.push({ key: 'tcp-jump', target: `${j.host}:${j.port}` }) - stages.push({ key: 'ssh-jump', target: `${j.host}:${j.port}` }) - stages.push({ key: 'tcp-target', target: `${t.host}:${t.port}` }) - stages.push({ key: 'ssh-target', target: `${t.host}:${t.port}` }) - } else { - stages.push({ key: 'tcp-ssh', target: `${t.host}:${t.port}` }) - stages.push({ key: 'ssh', target: `${t.host}:${t.port}` }) + return [ + { key: 'tcp-jump', target: `${j.host}:${j.port}` }, + { key: 'ssh-jump', target: `${j.host}:${j.port}` } + ] + } + // scope === 'ssh' — the target hop only (through the jump when configured). + if (opts.jump) { + return [ + { key: 'tcp-target', target: `${t.host}:${t.port}` }, + { key: 'ssh-target', target: `${t.host}:${t.port}` } + ] } - stages.push({ key: 'tcp-mongo', target: `${opts.destHost}:${opts.destPort}` }) - return stages + return [ + { key: 'tcp-ssh', target: `${t.host}:${t.port}` }, + { key: 'ssh', target: `${t.host}:${t.port}` } + ] } diff --git a/src/preload/index.ts b/src/preload/index.ts index 14a1b53..345fba1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,7 +12,7 @@ const api: Api = { save: (input) => ipcRenderer.invoke(IPC.connectionsSave, input), delete: (id) => ipcRenderer.invoke(IPC.connectionsDelete, id), test: (input) => ipcRenderer.invoke(IPC.connectionsTest, input), - diagnose: (input) => ipcRenderer.invoke(IPC.connectionsDiagnose, input), + diagnose: (input, scope) => ipcRenderer.invoke(IPC.connectionsDiagnose, input, scope), buildUri: (input, opts) => ipcRenderer.invoke(IPC.connectionsBuildUri, input, opts), export: () => ipcRenderer.invoke(IPC.connectionsExport), import: () => ipcRenderer.invoke(IPC.connectionsImport) diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index aae3b46..fe4eec8 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ClipboardPaste, Link } from 'lucide-react' +import { ChevronDown, ChevronUp, ClipboardPaste, Link } from 'lucide-react' import type { ConnectionConfig, ConnectionInput, @@ -31,6 +31,84 @@ interface ConnectionFormProps { onClose: () => void } +/** Per-step ✓/✗ list for one hop's connectivity check (shared by SSH + jump). */ +function DiagnoseResult({ stages }: { stages: DiagnoseStage[] }): JSX.Element { + const { t } = useTranslation() + return ( +
+ {stages.map((s) => { + const color = s.status === 'ok' ? '#4ade80' : s.status === 'fail' ? '#f87171' : '#9ca3af' + const icon = s.status === 'ok' ? '✓' : s.status === 'fail' ? '✗' : '○' + return ( +
+ {icon} + {t(`connection.ssh.stage.${s.key}`)} + {s.target && {s.target}} + {s.ms != null && {s.ms}ms} + {s.detail &&
{s.detail}
} +
+ ) + })} +
+ ) +} + +/** + * "Check connectivity" trigger + an overall ✓/✗ and a chevron that toggles the + * per-step log — so a passing check stays quiet (just ✓) instead of dumping the + * whole list. A fresh result auto-opens on failure and collapses when all-ok. + */ +function DiagnoseControl({ + busy, + stages, + onRun +}: { + busy: boolean + stages: DiagnoseStage[] | null + onRun: () => void +}): JSX.Element { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + useEffect(() => { + if (stages) setOpen(stages.some((s) => s.status === 'fail')) + }, [stages]) + const allOk = stages != null && stages.every((s) => s.status === 'ok') + return ( + <> +
+ + {stages != null && !busy && ( + <> + + {allOk ? '✓' : '✗'} + + + + )} +
+ {open && stages != null && } + + ) +} + /** * Create / edit a connection. Secret fields (password, sshPassword, * sshPassphrase) come back BLANK on edit (the sanitized config only carries @@ -111,8 +189,10 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [jumpKeyPath, setJumpKeyPath] = useState(editing?.ssh.jump?.privateKeyPath ?? '') const [jumpSshPassphrase, setJumpSshPassphrase] = useState('') const [jumpSshPassphraseTouched, setJumpSshPassphraseTouched] = useState(false) - const [diagnosing, setDiagnosing] = useState(false) - const [diagnosis, setDiagnosis] = useState(null) + const [sshDiagBusy, setSshDiagBusy] = useState(false) + const [sshDiag, setSshDiag] = useState(null) + const [jumpDiagBusy, setJumpDiagBusy] = useState(false) + const [jumpDiag, setJumpDiag] = useState(null) // Browse for a private key file via the native picker, default to ~/.ssh. const browseKey = async (setter: (v: string) => void): Promise => { @@ -345,12 +425,18 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E setTesting(false) } - const runDiagnose = async (): Promise => { - setDiagnosing(true) - setDiagnosis(null) - const stages = await diagnoseConnection(buildInput()) - setDiagnosis(stages) - setDiagnosing(false) + const runSshDiag = async (): Promise => { + setSshDiagBusy(true) + setSshDiag(null) + setSshDiag(await diagnoseConnection(buildInput(), 'ssh')) + setSshDiagBusy(false) + } + + const runJumpDiag = async (): Promise => { + setJumpDiagBusy(true) + setJumpDiag(null) + setJumpDiag(await diagnoseConnection(buildInput(), 'jump')) + setJumpDiagBusy(false) } const secretPlaceholder = (has?: boolean): string => @@ -652,6 +738,9 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E )} + {/* Connectivity check for the SSH/target host (through the jump if one is set). */} + void runSshDiag()} /> + {/* Jump host (bastion / ProxyJump): reach the target through it. */}
-
{tFn('connection.ssh.jumpHint')}
setJumpHost(e.target.value)} /> @@ -707,32 +795,10 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E }} /> - - )} - {/* Visible connectivity check: per-hop ✓/✗ so the user sees where it breaks. */} -
- -
- {diagnosis && ( -
- {diagnosis.map((s) => { - const color = - s.status === 'ok' ? '#4ade80' : s.status === 'fail' ? '#f87171' : '#9ca3af' - const icon = s.status === 'ok' ? '✓' : s.status === 'fail' ? '✗' : '○' - return ( -
- {icon} - {tFn(`connection.ssh.stage.${s.key}`)} - {s.target && {s.target}} - {s.ms != null && {s.ms}ms} - {s.detail &&
{s.detail}
} -
- ) - })} -
+ {/* Connectivity check for the jump host alone. */} + void runJumpDiag()} /> + )} )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 06a0421..eb2828d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -126,7 +126,6 @@ "errPort": "SSH port must be an integer between 1 and 65535", "errKey": "Private-key auth requires a key path", "jumpEnableLabel": "Connect through a jump host (bastion)", - "jumpHint": "For a target reachable only via a bastion/jump host (equivalent to ssh ProxyJump / ProxyCommand -W). The jump host uses a private key file; if the key has a passphrase, enter it below.", "jumpHost": "Jump Host", "jumpPort": "Jump Port", "jumpUsername": "Jump Username", @@ -138,6 +137,8 @@ "pickKey": "Select private key file", "jumpPassphrase": "Jump Key Passphrase", "diagnose": "Check connectivity", + "diagShow": "Show details", + "diagHide": "Hide details", "stage": { "tcp-jump": "TCP reachable: jump host", "ssh-jump": "SSH login: jump host", @@ -145,7 +146,6 @@ "ssh-target": "SSH login: target", "tcp-ssh": "TCP reachable: SSH host", "ssh": "SSH login", - "tcp-mongo": "Reach MongoDB port", "config": "Configuration error" } }, diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 61e39f8..234e58f 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -126,7 +126,6 @@ "errPort": "SSH 端口需为 1–65535 之间的整数", "errKey": "私钥认证需填写私钥路径", "jumpEnableLabel": "通过跳板机连接(先连跳板机再到目标)", - "jumpHint": "适用于目标主机只能经堡垒机/跳板机访问的场景(等价于 ssh ProxyJump / ProxyCommand -W)。跳板机使用私钥文件;私钥带口令可直接在下方填写。", "jumpHost": "跳板机主机", "jumpPort": "跳板机端口", "jumpUsername": "跳板机用户名", @@ -138,6 +137,8 @@ "pickKey": "选择私钥文件", "jumpPassphrase": "跳板机私钥密码短语", "diagnose": "检测连通性", + "diagShow": "展开详情", + "diagHide": "收起详情", "stage": { "tcp-jump": "TCP 可达跳板机", "ssh-jump": "登录跳板机", @@ -145,7 +146,6 @@ "ssh-target": "登录目标主机", "tcp-ssh": "TCP 可达 SSH 主机", "ssh": "登录 SSH 主机", - "tcp-mongo": "到达 MongoDB 端口", "config": "配置错误" } }, diff --git a/src/renderer/src/i18n/locales/zh-TW.json b/src/renderer/src/i18n/locales/zh-TW.json index 3fe4f66..1ca8550 100644 --- a/src/renderer/src/i18n/locales/zh-TW.json +++ b/src/renderer/src/i18n/locales/zh-TW.json @@ -126,7 +126,6 @@ "errPort": "SSH 連接埠需為 1–65535 之間的整數", "errKey": "私密金鑰驗證需填寫金鑰路徑", "jumpEnableLabel": "透過跳板機連線(先連跳板機再到目標)", - "jumpHint": "適用於目標主機僅能經堡壘機/跳板機存取的情境(等同 ssh ProxyJump / ProxyCommand -W)。跳板機使用私密金鑰檔;金鑰若有密碼,可直接在下方填寫。", "jumpHost": "跳板機主機", "jumpPort": "跳板機連接埠", "jumpUsername": "跳板機使用者名稱", @@ -138,6 +137,8 @@ "pickKey": "選擇私密金鑰檔", "jumpPassphrase": "跳板機私密金鑰密碼短語", "diagnose": "檢測連通性", + "diagShow": "展開詳情", + "diagHide": "收起詳情", "stage": { "tcp-jump": "TCP 可達跳板機", "ssh-jump": "登入跳板機", @@ -145,7 +146,6 @@ "ssh-target": "登入目標主機", "tcp-ssh": "TCP 可達 SSH 主機", "ssh": "登入 SSH 主機", - "tcp-mongo": "到達 MongoDB 連接埠", "config": "設定錯誤" } }, diff --git a/src/renderer/src/store/useAppStore.ts b/src/renderer/src/store/useAppStore.ts index 6f5a079..6e518d5 100644 --- a/src/renderer/src/store/useAppStore.ts +++ b/src/renderer/src/store/useAppStore.ts @@ -19,6 +19,7 @@ import type { ConnectionStatus, DatabaseInfo, DataOpResult, + DiagnoseScope, DiagnoseStage, DocMutateRequest, DocMutateResult, @@ -164,8 +165,8 @@ interface AppState { buildConnectionUri(input: ConnectionInput, opts: { includePassword: boolean }): Promise /** Open a native file picker (e.g. SSH private key); resolves the path or null. */ pickFile(opts?: { title?: string; defaultPath?: string }): Promise - /** Run a staged SSH-tunnel connectivity check for the current form fields. */ - diagnoseConnection(input: ConnectionInput): Promise + /** Run a single-hop SSH connectivity check (target or jump) for the form fields. */ + diagnoseConnection(input: ConnectionInput, scope: DiagnoseScope): Promise /** Back up all connections to a JSON file (secrets excluded). */ exportConnections(): Promise /** Restore connections from a JSON backup (adds; secrets must be re-entered). */ @@ -491,9 +492,9 @@ export const useAppStore = create((set, get) => ({ } }, - async diagnoseConnection(input) { + async diagnoseConnection(input, scope) { try { - return await window.api.connections.diagnose(input) + return await window.api.connections.diagnose(input, scope) } catch (e) { set({ lastError: tr('notify.diagnoseFailed', { error: errMessage(e) }) }) return [] diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e3519da..8012e6e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,6 +10,7 @@ import type { ConnectionInput, ConnectionStatus, DatabaseInfo, + DiagnoseScope, DiagnoseStage, OpenFileOptions, DataOpResult, @@ -79,8 +80,8 @@ export interface Api { save(input: ConnectionInput): Promise delete(id: string): Promise test(input: ConnectionInput): Promise - /** Staged SSH-tunnel connectivity check; reports each hop's reachability. */ - diagnose(input: ConnectionInput): Promise + /** Single-hop SSH connectivity check (`scope`: the target host or the jump host). */ + diagnose(input: ConnectionInput, scope: DiagnoseScope): Promise /** * Build a connection string from the CURRENT form fields ("To URL" export) — * works while creating or editing. With `includePassword`, the plaintext diff --git a/src/shared/types.ts b/src/shared/types.ts index 3e44cd1..5de12d6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -127,9 +127,12 @@ export interface OpenFileOptions { filters?: { name: string; extensions: string[] }[] } +/** Which hop a connectivity check targets: the SSH/target host, or the jump host. */ +export type DiagnoseScope = 'ssh' | 'jump' + /** One step of an SSH-tunnel connectivity diagnosis. `key` maps to a localized label. */ export interface DiagnoseStage { - /** Stable stage id: tcp-jump | ssh-jump | tcp-target | ssh-target | tcp-ssh | ssh | tcp-mongo | config */ + /** Stable stage id: tcp-jump | ssh-jump | tcp-target | ssh-target | tcp-ssh | ssh | config */ key: string /** The host:port this step checks (empty for a config error). */ target: string diff --git a/test/unit/main/tunnelCore.test.ts b/test/unit/main/tunnelCore.test.ts index b749f65..8a68723 100644 --- a/test/unit/main/tunnelCore.test.ts +++ b/test/unit/main/tunnelCore.test.ts @@ -179,22 +179,22 @@ describe('planDiagnoseStages', () => { destHost: '127.0.0.1', destPort: 27017 }) - it('plans tcp→ssh→mongo without a jump', () => { - const keys = planDiagnoseStages(opts()).map((s) => s.key) - expect(keys).toEqual(['tcp-ssh', 'ssh', 'tcp-mongo']) - }) - it('plans both hops when a jump is present, with correct targets', () => { - const stages = planDiagnoseStages(opts({ host: 'cao', port: 3522, username: 'shawn' })) - expect(stages.map((s) => s.key)).toEqual([ - 'tcp-jump', - 'ssh-jump', - 'tcp-target', - 'ssh-target', - 'tcp-mongo' - ]) - expect(stages.find((s) => s.key === 'tcp-jump')?.target).toBe('cao:3522') + it('ssh scope without a jump: direct tcp + ssh to the target (no mongo step)', () => { + const keys = planDiagnoseStages(opts(), 'ssh').map((s) => s.key) + expect(keys).toEqual(['tcp-ssh', 'ssh']) + }) + it('ssh scope with a jump: reaches the target through the jump', () => { + const stages = planDiagnoseStages(opts({ host: 'cao', port: 3522, username: 'shawn' }), 'ssh') + expect(stages.map((s) => s.key)).toEqual(['tcp-target', 'ssh-target']) expect(stages.find((s) => s.key === 'tcp-target')?.target).toBe('cai:22') - expect(stages.find((s) => s.key === 'tcp-mongo')?.target).toBe('127.0.0.1:27017') + }) + it('jump scope: only the bastion hop', () => { + const stages = planDiagnoseStages(opts({ host: 'cao', port: 3522, username: 'shawn' }), 'jump') + expect(stages.map((s) => s.key)).toEqual(['tcp-jump', 'ssh-jump']) + expect(stages.find((s) => s.key === 'tcp-jump')?.target).toBe('cao:3522') + }) + it('jump scope with no jump configured: empty', () => { + expect(planDiagnoseStages(opts(), 'jump')).toEqual([]) }) }) From 91ceeac3565b1437d0b2aa8ee1e817afadaff695 Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 16:58:36 +0800 Subject: [PATCH 12/13] =?UTF-8?q?style:=20=E8=BF=9E=E9=80=9A=E6=80=A7?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=B1=95=E5=BC=80=E5=9B=BE=E6=A0=87=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20message-square-text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/components/sidebar/ConnectionForm.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/sidebar/ConnectionForm.tsx b/src/renderer/src/components/sidebar/ConnectionForm.tsx index fe4eec8..6751f66 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronDown, ChevronUp, ClipboardPaste, Link } from 'lucide-react' +import { ClipboardPaste, Link, MessageSquareText } from 'lucide-react' import type { ConnectionConfig, ConnectionInput, @@ -96,10 +96,10 @@ function DiagnoseControl({ background: 'none', border: 'none', padding: 0, - color: 'inherit' + color: open ? 'inherit' : 'var(--fg-3, #9ca3af)' }} > - {open ? : } + )} From e646c3fa47a9d77b103f1c0ef6735b90d62956cc Mon Sep 17 00:00:00 2001 From: shawn Date: Thu, 18 Jun 2026 17:38:30 +0800 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20=E8=B7=B3=E6=9D=BF=E6=9C=BA?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E4=B8=BA=E7=A7=81=E9=92=A5=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=20+=20=E4=BF=AE=E6=AD=A3=20TODO=20=E6=96=87=E6=A1=A3=E5=81=8F?= =?UTF-8?q?=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SshHopConfig.authMethod 收紧为 'privateKey',类型层杜绝跳板机密码登录 - TODO: 文件选择器已实现(误列为待办);补上逐跳连通性检测到已交付清单 --- TODO.md | 4 ++-- src/shared/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 530e893..f04fb70 100644 --- a/TODO.md +++ b/TODO.md @@ -20,12 +20,12 @@ ## 3. SSH 隧道增强(PR #14 之后的后续) `[难度: 中–高] [风险: 中]` -PR #14(`feat/ssh-agent-auth`,待合并)已实现:主机密钥 TOFU 校验(静默,无 UI,仅首连学习、变更即拒)/ 跳板机(**单跳** ProxyJump,ssh2 嵌套连接,私钥认证)/ `~` 展开与并发隧道句柄竞态修复 / SSH 表单校验 + 连通性预检 / 副本集+隧道告警 / 私钥友好报错。**SSH 认证仅密码 + 私钥两种(ssh-agent 已移除)。** 隧道核心已拆为 `main/ssh/tunnelCore.ts`(纯函数,有单测)+ `tunnel.ts`(ssh2 副作用)。以下为**尚未做**的: +PR #14(`feat/ssh-agent-auth`,待合并)已实现:主机密钥 TOFU 校验(静默,无 UI,仅首连学习、变更即拒)/ 跳板机(**单跳** ProxyJump,ssh2 嵌套连接,私钥认证)/ `~` 展开与并发隧道句柄竞态修复 / SSH 表单校验 / 连接前 TCP 预检 / 逐跳连通性检测(Check connectivity,SSH 主机与跳板机各自检测、结果默认折叠)/ 副本集+隧道告警 / 私钥友好报错 + 文件选择器。**SSH 认证仅密码 + 私钥两种(ssh-agent 已移除)。** 隧道核心已拆为 `main/ssh/tunnelCore.ts`(纯函数,有单测)+ `tunnel.ts`(ssh2 副作用)。以下为**尚未做**的: - **运行期 SSH 掉线上报** `[难度: 中] [风险: 中–高]` —— 隧道在 `ready` 之后掉线不翻状态,`getStatus` 仍报 connected(“假在线”),查询被 30s `serverSelectionTimeout` 拖死。根因:`tunnel.ts` 仅 open 期监听 client error、resolve 后无 close/error 处理;`sessionManager` 不挂运行期监听。**卡点是架构**——本仓目前纯拉取式 IPC,全仓 0 处 `webContents.send`,要把“隧道断→状态 error”推给渲染层需新增一条**单向推送通道**(动 `shared/ipc.ts` + `preload` + `registerIpc` + store 订阅),这条通道也是未来一切实时状态推送的地基。**价值最高的下一步。** - **多跳跳板链** `[难度: 中] [风险: 低]` —— 现仅支持单跳 `SshConfig.jump`。多跳需把 jump 改为链(数组或递归)并在 `tunnel.ts` 顺序嵌套 `connectHop`/`forwardOut`。 - **跳板机密码认证** `[难度: 中] [风险: 中]` —— 当前跳板机仅私钥文件认证(带口令私钥已支持,走 `encJumpSshPassphrase`)。要支持跳板机密码,需扩 `connectionStore` 的密钥模型(加 `encJumpSshPassword`)+ ConnectionInput/sanitize/表单。 -- **私钥粘贴内容 / 文件选择器** `[难度: 低–中] [风险: 低]` —— 私钥现只支持磁盘路径手输。可加 textarea 粘贴内容(走 safeStorage 加密)+ 一个通用 `dialog:openFile` IPC 供“浏览”。 +- **私钥粘贴内容(textarea)** `[难度: 低–中] [风险: 低]` —— 文件选择器(`dialog:openFile` IPC + 「浏览…」按钮)已实现;尚缺 textarea 直接粘贴私钥内容(走 safeStorage 加密),供不便提供文件路径的场景。 - **连接总超时 + 取消** `[难度: 低] [风险: 低]` —— 隧道 20s + mongo 30s 串行最坏 ~50s,renderer 停在 'connecting' 无取消入口。加统一 deadline(`Promise.race` + AbortSignal)+ 取消按钮(参考 `shellEngine` 的 abort 模式)。 - **隧道 / 驱动自动重连** `[难度: 中] [风险: 中]` —— 断线后有限次自动重建隧道 + 重连(依赖上面的掉线事件机制)。 diff --git a/src/shared/types.ts b/src/shared/types.ts index 5de12d6..9ac28fe 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -34,8 +34,8 @@ export interface SshHopConfig { host?: string port?: number // default 22 username?: string - /** A jump hop uses a private key file only (no stored password). */ - authMethod?: SshAuthMethod + /** Always 'privateKey' — a jump hop never uses password auth (a bastion should be key-secured). */ + authMethod?: 'privateKey' privateKeyPath?: string /** Pinned SHA256 host-key fingerprint (hex), learned via TOFU. Not a secret. */ pinnedHostKey?: string