ylhan 430d78b9f0 NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals
grouped by host, a pinned local shell, TOFU host key verification, and
credentials held by the OS keyring.

Terminal bytes travel on a per-session MessagePort with credit-based flow
control, so one session running `cat` on a large file cannot stall the
others. Transports reconnect on their own with jittered backoff, keeping
the scrollback and the tab while replacing only the connection beneath.

156 tests, including integration tests against a real sshd and a
migration exercised against a database built in the old shape.
2026-09-17 03:12:24 +08:00

NexusTerm

A desktop SSH session manager for Linux — connection tree, tabbed terminals, tunnels, local shell and serial.

Think Xshell's session management with tmux's multiplexing, local-first: your connection inventory never leaves the machine, and credentials are held by the OS keyring rather than a vendor's cloud.

Status

Early. See the milestone table in the implementation plan.

Milestone Scope State
M0 Scaffold — Electron + React + TS three-process pipeline done
M1 Persistence + key vault (SQLite, safeStorage, degradation handling) done
M2 Prove the native module chain (better-sqlite3, later node-pty / serialport) folded into M1/M8
M3 Connection tree UI done
M4 SSH connect + terminal done
M5 Tabs and same-host grouping partly — tabs exist, grouping does not
M6 Flow control and reconnect resilience done
M7 Port forwarding (-L / -R / -D)
M8 Local terminal + serial local terminal done, serial not
M9 Packaging

Prerequisites

Build toolchain (for native modules)

sudo apt install build-essential python3 pkg-config libudev-dev

libudev-dev is required by @serialport/bindings-cpp. It is not needed until M8, but installing it up front avoids a confusing build failure later.

Runtime keyring

Credential storage depends on an OS keyring daemon. Verify one is running:

ps -e -o comm= | grep -E 'gnome-keyring|kwalletd'
echo "$DBUS_SESSION_BUS_ADDRESS"

If neither is present, Electron silently falls back to a basic_text backend that "encrypts" with a hardcoded key — equivalent to plaintext, but the database still looks like ciphertext. The app detects this and warns rather than pretending to be secure.

Serial port access

Membership in the dialout group is required:

sudo usermod -aG dialout "$USER"

A full logout and login is required for this to take effect — a new shell is not enough. On Arch the group is called uucp.

Two daemons are known to hijack USB serial adapters before the app can open them:

  • ModemManager probes newly attached devices with AT commands. Add a udev rule with ID_MM_DEVICE_IGNORE=1.
  • brltty (braille display daemon) claims CH340/CP210x adapters on Ubuntu, presenting as a device that mysteriously vanishes.

Development

npm install
npm run dev

Other scripts:

npm test             # vitest — see "Testing" below
npm run typecheck    # main/preload and renderer are checked separately
npm run build        # production bundles into out/
npm run dist         # AppImage + deb into release/
npm run rebuild:native   # rebuild native modules against Electron's ABI

Testing

npm test

The suite runs under plain Node — no Electron process required. That is possible because Vault takes a Keyring interface rather than importing safeStorage directly, so the security-critical paths (master key lifecycle, refusing to run on an untrusted keyring, locking) are exercised for real instead of against a mock so loose it would prove nothing.

Tests use a real file-backed SQLite database in a temp directory, not :memory:. Two reasons: the assertion that a secret never reaches disk needs an actual file to inspect, and WAL mode — where a secret could plausibly end up — does not apply to in-memory databases.

The assertion worth knowing about is in tests/credentials.test.ts: after storing a password it checkpoints the WAL and reads the raw database file, asserting the plaintext appears nowhere in it. Checking the column instead would only prove the column holds bytes.

Integration smoke test

Unit tests cover the repositories and the pure tree logic, but nothing in them exercises the wire between the renderer and the main process. For that:

npm run build
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
npm run smoke

scripts/ipc-smoke.mjs attaches to the running app over the Chrome DevTools Protocol and calls window.nexusterm — the same object the UI calls. That makes it a real end-to-end check of preload bridge → ipcRenderer.invoke → handler → zod validation → repository → SQLite → reply, without needing a way to drive the UI by hand.

It currently verifies folder and host creation, nesting, rename, moving to the root layer, refusal of a folder move into its own descendant, rejection of an invalid payload, and that it cleans up after itself.

SSH, against a real server

npm run build
./node_modules/electron/dist/electron --no-sandbox --remote-debugging-port=9222 .
npm run e2e:ssh

scripts/ssh-e2e.mjs goes further: it double-clicks a host in the tree, answers the host-key prompt, and types into the resulting terminal with synthetic keystrokes. Requires the test sshd described in tests/dial.test.ts.

Because the WebGL renderer leaves no text in the DOM, it does not assert on terminal output. Instead the typed command makes the remote shell create a file, which is then checked from outside the browser:

keystroke → xterm → MessagePort → main → SSH channel → remote shell → /tmp/…

That covers the whole outbound path, and the result cannot be faked by a rendering bug. The final report carries remoteUser, read back from a file the remote wrote — which is the return path proven separately.

Toolchain constraints

Why Electron is pinned exactly

Native modules are compiled against a specific Electron ABI. A caret range would let a silent upgrade invalidate the rebuilt binaries, and the resulting failure appears at runtime rather than at install time. Keep the version exact.

Electron 44.4.1 bundles Node 24.21.0 and Chrome 152 — confirmed at runtime, not inferred from the dependency graph.

Three version constraints that are load-bearing

Established empirically. Each one fails in a way that does not point at its own cause.

Vite is held at 7.x, and @vitejs/plugin-react at 5.x.

electron-vite@5 declares peer vite@"^5 || ^6 || ^7", while @vitejs/plugin-react@6 requires vite@"^8". The two cannot coexist. The 5.x line of the React plugin is the newest one that accepts Vite 7. Upgrading Vite means waiting for electron-vite to widen its peer range.

TypeScript 7 removed baseUrl.

paths mappings must now be relative, with a leading ./. The error is TS5102: Option 'baseUrl' has been removed. It also enables noUncheckedSideEffectImports, so bare imports like import './styles.css' need a module declaration — "types": ["vite/client"] supplies it.

better-sqlite3 ships N-API prebuilds.

node_modules/better-sqlite3/prebuilds/ contains linux-x64.node and friends, built with node-addon-api. Because N-API is ABI-stable across Node and Electron versions, this normally removes the need for an electron-rebuild step. If a native load ever fails at runtime, check here first before reaching for a rebuild.

The Electron binary may need a manual download.

If npm install completes but node_modules/electron/dist/electron is missing, the package's postinstall was skipped. Run:

node node_modules/electron/install.js

Architecture

main process          Node-native work only: ssh2 clients, PTYs, serial ports,
                      SQLite (sole writer), safeStorage (sole decryptor)
      │
      │  control plane  ipcMain.handle / invoke  — request/response, low volume
      │  data plane     one MessagePort per session — terminal bytes, high volume
      ▼
preload               contextBridge, narrow allowlist — this file IS the
                      security boundary
      ▼
renderer              React + xterm.js. sandbox: true, contextIsolation: true,
                      nodeIntegration: false. No Node access.

Two rules the design depends on:

  1. Terminal bytes cross the boundary as raw Uint8Array, never as a string. UTF-8 multibyte characters get split across TCP segments, so converting to a string in the main process corrupts text at chunk boundaries. The bug only shows up with CJK or emoji output, which makes it easy to miss.

  2. No IPC call returns a stored secret. The renderer asks for connect(hostId), not for a password. A getSecret()-style API would let any renderer-side compromise drain every saved credential at once.

SSH sessions

Host key verification is two-phase, and the first phase always refuses. A host whose key is not in known_hosts returns unknown-host-key with the fingerprint rather than connecting; the UI shows it, and only a retry carrying that exact fingerprint gets through and records it. The cost is dialling twice on a first connection. The alternative — a prompt round-tripped through the renderer while a socket sits half-open — buys nothing but latency, and a client that connects first and asks later is a client that has already lost.

A key that changed is never a prompt. host-key-mismatch is reported as an error with both fingerprints, because a dialog with a continue button is precisely what an attacker would want the user to see.

Fingerprints are rendered in OpenSSH's SHA256:<base64> form so they can be checked against ssh-keygen -lf. A fingerprint the user cannot compare to anything is not worth showing.

The session data path

Terminal bytes travel on a MessagePort dedicated to one session, not the shared IPC channel. On a shared channel every session's output is serialised through one queue, so a single cat of a large file delays input and output for every other session.

The port cannot be handed over through contextBridge — a MessagePort passed through it arrives unusable (electron/electron#27024). The preload receives it and reposts with window.postMessage(..., ports), which does transfer properly.

Flow control is credit-based: the main process stops reading from the SSH channel once a megabyte is unacknowledged and resumes once the renderer has drawn all but a quarter of it. Without it, cat of a large file queues the whole output in the renderer and the UI dies — a certainty, not a risk.

Bytes cross as Uint8Array, never as a string. TCP splits UTF-8 characters across chunks routinely, and decoding either half alone corrupts them; the bug appears only with CJK or emoji output, which makes it easy to miss.

Reconnecting

When a transport drops, the Session survives and only the backend beneath it is replaced — the reason those two are separate objects. The renderer keeps its scrollback and its tab because the terminal was never torn down.

What does not survive is the shell. A reconnect gets a new process on the far end with a fresh working directory, and the terminal says so in as many words rather than letting someone believe they are still where they were before the blip. The notice is written into the scrollback rather than shown as a banner, because scrolling up later needs to show where the old shell ended and the new one began.

Two rules the retry loop follows:

  • Only transport failures are retried. A changed host key or credentials the server rejected will fail identically every time; retrying would hammer the server for nothing and delay telling the user something is actually wrong.
  • The retry is jittered. This is precisely the case where many sessions drop at once — a network goes down — and identical delays would have them all hit the server on the same tick.

Backoff starts at a second and caps at thirty, giving up after ten attempts: long enough to ride out a reboot or a flaky link, short enough that a session pointed at a decommissioned host stops rather than living forever in a loop.

The password is held in memory for the session's lifetime so a reconnect can redial without prompting. Reading it back from the vault does not work for a password the user chose not to save, and interrupting a blip with an authentication prompt makes a two-second outage into an interrogation.

One backend, two transports

An SSH channel and a local PTY are the same thing from the terminal's point of view, so both sit behind SessionBackend and everything above it — flow control, the port, the tab, the WebGL lifecycle — is written once. The serial backend, when it lands, is a third implementation of the same interface.

The two differ in one place worth knowing: ssh2 hands back Buffer, node-pty hands back string because it decodes the PTY stream itself, holding partial multi-byte sequences across reads rather than mangling them. Re-encoding with Buffer.from(chunk, 'utf8') is therefore lossless, and it keeps the boundary uniform — everything above speaks bytes.

node-pty needs no rebuild against Electron. It is built on node-addon-api, so the binary compiled during npm install loads unchanged in Electron's Node — verified by spawning a real PTY from inside Electron 44. This retired what the plan had ranked as the project's highest risk.

On Linux there is also no spawn-helper: that exists only for macOS and Windows. The asarUnpack rule covers the whole package anyway, so the executable surviving the asar boundary is handled without a special case.

The sidebar, and where sessions live

The sidebar merges two sources that have nothing to do with each other: the connection tree, which is persisted, and the open sessions, which exist only in the renderer and vanish when the app closes.

A host is therefore an interior node for exactly as long as it has sessions under it, and a leaf again once they are all closed. Its expandability is derived from the session list rather than stored, so there is no flag to fall out of step with reality.

The pinned local entry is a virtual row — not a database row. It cannot be dragged, renamed or deleted, which is the point: it is the one terminal that does not depend on the network being up, and it should not be possible to lose it. Double-clicking opens another shell on this machine; each call makes a new one.

Session ordering is seq — a monotonic counter from open time — rather than array position, so pinning can reorder the display without rewriting the list and closing a session does not disturb the others' places.

Session numbering

Second and later sessions for a host carry a number: n5095, n5095 2, n5095 3. The first keeps the bare name, so the common case stays uncluttered. Local shells are numbered as their own group.

The index is assigned when the session opens and stored, not derived from how many sessions happen to be open. Derived numbering renames the survivors: open three, close the middle one, and the third would silently become the second. It also collides — count-plus-one hands out a number that is still in use. Tracking the highest index instead means closing every session restarts the group at 1, which is what a fresh start should look like.

The base name is stored separately from the index because the base for a local shell is a translated string, and the module that composes labels has no business knowing a language.

Pinning is scoped to one host's group. A session that jumped to the top of the whole list would have left the group, and keeping a machine's sessions together is the reason the grouping exists.

The tab bar

Shows the same order as the sidebar, so a session is in the same place in both views. Tabs can be dragged to reorder, but only within their own host's group — a tab that left its block would have no place in a sidebar that groups by host, and the two views would stop corresponding.

Tabs and sessions carry the same five actions, so neither view is a partial vocabulary of the other:

重命名 Names this session only. The host is untouched, and the name goes when the session closes — it is not a rename of the connection.
置顶 Moves it to the front of its host's group.
复制 Another session to the same host, placed next to the original.
打开新标签 Another session to the same host, appended to the group.
关闭 Ends the session.

Duplicating copies the original's pin state as well as its position. Without that the copy would land in the other bucket — at the far end of the group — and "next to the original" would not be expressible at all.

Position is a fractional seq rather than an array index, so a drag or a duplicate writes the midpoint between two neighbours instead of renumbering the group. Same trade as the connection tree's sort_order, and the same reason: the cost of a reorder stays constant no matter how many tabs are open.

Known gap: the terminal is invisible to screen readers

The WebGL renderer draws into canvases, so there is no text in the DOM for assistive technology to read. xterm's screenReaderMode fixes this at some throughput cost, which makes it a decision rather than a default — it has not been made yet.

This also means end-to-end tests cannot assert on terminal text; see below for what they assert on instead.

The connection tree

Folders and hosts are rows in one nodes table with a kind discriminator, sharing one id space and one ordering per layer. That is what lets them interleave: a host can be dropped between two folders, and a folder between two hosts.

This was not the original design. Folders and hosts began in separate tables with independent sort_order sequences, which meant their positions were not comparable — every folder necessarily rendered before every host, and a host between two folders was not expressible at all. Migration 002_interleave.sql rebuilt the tree and carried existing rows across.

Their attributes live in folders and hosts, keyed by node id, because a kind discriminator alone cannot stop a foreign key from pointing at the wrong kind. credentials and port_forwards reference hosts(id), and that reference is genuinely unsatisfiable by a folder.

Migrations that rebuild tables

A restructure is SQLite's only way to change a table's shape, and it needs two pragmas set outside the transaction — SQLite ignores both inside one:

  • foreign_keys = OFF, because intermediate rebuild states legitimately violate foreign keys
  • legacy_alter_table = ON, so ALTER TABLE ... RENAME does not rewrite references in other tables — which would silently repoint credentials at a table about to be dropped

Mark such a migration rebuildsTables: true and the runner handles both, then runs PRAGMA foreign_key_check inside the transaction. A rebuild that leaves dangling references throws and rolls back, so a bad migration leaves the previous schema intact rather than a half-converted database.

tests/migration002.test.ts exercises this against a database built in the old shape and filled with old-format rows. Testing a migration against a fresh database would exercise none of its code paths.

See the implementation plan for the full design, including the data model, flow-control protocol, and the packaging notes.

S
Description
No description provided
Readme
454 KiB
Languages
TypeScript 87.5%
JavaScript 7.2%
CSS 5.2%