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:
ylhan
2026-09-17 03:12:24 +08:00
commit 430d78b9f0
88 changed files with 19793 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import init001 from '../src/main/db/migrations/001_init.sql?raw'
import { migrate } from '../src/main/db/database'
import { getConnectionTree, getHost } from '../src/main/db/repositories/connections'
/**
* Migration 002 rebuilds the tree into an interleaved structure, remapping ids
* as it goes. It is the most intricate SQL in the project and the failure mode
* is silent data loss, so it is tested against a database built in the *old*
* shape and filled with old-format rows — not against a fresh one, where every
* code path would be trivially empty.
*/
let db: Database.Database
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'nexusterm-migration-'))
db = new Database(join(dir, 'v1.db'))
db.pragma('foreign_keys = ON')
// Build the pre-migration schema exactly as it shipped, then declare it
// version 1 so `migrate` sees migration 2 as pending.
db.exec(init001)
db.pragma('user_version = 1')
})
afterEach(() => {
db.close()
rmSync(dir, { recursive: true, force: true })
})
function seedFolder(parentId: number | null, name: string, sortOrder: number): number {
const now = Date.now()
return Number(
db
.prepare(
'INSERT INTO folders (parent_id, name, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
)
.run(parentId, name, sortOrder, now, now).lastInsertRowid,
)
}
function seedHost(
folderId: number | null,
label: string,
sortOrder: number,
overrides: { hostname?: string; port?: number } = {},
): number {
const now = Date.now()
return Number(
db
.prepare(
`INSERT INTO hosts (folder_id, label, hostname, port, username, auth_type, key_path, color, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'root', 'password', NULL, NULL, ?, ?, ?)`,
)
.run(folderId, label, overrides.hostname ?? '10.0.1.5', overrides.port ?? 22, sortOrder, now, now)
.lastInsertRowid,
)
}
/** A v1 database with two root folders, a nested host set, and a root host. */
function seedV1(): { prod: number; staging: number; web: number; database: number; rooted: number } {
const prod = seedFolder(null, 'prod', 1024)
const staging = seedFolder(null, 'staging', 2048)
const web = seedHost(prod, 'web-01', 1024, { hostname: '10.0.1.5' })
const database = seedHost(prod, 'db-01', 2048, { hostname: '10.0.2.10', port: 5432 })
const rooted = seedHost(null, 'rooted', 1024, { hostname: '203.0.113.7' })
return { prod, staging, web, database, rooted }
}
function names(nodes: ReturnType<typeof getConnectionTree>): string[] {
return nodes.map((n) => (n.kind === 'folder' ? n.name : n.label))
}
describe('migration 002: interleaving folders and hosts', () => {
it('advances the schema version', () => {
seedV1()
migrate(db)
expect(db.pragma('user_version', { simple: true })).toBe(2)
})
it('leaves no dangling references', () => {
seedV1()
migrate(db)
// The runner already asserts this and rolls back on failure; checking again
// here documents that the guarantee is deliberate.
expect(db.pragma('foreign_key_check')).toEqual([])
})
it('preserves every folder and host', () => {
seedV1()
migrate(db)
const folders = db.prepare('SELECT COUNT(*) AS n FROM folders').get() as { n: number }
const hosts = db.prepare('SELECT COUNT(*) AS n FROM hosts').get() as { n: number }
const nodes = db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number }
expect(folders.n).toBe(2)
expect(hosts.n).toBe(3)
expect(nodes.n).toBe(5)
})
it('preserves the nesting', () => {
const ids = seedV1()
migrate(db)
const tree = getConnectionTree(db)
expect(names(tree)).toEqual(['prod', 'staging', 'rooted'])
const prod = tree.find((n) => n.kind === 'folder' && n.name === 'prod')
expect(prod?.kind).toBe('folder')
if (prod?.kind === 'folder') {
expect(names(prod.children)).toEqual(['web-01', 'db-01'])
expect(prod.children.map((c) => c.id)).not.toContain(ids.web)
}
})
it('reproduces the order the old tree displayed, folders first', () => {
// The two old sequences collided head-on — folder 1024 against host 1024.
// Renumbering each layer resolves the ties into the order the UI already
// showed, so nothing visibly jumps after upgrading.
seedV1()
migrate(db)
expect(names(getConnectionTree(db))).toEqual(['prod', 'staging', 'rooted'])
})
it('preserves the relative order of hosts within a layer', () => {
// A real inventory contains hosts whose sort_order is a midpoint, left
// there by an earlier drag — the seeded tree this migration first ran
// against had exactly that shape. The renumbering must carry the order
// across, not merely the membership.
const prod = seedFolder(null, 'prod', 1024)
seedHost(prod, 'first', 1024)
seedHost(prod, 'dropped-in', 1536)
seedHost(prod, 'last', 2048)
migrate(db)
const prodNode = getConnectionTree(db).find((n) => n.kind === 'folder')
expect(prodNode?.kind).toBe('folder')
if (prodNode?.kind === 'folder') {
expect(names(prodNode.children)).toEqual(['first', 'dropped-in', 'last'])
}
})
it('preserves host attributes through the id remap', () => {
const ids = seedV1()
migrate(db)
const all = db.prepare('SELECT id, label, hostname, port FROM hosts').all() as {
id: number
label: string
hostname: string
port: number
}[]
const db01 = all.find((h) => h.label === 'db-01')
expect(db01).toMatchObject({ hostname: '10.0.2.10', port: 5432 })
// Ids are reassigned by the rebuild, so the old id must not be assumed.
expect(db01?.id).not.toBe(ids.database)
})
it('remaps credentials onto the rebuilt host rows', () => {
// The tables are empty in practice at this point, but a migration that
// silently orphans saved credentials is exactly the bug that surfaces
// months later — so it carries the data across.
const ids = seedV1()
db.prepare(
`INSERT INTO credentials (host_id, kind, ciphertext, nonce, salt, created_at)
VALUES (?, 'password', X'01', X'02', X'03', ?)`,
).run(ids.web, Date.now())
migrate(db)
const row = db
.prepare('SELECT c.host_id, h.label FROM credentials c JOIN hosts h ON h.id = c.host_id')
.get() as { host_id: number; label: string } | undefined
expect(row?.label).toBe('web-01')
expect(row?.host_id).not.toBe(ids.web)
})
it('remaps port forwards onto the rebuilt host rows', () => {
const ids = seedV1()
db.prepare(
`INSERT INTO port_forwards (host_id, type, bind_host, bind_port, dest_host, dest_port, auto_start, created_at)
VALUES (?, 'local', '127.0.0.1', 8080, 'localhost', 80, 0, ?)`,
).run(ids.database, Date.now())
migrate(db)
const row = db
.prepare('SELECT f.host_id, h.label FROM port_forwards f JOIN hosts h ON h.id = f.host_id')
.get() as { host_id: number; label: string } | undefined
expect(row?.label).toBe('db-01')
})
it('removes the temporary legacy_id column', () => {
seedV1()
migrate(db)
const columns = db.pragma('table_info(nodes)') as { name: string }[]
expect(columns.map((c) => c.name)).not.toContain('legacy_id')
})
it('leaves foreign keys enforced again afterwards', () => {
seedV1()
migrate(db)
expect(db.pragma('foreign_keys', { simple: true })).toBe(1)
// And the enforcement is real, not just the flag.
const bad = db.prepare('INSERT INTO hosts (id, label, hostname, port, username, auth_type) VALUES (?, ?, ?, ?, ?, ?)')
expect(() => bad.run(999_999, 'ghost', 'h', 22, 'u', 'password')).toThrow()
})
})
describe('migration 002 on an empty database', () => {
it('succeeds without any rows to carry across', () => {
migrate(db)
expect(db.pragma('user_version', { simple: true })).toBe(2)
expect(getConnectionTree(db)).toEqual([])
})
})
describe('migration 002 is idempotent', () => {
it('does nothing on a second run', () => {
seedV1()
migrate(db)
const before = getHost(db, (db.prepare("SELECT id FROM hosts WHERE label = 'web-01'").get() as { id: number }).id)
const applied = migrate(db)
expect(applied).toBe(0)
const after = getHost(db, (db.prepare("SELECT id FROM hosts WHERE label = 'web-01'").get() as { id: number }).id)
expect(after).toEqual(before)
})
})