Files
nexusterm/README.md
T

516 lines
22 KiB
Markdown

# NexusTerm
**English** · [中文](README.zh-CN.md)
A desktop SSH session manager for Windows and 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. Everything below reflects what actually works today.
| 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 | done |
| M6 | Flow control and reconnect resilience | done |
| M7 | Port forwarding (`-L` / `-R` / `-D`) | |
| M8 | Local terminal + serial | local terminal done, serial not |
| M9 | Packaging | done |
## Prerequisites
### Windows
Node.js 22.12 or newer is required. The dependencies ship Windows x64/ARM64
prebuilds for `better-sqlite3` and `node-pty`, so Visual Studio is normally not
required. If `npm install` still falls back to `node-gyp`, use the verified
no-compile installation path:
```powershell
npm ci --ignore-scripts
node node_modules/electron/install.js
```
With this path, `ssh2` uses its JavaScript crypto implementation. Functionality
is unchanged, but the optional native acceleration is absent. To compile every
optional native module, install Python 3 and Visual Studio Build Tools with the
Desktop development with C++ workload.
### Linux 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
Windows uses DPAPI and needs no additional keyring service. On Linux,
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 # package for the current platform into release/
npm run dist:win # explicitly build the Windows x64 NSIS installer
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
```
On Windows PowerShell, replace the second line with:
```powershell
.\node_modules\electron\dist\electron.exe --remote-debugging-port=9222 .
```
`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, opening and closing a local PTY, 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.
## Packaging
```sh
npm run dist # package for the current platform into release/
npm run dist:win # Windows x64 NSIS installer into release/
npm run verify:package # check the result can actually run
```
The Windows installer is written to `release/NexusTerm-<version>-x64.exe`. The
uninstalled layout is kept in `release/win-unpacked/` for quick testing before
running the installer.
`scripts/verify-package.mjs` exists because a package that is broken in the
ways that matter still builds, still installs, and still starts. Native modules
cannot be loaded from inside an asar archive, and when they are left there the
failure appears on a user's machine the first time they open a terminal — not
in CI, and not in `npm test`. The script checks a Linux AppImage or the Windows
`win-unpacked` directory and asserts that each current-platform native binary
really is in `app.asar.unpacked`.
Two things it checks that are easy to get wrong:
- **`ssh2` carries an optional native module.** `sshcrypto.node` is easy to miss
because the library reads as pure JavaScript. A no-compile Windows install
warns when it is absent and uses the JS implementation.
- **`node-pty` must be unpacked whole, not just its `.node` files.** Windows
also needs `winpty-agent.exe`, `OpenConsole.exe`, and their DLLs; unpacking
only `*.node` produces a terminal that fails after installation.
`npmRebuild` is off: every native module here is built on `node-addon-api`, so
the binaries from `npm install` load unchanged in Electron's Node. Rebuilding
would cost time and risk replacing working binaries for no gain.
Both `.deb` metadata and `desktopName` are set, and the AppImage's
`.desktop` entry is generated from `linux.desktop.entry`. Windows uses an NSIS
installer with a selectable install directory plus Start menu and desktop
shortcuts.
### Packaging notes for whoever picks this up
- The `author`, `homepage` and `maintainer` fields are **placeholders**
(`ylhan@example.com`, `example.com`). They are required by the deb format and
must be replaced before distributing.
- The Windows installer is currently **not digitally signed**, so SmartScreen
may report an unknown publisher. Configure a code-signing certificate before
public distribution.
- `asarUnpack` lists `node_modules/@serialport/**` for a backend that does not
exist yet. Harmless now; load-bearing once the serial transport lands.
## 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`,
`win32-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` (or
`electron.exe` on Windows) 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 selected or compiled during dependency installation 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.
Linux has no additional terminal helper executable. The Windows prebuilds carry
`winpty-agent.exe` and `OpenConsole.exe`; the `asarUnpack` rule covers the whole
package so both survive the asar boundary.
### 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.