Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
@@ -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 / 集中设置 / 聚合管道构建器)。剩下的是**性能加固与健壮性**两个缺口,都不紧急。
Expand All @@ -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)
Expand Down
32 changes: 29 additions & 3 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
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'
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'
Expand All @@ -38,28 +42,31 @@ 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
}

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 = '<password>'
Expand Down Expand Up @@ -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 }) =>
Expand All @@ -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))
Expand Down
61 changes: 17 additions & 44 deletions src/main/mongo/sessionManager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,32 +32,12 @@ class SessionManager {
return this.sessions.get(id)?.tunnel?.localPort
}

private async openTunnel(dec: DecryptedConnection): Promise<number> {
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<SshTunnel> {
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')
Expand All @@ -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)
Expand All @@ -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',
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/main/mongo/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface DecryptedConnection {
password?: string
sshPassword?: string
sshPassphrase?: string
jumpSshPassphrase?: string
}

export interface ClientArgs {
Expand Down
39 changes: 39 additions & 0 deletions src/main/ssh/diagnose.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
})
}
Loading