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.
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
# 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)
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Other scripts:
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
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.
|
||||
Reference in New Issue
Block a user