From 65e212e6cc63acfc21bb5249291b2e6d5e5d2d90 Mon Sep 17 00:00:00 2001 From: ByteExceptionM Date: Mon, 10 Aug 2026 15:26:58 +0200 Subject: [PATCH] feat: SSH tunnel support for connections Opens an SSH session with a loopback SOCKS5 proxy in front of it and hands that to the driver, so topology discovery reaches every replica-set member by the names the SSH server resolves. Key, password and agent auth; host keys checked against known_hosts, then pinned on first use. --- README.md | 10 +- package-lock.json | 141 +++++++ package.json | 5 +- src/main/index.ts | 19 +- src/main/ipc/channels.ts | 7 +- src/main/ipc/router.ts | 25 +- src/main/lib/errorMap.ts | 15 + src/main/lib/knownHosts.test.ts | 129 +++++++ src/main/lib/knownHosts.ts | 121 ++++++ src/main/lib/socks5.test.ts | 134 +++++++ src/main/lib/socks5.ts | 256 +++++++++++++ src/main/services/ConnectionService.ts | 159 +++++++- src/main/services/SshTunnelService.test.ts | 290 +++++++++++++++ src/main/services/SshTunnelService.ts | 318 ++++++++++++++++ src/main/stores/ConnectionsRepository.test.ts | 198 ++++++++++ src/main/stores/ConnectionsRepository.ts | 81 +++- src/main/stores/HostKeysStore.ts | 82 ++++ src/preload/index.ts | 12 +- src/renderer/src/App.tsx | 13 + .../connections/ConnectionFormDialog.tsx | 351 ++++++++++++++++-- .../connections/pinnedHostKeyToast.ts | 16 + .../src/features/explorer/ConnectionGroup.tsx | 4 +- .../src/features/palette/CommandPalette.tsx | 3 +- src/renderer/src/features/welcome/Welcome.tsx | 4 +- src/renderer/src/lib/api.ts | 13 +- src/renderer/src/lib/queryClient.ts | 13 +- src/shared/api.ts | 12 +- src/shared/events.ts | 16 +- src/shared/result.ts | 3 + src/shared/schemas.ts | 36 ++ src/shared/types.ts | 73 ++++ 31 files changed, 2474 insertions(+), 85 deletions(-) create mode 100644 src/main/lib/knownHosts.test.ts create mode 100644 src/main/lib/knownHosts.ts create mode 100644 src/main/lib/socks5.test.ts create mode 100644 src/main/lib/socks5.ts create mode 100644 src/main/services/SshTunnelService.test.ts create mode 100644 src/main/services/SshTunnelService.ts create mode 100644 src/main/stores/ConnectionsRepository.test.ts create mode 100644 src/main/stores/HostKeysStore.ts create mode 100644 src/renderer/src/features/connections/pinnedHostKeyToast.ts diff --git a/README.md b/README.md index 5de3b84..7e59edb 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A modern, dark-mode-first MongoDB GUI. Built as a daily-driver alternative to Mo ## Highlights - **Multiple connections, side-by-side.** Connect to as many clusters as you want; each runs an independent pool. Drag-reorder them in the sidebar, manage them with right-click context menus. +- **Built-in SSH tunnelling.** Point a connection at a cluster that is only reachable from a jump host — key, password or agent auth, no external port forwards. Because the tunnel is a dynamic SOCKS5 proxy rather than a single forwarded port, the driver reaches **every replica-set member** it discovers, resolved on the SSH server's side. - **Three query modes per tab.** Switch a single tab between **Simple** (filter / projection / sort), **Aggregation** pipeline, and **Shell** (`db.coll.find().limit()` syntax) without losing state. - **Mongo shell syntax everywhere.** Type `ObjectId("…")`, `ISODate("…")`, `UUID("…")`, `NumberLong("…")` etc. directly in filters and editors — MongoBench parses it into canonical EJSON before sending. - **Optimistic concurrency on writes.** Every edit and delete includes a sha-256 hash precondition of the document; concurrent edits surface as conflicts instead of silently overwriting. @@ -31,7 +32,7 @@ Saved connections at a glance with quick-connect buttons and inline tips. ### Connection form -Full driver-option surface — auth, topology, pool, timeouts, UUID encoding, display timezone. +Full driver-option surface — auth, SSH tunnel, topology, pool, timeouts, UUID encoding, display timezone. ![New connection dialog](https://i.masel.io/ZIXO8/huKuWAvE28.png/raw) @@ -93,6 +94,13 @@ Per-database user management. Common-role shortcuts plus arbitrary custom roles. - Multiple **active connections** simultaneously, each with its own pool - **Encrypted password storage** via OS keystore (Windows DPAPI, libsecret on Linux) - **Test before save** — probes server, reports MongoDB version + ping latency +- **SSH tunnel** per connection, for hosts that are only routable from an SSH server: + - Auth via **private key** (+ passphrase), **password**, or the running **SSH agent** + - Key files are referenced by path — the key is read at connect time and never stored or copied + - Passwords and passphrases go into the same OS keystore as the MongoDB password + - Host keys are checked against your `~/.ssh/known_hosts`; an unknown host is pinned on first use and its `SHA256:` fingerprint surfaced for you to verify. A key that later changes is a hard failure + - Implemented as a loopback SOCKS5 proxy over the SSH session (with per-tunnel random credentials), handed to the driver as `proxyHost` / `proxyPort` — so **topology discovery works**: list every replica-set member in the URI under the names the SSH server resolves. `mongodb+srv://` is the exception, as its DNS lookup still happens locally + - A tunnel that dies takes its connection down and says so, instead of leaving a connection that only looks alive - **Drag-to-reorder** saved connections in the sidebar - **Right-click context menu** per connection: connect / disconnect, edit, delete, new database, refresh, copy URI - Full driver option surface, persisted per connection: diff --git a/package-lock.json b/package-lock.json index 77aa390..bb3b2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,9 @@ "mongodb": "^7.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "socks": "^2.8.9", "sonner": "^2.0.7", + "ssh2": "^1.17.0", "tailwind-merge": "^2.5.2", "uuid": "^14.0.0", "zod": "^4.4.3", @@ -41,6 +43,7 @@ "@types/node": "^22.7.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", + "@types/ssh2": "^1.15.5", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.8.0", "@typescript-eslint/parser": "^8.8.0", @@ -3128,6 +3131,33 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4029,6 +4059,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -4310,6 +4349,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4426,6 +4474,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builder-util": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", @@ -4952,6 +5009,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -7144,6 +7215,15 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8442,6 +8522,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -10098,6 +10185,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -10388,6 +10481,31 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "peer": true, + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10446,6 +10564,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -11200,6 +11335,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index fba2baf..6100d3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mongobench", - "version": "1.3.1", + "version": "1.4.0", "private": true, "description": "A modern, dark-mode-first MongoDB GUI.", "author": "ByteExceptionM", @@ -52,7 +52,9 @@ "mongodb": "^7.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "socks": "^2.8.9", "sonner": "^2.0.7", + "ssh2": "^1.17.0", "tailwind-merge": "^2.5.2", "uuid": "^14.0.0", "zod": "^4.4.3", @@ -62,6 +64,7 @@ "@types/node": "^22.7.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", + "@types/ssh2": "^1.15.5", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.8.0", "@typescript-eslint/parser": "^8.8.0", diff --git a/src/main/index.ts b/src/main/index.ts index e99cacd..eaef1cc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,9 +12,11 @@ import { ConnectionService } from './services/ConnectionService' import { DatabaseService } from './services/DatabaseService' import { IndexService } from './services/IndexService' import { QueryService } from './services/QueryService' +import { SshTunnelService } from './services/SshTunnelService' import { UpdaterService } from './services/UpdaterService' import { UserService } from './services/UserService' import { ConnectionsRepository } from './stores/ConnectionsRepository' +import { HostKeysStore } from './stores/HostKeysStore' import { SecretsStore } from './stores/SecretsStore' log.initialize() @@ -30,9 +32,15 @@ const services = { connections: null as ConnectionService | null } -// Held module-wide so the updater can push progress to the renderer. +// Held module-wide so main can push to the renderer outside of a request. let mainWindow: BrowserWindow | null = null +function pushToRenderer(channel: string, payload: unknown): void { + if (mainWindow !== null && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(channel, payload) + } +} + function createWindow(): void { const window = new BrowserWindow({ width: 1280, @@ -105,15 +113,16 @@ function createWindow(): void { app.whenReady().then(() => { const secrets = new SecretsStore() const repo = new ConnectionsRepository(secrets) - const connections = new ConnectionService(repo) + const tunnels = new SshTunnelService(new HostKeysStore()) + const connections = new ConnectionService(repo, tunnels, (connectionId, reason) => + pushToRenderer(EventChannels.ConnectionDropped, { connectionId, reason }) + ) const databases = new DatabaseService(connections) const queries = new QueryService(connections) const users = new UserService(connections) const indexes = new IndexService(connections) const updater = new UpdaterService((progress) => { - if (mainWindow !== null && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(EventChannels.UpdaterProgress, progress) - } + pushToRenderer(EventChannels.UpdaterProgress, progress) }) services.repo = repo services.connections = connections diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 0ab799e..5e0f0db 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -43,12 +43,15 @@ export const Channels = { UpdaterCheck: 'updater:check', UpdaterDownload: 'updater:download', - UpdaterInstall: 'updater:install' + UpdaterInstall: 'updater:install', + + DialogPickPrivateKey: 'dialog:pickPrivateKey' } as const export type ChannelName = (typeof Channels)[keyof typeof Channels] /** main → renderer pushes. Subscribed to in the preload, never `handle`d. */ export const EventChannels = { - UpdaterProgress: 'updater:progress' + UpdaterProgress: 'updater:progress', + ConnectionDropped: 'connections:dropped' } as const diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index aea1936..0c2ef04 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -1,4 +1,6 @@ -import { ipcMain, type IpcMainInvokeEvent } from 'electron' +import { BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron' +import { homedir } from 'node:os' +import { join } from 'node:path' import log from 'electron-log/main' import type { ZodType } from 'zod' import { @@ -295,4 +297,25 @@ export function registerIpcHandlers(services: Services): void { updater.install() }) ) + + // Only the path travels back to the renderer; the key itself is read in + // main at connect time and never leaves it. + ipcMain.handle( + Channels.DialogPickPrivateKey, + withoutInput(async () => { + const options: Electron.OpenDialogOptions = { + title: 'Select an SSH private key', + defaultPath: join(homedir(), '.ssh'), + // Key files carry no extension, and .ssh is a hidden directory. + properties: ['openFile', 'showHiddenFiles', 'dontAddToRecent'] + } + const parent = BrowserWindow.getFocusedWindow() + const result = + parent === null + ? await dialog.showOpenDialog(options) + : await dialog.showOpenDialog(parent, options) + if (result.canceled) return null + return result.filePaths[0] ?? null + }) + ) } diff --git a/src/main/lib/errorMap.ts b/src/main/lib/errorMap.ts index e911129..9266c58 100644 --- a/src/main/lib/errorMap.ts +++ b/src/main/lib/errorMap.ts @@ -11,6 +11,21 @@ export function mapError(error: unknown): { code: ErrorCode; message: string; de const message = error.message const code = readCodeField(error) + // SSH failures come before the driver checks: when a tunnel cannot be + // opened the driver never runs, and reporting "server selection timed out" + // for a rejected SSH key would point at the wrong end of the problem. + if (name === 'SshHostKeyMismatchError') { + return { code: 'ssh_host_key_mismatch', message } + } + + if (name === 'SshAuthError') { + return { code: 'ssh_auth_failed', message } + } + + if (name === 'SshConnectError') { + return { code: 'ssh_connect_failed', message } + } + if (name === 'MongoServerSelectionError') { return { code: 'server_selection_timeout', message } } diff --git a/src/main/lib/knownHosts.test.ts b/src/main/lib/knownHosts.test.ts new file mode 100644 index 0000000..46bc999 --- /dev/null +++ b/src/main/lib/knownHosts.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { fingerprint, findHostKeys, keyMatches, parseKnownHosts } from './knownHosts' + +// Real ed25519 key plus the two hashed lines OpenSSH itself produced for it +// via `ssh-keygen -H`, so the HMAC matching is checked against the reference +// implementation rather than against our own arithmetic. +const KEY_BASE64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIC3ZaX2ORSFJDIra++POwfcRoWepjw8gcywl33ojmW9U' +const KEY = Buffer.from(KEY_BASE64, 'base64') +const FINGERPRINT = 'SHA256:q+XnGzOPN1oDhKcAZC4Q2F03RfNaJ5zPwCLwaTc+jaw' +const HASHED_DEFAULT_PORT = '|1|a4f9lggJrrrtBTGjg90w3NUPHNk=|fNvB+sOQiLKPXVSEwRHNXZ5uQQ0=' +const HASHED_PORT_2222 = '|1|6ztXlwedZiR/OZHPHaSRp1+559k=|KgysaTZ17zd24ZXmROwPufpfvgE=' + +const OTHER_KEY = Buffer.from( + 'AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'base64' +) + +describe('parseKnownHosts', () => { + it('skips comments, blank lines and marker lines', () => { + const entries = parseKnownHosts( + [ + '# a comment', + '', + ' ', + `@cert-authority *.example.com ssh-ed25519 ${KEY_BASE64}`, + `@revoked gate.example.com ssh-ed25519 ${KEY_BASE64}`, + `gate.example.com ssh-ed25519 ${KEY_BASE64}` + ].join('\n') + ) + expect(entries).toHaveLength(1) + expect(entries[0]?.keyType).toBe('ssh-ed25519') + }) + + it('reads several patterns off one line', () => { + const entries = parseKnownHosts(`gate.example.com,10.0.0.1 ssh-ed25519 ${KEY_BASE64}`) + expect(entries[0]?.hosts).toHaveLength(2) + }) + + it('tolerates CRLF line endings', () => { + const entries = parseKnownHosts(`gate.example.com ssh-ed25519 ${KEY_BASE64}\r\n`) + expect(entries).toHaveLength(1) + }) + + it('drops lines without a key', () => { + expect(parseKnownHosts('gate.example.com ssh-ed25519')).toEqual([]) + }) +}) + +describe('findHostKeys', () => { + it('matches a plain entry on the default port', () => { + const entries = parseKnownHosts(`gate.example.com ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([KEY]) + }) + + it('is case-insensitive on the host name', () => { + const entries = parseKnownHosts(`Gate.Example.COM ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.EXAMPLE.com', 22)).toEqual([KEY]) + }) + + it('does not match a plain entry when a non-default port is requested', () => { + const entries = parseKnownHosts(`gate.example.com ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 2222)).toEqual([]) + }) + + it('matches the [host]:port form', () => { + const entries = parseKnownHosts(`[gate.example.com]:2222 ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 2222)).toEqual([KEY]) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([]) + }) + + it('accepts an explicit [host]:22 entry for the default port', () => { + const entries = parseKnownHosts(`[gate.example.com]:22 ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([KEY]) + }) + + it('matches a hashed entry produced by ssh-keygen -H', () => { + const entries = parseKnownHosts(`${HASHED_DEFAULT_PORT} ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([KEY]) + expect(findHostKeys(entries, 'other.example.com', 22)).toEqual([]) + }) + + it('matches a hashed entry for a non-default port', () => { + const entries = parseKnownHosts(`${HASHED_PORT_2222} ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 2222)).toEqual([KEY]) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([]) + }) + + it('honours wildcard patterns', () => { + const entries = parseKnownHosts(`*.example.com ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([KEY]) + expect(findHostKeys(entries, 'gate.example.org', 22)).toEqual([]) + }) + + it('lets a negated pattern veto its own line', () => { + const entries = parseKnownHosts(`*.example.com,!gate.example.com ssh-ed25519 ${KEY_BASE64}`) + expect(findHostKeys(entries, 'gate.example.com', 22)).toEqual([]) + expect(findHostKeys(entries, 'other.example.com', 22)).toEqual([KEY]) + }) + + it('returns every key a host is allowed to present', () => { + const entries = parseKnownHosts( + [ + `gate.example.com ssh-ed25519 ${KEY_BASE64}`, + `gate.example.com ssh-ed25519 ${OTHER_KEY.toString('base64')}` + ].join('\n') + ) + expect(findHostKeys(entries, 'gate.example.com', 22)).toHaveLength(2) + }) +}) + +describe('keyMatches', () => { + it('accepts an identical blob', () => { + expect(keyMatches(Buffer.from(KEY), KEY)).toBe(true) + }) + + it('rejects a different blob of the same length', () => { + expect(keyMatches(OTHER_KEY, KEY)).toBe(false) + }) + + it('rejects a blob of a different length without throwing', () => { + expect(keyMatches(KEY.subarray(0, 10), KEY)).toBe(false) + }) +}) + +describe('fingerprint', () => { + it('matches what ssh-keygen -l reports', () => { + expect(fingerprint(KEY)).toBe(FINGERPRINT) + }) +}) diff --git a/src/main/lib/knownHosts.ts b/src/main/lib/knownHosts.ts new file mode 100644 index 0000000..b84dcfd --- /dev/null +++ b/src/main/lib/knownHosts.ts @@ -0,0 +1,121 @@ +/** + * Reading side of OpenSSH's `known_hosts`. MongoBench never writes that file, + * it only consults it, so a host the user already accepted in their own SSH + * client is trusted here too. Pure — the caller supplies the contents. + * + * Supported: plain patterns (with `*` / `?` wildcards and `!` negation), the + * `[host]:port` form, and `|1|salt|hash` hashed entries. `@cert-authority` and + * `@revoked` lines are skipped; we do not implement CA validation, and + * skipping is the safe direction since such a line then authorises nothing. + */ + +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' +import { DEFAULT_SSH_PORT } from '@shared/types' + +export type HostMatcher = + | { kind: 'plain'; pattern: string; negated: boolean } + | { kind: 'hashed'; salt: Buffer; digest: Buffer } + +export type KnownHostEntry = { + hosts: HostMatcher[] + /** e.g. `ssh-ed25519`. Diagnostics only; matching goes by key bytes. */ + keyType: string + key: Buffer +} + +export function parseKnownHosts(content: string): KnownHostEntry[] { + const entries: KnownHostEntry[] = [] + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (line.length === 0 || line.startsWith('#') || line.startsWith('@')) continue + + const [hostField, keyType, keyBase64] = line.split(/\s+/) + if (hostField === undefined || keyType === undefined || keyBase64 === undefined) continue + + const key = Buffer.from(keyBase64, 'base64') + if (key.length === 0) continue + + const hosts = hostField.split(',').flatMap(parseMatcher) + if (hosts.length === 0) continue + + entries.push({ hosts, keyType, key }) + } + return entries +} + +function parseMatcher(token: string): HostMatcher[] { + if (token.length === 0) return [] + + if (token.startsWith('|')) { + // |1|| + const parts = token.split('|') + if (parts.length !== 4 || parts[1] !== '1') return [] + const salt = Buffer.from(parts[2] ?? '', 'base64') + const digest = Buffer.from(parts[3] ?? '', 'base64') + if (salt.length === 0 || digest.length === 0) return [] + return [{ kind: 'hashed', salt, digest }] + } + + const negated = token.startsWith('!') + return [{ kind: 'plain', pattern: negated ? token.slice(1) : token, negated }] +} + +/** + * The names OpenSSH looks up: the bare name on port 22, `[name]:port` + * otherwise. Hashed entries hash exactly these strings, so one list drives + * both matcher kinds. + */ +function candidateNames(host: string, port: number): string[] { + const name = host.toLowerCase() + return port === DEFAULT_SSH_PORT ? [name, `[${name}]:${port}`] : [`[${name}]:${port}`] +} + +/** Every key the file authorises for this host, in file order. */ +export function findHostKeys(entries: KnownHostEntry[], host: string, port: number): Buffer[] { + const names = candidateNames(host, port) + const keys: Buffer[] = [] + for (const entry of entries) { + if (entryMatches(entry, names)) keys.push(entry.key) + } + return keys +} + +function entryMatches(entry: KnownHostEntry, names: string[]): boolean { + let matched = false + for (const matcher of entry.hosts) { + for (const name of names) { + if (!matcherMatches(matcher, name)) continue + // A negated pattern vetoes the whole line, however else it matched. + if (matcher.kind === 'plain' && matcher.negated) return false + matched = true + } + } + return matched +} + +function matcherMatches(matcher: HostMatcher, name: string): boolean { + if (matcher.kind === 'hashed') { + const digest = createHmac('sha1', matcher.salt).update(name).digest() + return keyMatches(digest, matcher.digest) + } + return globMatches(matcher.pattern.toLowerCase(), name) +} + +function globMatches(pattern: string, value: string): boolean { + if (!pattern.includes('*') && !pattern.includes('?')) return pattern === value + const expression = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + return new RegExp(`^${expression}$`).test(value) +} + +/** Length-tolerant constant-time compare of two key blobs. */ +export function keyMatches(candidate: Buffer, known: Buffer): boolean { + return candidate.length === known.length && timingSafeEqual(candidate, known) +} + +/** OpenSSH's `SHA256:…` fingerprint of a raw public key blob. */ +export function fingerprint(key: Buffer): string { + return `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=+$/, '')}` +} diff --git a/src/main/lib/socks5.test.ts b/src/main/lib/socks5.test.ts new file mode 100644 index 0000000..a4b60fa --- /dev/null +++ b/src/main/lib/socks5.test.ts @@ -0,0 +1,134 @@ +import type { Socket } from 'node:net' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import { SocksClient } from 'socks' +import { createSocks5Server, type Socks5Server } from './socks5' + +const USERNAME = 'proxy-user' +const PASSWORD = 'proxy-pass' + +let server: Socks5Server | null = null + +afterEach(async () => { + await server?.close() + server = null +}) + +type Requested = { host: string; port: number } + +/** + * Starts a proxy whose outbound leg is a PassThrough — whatever the client + * writes comes straight back, so a round-trip proves both pipe directions. + */ +async function startProxy( + requested: Requested[], + connect?: (host: string, port: number) => Promise +): Promise { + server = await createSocks5Server({ + username: USERNAME, + password: PASSWORD, + connect: (host, port) => { + requested.push({ host, port }) + return connect ? connect(host, port) : Promise.resolve(new PassThrough()) + } + }) + return server +} + +function connectThrough( + proxyPort: number, + destination: Requested, + credentials: { userId?: string; password?: string } = { userId: USERNAME, password: PASSWORD } +): Promise<{ socket: Socket }> { + return SocksClient.createConnection({ + proxy: { host: '127.0.0.1', port: proxyPort, type: 5, ...credentials }, + command: 'connect', + destination + }) +} + +function firstChunk(socket: Socket): Promise { + return new Promise((resolve, reject) => { + socket.once('data', (chunk: Buffer) => resolve(chunk.toString('utf8'))) + socket.once('error', reject) + }) +} + +describe('createSocks5Server', () => { + it('binds an ephemeral loopback port', async () => { + const proxy = await startProxy([]) + expect(proxy.port).toBeGreaterThan(0) + }) + + it('pipes payload in both directions after a successful CONNECT', async () => { + const proxy = await startProxy([]) + const { socket } = await connectThrough(proxy.port, { host: 'mongo1.internal', port: 27017 }) + socket.write('ping') + await expect(firstChunk(socket)).resolves.toBe('ping') + socket.destroy() + }) + + it('passes a hostname destination through unresolved', async () => { + const requested: Requested[] = [] + const proxy = await startProxy(requested) + const { socket } = await connectThrough(proxy.port, { host: 'mongo2.internal', port: 27018 }) + socket.destroy() + // The whole replica-set story hangs on this: the name must reach the + // outbound leg untouched so the far side resolves it. + expect(requested).toEqual([{ host: 'mongo2.internal', port: 27018 }]) + }) + + it('formats an IPv4 destination as a dotted quad', async () => { + const requested: Requested[] = [] + const proxy = await startProxy(requested) + const { socket } = await connectThrough(proxy.port, { host: '10.0.0.7', port: 27017 }) + socket.destroy() + expect(requested).toEqual([{ host: '10.0.0.7', port: 27017 }]) + }) + + it('formats an IPv6 destination as colon-separated groups', async () => { + const requested: Requested[] = [] + const proxy = await startProxy(requested) + const { socket } = await connectThrough(proxy.port, { host: '::1', port: 27017 }) + socket.destroy() + expect(requested).toEqual([{ host: '0:0:0:0:0:0:0:1', port: 27017 }]) + }) + + it('rejects wrong credentials', async () => { + const requested: Requested[] = [] + const proxy = await startProxy(requested) + await expect( + connectThrough( + proxy.port, + { host: 'mongo1.internal', port: 27017 }, + { userId: USERNAME, password: 'wrong' } + ) + ).rejects.toThrow() + expect(requested).toEqual([]) + }) + + it('rejects a client that only offers no-auth', async () => { + const requested: Requested[] = [] + const proxy = await startProxy(requested) + await expect( + connectThrough(proxy.port, { host: 'mongo1.internal', port: 27017 }, {}) + ).rejects.toThrow() + expect(requested).toEqual([]) + }) + + it('reports a failed outbound leg to the client', async () => { + const proxy = await startProxy([], () => Promise.reject(new Error('channel open failed'))) + await expect( + connectThrough(proxy.port, { host: 'mongo1.internal', port: 27017 }) + ).rejects.toThrow() + }) + + it('destroys live connections on close', async () => { + const proxy = await startProxy([]) + const { socket } = await connectThrough(proxy.port, { host: 'mongo1.internal', port: 27017 }) + const closed = new Promise((resolve) => socket.once('close', () => resolve())) + await proxy.close() + server = null + await expect(closed).resolves.toBeUndefined() + }) +}) diff --git a/src/main/lib/socks5.ts b/src/main/lib/socks5.ts new file mode 100644 index 0000000..0dbf1c3 --- /dev/null +++ b/src/main/lib/socks5.ts @@ -0,0 +1,256 @@ +/** + * A loopback-bound SOCKS5 proxy — just enough of RFC 1928 for the MongoDB + * driver, the only client that ever talks to it. + * + * The outbound leg is not opened here: the requested host and port go to the + * injected `connect` callback, and the host string is passed through + * unresolved. That is what lets the far side of an SSH tunnel resolve names + * that only exist there. + * + * Username/password auth (RFC 1929) is mandatory — the port is loopback-only, + * but every local process can still reach it, and this is a hole into a + * remote network. + */ + +import { createHash, timingSafeEqual } from 'node:crypto' +import { createServer, type Socket } from 'node:net' +import type { Duplex } from 'node:stream' + +const VERSION = 0x05 +const AUTH_VERSION = 0x01 +const METHOD_USERNAME_PASSWORD = 0x02 +const METHOD_NONE_ACCEPTABLE = 0xff +const AUTH_FAILURE = 0x01 +const CMD_CONNECT = 0x01 + +const ATYP_IPV4 = 0x01 +const ATYP_DOMAIN = 0x03 +const ATYP_IPV6 = 0x04 + +const REPLY_SUCCESS = 0x00 +const REPLY_GENERAL_FAILURE = 0x01 +const REPLY_CONNECTION_REFUSED = 0x05 +const REPLY_COMMAND_NOT_SUPPORTED = 0x07 +const REPLY_ADDRESS_NOT_SUPPORTED = 0x08 + +const LOOPBACK = '127.0.0.1' +const HANDSHAKE_TIMEOUT_MS = 15_000 + +export type Socks5Options = { + /** Opens the outbound leg. An unresolved `host` stays unresolved. */ + connect: (host: string, port: number) => Promise + username: string + password: string + /** Handshake and forwarding failures, for logging. */ + onError?: (error: Error) => void +} + +export type Socks5Server = { + /** Ephemeral port on 127.0.0.1. */ + port: number + /** Closes the listener and destroys every connection still open on it. */ + close: () => Promise +} + +/** Carries the SOCKS reply code to send before hanging up. */ +class Socks5ProtocolError extends Error { + readonly reply: number + + constructor(message: string, reply: number) { + super(message) + this.name = 'Socks5ProtocolError' + this.reply = reply + } +} + +/** + * Reads exactly `need` bytes. The socket never enters flowing mode, so + * anything pipelined behind the handshake stays buffered for the later + * `pipe()`. + */ +function readBytes(socket: Socket, need: number): Promise { + if (need === 0) return Promise.resolve(Buffer.alloc(0)) + return new Promise((resolve, reject) => { + const cleanup = (): void => { + socket.removeListener('readable', onReadable) + socket.removeListener('end', onEnd) + socket.removeListener('error', onError) + socket.removeListener('timeout', onTimeout) + } + const fail = (error: Error): void => { + cleanup() + reject(error) + } + const onReadable = (): void => { + const chunk: Buffer | null = socket.read(need) + if (chunk === null) return + cleanup() + resolve(chunk) + } + const onEnd = (): void => fail(new Error('client closed the connection mid-handshake')) + const onError = (error: Error): void => fail(error) + const onTimeout = (): void => fail(new Error('SOCKS5 handshake timed out')) + + socket.on('readable', onReadable) + socket.once('end', onEnd) + socket.once('error', onError) + socket.once('timeout', onTimeout) + onReadable() + }) +} + +/** Constant-time compare that tolerates differing lengths. */ +function secretMatches(received: Buffer, expected: string): boolean { + const a = createHash('sha256').update(received).digest() + const b = createHash('sha256').update(expected, 'utf8').digest() + return timingSafeEqual(a, b) +} + +async function readAddress(socket: Socket, addressType: number): Promise { + if (addressType === ATYP_IPV4) { + return [...(await readBytes(socket, 4))].join('.') + } + if (addressType === ATYP_DOMAIN) { + const length = (await readBytes(socket, 1)).readUInt8(0) + if (length === 0) { + throw new Socks5ProtocolError('empty destination hostname', REPLY_ADDRESS_NOT_SUPPORTED) + } + return (await readBytes(socket, length)).toString('utf8') + } + if (addressType === ATYP_IPV6) { + const raw = await readBytes(socket, 16) + const groups: string[] = [] + for (let offset = 0; offset < raw.length; offset += 2) { + groups.push(raw.readUInt16BE(offset).toString(16)) + } + return groups.join(':') + } + throw new Socks5ProtocolError( + `unsupported address type 0x${addressType.toString(16)}`, + REPLY_ADDRESS_NOT_SUPPORTED + ) +} + +/** Greeting → authentication → CONNECT request. */ +async function negotiate( + socket: Socket, + options: Socks5Options +): Promise<{ host: string; port: number }> { + const greeting = await readBytes(socket, 2) + if (greeting.readUInt8(0) !== VERSION) { + throw new Socks5ProtocolError( + `unsupported SOCKS version 0x${greeting.readUInt8(0).toString(16)}`, + REPLY_GENERAL_FAILURE + ) + } + const methods = await readBytes(socket, greeting.readUInt8(1)) + if (!methods.includes(METHOD_USERNAME_PASSWORD)) { + socket.end(Buffer.from([VERSION, METHOD_NONE_ACCEPTABLE])) + throw new Socks5ProtocolError( + 'client did not offer username/password authentication', + REPLY_GENERAL_FAILURE + ) + } + socket.write(Buffer.from([VERSION, METHOD_USERNAME_PASSWORD])) + + const authHeader = await readBytes(socket, 2) + if (authHeader.readUInt8(0) !== AUTH_VERSION) { + throw new Socks5ProtocolError( + 'unsupported authentication subnegotiation version', + REPLY_GENERAL_FAILURE + ) + } + const username = await readBytes(socket, authHeader.readUInt8(1)) + const passwordLength = (await readBytes(socket, 1)).readUInt8(0) + const password = await readBytes(socket, passwordLength) + if (!secretMatches(username, options.username) || !secretMatches(password, options.password)) { + socket.end(Buffer.from([AUTH_VERSION, AUTH_FAILURE])) + throw new Socks5ProtocolError('rejected SOCKS5 credentials', REPLY_GENERAL_FAILURE) + } + socket.write(Buffer.from([AUTH_VERSION, REPLY_SUCCESS])) + + const request = await readBytes(socket, 4) + if (request.readUInt8(0) !== VERSION) { + throw new Socks5ProtocolError('malformed SOCKS5 request', REPLY_GENERAL_FAILURE) + } + if (request.readUInt8(1) !== CMD_CONNECT) { + throw new Socks5ProtocolError('only CONNECT is supported', REPLY_COMMAND_NOT_SUPPORTED) + } + const host = await readAddress(socket, request.readUInt8(3)) + const port = (await readBytes(socket, 2)).readUInt16BE(0) + return { host, port } +} + +/** BND.ADDR / BND.PORT stay zero — meaningless for CONNECT, ignored by clients. */ +function replyFrame(code: number): Buffer { + return Buffer.from([VERSION, code, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]) +} + +function handleClient(socket: Socket, options: Socks5Options): void { + socket.setTimeout(HANDSHAKE_TIMEOUT_MS) + // Kept for the whole life of the socket: an unhandled 'error' on a bare + // net.Socket takes the process down, and the handshake's own listeners are + // removed after each read. + socket.on('error', (error) => options.onError?.(error)) + negotiate(socket, options) + .then(async ({ host, port }) => { + let remote: Duplex + try { + remote = await options.connect(host, port) + } catch (cause) { + socket.end(replyFrame(REPLY_CONNECTION_REFUSED)) + throw new Error(`failed to forward to ${host}:${port}`, { cause }) + } + // The driver drives its own idle timeouts from here on. + socket.setTimeout(0) + socket.write(replyFrame(REPLY_SUCCESS)) + + remote.on('error', () => socket.destroy()) + socket.once('close', () => remote.destroy()) + remote.once('close', () => socket.destroy()) + socket.pipe(remote) + remote.pipe(socket) + }) + .catch((error: unknown) => { + if (error instanceof Socks5ProtocolError && !socket.writableEnded) { + socket.end(replyFrame(error.reply)) + } + options.onError?.(error instanceof Error ? error : new Error(String(error))) + if (!socket.writableEnded) socket.destroy() + }) +} + +export function createSocks5Server(options: Socks5Options): Promise { + const live = new Set() + const server = createServer((socket) => { + live.add(socket) + socket.once('close', () => live.delete(socket)) + handleClient(socket, options) + }) + + return new Promise((resolve, reject) => { + const rejectListen = (error: Error): void => reject(error) + server.once('error', rejectListen) + server.listen(0, LOOPBACK, () => { + server.removeListener('error', rejectListen) + // Past bind, a listener error must not become an unhandled 'error'. + server.on('error', (error) => options.onError?.(error)) + + const address = server.address() + if (address === null || typeof address === 'string') { + server.close() + reject(new Error('SOCKS5 server did not bind to a TCP port')) + return + } + resolve({ + port: address.port, + close: () => + new Promise((closed) => { + for (const socket of live) socket.destroy() + live.clear() + server.close(() => closed()) + }) + }) + }) + }) +} diff --git a/src/main/services/ConnectionService.ts b/src/main/services/ConnectionService.ts index f3b1c26..3f674b5 100644 --- a/src/main/services/ConnectionService.ts +++ b/src/main/services/ConnectionService.ts @@ -1,6 +1,14 @@ import { MongoClient, type MongoClientOptions } from 'mongodb' import log from 'electron-log/main' -import type { ConnectionInput, ConnectionTestResult, StoredConnection } from '@shared/types' +import { + DEFAULT_SSH_PORT, + type ConnectionInput, + type ConnectionTestResult, + type ConnectResult, + type StoredSshTunnel, + type SshTunnelInput, + type StoredConnection +} from '@shared/types' import { type ConnectionsRepository, ConnectionNotFoundError @@ -11,6 +19,7 @@ import { injectExternalCredentials, injectStoredPassword } from '../lib/connectionUri' +import type { ResolvedSshTunnel, SshTunnelService, Tunnel } from './SshTunnelService' const DEFAULT_TIMEOUT = 3000 @@ -21,15 +30,26 @@ export class NotConnectedError extends Error { } } +/** A live connection, plus the tunnel it runs through if it has one. */ +type Active = { client: MongoClient; tunnel: Tunnel | null } + /** * Holds open MongoClient instances keyed by connection id. Multiple * connections may be active concurrently (multi-active model — see * design spec §14.1). + * + * A tunnel is held next to the client it belongs to, so the two always come + * and go together. */ export class ConnectionService { - private clients = new Map() + private clients = new Map() - constructor(private readonly repo: ConnectionsRepository) {} + constructor( + private readonly repo: ConnectionsRepository, + private readonly tunnels: SshTunnelService, + /** Called when main takes a connection down by itself. */ + private readonly onDropped: (connectionId: string, reason: string) => void + ) {} /** * Open a temporary client, ping the server, close it. Reports latency @@ -41,9 +61,22 @@ export class ConnectionService { */ async test(input: ConnectionInput, existingId?: string): Promise { const uri = await this.materializeFromInput(input, existingId) - const client = new MongoClient(uri, this.optionsFromInput(input)) + // A probe's tunnel is nobody else's: it is closed in the finally below, and + // its death needs no drop callback — the in-flight ping reports it. + const tunnel = + input.ssh?.enabled === true + ? await this.tunnels.open(await this.resolveSshFromInput(input.ssh, existingId)) + : null + const startedAt = Date.now() + let client: MongoClient | null = null try { + // Inside the try: the constructor parses the URI and throws on a bad + // option, which would otherwise leak the tunnel. + client = new MongoClient(uri, { + ...this.optionsFromInput(input), + ...(tunnel?.proxyOptions ?? {}) + }) await client.connect() const ping = (await client.db('admin').command({ ping: 1 })) as { ok?: number } const buildInfo = (await client.db('admin').command({ buildInfo: 1 })) as { @@ -52,36 +85,66 @@ export class ConnectionService { return { ok: ping.ok === 1, latencyMs: Date.now() - startedAt, - ...(buildInfo.version !== undefined ? { serverVersion: buildInfo.version } : {}) + ...(buildInfo.version !== undefined ? { serverVersion: buildInfo.version } : {}), + ...(tunnel?.pinnedHostKey ? { pinnedHostKey: tunnel.pinnedHostKey } : {}) } } finally { - await client.close().catch(() => undefined) + await client?.close().catch(() => undefined) + await tunnel?.close().catch(() => undefined) } } - async connect(id: string): Promise<{ connectionId: string }> { + async connect(id: string): Promise { if (this.clients.has(id)) return { connectionId: id } const stored = await this.repo.getStored(id) if (!stored) throw new ConnectionNotFoundError(id) const uri = this.materializeFromStored(stored) - const client = new MongoClient(uri, this.optionsFromStored(stored)) - await client.connect() - this.clients.set(id, client) + + const ssh = stored.ssh + const tunnel = + ssh?.enabled === true + ? await this.tunnels.open(this.resolveSshFromStored(stored, ssh), (reason) => + this.dropConnection(id, reason) + ) + : null + + let client: MongoClient | null = null + try { + client = new MongoClient(uri, { + ...this.optionsFromStored(stored), + ...(tunnel?.proxyOptions ?? {}) + }) + await client.connect() + } catch (error) { + await client?.close().catch(() => undefined) + await tunnel?.close().catch(() => undefined) + throw error + } + this.clients.set(id, { client, tunnel }) log.info(`Connected ${stored.name} (${id})`) - return { connectionId: id } + return { + connectionId: id, + ...(tunnel?.pinnedHostKey ? { pinnedHostKey: tunnel.pinnedHostKey } : {}) + } } async disconnect(id: string): Promise { - const client = this.clients.get(id) - if (!client) return + const active = this.clients.get(id) + if (!active) return this.clients.delete(id) - await client.close() + try { + await active.client.close() + } finally { + await active.tunnel?.close() + } log.info(`Disconnected ${id}`) } async closeAll(): Promise { const ids = [...this.clients.keys()] await Promise.allSettled(ids.map((id) => this.disconnect(id))) + // Sweeps anything a failed open left behind. + await this.tunnels.closeAll() } isConnected(id: string): boolean { @@ -89,9 +152,19 @@ export class ConnectionService { } getClient(id: string): MongoClient { - const client = this.clients.get(id) - if (!client) throw new NotConnectedError(id) - return client + const active = this.clients.get(id) + if (!active) throw new NotConnectedError(id) + return active.client + } + + /** The tunnel died on its own; the client on top of it is finished too. */ + private dropConnection(id: string, reason: string): void { + const active = this.clients.get(id) + if (active === undefined) return + this.clients.delete(id) + void active.client.close().catch(() => undefined) + log.warn(`Closed ${id} because its SSH tunnel dropped: ${reason}`) + this.onDropped(id, reason) } /** @@ -105,9 +178,7 @@ export class ConnectionService { } private async materializeFromInput(input: ConnectionInput, existingId?: string): Promise { - const formPassword = - input.password !== undefined && input.password.length > 0 ? input.password : undefined - let effectivePassword = formPassword + let effectivePassword = nonEmpty(input.password) if (effectivePassword === undefined && existingId !== undefined) { const stored = await this.repo.getStored(existingId) if (stored) { @@ -143,6 +214,50 @@ export class ConnectionService { return stored.uri } + private resolveSshFromStored(stored: StoredConnection, ssh: StoredSshTunnel): ResolvedSshTunnel { + const secrets = this.repo.decryptSsh(stored) + return { + host: ssh.host, + port: ssh.port ?? DEFAULT_SSH_PORT, + username: ssh.username, + authMethod: ssh.authMethod, + ...(ssh.privateKeyPath !== undefined ? { privateKeyPath: ssh.privateKeyPath } : {}), + ...(secrets.password !== undefined ? { password: secrets.password } : {}), + ...(secrets.passphrase !== undefined ? { passphrase: secrets.passphrase } : {}) + } + } + + /** + * Same, for the unsaved form payload behind "Test connection". A blank secret + * falls back to what the edited connection has stored, exactly as + * materializeFromInput does for the MongoDB password. + */ + private async resolveSshFromInput( + input: SshTunnelInput, + existingId?: string + ): Promise { + let password = nonEmpty(input.password) + let passphrase = nonEmpty(input.passphrase) + if ((password === undefined || passphrase === undefined) && existingId !== undefined) { + const stored = await this.repo.getStored(existingId) + if (stored) { + const secrets = this.repo.decryptSsh(stored) + password ??= secrets.password + passphrase ??= secrets.passphrase + } + } + const privateKeyPath = nonEmpty(input.privateKeyPath) + return { + host: input.host, + port: input.port ?? DEFAULT_SSH_PORT, + username: input.username, + authMethod: input.authMethod, + ...(privateKeyPath !== undefined ? { privateKeyPath } : {}), + ...(password !== undefined ? { password } : {}), + ...(passphrase !== undefined ? { passphrase } : {}) + } + } + private optionsFromInput(input: ConnectionInput): MongoClientOptions { return buildOptions({ serverSelectionTimeoutMS: input.serverSelectionTimeoutMS, @@ -182,6 +297,10 @@ export class ConnectionService { } } +function nonEmpty(value: string | undefined): string | undefined { + return value !== undefined && value.length > 0 ? value : undefined +} + type DriverInputs = { serverSelectionTimeoutMS?: number appName?: string diff --git a/src/main/services/SshTunnelService.test.ts b/src/main/services/SshTunnelService.test.ts new file mode 100644 index 0000000..08d9175 --- /dev/null +++ b/src/main/services/SshTunnelService.test.ts @@ -0,0 +1,290 @@ +import { generateKeyPairSync } from 'node:crypto' +import type { AddressInfo } from 'node:net' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { SocksClient } from 'socks' +import { Server, utils, type Connection } from 'ssh2' + +// The service logs through electron-log, which pulls in electron itself. +vi.mock('electron-log/main', () => ({ + default: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() } +})) + +const { SshAuthError, SshHostKeyMismatchError, SshTunnelService } = + await import('./SshTunnelService') +const { fingerprint } = await import('../lib/knownHosts') +type SshTunnelServiceType = InstanceType +type TunnelProxyOptions = Awaited>['proxyOptions'] + +const USERNAME = 'tunneluser' +const PASSWORD = 'tunnelsecret' +/** Points at nothing, so the developer's own known_hosts never interferes. */ +const NO_KNOWN_HOSTS = 'C:\\nonexistent\\mongobench-test\\known_hosts' + +let hostKeyPem: string + +beforeAll(() => { + // RSA rather than ed25519: PEM is the format ssh2's server side reads + // without any conversion. + hostKeyPem = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs1', format: 'pem' } + }).privateKey +}) + +/** The public key blob as it goes over the wire, for the pin-store fixtures. */ +function hostKeyBlob(): Buffer { + const parsed = utils.parseKey(hostKeyPem) + if (parsed instanceof Error) throw parsed + const key = Array.isArray(parsed) ? parsed[0] : parsed + if (key === undefined) throw new Error('no key parsed') + return key.getPublicSSH() +} + +type Forwarded = { host: string; port: number } + +type Fixture = { + service: SshTunnelServiceType + sshPort: number + forwarded: Forwarded[] + pinned: Array<{ host: string; port: number }> + dropped: string[] + /** Hangs up on every live SSH connection, leaving the listener up. */ + killClients: () => void + /** Hangs up and stops the listener. */ + stop: () => Promise +} + +let fixture: Fixture | null = null + +afterEach(async () => { + await fixture?.service.closeAll() + await fixture?.stop() + fixture = null +}) + +/** + * A real in-process SSH server that accepts password auth and echoes back + * everything sent through a direct-tcpip channel. + */ +async function startFixture(options: { storedHostKey?: Buffer } = {}): Promise { + const forwarded: Forwarded[] = [] + const pinned: Array<{ host: string; port: number }> = [] + const dropped: string[] = [] + + const live = new Set() + + const server = new Server({ hostKeys: [hostKeyPem] }, (client: Connection) => { + live.add(client) + client.on('close', () => live.delete(client)) + client.on('authentication', (ctx) => { + if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) { + ctx.accept() + return + } + // Announce password auth so the client does not keep guessing. + ctx.reject(['password']) + }) + client.on('ready', () => { + client.on('tcpip', (accept, _reject, info) => { + forwarded.push({ host: info.destIP, port: info.destPort }) + const channel = accept() + channel.on('data', (chunk: Buffer) => channel.write(chunk)) + }) + }) + // A rejected handshake surfaces as an error here; nothing to do. + client.on('error', () => undefined) + }) + + const sshPort = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve((server.address() as AddressInfo).port) + }) + }) + + const trustStore = { + get: () => Promise.resolve(options.storedHostKey ?? null), + pin: (host: string, port: number) => { + pinned.push({ host, port }) + return Promise.resolve() + } + } + + const service = new SshTunnelService(trustStore, NO_KNOWN_HOSTS) + + const killClients = (): void => { + for (const client of live) client.end() + live.clear() + } + + fixture = { + service, + sshPort, + forwarded, + pinned, + dropped, + killClients, + // close() alone waits for open connections, so hang up first. + stop: () => + new Promise((resolve) => { + killClients() + server.close(() => resolve()) + }) + } + return fixture +} + +function passwordConfig(port: number) { + return { + host: '127.0.0.1', + port, + username: USERNAME, + authMethod: 'password' as const, + password: PASSWORD + } +} + +function throughProxy( + tunnel: { proxyOptions: TunnelProxyOptions }, + destination: { host: string; port: number }, + timeout?: number +): ReturnType { + return SocksClient.createConnection({ + proxy: { + host: tunnel.proxyOptions.proxyHost, + port: tunnel.proxyOptions.proxyPort, + type: 5, + userId: tunnel.proxyOptions.proxyUsername, + password: tunnel.proxyOptions.proxyPassword + }, + command: 'connect', + destination, + ...(timeout !== undefined ? { timeout } : {}) + }) +} + +describe('SshTunnelService', () => { + it('forwards a hostname destination through the SSH session unresolved', async () => { + const f = await startFixture() + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + const { socket } = await throughProxy(tunnel, { host: 'mongo2.internal', port: 27017 }) + + const echoed = await new Promise((resolve, reject) => { + socket.once('data', (chunk: Buffer) => resolve(chunk.toString('utf8'))) + socket.once('error', reject) + socket.write('hello') + }) + socket.destroy() + + expect(echoed).toBe('hello') + // The name reached the SSH server, which is where it gets resolved — + // this is what makes discovered replica-set members reachable. + expect(f.forwarded).toEqual([{ host: 'mongo2.internal', port: 27017 }]) + }) + + it('pins a host key it has never seen and reports it', async () => { + const f = await startFixture() + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + expect(tunnel.pinnedHostKey).toEqual({ + host: '127.0.0.1', + fingerprint: fingerprint(hostKeyBlob()) + }) + expect(f.pinned).toEqual([{ host: '127.0.0.1', port: f.sshPort }]) + }) + + it('reports nothing when the pinned key already matches', async () => { + const f = await startFixture({ storedHostKey: hostKeyBlob() }) + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + expect(tunnel.pinnedHostKey).toBeNull() + expect(f.pinned).toEqual([]) + }) + + it('refuses a host key that differs from the pinned one', async () => { + const f = await startFixture({ storedHostKey: Buffer.from('a different key entirely') }) + await expect(f.service.open(passwordConfig(f.sshPort))).rejects.toThrow(SshHostKeyMismatchError) + expect(f.service.openCount).toBe(0) + }) + + it('reports bad credentials as an auth failure', async () => { + const f = await startFixture() + await expect( + f.service.open({ ...passwordConfig(f.sshPort), password: 'wrong' }) + ).rejects.toThrow(SshAuthError) + expect(f.service.openCount).toBe(0) + }) + + it('rejects password auth with no stored password before touching the network', async () => { + const f = await startFixture() + await expect(f.service.open({ ...passwordConfig(f.sshPort), password: '' })).rejects.toThrow( + SshAuthError + ) + }) + + it('fails when the private key file does not exist', async () => { + const f = await startFixture() + await expect( + f.service.open({ + host: '127.0.0.1', + port: f.sshPort, + username: USERNAME, + authMethod: 'privateKey', + privateKeyPath: 'C:\\nonexistent\\mongobench-test\\id_ed25519' + }) + ).rejects.toThrow(SshAuthError) + }) + + it('closes the proxy along with the tunnel', async () => { + const f = await startFixture() + const tunnel = await f.service.open(passwordConfig(f.sshPort)) + expect(f.service.openCount).toBe(1) + + await tunnel.close() + expect(f.service.openCount).toBe(0) + + await expect( + throughProxy(tunnel, { host: 'mongo1.internal', port: 27017 }, 2000) + ).rejects.toThrow() + }) + + it('does not report a drop for a tunnel closed on request', async () => { + const f = await startFixture() + const tunnel = await f.service.open(passwordConfig(f.sshPort), (reason) => + f.dropped.push(reason) + ) + await tunnel.close() + // Give any stray close/end handler a chance to fire. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(f.dropped).toEqual([]) + }) + + it('reports a drop when the SSH server hangs up', async () => { + const f = await startFixture() + await f.service.open(passwordConfig(f.sshPort), (reason) => f.dropped.push(reason)) + + const reported = new Promise((resolve) => { + const poll = setInterval(() => { + if (f.dropped.length > 0) { + clearInterval(poll) + resolve() + } + }, 10) + }) + f.killClients() + await reported + + expect(f.dropped[0]).toBeTypeOf('string') + // Cleaned up without a close() call, so a reconnect starts from scratch. + expect(f.service.openCount).toBe(0) + }) + + it('closeAll closes every open tunnel', async () => { + const f = await startFixture() + await f.service.open(passwordConfig(f.sshPort)) + await f.service.open(passwordConfig(f.sshPort)) + expect(f.service.openCount).toBe(2) + + await f.service.closeAll() + expect(f.service.openCount).toBe(0) + expect(f.dropped).toEqual([]) + }) +}) diff --git a/src/main/services/SshTunnelService.ts b/src/main/services/SshTunnelService.ts new file mode 100644 index 0000000..65d1b8d --- /dev/null +++ b/src/main/services/SshTunnelService.ts @@ -0,0 +1,318 @@ +import { randomBytes } from 'node:crypto' +import { promises as fs } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Duplex } from 'node:stream' +import log from 'electron-log/main' +import { Client, type ConnectConfig } from 'ssh2' +import type { PinnedHostKeyNotice, SshAuthMethod } from '@shared/types' +import { + findHostKeys, + fingerprint, + keyMatches, + parseKnownHosts, + type KnownHostEntry +} from '../lib/knownHosts' +import { createSocks5Server, type Socks5Server } from '../lib/socks5' + +const READY_TIMEOUT_MS = 15_000 +/** Idle sessions get dropped by firewalls and by sshd's own timeouts. */ +const KEEPALIVE_INTERVAL_MS = 20_000 +const LOOPBACK = '127.0.0.1' + +/** Tunnel settings with every secret already decrypted. */ +export type ResolvedSshTunnel = { + host: string + port: number + username: string + authMethod: SshAuthMethod + privateKeyPath?: string + password?: string + passphrase?: string +} + +/** What the driver needs to route through the tunnel. */ +export type TunnelProxyOptions = { + proxyHost: string + proxyPort: number + proxyUsername: string + proxyPassword: string +} + +/** An open tunnel. The caller owns it and is responsible for closing it. */ +export type Tunnel = { + proxyOptions: TunnelProxyOptions + /** Set only when this open pinned a host key it had never seen. */ + pinnedHostKey: PinnedHostKeyNotice | null + close: () => Promise +} + +export class SshConnectError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SshConnectError' + } +} + +export class SshAuthError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SshAuthError' + } +} + +export class SshHostKeyMismatchError extends Error { + constructor(message: string) { + super(message) + this.name = 'SshHostKeyMismatchError' + } +} + +/** The slice of HostKeysStore this service needs. */ +export type HostKeyTrustStore = { + get: (host: string, port: number) => Promise + pin: (host: string, port: number, key: Buffer) => Promise +} + +/** + * Opens SSH sessions with a loopback SOCKS5 proxy in front of each one. + * + * A dynamic proxy rather than a fixed port forward, because the driver picks + * its own hosts: after topology discovery it dials the replica-set members the + * server named, and those names only resolve on the far side. A SOCKS5 proxy + * forwards them by name, so every socket the driver opens lands in the tunnel. + * + * `open()` returns a handle instead of registering an id — the caller already + * knows what the tunnel belongs to, and a handle cannot be closed by anyone + * who does not hold it. The service only tracks what is open so it can close + * everything at quit. + */ +export class SshTunnelService { + private readonly active = new Set() + + constructor( + private readonly hostKeys: HostKeyTrustStore, + /** Overridable so tests do not depend on the developer's own file. */ + private readonly knownHostsPath: string = join(homedir(), '.ssh', 'known_hosts') + ) {} + + get openCount(): number { + return this.active.size + } + + /** `onDropped` fires when the session dies on its own, never on `close()`. */ + async open(config: ResolvedSshTunnel, onDropped?: (reason: string) => void): Promise { + const auth = await authConfig(config) + const client = new Client() + + // hostVerifier can only answer yes/no, so the reason is kept here to make + // the rejection say more than 'Handshake failed'. + let hostKeyError: Error | null = null + let pinnedHostKey: PinnedHostKeyNotice | null = null + + // This 'error' listener stays attached after 'ready' wins the race, and + // deliberately so: an ssh2 client with no 'error' listener takes the + // process down. Rejecting a settled promise does nothing. + const ready = new Promise((resolve, reject) => { + client.once('ready', resolve) + client.once('error', (error: Error) => reject(hostKeyError ?? translateSshError(error))) + }) + + try { + client.connect({ + host: config.host, + port: config.port, + username: config.username, + readyTimeout: READY_TIMEOUT_MS, + keepaliveInterval: KEEPALIVE_INTERVAL_MS, + hostVerifier: (key: Buffer, verify: (valid: boolean) => void): void => { + this.verifyHostKey(config.host, config.port, key) + .then((pinned) => { + pinnedHostKey = pinned + verify(true) + }) + .catch((error: unknown) => { + hostKeyError = error instanceof Error ? error : new Error(String(error)) + verify(false) + }) + }, + ...auth + }) + await ready + } catch (error) { + client.destroy() + // connect() also throws synchronously, e.g. for an unparseable key. + throw asSshError(error) + } + + const proxyUsername = randomBytes(16).toString('hex') + const proxyPassword = randomBytes(24).toString('hex') + + let server: Socks5Server + try { + server = await createSocks5Server({ + username: proxyUsername, + password: proxyPassword, + connect: (host, port) => forwardOut(client, host, port), + onError: (error) => log.debug(`SOCKS5 proxy: ${error.message}`) + }) + } catch (error) { + client.destroy() + throw error + } + + let closed = false + const teardown = async (): Promise => { + closed = true + this.active.delete(tunnel) + await server.close() + } + + const tunnel: Tunnel = { + proxyOptions: { proxyHost: LOOPBACK, proxyPort: server.port, proxyUsername, proxyPassword }, + pinnedHostKey, + close: async () => { + if (closed) return + await teardown() + client.end() + log.info(`SSH tunnel to ${config.host} closed`) + } + } + this.active.add(tunnel) + + const drop = (reason: string): void => { + if (closed) return + void teardown() + client.destroy() + log.warn(`SSH tunnel to ${config.host} dropped: ${reason}`) + onDropped?.(reason) + } + client.on('error', (error: Error) => drop(error.message)) + client.on('end', () => drop('the SSH server ended the connection')) + client.on('close', () => drop('the SSH connection closed')) + + log.info( + `SSH tunnel up via ${config.username}@${config.host}:${config.port}, SOCKS5 on ${LOOPBACK}:${server.port}` + ) + return tunnel + } + + async closeAll(): Promise { + const tunnels = [...this.active] + await Promise.allSettled(tunnels.map((tunnel) => tunnel.close())) + } + + /** + * Trust order: the user's own known_hosts wins, then our pin store, and only + * a host neither knows about gets pinned on the spot. + */ + private async verifyHostKey( + host: string, + port: number, + key: Buffer + ): Promise { + const allowed = findHostKeys(await readKnownHosts(this.knownHostsPath), host, port) + if (allowed.length > 0) { + if (allowed.some((known) => keyMatches(key, known))) return null + throw new SshHostKeyMismatchError( + `${host} presented host key ${fingerprint(key)}, which is not one of the keys your ~/.ssh/known_hosts lists for it!` + ) + } + + const pinned = await this.hostKeys.get(host, port) + if (pinned !== null) { + if (keyMatches(key, pinned)) return null + throw new SshHostKeyMismatchError( + `${host} presented host key ${fingerprint(key)}, which differs from the key MongoBench pinned for it earlier!` + ) + } + + await this.hostKeys.pin(host, port, key) + const notice = { host, fingerprint: fingerprint(key) } + log.warn(`Pinned a previously unseen SSH host key for ${host}:${port} — ${notice.fingerprint}`) + return notice + } +} + +function forwardOut(client: Client, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + // srcIP / srcPort are only reported to the server for logging. + client.forwardOut(LOOPBACK, 0, host, port, (error, channel) => { + if (error) reject(error) + else resolve(channel) + }) + }) +} + +async function readKnownHosts(path: string): Promise { + try { + return parseKnownHosts(await fs.readFile(path, 'utf8')) + } catch { + // No file, no permission — either way there is nothing to compare against. + return [] + } +} + +async function authConfig(config: ResolvedSshTunnel): Promise { + if (config.authMethod === 'password') { + if (config.password === undefined || config.password.length === 0) { + throw new SshAuthError('No SSH password is stored for this connection!') + } + return { password: config.password } + } + + if (config.authMethod === 'privateKey') { + const path = config.privateKeyPath ?? '' + if (path.length === 0) throw new SshAuthError('No private key file is configured!') + let privateKey: Buffer + try { + privateKey = await fs.readFile(path) + } catch (cause) { + throw new SshAuthError(`Cannot read the private key at ${path}!`, { cause }) + } + return { + privateKey, + ...(config.passphrase !== undefined && config.passphrase.length > 0 + ? { passphrase: config.passphrase } + : {}) + } + } + + return { agent: agentAddress() } +} + +function agentAddress(): string { + const fromEnv = process.env['SSH_AUTH_SOCK'] + if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv + if (process.platform === 'win32') return '\\\\.\\pipe\\openssh-ssh-agent' + throw new SshAuthError('No SSH agent found: SSH_AUTH_SOCK is not set!') +} + +function asSshError(error: unknown): Error { + if ( + error instanceof SshAuthError || + error instanceof SshConnectError || + error instanceof SshHostKeyMismatchError + ) { + return error + } + return translateSshError(error instanceof Error ? error : new Error(String(error))) +} + +/** + * ssh2 reports everything as a plain Error, so "your credentials are wrong" vs + * "the server is unreachable" has to be recovered from the message. + */ +function translateSshError(error: Error): Error { + const message = error.message + if ( + /authentication methods failed/i.test(message) || + /Cannot parse privateKey/i.test(message) || + /does not contain a \(valid\) private key/i.test(message) || + /no passphrase given/i.test(message) || + /bad passphrase/i.test(message) + ) { + return new SshAuthError(`SSH authentication failed: ${message}!`, { cause: error }) + } + return new SshConnectError(`Cannot reach the SSH server: ${message}!`, { cause: error }) +} diff --git a/src/main/stores/ConnectionsRepository.test.ts b/src/main/stores/ConnectionsRepository.test.ts new file mode 100644 index 0000000..af21daa --- /dev/null +++ b/src/main/stores/ConnectionsRepository.test.ts @@ -0,0 +1,198 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionInput, SshTunnelInput } from '@shared/types' + +// The repository imports electron for the default userData path; every test +// passes an explicit directory instead, so getPath is never actually called. +vi.mock('electron', () => ({ app: { getPath: () => '' } })) + +const { ConnectionsRepository, toRendererView } = await import('./ConnectionsRepository') + +/** + * Stand-in for safeStorage. Reversible on purpose — the point is not to test + * DPAPI but to prove that whatever reaches the disk went through encrypt() + * and that no cleartext travels alongside it. + */ +const CIPHER_PREFIX = 'enc:' +const secrets = { + isAvailable: () => true, + encrypt: (plaintext: string) => CIPHER_PREFIX + Buffer.from(plaintext, 'utf8').toString('base64'), + decrypt: (cipher: string) => + Buffer.from(cipher.slice(CIPHER_PREFIX.length), 'base64').toString('utf8') +} + +const SSH_PASSWORD = 'ssh-cleartext-password' +const SSH_PASSPHRASE = 'key-cleartext-passphrase' +const MONGO_PASSWORD = 'mongo-cleartext-password' + +let dir: string +let repo: InstanceType + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mongobench-repo-')) + repo = new ConnectionsRepository(secrets, dir) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const onDisk = (): Promise => readFile(join(dir, 'connections.json'), 'utf8') + +function input(ssh?: Partial): ConnectionInput { + return { + name: 'Cluster', + uri: 'mongodb://user@host:27017', + password: MONGO_PASSWORD, + ...(ssh !== undefined + ? { + ssh: { + enabled: true, + host: 'gateway.example.com', + port: 22, + username: 'tunneluser', + authMethod: 'password', + ...ssh + } + } + : {}) + } +} + +describe('secrets on disk', () => { + it('never writes a cleartext password or passphrase', async () => { + await repo.create( + input({ authMethod: 'privateKey', privateKeyPath: '/home/me/.ssh/id_ed25519' }) + ) + // Re-create with both secret kinds set to cover each field. + await repo.create(input({ password: SSH_PASSWORD })) + await repo.create( + input({ + authMethod: 'privateKey', + privateKeyPath: '/home/me/.ssh/id_ed25519', + passphrase: SSH_PASSPHRASE + }) + ) + + const raw = await onDisk() + expect(raw).not.toContain(MONGO_PASSWORD) + expect(raw).not.toContain(SSH_PASSWORD) + expect(raw).not.toContain(SSH_PASSPHRASE) + }) + + it('stores the SSH secrets as ciphertext that decrypts back', async () => { + const created = await repo.create(input({ password: SSH_PASSWORD })) + const stored = await repo.getStored(created.id) + expect(stored?.ssh?.encryptedPassword).toMatch(/^enc:/) + expect(repo.decryptSsh(stored!)).toEqual({ password: SSH_PASSWORD }) + }) + + it('keeps the MongoDB password out of the stored URI', async () => { + const created = await repo.create(input()) + const stored = await repo.getStored(created.id) + expect(stored?.uri).not.toContain(MONGO_PASSWORD) + expect(stored?.uri).toContain('%3CMONGOBENCH_PWD%3E') + }) + + it('stores only the private key path, never key material', async () => { + const created = await repo.create( + input({ authMethod: 'privateKey', privateKeyPath: '/home/me/.ssh/id_ed25519' }) + ) + const stored = await repo.getStored(created.id) + expect(Object.keys(stored?.ssh ?? {}).sort()).toEqual([ + 'authMethod', + 'enabled', + 'host', + 'port', + 'privateKeyPath', + 'username' + ]) + }) +}) + +describe('secrets across edits', () => { + it('keeps the stored secret when the form leaves the field blank', async () => { + const created = await repo.create(input({ password: SSH_PASSWORD })) + await repo.update(created.id, input({ password: '' })) + const stored = await repo.getStored(created.id) + expect(repo.decryptSsh(stored!)).toEqual({ password: SSH_PASSWORD }) + }) + + it('drops the password when the auth method stops using it', async () => { + const created = await repo.create(input({ password: SSH_PASSWORD })) + await repo.update( + created.id, + input({ authMethod: 'privateKey', privateKeyPath: '/home/me/.ssh/id_ed25519' }) + ) + const stored = await repo.getStored(created.id) + expect(stored?.ssh?.encryptedPassword).toBeUndefined() + expect(repo.decryptSsh(stored!)).toEqual({}) + }) + + it('drops the passphrase when the auth method stops using it', async () => { + const created = await repo.create( + input({ + authMethod: 'privateKey', + privateKeyPath: '/home/me/.ssh/id_ed25519', + passphrase: SSH_PASSPHRASE + }) + ) + await repo.update(created.id, input({ authMethod: 'agent' })) + const stored = await repo.getStored(created.id) + expect(stored?.ssh?.encryptedPassphrase).toBeUndefined() + }) + + it('keeps the tunnel settings when it is switched off', async () => { + const created = await repo.create(input({ password: SSH_PASSWORD })) + await repo.update(created.id, input({ enabled: false, password: '' })) + const stored = await repo.getStored(created.id) + expect(stored?.ssh?.enabled).toBe(false) + expect(stored?.ssh?.host).toBe('gateway.example.com') + expect(repo.decryptSsh(stored!)).toEqual({ password: SSH_PASSWORD }) + }) +}) + +describe('toRendererView', () => { + it('replaces the SSH secrets with hasStored flags', async () => { + const created = await repo.create(input({ password: SSH_PASSWORD })) + const stored = await repo.getStored(created.id) + const view = toRendererView(stored!) + + expect(view.ssh).toEqual({ + enabled: true, + host: 'gateway.example.com', + port: 22, + username: 'tunneluser', + authMethod: 'password', + hasStoredPassword: true, + hasStoredPassphrase: false + }) + // Nothing secret survives the projection, in any nesting. + const serialized = JSON.stringify(view) + expect(serialized).not.toContain(CIPHER_PREFIX) + expect(serialized).not.toContain(SSH_PASSWORD) + expect(serialized).not.toContain('MONGOBENCH_PWD') + }) + + it('reports hasStoredPassphrase for a key with one', async () => { + const created = await repo.create( + input({ + authMethod: 'privateKey', + privateKeyPath: '/home/me/.ssh/id_ed25519', + passphrase: SSH_PASSPHRASE + }) + ) + const view = toRendererView((await repo.getStored(created.id))!) + expect(view.ssh?.hasStoredPassphrase).toBe(true) + expect(view.ssh?.hasStoredPassword).toBe(false) + }) + + it('omits ssh entirely for connections that never had a tunnel', async () => { + const created = await repo.create(input()) + const view = toRendererView((await repo.getStored(created.id))!) + expect(view.ssh).toBeUndefined() + expect(view.hasStoredPassword).toBe(true) + }) +}) diff --git a/src/main/stores/ConnectionsRepository.ts b/src/main/stores/ConnectionsRepository.ts index cfa3ee3..4c741d9 100644 --- a/src/main/stores/ConnectionsRepository.ts +++ b/src/main/stores/ConnectionsRepository.ts @@ -2,7 +2,14 @@ import { app } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import { randomUUID } from 'node:crypto' -import type { ConnectionConfig, ConnectionInput, StoredConnection } from '@shared/types' +import type { + ConnectionConfig, + ConnectionInput, + SshTunnelInput, + SshTunnelView, + StoredConnection, + StoredSshTunnel +} from '@shared/types' import { canonicalize, ensurePasswordPlaceholder, parseUri } from '../lib/connectionUri' import type { SecretsStore } from './SecretsStore' @@ -95,6 +102,20 @@ export class ConnectionsRepository { return this.secrets.decrypt(stored.encryptedPassword) } + /** The SSH secrets in cleartext. Empty when the connection has none. */ + decryptSsh(stored: StoredConnection): { password?: string; passphrase?: string } { + const ssh = stored.ssh + if (ssh === undefined) return {} + return { + ...(ssh.encryptedPassword !== undefined + ? { password: this.secrets.decrypt(ssh.encryptedPassword) } + : {}), + ...(ssh.encryptedPassphrase !== undefined + ? { passphrase: this.secrets.decrypt(ssh.encryptedPassphrase) } + : {}) + } + } + private fromInput(input: ConnectionInput, existing?: StoredConnection): StoredConnection { const now = new Date().toISOString() const canonical = canonicalize({ @@ -105,6 +126,7 @@ export class ConnectionsRepository { : {}) }) + const ssh = this.sshFromInput(input.ssh, existing?.ssh) let encryptedPassword: string | undefined = existing?.encryptedPassword let storageUri = canonical.storageUri @@ -134,6 +156,7 @@ export class ConnectionsRepository { ? { serverSelectionTimeoutMS: input.serverSelectionTimeoutMS } : {}), ...(input.appName !== undefined ? { appName: input.appName } : {}), + ...(ssh !== undefined ? { ssh } : {}), ...(input.directConnection !== undefined ? { directConnection: input.directConnection } : {}), ...(input.replicaSet !== undefined ? { replicaSet: input.replicaSet } : {}), ...(input.readPreference !== undefined ? { readPreference: input.readPreference } : {}), @@ -151,6 +174,49 @@ export class ConnectionsRepository { } } + /** + * Encrypts the SSH secrets, or carries the stored ciphertext over when the + * form left the field blank — the same "blank means keep" convention the + * MongoDB password uses. + * + * A secret only survives while the auth method it belongs to is still + * selected, so switching to key auth does not leave a password behind in + * connections.json that nothing will ever use again. + */ + private sshFromInput( + input: SshTunnelInput | undefined, + existing: StoredSshTunnel | undefined + ): StoredSshTunnel | undefined { + if (input === undefined) return undefined + + const carriedPassword = + input.authMethod === 'password' ? existing?.encryptedPassword : undefined + const carriedPassphrase = + input.authMethod === 'privateKey' ? existing?.encryptedPassphrase : undefined + + const encryptedPassword = + input.password !== undefined && input.password.length > 0 + ? this.secrets.encrypt(input.password) + : carriedPassword + const encryptedPassphrase = + input.passphrase !== undefined && input.passphrase.length > 0 + ? this.secrets.encrypt(input.passphrase) + : carriedPassphrase + + return { + enabled: input.enabled, + host: input.host, + ...(input.port !== undefined ? { port: input.port } : {}), + username: input.username, + authMethod: input.authMethod, + ...(input.privateKeyPath !== undefined && input.privateKeyPath.length > 0 + ? { privateKeyPath: input.privateKeyPath } + : {}), + ...(encryptedPassword !== undefined ? { encryptedPassword } : {}), + ...(encryptedPassphrase !== undefined ? { encryptedPassphrase } : {}) + } + } + private async load(): Promise { if (this.cache !== null) return [...this.cache] try { @@ -182,16 +248,27 @@ export class ConnectionsRepository { * renderer never sees either the placeholder token or any cleartext. * The username remains available as its own field; the renderer can * reconstruct a display string from `{uri, username, hasStoredPassword}`. + * The SSH secrets go the same way, down to `hasStored…` flags. * * THIS IS THE ONLY PLACE THIS PROJECTION SHOULD HAPPEN. */ export function toRendererView(stored: StoredConnection): ConnectionConfig { - const { encryptedPassword, ...rest } = stored + const { encryptedPassword, ssh, ...rest } = stored const parts = parseUri(stored.uri) const bareUri = `${parts.schemeWithSep}${parts.hostAndRest}` return { ...rest, uri: bareUri, + ...(ssh !== undefined ? { ssh: toSshView(ssh) } : {}), hasStoredPassword: encryptedPassword !== undefined } } + +function toSshView(ssh: StoredSshTunnel): SshTunnelView { + const { encryptedPassword, encryptedPassphrase, ...rest } = ssh + return { + ...rest, + hasStoredPassword: encryptedPassword !== undefined, + hasStoredPassphrase: encryptedPassphrase !== undefined + } +} diff --git a/src/main/stores/HostKeysStore.ts b/src/main/stores/HostKeysStore.ts new file mode 100644 index 0000000..370d1e7 --- /dev/null +++ b/src/main/stores/HostKeysStore.ts @@ -0,0 +1,82 @@ +import { app } from 'electron' +import { promises as fs } from 'node:fs' +import { join } from 'node:path' +import { fingerprint } from '../lib/knownHosts' + +const FILE_NAME = 'ssh-host-keys.json' +const FILE_VERSION = 1 + +export type PinnedHostKey = { + /** Raw public key blob, base64. */ + key: string + fingerprint: string + pinnedAt: string +} + +type FileShape = { + version: number + hosts: Record +} + +/** + * Trust-on-first-use store for SSH host keys, consulted only for hosts the + * user's own `~/.ssh/known_hosts` says nothing about. Recording the first key + * is what turns the tunnel from "encrypted to whoever answers" into "encrypted + * to the same server as last time". + * + * Its own file, not part of connections.json: trust belongs to a host, and + * several connections may share one SSH server. + */ +export class HostKeysStore { + private readonly filePath: string + private cache: Record | null = null + + constructor(userDataPath?: string) { + this.filePath = join(userDataPath ?? app.getPath('userData'), FILE_NAME) + } + + async get(host: string, port: number): Promise { + const hosts = await this.load() + const pinned = hosts[hostKey(host, port)] + return pinned === undefined ? null : Buffer.from(pinned.key, 'base64') + } + + async pin(host: string, port: number, key: Buffer): Promise { + const hosts = await this.load() + hosts[hostKey(host, port)] = { + key: key.toString('base64'), + // Not read back — it is here so the file can be eyeballed. + fingerprint: fingerprint(key), + pinnedAt: new Date().toISOString() + } + await this.save(hosts) + } + + private async load(): Promise> { + if (this.cache !== null) return this.cache + try { + const data = await fs.readFile(this.filePath, 'utf8') + const parsed = JSON.parse(data) as FileShape + this.cache = typeof parsed.hosts === 'object' && parsed.hosts !== null ? parsed.hosts : {} + return this.cache + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.cache = {} + return this.cache + } + throw error + } + } + + private async save(hosts: Record): Promise { + this.cache = hosts + const tmpPath = `${this.filePath}.tmp` + const payload: FileShape = { version: FILE_VERSION, hosts } + await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8') + await fs.rename(tmpPath, this.filePath) + } +} + +function hostKey(host: string, port: number): string { + return `${host.toLowerCase()}:${port}` +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d8ad69a..b32fd1b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import type { Api } from '@shared/api' -import type { UpdateCheckResult, UpdateProgress } from '@shared/events' +import type { ConnectionDropped, UpdateCheckResult, UpdateProgress } from '@shared/events' import type { Result } from '@shared/result' import type { AggregateRequest, @@ -11,6 +11,7 @@ import type { ConnectionInput, ConnectionTestResult, ConnectionUpdatePayload, + ConnectResult, CountRequest, CountResponse, CreateIndexPayload, @@ -62,9 +63,14 @@ const api: Api = { delete: (id: string) => invoke('connections:delete', { id }), test: (input: ConnectionInput, existingId?: string) => invoke('connections:test', { input, existingId }), - connect: (id: string) => invoke<{ connectionId: string }>('connections:connect', { id }), + connect: (id: string) => invoke('connections:connect', { id }), disconnect: (connectionId: string) => invoke('connections:disconnect', { connectionId }), - reorder: (ids: string[]) => invoke('connections:reorder', { ids }) + reorder: (ids: string[]) => invoke('connections:reorder', { ids }), + onDropped: (listener: (payload: ConnectionDropped) => void) => + subscribe('connections:dropped', listener) + }, + dialog: { + pickPrivateKey: () => invoke('dialog:pickPrivateKey') }, databases: { list: (connectionId: string) => invoke('databases:list', { connectionId }), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 926d647..4228f75 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { toast } from 'sonner' import { ConnectionsExplorer } from '@/features/explorer/ConnectionsExplorer' import { TabBar } from '@/features/tabs/TabBar' import { CollectionTab } from '@/features/collection/CollectionTab' @@ -31,6 +32,7 @@ export default function App() { }, []) const activeIds = useAppStore((s) => s.activeConnectionIds) + const markDisconnected = useAppStore((s) => s.markDisconnected) const { data: connections } = useQuery({ queryKey: queryKeys.connections, queryFn: () => api.connections.list() @@ -38,6 +40,17 @@ export default function App() { const dashboardConnection = !activeTab ? connections?.find((c) => activeIds.has(c.id)) : undefined const onWelcome = !activeTab && !dashboardConnection + // Main took a connection down without being asked — today that means a dead + // SSH tunnel. Subscribed here because the sidebar is not always mounted. + // Open tabs are left alone: a drop is usually followed by a reconnect, and + // discarding the user's queries over a network hiccup would be worse. + useEffect(() => { + return api.connections.onDropped(({ connectionId, reason }) => { + markDisconnected(connectionId) + toast.error('Connection lost', { description: reason }) + }) + }, [markDisconnected]) + return (
diff --git a/src/renderer/src/features/connections/ConnectionFormDialog.tsx b/src/renderer/src/features/connections/ConnectionFormDialog.tsx index 881defa..78cec17 100644 --- a/src/renderer/src/features/connections/ConnectionFormDialog.tsx +++ b/src/renderer/src/features/connections/ConnectionFormDialog.tsx @@ -1,7 +1,15 @@ import { useEffect, useMemo, useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { CheckCircle2, ChevronDown, ChevronRight, Loader2, XCircle } from 'lucide-react' +import { + CheckCircle2, + ChevronDown, + ChevronRight, + FolderOpen, + Loader2, + ShieldQuestion, + XCircle +} from 'lucide-react' import { Dialog, DialogContent, @@ -25,15 +33,30 @@ import { TimezoneSelect } from './TimezoneSelect' import { api, ApiError } from '@/lib/api' import { queryKeys } from '@/lib/queryClient' import { cn } from '@/lib/utils' -import type { - AuthMechanism, - ConnectionConfig, - ConnectionInput, - ConnectionTestResult, - ReadPreference, - UuidEncoding +import { + DEFAULT_SSH_PORT, + type AuthMechanism, + type ConnectionConfig, + type ConnectionInput, + type ConnectionTestResult, + type ReadPreference, + type SshAuthMethod, + type SshTunnelInput, + type UuidEncoding } from '@shared/types' +type SshFormState = { + enabled: boolean + host: string + /** Text, like the other numeric inputs in this form. */ + port: string + username: string + authMethod: SshAuthMethod + privateKeyPath: string + password: string + passphrase: string +} + type FormState = { name: string uri: string @@ -56,8 +79,20 @@ type FormState = { socketTimeoutMS: string retryWrites: 'default' | 'on' | 'off' retryReads: 'default' | 'on' | 'off' + ssh: SshFormState } +const emptySsh = (): SshFormState => ({ + enabled: false, + host: '', + port: String(DEFAULT_SSH_PORT), + username: '', + authMethod: 'privateKey', + privateKeyPath: '', + password: '', + passphrase: '' +}) + const emptyForm = (): FormState => ({ name: '', uri: 'mongodb://localhost:27017', @@ -79,7 +114,8 @@ const emptyForm = (): FormState => ({ connectTimeoutMS: '', socketTimeoutMS: '', retryWrites: 'default', - retryReads: 'default' + retryReads: 'default', + ssh: emptySsh() }) const fromConnection = (conn: ConnectionConfig): FormState => ({ @@ -103,7 +139,21 @@ const fromConnection = (conn: ConnectionConfig): FormState => ({ connectTimeoutMS: conn.connectTimeoutMS !== undefined ? String(conn.connectTimeoutMS) : '', socketTimeoutMS: conn.socketTimeoutMS !== undefined ? String(conn.socketTimeoutMS) : '', retryWrites: conn.retryWrites === undefined ? 'default' : conn.retryWrites ? 'on' : 'off', - retryReads: conn.retryReads === undefined ? 'default' : conn.retryReads ? 'on' : 'off' + retryReads: conn.retryReads === undefined ? 'default' : conn.retryReads ? 'on' : 'off', + ssh: + conn.ssh === undefined + ? emptySsh() + : { + enabled: conn.ssh.enabled, + host: conn.ssh.host, + port: String(conn.ssh.port ?? DEFAULT_SSH_PORT), + username: conn.ssh.username, + authMethod: conn.ssh.authMethod, + privateKeyPath: conn.ssh.privateKeyPath ?? '', + // Secrets never come back from main; blank means "keep". + password: '', + passphrase: '' + } }) const parseInt = (raw: string): number | undefined => { @@ -112,6 +162,32 @@ const parseInt = (raw: string): number | undefined => { return Number.isFinite(n) && n > 0 ? n : undefined } +/** + * Undefined when the section is off and untouched, so connections that never + * needed a tunnel do not grow an empty `ssh` block. A disabled-but-filled + * section is still sent, so switching the tunnel off keeps its settings for + * the next time it is switched on. + */ +function buildSshInput(ssh: SshFormState): SshTunnelInput | undefined { + const host = ssh.host.trim() + const username = ssh.username.trim() + const privateKeyPath = ssh.privateKeyPath.trim() + const touched = host.length > 0 || username.length > 0 || privateKeyPath.length > 0 + if (!ssh.enabled && !touched) return undefined + + const port = parseInt(ssh.port) + return { + enabled: ssh.enabled, + host, + ...(port !== undefined ? { port } : {}), + username, + authMethod: ssh.authMethod, + ...(privateKeyPath.length > 0 ? { privateKeyPath } : {}), + ...(ssh.password.length > 0 ? { password: ssh.password } : {}), + ...(ssh.passphrase.length > 0 ? { passphrase: ssh.passphrase } : {}) + } +} + function buildInput(form: FormState): ConnectionInput { const input: ConnectionInput = { name: form.name.trim(), @@ -143,6 +219,8 @@ function buildInput(form: FormState): ConnectionInput { if (st !== undefined) input.socketTimeoutMS = st if (form.retryWrites !== 'default') input.retryWrites = form.retryWrites === 'on' if (form.retryReads !== 'default') input.retryReads = form.retryReads === 'on' + const ssh = buildSshInput(form.ssh) + if (ssh !== undefined) input.ssh = ssh return input } @@ -176,6 +254,13 @@ export function ConnectionFormDialog({ open, onOpenChange, connection }: Props) if (!input.uri.startsWith('mongodb://') && !input.uri.startsWith('mongodb+srv://')) { return 'URI must start with mongodb:// or mongodb+srv://' } + if (input.ssh?.enabled === true) { + if (input.ssh.host.length === 0) return 'SSH host is required' + if (input.ssh.username.length === 0) return 'SSH username is required' + if (input.ssh.authMethod === 'privateKey' && input.ssh.privateKeyPath === undefined) { + return 'Private key file is required' + } + } return null }, [input]) @@ -212,6 +297,9 @@ export function ConnectionFormDialog({ open, onOpenChange, connection }: Props) const update = (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })) + const updateSsh = (patch: Partial) => + setForm((prev) => ({ ...prev, ssh: { ...prev.ssh, ...patch } })) + return ( @@ -278,6 +366,24 @@ export function ConnectionFormDialog({ open, onOpenChange, connection }: Props)
+
+ updateSsh({ enabled: v })} + /> + {form.ssh.enabled && ( + + )} +
+ + + + + onChange({ passphrase: e.target.value })} + autoComplete="new-password" + placeholder={hasStoredPassphrase ? '••••••••' : 'optional'} + /> + + + )} + + {value.authMethod === 'password' && ( + + onChange({ password: e.target.value })} + autoComplete="new-password" + placeholder={hasStoredPassword ? '••••••••' : ''} + /> + + )} + + {value.authMethod === 'agent' && ( +

+ Uses the agent this machine already runs — SSH_AUTH_SOCK, or the OpenSSH + named pipe on Windows. No key or password is stored by MongoBench. +

+ )} + +

+ Every connection the driver opens is routed through the tunnel, so the connection string + above must name the hosts{' '} + as the SSH server sees them — list all replica-set + members and let it resolve them. mongodb+srv:// is the exception: its DNS + lookup still happens locally. +

+ + ) +} + function Field({ label, htmlFor, @@ -618,35 +879,51 @@ function TestStatus({ }) { if (!pending && !result && !error) return null return ( -
- {pending && } - {result?.ok && } - {error !== null && } -
- {pending && 'Probing server…'} - {result?.ok && ( - <> -
Connected
-
- {result.serverVersion ? `MongoDB ${result.serverVersion} · ` : ''} - {result.latencyMs} ms latency -
- - )} - {error !== null && ( - <> -
Connection failed
-
{error}
- +
+
+ {pending && } + {result?.ok && } + {error !== null && } +
+ {pending && 'Probing server…'} + {result?.ok && ( + <> +
Connected
+
+ {result.serverVersion ? `MongoDB ${result.serverVersion} · ` : ''} + {result.latencyMs} ms latency +
+ + )} + {error !== null && ( + <> +
Connection failed
+
{error}
+ + )} +
+ + {result?.pinnedHostKey && ( +
+ +
+
New SSH host key pinned
+
+ MongoBench had never seen {result.pinnedHostKey.host} before and has remembered the + key it presented. Compare it against the server before you trust this connection:{' '} + {result.pinnedHostKey.fingerprint} +
+
+
+ )}
) } diff --git a/src/renderer/src/features/connections/pinnedHostKeyToast.ts b/src/renderer/src/features/connections/pinnedHostKeyToast.ts new file mode 100644 index 0000000..6208580 --- /dev/null +++ b/src/renderer/src/features/connections/pinnedHostKeyToast.ts @@ -0,0 +1,16 @@ +import { toast } from 'sonner' +import type { ConnectResult } from '@shared/types' + +/** + * Warns the user when a tunnel just trusted an SSH host key MongoBench had + * never seen before. Shared by every place that connects, so the notice cannot + * be missed by taking a different route into a connection. + */ +export function notifyPinnedHostKey(result: ConnectResult): void { + const pinned = result.pinnedHostKey + if (pinned === undefined) return + toast.warning(`New SSH host key pinned for ${pinned.host}`, { + description: `Compare it against the server before trusting this tunnel: ${pinned.fingerprint}`, + duration: 15_000 + }) +} diff --git a/src/renderer/src/features/explorer/ConnectionGroup.tsx b/src/renderer/src/features/explorer/ConnectionGroup.tsx index d460baa..e042ae4 100644 --- a/src/renderer/src/features/explorer/ConnectionGroup.tsx +++ b/src/renderer/src/features/explorer/ConnectionGroup.tsx @@ -28,6 +28,7 @@ import { ContextMenuTrigger } from '@/components/ui/context-menu' import { CreateDatabaseDialog } from '@/features/collection/CreateDatabaseDialog' +import { notifyPinnedHostKey } from '@/features/connections/pinnedHostKeyToast' import { DatabaseGroup } from './DatabaseGroup' import type { ConnectionConfig } from '@shared/types' @@ -77,8 +78,9 @@ export function ConnectionGroup({ const connectMutation = useMutation({ mutationFn: () => api.connections.connect(connection.id), - onSuccess: () => { + onSuccess: (result) => { markConnected(connection.id) + notifyPinnedHostKey(result) void queryClient.invalidateQueries({ queryKey: queryKeys.databases(connection.id) }) }, onError: (e: unknown) => { diff --git a/src/renderer/src/features/palette/CommandPalette.tsx b/src/renderer/src/features/palette/CommandPalette.tsx index 9f6ca24..7da0ad9 100644 --- a/src/renderer/src/features/palette/CommandPalette.tsx +++ b/src/renderer/src/features/palette/CommandPalette.tsx @@ -10,6 +10,7 @@ import { CommandItem, CommandList } from '@/components/ui/command' +import { notifyPinnedHostKey } from '@/features/connections/pinnedHostKeyToast' import { api } from '@/lib/api' import { queryKeys } from '@/lib/queryClient' import { useAppStore } from '@/store' @@ -207,7 +208,7 @@ export function CommandPalette({ open, onOpenChange }: Props) { closeForConnection(c.id) } else { try { - await api.connections.connect(c.id) + notifyPinnedHostKey(await api.connections.connect(c.id)) markConnected(c.id) } catch { // toast handled at row level on the sidebar diff --git a/src/renderer/src/features/welcome/Welcome.tsx b/src/renderer/src/features/welcome/Welcome.tsx index b2d023c..f03bde1 100644 --- a/src/renderer/src/features/welcome/Welcome.tsx +++ b/src/renderer/src/features/welcome/Welcome.tsx @@ -15,6 +15,7 @@ import { import iconUrl from '@icon.png' import { Button } from '@/components/ui/button' import { ConnectionFormDialog } from '@/features/connections/ConnectionFormDialog' +import { notifyPinnedHostKey } from '@/features/connections/pinnedHostKeyToast' import { api, ApiError } from '@/lib/api' import { queryKeys } from '@/lib/queryClient' import { formatHostShort } from '@/lib/displayUri' @@ -136,10 +137,11 @@ function ConnectionRow({ const connectMutation = useMutation({ mutationFn: () => api.connections.connect(connection.id), - onSuccess: () => { + onSuccess: (result) => { markConnected(connection.id) onAfterConnect() toast.success(`Connected to ${connection.name}`) + notifyPinnedHostKey(result) }, onError: (e: unknown) => { const message = e instanceof ApiError ? e.message : String(e) diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 5710288..b747d66 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { UpdateCheckResult, UpdateProgress } from '@shared/events' +import type { ConnectionDropped, UpdateCheckResult, UpdateProgress } from '@shared/events' import type { ApiErrorPayload, ErrorCode, Result } from '@shared/result' import type { AggregateRequest, @@ -9,6 +9,7 @@ import type { ConnectionInput, ConnectionTestResult, ConnectionUpdatePayload, + ConnectResult, CountRequest, CountResponse, CreateIndexPayload, @@ -62,11 +63,12 @@ export const api = { delete: (id: string): Promise => unwrap(window.api.connections.delete(id)), test: (input: ConnectionInput, existingId?: string): Promise => unwrap(window.api.connections.test(input, existingId)), - connect: (id: string): Promise<{ connectionId: string }> => - unwrap(window.api.connections.connect(id)), + connect: (id: string): Promise => unwrap(window.api.connections.connect(id)), disconnect: (connectionId: string): Promise => unwrap(window.api.connections.disconnect(connectionId)), - reorder: (ids: string[]): Promise => unwrap(window.api.connections.reorder(ids)) + reorder: (ids: string[]): Promise => unwrap(window.api.connections.reorder(ids)), + onDropped: (listener: (payload: ConnectionDropped) => void): (() => void) => + window.api.connections.onDropped(listener) }, databases: { list: (connectionId: string): Promise => @@ -126,6 +128,9 @@ export const api = { unwrap(window.api.indexes.create(payload)), drop: (payload: DropIndexPayload): Promise => unwrap(window.api.indexes.drop(payload)) }, + dialog: { + pickPrivateKey: (): Promise => unwrap(window.api.dialog.pickPrivateKey()) + }, updater: { check: (): Promise => unwrap(window.api.updater.check()), download: (): Promise => unwrap(window.api.updater.download()), diff --git a/src/renderer/src/lib/queryClient.ts b/src/renderer/src/lib/queryClient.ts index 7cb231d..dbcbcf3 100644 --- a/src/renderer/src/lib/queryClient.ts +++ b/src/renderer/src/lib/queryClient.ts @@ -1,13 +1,20 @@ import { QueryClient } from '@tanstack/react-query' +import type { ErrorCode } from '@shared/result' import { ApiError } from './api' +/** Retrying these cannot change the answer — the input has to change first. */ +const NEVER_RETRIED = new Set([ + 'validation_error', + 'auth_failed', + 'ssh_auth_failed', + 'ssh_host_key_mismatch' +]) + export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: (failureCount, error) => { - if (error instanceof ApiError) { - if (error.code === 'validation_error' || error.code === 'auth_failed') return false - } + if (error instanceof ApiError && NEVER_RETRIED.has(error.code)) return false return failureCount < 1 }, staleTime: 30_000, diff --git a/src/shared/api.ts b/src/shared/api.ts index 3aea830..f442e9d 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -7,6 +7,7 @@ import type { ConnectionInput, ConnectionTestResult, ConnectionUpdatePayload, + ConnectResult, CountRequest, CountResponse, CreateIndexPayload, @@ -32,7 +33,7 @@ import type { ServerStats, UpdateUserPayload } from './types' -import type { UpdateCheckResult, UpdateProgress } from './events' +import type { ConnectionDropped, UpdateCheckResult, UpdateProgress } from './events' import type { Result } from './result' /** @@ -52,9 +53,16 @@ export type Api = { update: (payload: ConnectionUpdatePayload) => Promise> delete: (id: string) => Promise> test: (input: ConnectionInput, existingId?: string) => Promise> - connect: (id: string) => Promise> + connect: (id: string) => Promise> disconnect: (connectionId: string) => Promise> reorder: (ids: string[]) => Promise> + /** Main tore a connection down by itself. Returns an unsubscribe. */ + onDropped: (listener: (payload: ConnectionDropped) => void) => () => void + } + + dialog: { + /** Absolute path, or null when the user cancelled. */ + pickPrivateKey: () => Promise> } databases: { diff --git a/src/shared/events.ts b/src/shared/events.ts index b82b87f..7936529 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -1,4 +1,7 @@ -/** Update-flow payloads shared between main and renderer. */ +/** + * Payloads for the update flow plus the pushes main sends the renderer + * outside of any request it made. + */ export type UpdateSeverity = 'patch' | 'minor' | 'major' @@ -15,3 +18,14 @@ export type UpdateCheckResult = export type UpdateProgress = { percent: number } + +/** + * Pushed on `connections:dropped` when main tears a connection down on its + * own — today only when its SSH tunnel dies. The renderer has already been + * told the connection is live, so without this the sidebar would keep + * claiming so until the next query happened to fail. + */ +export type ConnectionDropped = { + connectionId: string + reason: string +} diff --git a/src/shared/result.ts b/src/shared/result.ts index 3efc6e8..84a11e6 100644 --- a/src/shared/result.ts +++ b/src/shared/result.ts @@ -16,6 +16,9 @@ export const ErrorCodes = [ 'auth_failed', 'network_error', 'server_selection_timeout', + 'ssh_connect_failed', + 'ssh_auth_failed', + 'ssh_host_key_mismatch', 'driver_error', 'not_found', 'conflict', diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 127b734..c16f3d7 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -9,6 +9,41 @@ const ReadPreferenceSchema = z.enum([ 'nearest' ]) const UuidEncodingSchema = z.enum(['default', 'java']) +const SshAuthMethodSchema = z.enum(['password', 'privateKey', 'agent']) + +/** + * Fields stay permissive while `enabled` is false so a half-filled tunnel + * section can still be saved with the tunnel switched off. Everything the + * tunnel actually needs is only required once it is on. + */ +export const SshTunnelInputSchema = z + .object({ + enabled: z.boolean(), + host: z.string().trim().max(255), + port: z.number().int().min(1).max(65_535).optional(), + username: z.string().trim().max(255), + authMethod: SshAuthMethodSchema, + privateKeyPath: z.string().trim().max(4096).optional(), + password: z.string().max(1024).optional(), + passphrase: z.string().max(1024).optional() + }) + .strict() + .superRefine((ssh, ctx) => { + if (!ssh.enabled) return + if (ssh.host.length === 0) { + ctx.addIssue({ code: 'custom', message: 'SSH host is required', path: ['host'] }) + } + if (ssh.username.length === 0) { + ctx.addIssue({ code: 'custom', message: 'SSH username is required', path: ['username'] }) + } + if (ssh.authMethod === 'privateKey' && (ssh.privateKeyPath ?? '').length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'Private key file is required', + path: ['privateKeyPath'] + }) + } + }) export const ConnectionInputSchema = z .object({ @@ -29,6 +64,7 @@ export const ConnectionInputSchema = z tls: z.boolean().optional(), serverSelectionTimeoutMS: z.number().int().min(1000).max(60_000).optional(), appName: z.string().trim().max(120).optional(), + ssh: SshTunnelInputSchema.optional(), directConnection: z.boolean().optional(), replicaSet: z.string().trim().max(120).optional(), readPreference: ReadPreferenceSchema.optional(), diff --git a/src/shared/types.ts b/src/shared/types.ts index 9d1a86e..ca94e0b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -31,6 +31,58 @@ export type ReadPreference = */ export type UuidEncoding = 'default' | 'java' +export type SshAuthMethod = 'password' | 'privateKey' | 'agent' + +export const DEFAULT_SSH_PORT = 22 + +/** + * Tunnel settings, in the same three shapes as the connection itself. + * + * Main puts a loopback SOCKS5 proxy in front of the SSH session and hands it + * to the driver, so every socket the driver opens — including the replica-set + * members it only learns about during topology discovery — goes through the + * tunnel and resolves on the SSH server. The URI therefore names the hosts as + * the SSH server sees them. + */ +export type SshTunnelInput = { + enabled: boolean + host: string + port?: number + username: string + authMethod: SshAuthMethod + /** Only the path is stored, never the key itself. */ + privateKeyPath?: string + /** Cleartext only in-flight. Blank on edit = keep what is stored. */ + password?: string + /** Cleartext only in-flight. Blank on edit = keep what is stored. */ + passphrase?: string +} + +/** On-disk shape — main process only. */ +export type StoredSshTunnel = { + enabled: boolean + host: string + port?: number + username: string + authMethod: SshAuthMethod + privateKeyPath?: string + /** Base64 of safeStorage.encryptString(cleartext). */ + encryptedPassword?: string + encryptedPassphrase?: string +} + +/** Renderer-facing view — never carries cleartext or ciphertext secrets. */ +export type SshTunnelView = { + enabled: boolean + host: string + port?: number + username: string + authMethod: SshAuthMethod + privateKeyPath?: string + hasStoredPassword: boolean + hasStoredPassphrase: boolean +} + export type AdvancedOptions = { directConnection?: boolean replicaSet?: string @@ -61,6 +113,8 @@ export type StoredConnection = { /** Default 3000 ms. */ serverSelectionTimeoutMS?: number appName?: string + /** Absent on connections that reach their hosts directly. */ + ssh?: StoredSshTunnel // Advanced driver options directConnection?: boolean replicaSet?: string @@ -96,6 +150,8 @@ export type ConnectionConfig = { /** Default 3000 ms. */ serverSelectionTimeoutMS?: number appName?: string + /** Absent on connections that reach their hosts directly. */ + ssh?: SshTunnelView directConnection?: boolean replicaSet?: string readPreference?: ReadPreference @@ -132,6 +188,7 @@ export type ConnectionInput = { /** Default 3000 ms. */ serverSelectionTimeoutMS?: number appName?: string + ssh?: SshTunnelInput directConnection?: boolean replicaSet?: string readPreference?: ReadPreference @@ -152,11 +209,27 @@ export type ConnectionInput = { retryReads?: boolean } +/** + * Reported when a host key was seen for the first time and pinned, so the user + * can check the fingerprint against the server before trusting the tunnel. + */ +export type PinnedHostKeyNotice = { + host: string + /** OpenSSH format, e.g. `SHA256:qGZ…`. */ + fingerprint: string +} + export type ConnectionTestResult = { ok: boolean latencyMs: number serverVersion?: string message?: string + pinnedHostKey?: PinnedHostKeyNotice +} + +export type ConnectResult = { + connectionId: string + pinnedHostKey?: PinnedHostKeyNotice } export type DatabaseInfo = {