diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d805e4e..fa9b912 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -112,7 +112,7 @@ jobs:
# `--publish always` uploads the installer + latest.yml /
# latest-linux.yml metadata to the GitHub Release matching the tag.
- # electron-updater reads latest.yml at runtime to detect new versions.
+ # The in-app update check reads latest.yml to detect new versions.
- name: Build and publish
run: npm run dist:${{ matrix.target }} -- --publish always
env:
diff --git a/README.md b/README.md
index 2eeb1c3..7e59edb 100644
--- a/README.md
+++ b/README.md
@@ -15,11 +15,12 @@ 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.
- **Built-in observability.** Per-connection dashboard with op rate, read/write/command latency, connection pool state, cache fill, network throughput, and a per-database storage breakdown.
-- **Auto-update on Windows and Linux** via `electron-updater`, pulling directly from this repo's GitHub Releases.
+- **Updates you decide on.** MongoBench tells you when a newer release exists and shows the download progress, but nothing is fetched or installed until you click. Builds are unsigned — the app never replaces its own binary behind your back.
## Screenshots
@@ -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.

@@ -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:
@@ -201,9 +209,13 @@ Per-connection live view, sampled every 5 s, sliding 5-min history:
Builds are **unsigned**. On first launch on Windows you'll see SmartScreen — click "More info → Run anyway".
-### Auto-update
+### Updates
-Installed builds check GitHub Releases at startup via `electron-updater`. New versions are downloaded in the background and applied on quit — no prompts, no clicks. AppImage updates self-replace; the Arch package updates via `pacman`.
+Installed builds ask GitHub Releases once at startup whether a newer version exists. If there is one, a notification appears in the corner — the further behind you are, the louder it is, and a missed major version is flagged in red.
+
+Nothing happens until you act on it. **Install update** starts the download and shows its progress; **Restart now** hands off to the installer, or you can ignore it and the update is applied the next time you start MongoBench. Since these builds are unsigned, an app that swaps out its own binary unprompted is not something we're willing to ship — and an installer running silently while you work will close the app mid-session to replace its files.
+
+The Arch package ships without an update feed and is updated through the AUR like any other package.
## Develop
@@ -239,13 +251,13 @@ src/
### Stack
-- **Electron 32** + **TypeScript** strict
+- **Electron 42** + **TypeScript** strict
- **electron-vite** — separate main / preload / renderer Vite configs
- **React 18** + **Zustand** + **TanStack Query**
- **Tailwind CSS v3** + **shadcn/ui** (Radix primitives)
- **Monaco** editor with custom `mongobench-dark` theme
- **mongodb** Node driver v7 + **bson** v7 (canonical / relaxed EJSON)
-- **electron-builder** for packaging, **electron-updater** for self-update
+- **electron-builder** for packaging, **electron-updater** driving the user-initiated update flow
- **Vitest** for unit tests
## Compatibility
diff --git a/electron-builder.yml b/electron-builder.yml
index 7e46438..f48ddee 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -20,8 +20,8 @@ extraResources:
extraMetadata:
main: out/main/index.js
win:
- # NSIS installer (per-user, no admin) — required by electron-updater
- # for self-replacing updates. User picks install dir.
+ # NSIS installer (per-user, no admin) — this is what lets a user-accepted
+ # update replace the install without a UAC prompt. User picks install dir.
target:
- target: nsis
arch:
@@ -34,7 +34,7 @@ nsis:
allowElevation: false
deleteAppDataOnUninstall: false
linux:
- # AppImage is supported by electron-updater for self-update on Linux.
+ # AppImage carries the metadata the in-app update flow needs on Linux.
target:
- target: AppImage
arch:
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 acea5be..eaef1cc 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -6,14 +6,17 @@ const APP_ICON = app.isPackaged
? join(process.resourcesPath, 'icon.png')
: join(app.getAppPath(), 'build', 'icon.png')
import log from 'electron-log/main'
+import { EventChannels } from './ipc/channels'
import { registerIpcHandlers } from './ipc/router'
import { ConnectionService } from './services/ConnectionService'
import { DatabaseService } from './services/DatabaseService'
import { IndexService } from './services/IndexService'
import { QueryService } from './services/QueryService'
-import { initAutoUpdater } from './services/UpdaterService'
+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()
@@ -29,6 +32,15 @@ const services = {
connections: null as ConnectionService | null
}
+// 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,
@@ -48,10 +60,16 @@ function createWindow(): void {
}
})
+ mainWindow = window
+
window.once('ready-to-show', () => {
window.show()
})
+ window.on('closed', () => {
+ if (mainWindow === window) mainWindow = null
+ })
+
window.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url)
return { action: 'deny' }
@@ -95,15 +113,21 @@ 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) => {
+ pushToRenderer(EventChannels.UpdaterProgress, progress)
+ })
services.repo = repo
services.connections = connections
- registerIpcHandlers({ repo, connections, databases, queries, users, indexes })
+ registerIpcHandlers({ repo, connections, databases, queries, users, indexes, updater })
createWindow()
@@ -112,7 +136,6 @@ app.whenReady().then(() => {
})
log.info(`MongoBench ${app.getVersion()} ready`)
- initAutoUpdater()
})
app.on('before-quit', async (event) => {
diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts
index ffc37b0..5e0f0db 100644
--- a/src/main/ipc/channels.ts
+++ b/src/main/ipc/channels.ts
@@ -39,7 +39,19 @@ export const Channels = {
QueryInsertOne: 'query:insertOne',
QueryInsertMany: 'query:insertMany',
QueryDeleteOne: 'query:deleteOne',
- QueryDeleteMany: 'query:deleteMany'
+ QueryDeleteMany: 'query:deleteMany',
+
+ UpdaterCheck: 'updater:check',
+ UpdaterDownload: 'updater:download',
+ 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',
+ ConnectionDropped: 'connections:dropped'
+} as const
diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts
index 1569945..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 {
@@ -36,6 +38,7 @@ import type { ConnectionService } from '../services/ConnectionService'
import type { DatabaseService } from '../services/DatabaseService'
import type { IndexService } from '../services/IndexService'
import type { QueryService } from '../services/QueryService'
+import type { UpdaterService } from '../services/UpdaterService'
import type { UserService } from '../services/UserService'
import { Channels } from './channels'
@@ -46,6 +49,7 @@ export type Services = {
queries: QueryService
users: UserService
indexes: IndexService
+ updater: UpdaterService
}
function withResult
(
@@ -82,7 +86,7 @@ function withoutInput(fn: () => Promise): (event: IpcMainInvokeEvent) => P
}
export function registerIpcHandlers(services: Services): void {
- const { repo, connections, databases, queries, users, indexes } = services
+ const { repo, connections, databases, queries, users, indexes, updater } = services
ipcMain.handle(
Channels.ConnectionsList,
@@ -274,4 +278,44 @@ export function registerIpcHandlers(services: Services): void {
users.dropUser(connectionId, db, username)
)
)
+
+ ipcMain.handle(
+ Channels.UpdaterCheck,
+ withoutInput(() => updater.check())
+ )
+
+ // Resolves only once the download has finished; progress arrives on
+ // updater:progress.
+ ipcMain.handle(
+ Channels.UpdaterDownload,
+ withoutInput(() => updater.download())
+ )
+
+ ipcMain.handle(
+ Channels.UpdaterInstall,
+ withoutInput(async () => {
+ 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..4988f60
--- /dev/null
+++ b/src/main/lib/socks5.test.ts
@@ -0,0 +1,157 @@
+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 credential of the right length but the wrong content', async () => {
+ const requested: Requested[] = []
+ const proxy = await startProxy(requested)
+ // Same length as PASSWORD, differing in one character — the length check
+ // cannot catch this, so it proves the byte comparison does the work.
+ expect('proxy-pasS'.length).toBe(PASSWORD.length)
+ await expect(
+ connectThrough(
+ proxy.port,
+ { host: 'mongo1.internal', port: 27017 },
+ { userId: USERNAME, password: 'proxy-pasS' }
+ )
+ ).rejects.toThrow()
+ await expect(
+ connectThrough(
+ proxy.port,
+ { host: 'mongo1.internal', port: 27017 },
+ { userId: 'proxy-useR', password: PASSWORD }
+ )
+ ).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..d4a5c50
--- /dev/null
+++ b/src/main/lib/socks5.ts
@@ -0,0 +1,259 @@
+/**
+ * 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 { 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. timingSafeEqual throws on a length mismatch, so the
+ * length is checked first — which reveals nothing, since both credentials are
+ * fixed-length random hex the caller generated itself.
+ */
+function secretMatches(received: Buffer, expected: string): boolean {
+ const want = Buffer.from(expected, 'utf8')
+ return received.length === want.length && timingSafeEqual(received, want)
+}
+
+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/lib/updateSeverity.test.ts b/src/main/lib/updateSeverity.test.ts
new file mode 100644
index 0000000..1b7f4df
--- /dev/null
+++ b/src/main/lib/updateSeverity.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { updateSeverity } from './updateSeverity'
+
+describe('updateSeverity', () => {
+ it('classifies a patch bump', () => {
+ expect(updateSeverity('1.3.0', '1.3.1')).toBe('patch')
+ })
+
+ it('classifies a minor bump', () => {
+ expect(updateSeverity('1.3.1', '1.4.0')).toBe('minor')
+ })
+
+ it('classifies a major bump', () => {
+ expect(updateSeverity('1.3.1', '2.0.0')).toBe('major')
+ })
+
+ it('reports the highest differing segment, not the lowest', () => {
+ expect(updateSeverity('1.3.1', '2.4.9')).toBe('major')
+ expect(updateSeverity('1.3.1', '1.9.9')).toBe('minor')
+ })
+
+ it('returns null when the versions are equal', () => {
+ expect(updateSeverity('1.3.1', '1.3.1')).toBeNull()
+ })
+
+ it('returns null when the candidate is older', () => {
+ expect(updateSeverity('1.3.1', '1.3.0')).toBeNull()
+ expect(updateSeverity('2.0.0', '1.9.9')).toBeNull()
+ })
+
+ it('tolerates a leading v', () => {
+ expect(updateSeverity('v1.3.0', 'v1.3.1')).toBe('patch')
+ })
+
+ it('ignores prerelease and build suffixes', () => {
+ expect(updateSeverity('1.3.0-beta.1', '1.4.0')).toBe('minor')
+ expect(updateSeverity('1.3.0', '1.3.1+build.7')).toBe('patch')
+ })
+
+ it('treats versions differing only by prerelease suffix as no change', () => {
+ expect(updateSeverity('1.3.1-beta.1', '1.3.1')).toBeNull()
+ })
+
+ it('returns null for unparseable versions', () => {
+ expect(updateSeverity('1.3', '1.4')).toBeNull()
+ expect(updateSeverity('', '1.0.0')).toBeNull()
+ expect(updateSeverity('1.0.0', 'not-a-version')).toBeNull()
+ expect(updateSeverity('1.0.0', '1.0.x')).toBeNull()
+ })
+
+ it('does not treat leading zeroes as a bigger number', () => {
+ expect(updateSeverity('1.3.9', '1.3.10')).toBe('patch')
+ })
+})
diff --git a/src/main/lib/updateSeverity.ts b/src/main/lib/updateSeverity.ts
new file mode 100644
index 0000000..97d8930
--- /dev/null
+++ b/src/main/lib/updateSeverity.ts
@@ -0,0 +1,32 @@
+import type { UpdateSeverity } from '@shared/events'
+
+type Version = { major: number; minor: number; patch: number }
+
+function parse(version: string): Version | null {
+ const core = version.trim().replace(/^v/, '').split(/[-+]/)[0]
+ if (core === undefined) return null
+ const segments = core.split('.')
+ if (segments.length !== 3) return null
+ const [rawMajor, rawMinor, rawPatch] = segments
+ if (rawMajor === undefined || rawMinor === undefined || rawPatch === undefined) return null
+ if (!/^\d+$/.test(rawMajor) || !/^\d+$/.test(rawMinor) || !/^\d+$/.test(rawPatch)) return null
+ return { major: Number(rawMajor), minor: Number(rawMinor), patch: Number(rawPatch) }
+}
+
+/**
+ * How far `latest` is ahead of `current`, driving how loud the update toast is.
+ * Null when `latest` is not newer or either version is unparseable.
+ *
+ * Hand-rolled because `semver` is only present transitively via electron-updater.
+ * Prerelease suffixes are ignored, so callers that already know an update exists
+ * should fall back to the quietest level rather than hide the notice.
+ */
+export function updateSeverity(current: string, latest: string): UpdateSeverity | null {
+ const from = parse(current)
+ const to = parse(latest)
+ if (from === null || to === null) return null
+ if (to.major !== from.major) return to.major > from.major ? 'major' : null
+ if (to.minor !== from.minor) return to.minor > from.minor ? 'minor' : null
+ if (to.patch !== from.patch) return to.patch > from.patch ? 'patch' : null
+ return null
+}
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/services/UpdaterService.ts b/src/main/services/UpdaterService.ts
index 5d40f18..85b99b7 100644
--- a/src/main/services/UpdaterService.ts
+++ b/src/main/services/UpdaterService.ts
@@ -1,40 +1,63 @@
import { app } from 'electron'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
+import type { UpdateCheckResult, UpdateProgress } from '@shared/events'
+import { updateSeverity } from '../lib/updateSeverity'
-// electron-updater needs a real packaged app + a published `latest.yml`
-// on GitHub Releases to do anything. In dev it would require a
-// `dev-app-update.yml`; we just skip instead.
-export function initAutoUpdater(): void {
- if (!app.isPackaged) {
- log.info('Updater: dev mode, skipping')
- return
+/**
+ * User-driven update flow. Builds are unsigned, and a silent installer
+ * terminates the running app mid-session to replace its binary — so nothing
+ * here happens without a click.
+ *
+ * electron-updater needs a packaged app plus a published `latest.yml`; in dev
+ * there is neither, so every method is a no-op.
+ */
+export class UpdaterService {
+ private downloading = false
+
+ constructor(private readonly emitProgress: (progress: UpdateProgress) => void) {
+ autoUpdater.logger = log
+ autoUpdater.autoDownload = false
+ autoUpdater.autoInstallOnAppQuit = false
+
+ autoUpdater.on('download-progress', (p) => {
+ this.emitProgress({ percent: Math.min(100, Math.max(0, Math.round(p.percent))) })
+ })
+ autoUpdater.on('error', (error) => {
+ log.error('Updater error', error)
+ })
+ }
+
+ async check(): Promise {
+ if (!app.isPackaged) return { available: false }
+
+ const result = await autoUpdater.checkForUpdates()
+ const latest = result?.updateInfo.version
+ if (latest === undefined) return { available: false }
+
+ const current = app.getVersion()
+ const severity = updateSeverity(current, latest)
+ if (severity === null) {
+ log.info(`Updater: up to date (${current})`)
+ return { available: false }
+ }
+
+ log.info(`Updater: ${latest} available (${severity}), current ${current}`)
+ return { available: true, version: latest, currentVersion: current, severity }
}
- autoUpdater.logger = log
- autoUpdater.autoDownload = true
- autoUpdater.autoInstallOnAppQuit = true
-
- autoUpdater.on('checking-for-update', () => {
- log.info('Updater: checking for update')
- })
- autoUpdater.on('update-available', (info) => {
- log.info(`Updater: update available — ${info.version}`)
- })
- autoUpdater.on('update-not-available', () => {
- log.info('Updater: up to date')
- })
- autoUpdater.on('download-progress', (p) => {
- log.info(`Updater: downloading ${Math.round(p.percent)}%`)
- })
- autoUpdater.on('update-downloaded', (info) => {
- log.info(`Updater: downloaded ${info.version} — will install on quit`)
- })
- autoUpdater.on('error', (err) => {
- log.error('Updater error', err)
- })
-
- void autoUpdater.checkForUpdates().catch((err) => {
- log.error('Updater: initial check failed', err)
- })
+ async download(): Promise {
+ if (!app.isPackaged || this.downloading) return
+ this.downloading = true
+ try {
+ await autoUpdater.downloadUpdate()
+ } finally {
+ this.downloading = false
+ }
+ }
+
+ install(): void {
+ if (!app.isPackaged) return
+ autoUpdater.quitAndInstall()
+ }
}
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 c8fd8e1..b32fd1b 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { Api } from '@shared/api'
+import type { ConnectionDropped, UpdateCheckResult, UpdateProgress } from '@shared/events'
import type { Result } from '@shared/result'
import type {
AggregateRequest,
@@ -10,6 +11,7 @@ import type {
ConnectionInput,
ConnectionTestResult,
ConnectionUpdatePayload,
+ ConnectResult,
CountRequest,
CountResponse,
CreateIndexPayload,
@@ -39,6 +41,19 @@ import type {
const invoke = (channel: string, payload?: unknown): Promise> =>
ipcRenderer.invoke(channel, payload) as Promise>
+/**
+ * Subscribes to a main → renderer push and returns an unsubscribe. The raw
+ * IpcRendererEvent is dropped — it carries `sender`, which would hand the
+ * renderer an Electron object and defeat contextIsolation.
+ */
+const subscribe = (channel: string, listener: (payload: T) => void): (() => void) => {
+ const handler = (_event: unknown, payload: T): void => listener(payload)
+ ipcRenderer.on(channel, handler)
+ return () => {
+ ipcRenderer.removeListener(channel, handler)
+ }
+}
+
const api: Api = {
connections: {
list: () => invoke('connections:list'),
@@ -48,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 }),
@@ -97,6 +117,13 @@ const api: Api = {
invoke('indexes:list', payload),
create: (payload: CreateIndexPayload) => invoke<{ name: string }>('indexes:create', payload),
drop: (payload: DropIndexPayload) => invoke('indexes:drop', payload)
+ },
+ updater: {
+ check: () => invoke('updater:check'),
+ download: () => invoke('updater:download'),
+ install: () => invoke('updater:install'),
+ onProgress: (listener: (progress: UpdateProgress) => void) =>
+ subscribe('updater:progress', listener)
}
}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 475ecc6..4228f75 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1,11 +1,13 @@
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'
import { ConnectionDashboard } from '@/features/dashboard/ConnectionDashboard'
import { ServerStatsCollector } from '@/features/dashboard/ServerStatsCollector'
import { CommandPalette } from '@/features/palette/CommandPalette'
+import { UpdateNotifier } from '@/features/updater/UpdateNotifier'
import { Welcome } from '@/features/welcome/Welcome'
import { useTabsStore } from '@/store/tabs'
import { useAppStore } from '@/store'
@@ -30,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()
@@ -37,9 +40,21 @@ 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 (
+
{!onWelcome && }
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 (
+
+ updateSsh({ enabled: v })}
+ />
+ {form.ssh.enabled && (
+
+ )}
+
+