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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A modern, dark-mode-first MongoDB GUI. Built as a daily-driver alternative to Mo
## Highlights

- **Multiple connections, side-by-side.** Connect to as many clusters as you want; each runs an independent pool. Drag-reorder them in the sidebar, manage them with right-click context menus.
- **Built-in SSH tunnelling.** Point a connection at a cluster that is only reachable from a jump host — key, password or agent auth, no external port forwards. Because the tunnel is a dynamic SOCKS5 proxy rather than a single forwarded port, the driver reaches **every replica-set member** it discovers, resolved on the SSH server's side.
- **Three query modes per tab.** Switch a single tab between **Simple** (filter / projection / sort), **Aggregation** pipeline, and **Shell** (`db.coll.find().limit()` syntax) without losing state.
- **Mongo shell syntax everywhere.** Type `ObjectId("…")`, `ISODate("…")`, `UUID("…")`, `NumberLong("…")` etc. directly in filters and editors — MongoBench parses it into canonical EJSON before sending.
- **Optimistic concurrency on writes.** Every edit and delete includes a sha-256 hash precondition of the document; concurrent edits surface as conflicts instead of silently overwriting.
Expand All @@ -31,7 +32,7 @@ Saved connections at a glance with quick-connect buttons and inline tips.

### Connection form

Full driver-option surface — auth, topology, pool, timeouts, UUID encoding, display timezone.
Full driver-option surface — auth, SSH tunnel, topology, pool, timeouts, UUID encoding, display timezone.

![New connection dialog](https://i.masel.io/ZIXO8/huKuWAvE28.png/raw)

Expand Down Expand Up @@ -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:
Expand Down
141 changes: 141 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { ConnectionService } from './services/ConnectionService'
import { DatabaseService } from './services/DatabaseService'
import { IndexService } from './services/IndexService'
import { QueryService } from './services/QueryService'
import { SshTunnelService } from './services/SshTunnelService'
import { UpdaterService } from './services/UpdaterService'
import { UserService } from './services/UserService'
import { ConnectionsRepository } from './stores/ConnectionsRepository'
import { HostKeysStore } from './stores/HostKeysStore'
import { SecretsStore } from './stores/SecretsStore'

log.initialize()
Expand All @@ -30,9 +32,15 @@ const services = {
connections: null as ConnectionService | null
}

// Held module-wide so the updater can push progress to the renderer.
// Held module-wide so main can push to the renderer outside of a request.
let mainWindow: BrowserWindow | null = null

function pushToRenderer(channel: string, payload: unknown): void {
if (mainWindow !== null && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload)
}
}

function createWindow(): void {
const window = new BrowserWindow({
width: 1280,
Expand Down Expand Up @@ -105,15 +113,16 @@ function createWindow(): void {
app.whenReady().then(() => {
const secrets = new SecretsStore()
const repo = new ConnectionsRepository(secrets)
const connections = new ConnectionService(repo)
const tunnels = new SshTunnelService(new HostKeysStore())
const connections = new ConnectionService(repo, tunnels, (connectionId, reason) =>
pushToRenderer(EventChannels.ConnectionDropped, { connectionId, reason })
)
const databases = new DatabaseService(connections)
const queries = new QueryService(connections)
const users = new UserService(connections)
const indexes = new IndexService(connections)
const updater = new UpdaterService((progress) => {
if (mainWindow !== null && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(EventChannels.UpdaterProgress, progress)
}
pushToRenderer(EventChannels.UpdaterProgress, progress)
})
services.repo = repo
services.connections = connections
Expand Down
7 changes: 5 additions & 2 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ export const Channels = {

UpdaterCheck: 'updater:check',
UpdaterDownload: 'updater:download',
UpdaterInstall: 'updater:install'
UpdaterInstall: 'updater:install',

DialogPickPrivateKey: 'dialog:pickPrivateKey'
} as const

export type ChannelName = (typeof Channels)[keyof typeof Channels]

/** main → renderer pushes. Subscribed to in the preload, never `handle`d. */
export const EventChannels = {
UpdaterProgress: 'updater:progress'
UpdaterProgress: 'updater:progress',
ConnectionDropped: 'connections:dropped'
} as const
25 changes: 24 additions & 1 deletion src/main/ipc/router.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -295,4 +297,25 @@ export function registerIpcHandlers(services: Services): void {
updater.install()
})
)

// Only the path travels back to the renderer; the key itself is read in
// main at connect time and never leaves it.
ipcMain.handle(
Channels.DialogPickPrivateKey,
withoutInput(async () => {
const options: Electron.OpenDialogOptions = {
title: 'Select an SSH private key',
defaultPath: join(homedir(), '.ssh'),
// Key files carry no extension, and .ssh is a hidden directory.
properties: ['openFile', 'showHiddenFiles', 'dontAddToRecent']
}
const parent = BrowserWindow.getFocusedWindow()
const result =
parent === null
? await dialog.showOpenDialog(options)
: await dialog.showOpenDialog(parent, options)
if (result.canceled) return null
return result.filePaths[0] ?? null
})
)
}
Loading
Loading