516 lines
22 KiB
Markdown
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.
|