diff --git a/TODO.md b/TODO.md index 858fe83..f04fb70 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`,待合并)已实现:主机密钥 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)** `[难度: 低–中] [风险: 低]` —— 文件选择器(`dialog:openFile` IPC + 「浏览…」按钮)已实现;尚缺 textarea 直接粘贴私钥内容(走 safeStorage 加密),供不便提供文件路径的场景。 +- **连接总超时 + 取消** `[难度: 低] [风险: 低]` —— 隧道 20s + mongo 30s 串行最坏 ~50s,renderer 停在 'connecting' 无取消入口。加统一 deadline(`Promise.race` + AbortSignal)+ 取消按钮(参考 `shellEngine` 的 abort 模式)。 +- **隧道 / 驱动自动重连** `[难度: 中] [风险: 中]` —— 断线后有限次自动重建隧道 + 重连(依赖上面的掉线事件机制)。 + --- ## 已交付(归档,详情见 git / PR) diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 98cad89..1758ac5 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,15 +1,18 @@ -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 { AppSettings, ConnectionConfig, ConnectionInput, + DiagnoseScope, DocMutateRequest, DocSetFieldRequest, DocUpdateRequest, ExportRequest, ImportRequest, + OpenFileOptions, SavedQueryInput, ShellRequest } from '../../shared/types' @@ -17,6 +20,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' @@ -38,12 +42,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 +56,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 = '' @@ -112,6 +119,9 @@ export function registerIpc(): void { ipcMain.handle(IPC.connectionsTest, (_e, input: ConnectionInput) => sessionManager.test(inputToDecrypted(input)) ) + ipcMain.handle(IPC.connectionsDiagnose, (_e, input: ConnectionInput, scope: DiagnoseScope) => + diagnoseConnection(inputToDecrypted(input), scope) + ) ipcMain.handle( IPC.connectionsBuildUri, (_e, input: ConnectionInput, opts: { includePassword: boolean }) => @@ -124,6 +134,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/sessionManager.ts b/src/main/mongo/sessionManager.ts index ae7263d..990bcf6 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 { @@ -32,32 +32,12 @@ class SessionManager { return this.sessions.get(id)?.tunnel?.localPort } - 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.') - } + private async openTunnel(dec: DecryptedConnection): Promise { 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 - }) - // 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') @@ -84,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) @@ -102,12 +83,17 @@ 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 }) + // 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) { - this.pendingTunnel?.close() - this.pendingTunnel = undefined + tunnel?.close() const status: ConnectionStatus = { id, state: 'error', @@ -135,21 +121,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/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/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 edb1fda..cf25b7b 100644 --- a/src/main/ssh/tunnel.ts +++ b/src/main/ssh/tunnel.ts @@ -1,81 +1,91 @@ import net from 'node:net' +import type { Duplex } from 'node:stream' import { Client, type ConnectConfig } from 'ssh2' +import type { DiagnoseScope, 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 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 - * 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 + /** 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 - open(opts: TunnelOptions): Promise { - return new Promise((resolve, reject) => { - const connectConfig: ConnectConfig = { - host: opts.sshHost, - port: opts.sshPort, - username: opts.username, - password: opts.password, - privateKey: opts.privateKey, - passphrase: opts.passphrase, - readyTimeout: 20000, - keepaliveInterval: 15000 - } - - this.client.on('error', (err) => reject(err)) + 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) - 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()) - } - ) - }) + let transport: Duplex | undefined + if (opts.jump) { + 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 { + 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 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 + } - 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')) + }) }) } @@ -85,10 +95,165 @@ 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) + }) + }) +} + +/** + * 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, + 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, delete and re-create this connection to trust the new key.' + ) + 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 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, + scope: DiagnoseScope +): 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 + + // Connect the bastion lazily, on the first target step that needs it (the + // 'ssh' scope reaches the target *through* the jump). Cached so both target + // steps share one jump connection; a failure here surfaces on that step. + const ensureJump = async (): 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': + await connectHop(opts.jump!, undefined, 'jump host', clients) + return + case 'tcp-ssh': + return tcpProbe(opts.target.host, opts.target.port) + case 'ssh': + await connectHop(opts.target, undefined, 'target', clients) + return + 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, scope)) { + 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 new file mode 100644 index 0000000..863b189 --- /dev/null +++ b/src/main/ssh/tunnelCore.ts @@ -0,0 +1,209 @@ +/** + * 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 { homedir } from 'node:os' +import { join } from 'node:path' +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). */ +export interface SshHopOptions { + host: string + port: number + username: string + password?: string + privateKey?: Buffer + passphrase?: string + /** Previously-pinned SHA256 host-key fingerprint (TOFU); undefined on first use. */ + pinnedHostKey?: string +} + +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 +} + +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 } +} + +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, 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 } + 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 + * 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 +} + +/** + * 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.`) + } +} + +/** 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 +} + +/** 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 +): SshHopOptions { + const base: SshHopOptions = { + host: hop.host || '', + port: hop.port || 22, + username: hop.username || '', + pinnedHostKey: hop.pinnedHostKey + } + switch (hop.authMethod ?? 'password') { + case 'privateKey': + if (!hop.privateKeyPath) { + throw new Error('SSH private-key auth requires a private key path.') + } + return { ...base, privateKey: readKey(hop.privateKeyPath), passphrase: secrets.passphrase } + default: // 'password' + 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 a private key file only — no stored password. + */ +export function buildTunnelOptions( + dec: DecryptedConnection, + 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) + const jump = config.ssh.jump + ? buildHop(config.ssh.jump, { passphrase: dec.jumpSshPassphrase }, readKey) + : undefined + + return { target, jump, destHost: config.host, destPort: config.port ?? 27017 } +} + +/** + * 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. 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, + scope: DiagnoseScope +): { key: string; target: string }[] { + const t = opts.target + if (scope === 'jump') { + if (!opts.jump) return [] + const j = opts.jump + 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}` } + ] + } + return [ + { key: 'tcp-ssh', target: `${t.host}:${t.port}` }, + { key: 'ssh', target: `${t.host}:${t.port}` } + ] +} diff --git a/src/main/store/connectionStore.ts b/src/main/store/connectionStore.ts index d6db31d..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 } @@ -129,12 +132,33 @@ 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() + } + + /** 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 password?: string sshPassword?: string sshPassphrase?: string + jumpSshPassphrase?: string } | null { const stored = this.data.connections.find((c) => c.id === id) if (!stored) return null @@ -142,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..345fba1 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, 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) @@ -56,6 +57,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 cd61fdf..6751f66 100644 --- a/src/renderer/src/components/sidebar/ConnectionForm.tsx +++ b/src/renderer/src/components/sidebar/ConnectionForm.tsx @@ -1,9 +1,10 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ClipboardPaste, Link } from 'lucide-react' +import { ClipboardPaste, Link, MessageSquareText } from 'lucide-react' import type { ConnectionConfig, ConnectionInput, + DiagnoseStage, ScramMechanism, SshAuthMethod, TestResult @@ -30,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 @@ -46,6 +125,8 @@ 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 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) @@ -100,6 +181,59 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E const [sshPassphrase, setSshPassphrase] = useState('') const [sshPassphraseTouched, setSshPassphraseTouched] = useState(false) + // ---- 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 [jumpKeyPath, setJumpKeyPath] = useState(editing?.ssh.jump?.privateKeyPath ?? '') + const [jumpSshPassphrase, setJumpSshPassphrase] = useState('') + const [jumpSshPassphraseTouched, setJumpSshPassphraseTouched] = useState(false) + 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 => { + 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(() => { + 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') + 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 (!jumpKeyPath.trim()) return tFn('connection.ssh.errJumpKey') + } + return undefined + }, [ + sshEnabled, + sshHost, + sshUser, + sshPort, + sshAuthMethod, + privateKeyPath, + jumpEnabled, + jumpHost, + jumpUser, + jumpPort, + jumpKeyPath, + tFn + ]) + // ---- TLS ---- const [tlsEnabled, setTlsEnabled] = useState(editing?.tls.enabled ?? false) const [allowInvalidCertificates, setAllowInvalid] = useState( @@ -208,7 +342,19 @@ 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 (verified silently at connect). + pinnedHostKey: editing?.ssh.pinnedHostKey, + jump: jumpEnabled + ? { + host: jumpHost.trim() || undefined, + port: Number(jumpPort) || 22, + username: jumpUser.trim() || undefined, + authMethod: 'privateKey', + privateKeyPath: jumpKeyPath.trim() || undefined, + pinnedHostKey: editing?.ssh.jump?.pinnedHostKey + } + : undefined }, tls: { enabled: tlsEnabled, @@ -221,6 +367,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 }, [ @@ -249,6 +396,13 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E sshPasswordTouched, sshPassphrase, sshPassphraseTouched, + jumpEnabled, + jumpHost, + jumpPort, + jumpUser, + jumpKeyPath, + jumpSshPassphrase, + jumpSshPassphraseTouched, tlsEnabled, allowInvalidCertificates, caFile, @@ -271,6 +425,20 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E setTesting(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 => has ? tFn('connection.secret.placeholder') : '' @@ -297,11 +465,21 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E : tFn('connection.testResult.failed', { error: test.error ?? 'unknown' })} )} + {sshError && ( + + {sshError} + + )} - @@ -410,6 +588,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(' · ') })} @@ -510,7 +692,7 @@ export function ConnectionForm({ editing, onClose }: ConnectionFormProps): JSX.E
- {sshAuthMethod === 'password' ? ( + {sshAuthMethod === 'password' && ( - ) : ( + )} + + {sshAuthMethod === 'privateKey' && ( <> - setPrivateKeyPath(e.target.value)} - placeholder="~/.ssh/id_ed25519" - /> +
+ setPrivateKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + +
- + { setSshPassphrase(e.target.value) setSshPassphraseTouched(true) @@ -547,6 +737,69 @@ 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. */} +
+ +
+ + {jumpEnabled && ( + <> +
+ + setJumpHost(e.target.value)} /> + + + setJumpPort(e.target.value)} + placeholder="22" + /> + +
+ + setJumpUser(e.target.value)} /> + + + +
+ setJumpKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + +
+
+ + { + setJumpSshPassphrase(e.target.value) + setJumpSshPassphraseTouched(true) + }} + /> + + + {/* 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 158b7fc..eb2828d 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" }, @@ -95,6 +96,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}}" }, @@ -118,7 +120,34 @@ "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.", + "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)", + "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", + "browse": "Browse…", + "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", + "tcp-target": "Reach target SSH port via jump", + "ssh-target": "SSH login: target", + "tcp-ssh": "TCP reachable: SSH host", + "ssh": "SSH login", + "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 e6cadab..234e58f 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": "新建连接" }, @@ -95,6 +96,7 @@ "srvHost": "SRV 主机", "port": "端口", "replicaSet": "副本集", + "replicaSetSshIgnored": "启用 SSH 隧道时副本集名称将被忽略(按单节点 directConnection 连接)。", "defaultDatabase": "默认数据库", "extraOptions": "额外选项:{{opts}}" }, @@ -118,7 +120,34 @@ "password": "SSH 密码", "privateKeyPath": "私钥路径", "passphrase": "私钥密码短语", - "passphraseHint": "如果私钥没有密码短语,请留空。" + "passphraseHint": "如果私钥没有密码短语,请留空。", + "errHost": "请填写 SSH 主机", + "errUser": "请填写 SSH 用户名", + "errPort": "SSH 端口需为 1–65535 之间的整数", + "errKey": "私钥认证需填写私钥路径", + "jumpEnableLabel": "通过跳板机连接(先连跳板机再到目标)", + "jumpHost": "跳板机主机", + "jumpPort": "跳板机端口", + "jumpUsername": "跳板机用户名", + "errJumpHost": "请填写跳板机主机", + "errJumpUser": "请填写跳板机用户名", + "errJumpPort": "跳板机端口需为 1–65535 之间的整数", + "errJumpKey": "跳板机私钥认证需填写私钥路径", + "browse": "浏览…", + "pickKey": "选择私钥文件", + "jumpPassphrase": "跳板机私钥密码短语", + "diagnose": "检测连通性", + "diagShow": "展开详情", + "diagHide": "收起详情", + "stage": { + "tcp-jump": "TCP 可达跳板机", + "ssh-jump": "登录跳板机", + "tcp-target": "经跳板机到达目标 SSH 端口", + "ssh-target": "登录目标主机", + "tcp-ssh": "TCP 可达 SSH 主机", + "ssh": "登录 SSH 主机", + "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 6e37988..1ca8550 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": "新增連線" }, @@ -95,6 +96,7 @@ "srvHost": "SRV 主機", "port": "連接埠", "replicaSet": "副本集", + "replicaSetSshIgnored": "啟用 SSH 通道時副本集名稱將被忽略(以單節點 directConnection 連線)。", "defaultDatabase": "預設資料庫", "extraOptions": "額外選項:{{opts}}" }, @@ -118,7 +120,34 @@ "password": "SSH 密碼", "privateKeyPath": "私密金鑰路徑", "passphrase": "私密金鑰密碼短語", - "passphraseHint": "若私密金鑰無密碼短語,請留空。" + "passphraseHint": "若私密金鑰無密碼短語,請留空。", + "errHost": "請填寫 SSH 主機", + "errUser": "請填寫 SSH 使用者名稱", + "errPort": "SSH 連接埠需為 1–65535 之間的整數", + "errKey": "私密金鑰驗證需填寫金鑰路徑", + "jumpEnableLabel": "透過跳板機連線(先連跳板機再到目標)", + "jumpHost": "跳板機主機", + "jumpPort": "跳板機連接埠", + "jumpUsername": "跳板機使用者名稱", + "errJumpHost": "請填寫跳板機主機", + "errJumpUser": "請填寫跳板機使用者名稱", + "errJumpPort": "跳板機連接埠需為 1–65535 之間的整數", + "errJumpKey": "跳板機私密金鑰驗證需填寫金鑰路徑", + "browse": "瀏覽…", + "pickKey": "選擇私密金鑰檔", + "jumpPassphrase": "跳板機私密金鑰密碼短語", + "diagnose": "檢測連通性", + "diagShow": "展開詳情", + "diagHide": "收起詳情", + "stage": { + "tcp-jump": "TCP 可達跳板機", + "ssh-jump": "登入跳板機", + "tcp-target": "經跳板機到達目標 SSH 連接埠", + "ssh-target": "登入目標主機", + "tcp-ssh": "TCP 可達 SSH 主機", + "ssh": "登入 SSH 主機", + "config": "設定錯誤" + } }, "tls": { "enableLabel": "啟用 TLS/SSL", diff --git a/src/renderer/src/store/useAppStore.ts b/src/renderer/src/store/useAppStore.ts index a883f6a..6e518d5 100644 --- a/src/renderer/src/store/useAppStore.ts +++ b/src/renderer/src/store/useAppStore.ts @@ -19,6 +19,8 @@ import type { ConnectionStatus, DatabaseInfo, DataOpResult, + DiagnoseScope, + DiagnoseStage, DocMutateRequest, DocMutateResult, DocSetFieldRequest, @@ -161,6 +163,10 @@ 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 + /** 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). */ @@ -478,6 +484,23 @@ export const useAppStore = create((set, get) => ({ } }, + async pickFile(opts) { + try { + return await window.api.dialog.openFile(opts) + } catch { + return null + } + }, + + async diagnoseConnection(input, scope) { + try { + return await window.api.connections.diagnose(input, scope) + } 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 3abb43c..8012e6e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,6 +10,9 @@ import type { ConnectionInput, ConnectionStatus, DatabaseInfo, + DiagnoseScope, + DiagnoseStage, + OpenFileOptions, DataOpResult, DocMutateRequest, DocMutateResult, @@ -32,6 +35,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', @@ -64,7 +68,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). */ @@ -74,6 +80,8 @@ export interface Api { save(input: ConnectionInput): Promise delete(id: string): Promise test(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 @@ -131,4 +139,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 cb94f65..9ac28fe 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,6 +24,23 @@ export interface AuthConfig { export type SshAuthMethod = 'password' | 'privateKey' +/** + * A single SSH hop in front of the target (a bastion / jump host, i.e. ProxyJump). + * 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. + */ +export interface SshHopConfig { + host?: string + port?: number // default 22 + username?: string + /** 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 +} + export interface SshConfig { enabled: boolean host?: string @@ -32,6 +49,13 @@ 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 + /** Optional single bastion in front of the target (reach the target through it). */ + jump?: SshHopConfig } export interface TlsConfig { @@ -73,6 +97,7 @@ export interface ConnectionConfig { hasPassword?: boolean hasSshPassword?: boolean hasSshPassphrase?: boolean + hasJumpSshPassphrase?: boolean createdAt: number updatedAt: number @@ -84,10 +109,38 @@ 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[] }[] +} + +/** 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 | 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 } // --------------------------------------------------------------------------- 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 new file mode 100644 index 0000000..8a68723 --- /dev/null +++ b/test/unit/main/tunnelCore.test.ts @@ -0,0 +1,228 @@ +/** + * SSH tunnel option assembly — auth-method decision + host-key TOFU + diagnosis plan. + */ +import { describe, it, expect } from 'vitest' +import { + buildTunnelOptions, + classifyConnError, + evaluateHostKey, + expandHome, + planDiagnoseStages, + readPrivateKey +} 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('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('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('buildTunnelOptions — target auth methods', () => { + it('password: carries sshPassword, no key', () => { + const o = buildTunnelOptions(dec(cfg({ authMethod: 'password' }), { sshPassword: 'pw' }), stubKey) + expect(o.target).toMatchObject({ + host: 'gw.example.com', + port: 22, + username: 'deploy', + password: 'pw' + }) + expect(o.destHost).toBe('db.internal') + expect(o.destPort).toBe(27017) + expect(o.jump).toBeUndefined() + expect(o.target.privateKey).toBeUndefined() + }) + + it('defaults to password when authMethod is unset', () => { + const o = buildTunnelOptions(dec(cfg({ authMethod: undefined }), { sshPassword: 'pw' }), stubKey) + expect(o.target.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.target.privateKey?.toString()).toBe('PRIV') + expect(o.target.passphrase).toBe('pp') + expect(o.target.password).toBeUndefined() + }) + + it('privateKey without a path throws', () => { + expect(() => buildTunnelOptions(dec(cfg({ authMethod: 'privateKey' })), stubKey)).toThrow(/private key/i) + }) + + it('threads the pinned host key through', () => { + 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 key + pin', () => { + const o = buildTunnelOptions( + dec( + cfg({ + authMethod: 'privateKey', + privateKeyPath: '/k/target', + pinnedHostKey: 'target-fp', + jump: { + host: 'bastion.example.com', + port: 3522, + username: 'shawn', + authMethod: 'privateKey', + privateKeyPath: '/k/jump', + pinnedHostKey: 'jump-fp' + } + }), + {} + ), + stubKey + ) + 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', + 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: 'password', + jump: { host: 'b', username: 'u', authMethod: 'privateKey', privateKeyPath: '/k/jump' } + }), + { sshPassword: 'pw', sshPassphrase: 'pp', jumpSshPassphrase: 'jpp' } + ), + () => Buffer.from('JUMPKEY') + ) + expect(o.jump?.privateKey?.toString()).toBe('JUMPKEY') + 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 + }) +}) + +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('planDiagnoseStages', () => { + const opts = (jump?: object) => ({ + target: { host: 'cai', port: 22, username: 'root' }, + jump: jump as never, + destHost: '127.0.0.1', + destPort: 27017 + }) + 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') + }) + 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([]) + }) +}) + +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', () => { + 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: 'password', port: undefined }, { port: undefined })), + stubKey + ) + expect(o.target.port).toBe(22) + expect(o.destPort).toBe(27017) + }) +})