Files
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

328 lines
12 KiB
JavaScript

// End-to-end SSH verification.
//
// Drives the real UI over the Chrome DevTools Protocol — creating a host,
// double-clicking it, answering the host-key prompt, and typing into the
// resulting terminal. Nothing here is stubbed: the keystrokes go through
// xterm, the port, the SSH channel, and a real sshd.
//
// Prerequisites (see tests/dial.test.ts for the server setup):
// - sshd listening on 127.0.0.1:2222 with key auth
// - the app running with --remote-debugging-port=9222
//
// npm run build && electron . --no-sandbox --remote-debugging-port=9222 &
// node scripts/ssh-e2e.mjs
//
// DESTRUCTIVE: this reloads the renderer, which closes every open session.
// Run it against a throwaway instance, not one you are working in.
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { userInfo } from 'node:os'
const PORT = 9222
const HOST_LABEL = 'E2E-LOCAL'
const MARKER = 'E2E_MARKER_OK'
let nextId = 1
let ws
async function findPage() {
for (let attempt = 0; attempt < 40; attempt++) {
try {
const res = await fetch(`http://127.0.0.1:${PORT}/json`)
const targets = await res.json()
const page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl)
if (page) return page
} catch {
// DevTools endpoint not up yet.
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`no debuggable page on port ${PORT}`)
}
/**
* Issue a CDP command, with a deadline.
*
* The deadline is not decoration. This script reloads the page partway through,
* and a reload destroys the JavaScript execution context — a `Runtime.evaluate`
* issued into the old context is simply never answered. Without a timeout that
* hangs the whole run forever, which is a far worse failure than an error:
* nothing to read, nothing to retry from.
*/
function command(method, params = {}, timeoutMs = 15_000) {
const id = nextId++
return new Promise((resolve, reject) => {
const onMessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.id !== id) return
clearTimeout(timer)
ws.removeEventListener('message', onMessage)
if (msg.error) return reject(new Error(JSON.stringify(msg.error)))
resolve(msg.result)
}
const timer = setTimeout(() => {
ws.removeEventListener('message', onMessage)
reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`))
}, timeoutMs)
ws.addEventListener('message', onMessage)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function evaluate(expression) {
const result = await command('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (result.exceptionDetails) {
const d = result.exceptionDetails
throw new Error(d.exception?.description ?? d.text ?? 'evaluate failed')
}
return result.result.value
}
/**
* Poll an expression until it returns truthy, or give up.
*
* Evaluation failures are swallowed rather than propagated: during a
* navigation the execution context briefly does not exist, and a poll that gave
* up on the first such hiccup would be useless.
*/
async function waitFor(expression, { timeoutMs = 20_000, label = expression } = {}) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
if (await evaluate(expression)) return true
} catch {
// Context is being torn down or rebuilt; try again.
}
await new Promise((r) => setTimeout(r, 250))
}
throw new Error(`timed out waiting for: ${label}`)
}
async function click(selectorExpression) {
const clicked = await evaluate(`(() => {
const el = ${selectorExpression}
if (!el) return false
el.click()
return true
})()`)
if (!clicked) throw new Error(`nothing to click: ${selectorExpression}`)
}
async function type(text) {
for (const ch of text) {
await command('Input.dispatchKeyEvent', { type: 'char', text: ch })
}
}
async function pressEnter() {
await command('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
key: 'Enter',
})
await command('Input.dispatchKeyEvent', { type: 'char', text: '\r' })
await command('Input.dispatchKeyEvent', {
type: 'keyUp',
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
key: 'Enter',
})
}
const report = {}
const page = await findPage()
ws = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true })
ws.addEventListener('error', reject, { once: true })
})
// --- clean any previous run, then create the host ------------------------
await evaluate(`(async () => {
const tree = await window.nexusterm.tree.list()
for (const n of tree) {
if (n.kind === 'host' && n.label.startsWith('${HOST_LABEL}')) await window.nexusterm.tree.deleteNode(n.id)
}
return true
})()`)
await evaluate(`window.nexusterm.tree.createHost({
parentId: null,
label: '${HOST_LABEL}',
hostname: '127.0.0.1',
port: 2222,
username: ${JSON.stringify(userInfo().username)},
authType: 'key',
keyPath: '/tmp/testsshd/user_key',
color: null,
})`)
// Reload so the React tree picks the new host up the way a restart would.
await evaluate('location.reload()')
await new Promise((r) => setTimeout(r, 2_500))
await waitFor(
`!!document.querySelector('.row[title^="${HOST_LABEL}"]')`,
{ label: 'host row in the tree' },
)
report.hostCreated = true
// --- double-click it, exactly as a user would ---------------------------
await evaluate(`(() => {
const row = document.querySelector('.row[title^="${HOST_LABEL}"]')
row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }))
return true
})()`)
// --- the host key prompt, if this host has not been trusted yet ---------
//
// Only the first connection to a host prompts. A rerun against a host whose
// key is already recorded goes straight through — which is itself worth
// distinguishing, so the report says which path was taken rather than
// pretending both runs were identical.
const prompted = await waitFor(`document.body.textContent.includes('首次连接此主机')`, {
label: 'host key dialog',
timeoutMs: 6_000,
}).catch(() => false)
report.hostKeyPrompted = prompted
if (prompted) {
report.fingerprintShown = await evaluate(`(() => {
const el = document.querySelector('.hostkey__fingerprint')
return el ? el.textContent : null
})()`)
await click(`[...document.querySelectorAll('button')].find(b => b.textContent.includes('信任并连接'))`)
} else {
report.alreadyTrusted = true
}
// --- a terminal should now exist ----------------------------------------
await waitFor(`!!document.querySelector('.terminal-slot .xterm canvas')`, {
label: 'xterm mounted with a renderer',
})
report.terminalMounted = true
// Give the shell a moment to print its prompt and for the port to settle.
await new Promise((r) => setTimeout(r, 2_000))
// --- the local shell is pinned: first, and unclosable --------------------
report.tabOrder = await evaluate(`JSON.stringify(
[...document.querySelectorAll('.tab')].map(t => ({
label: t.querySelector('.tab__label').textContent,
pinned: t.classList.contains('tab--pinned'),
hasClose: !!t.querySelector('.tab__close'),
}))
)`)
report.pinnedTabIsFirst = await evaluate(`(() => {
const tabs = [...document.querySelectorAll('.tab')]
return tabs.length >= 2 && tabs[0].classList.contains('tab--pinned')
})()`)
report.pinnedTabHasNoCloseButton = await evaluate(
`!document.querySelector('.tab--pinned .tab__close')`,
)
// --- prove the input path through an observable side effect -------------
//
// Terminal output cannot be read back from the DOM: the WebGL renderer draws
// into canvases, so there is no text node to assert on. Asserting on pixels
// would be brittle and would only prove that *something* was drawn.
//
// Instead the command makes the remote shell touch a file, which is checked
// from outside the browser. That covers the whole outbound path — keystroke,
// xterm, port, main process, SSH channel, remote shell — and the result cannot
// be faked by a renderer bug.
const markerPath = `/tmp/${MARKER.toLowerCase()}`
rmSync(markerPath, { force: true })
await evaluate(`document.querySelector('.xterm-helper-textarea')?.focus()`)
await type(`touch ${markerPath}`)
await pressEnter()
const deadline = Date.now() + 15_000
while (Date.now() < deadline && !existsSync(markerPath)) {
await new Promise((r) => setTimeout(r, 200))
}
report.inputRoundTrip = existsSync(markerPath)
// --- and back again: the remote's output must reach the terminal --------
// `whoami` output is not readable from the DOM either, so this uses a command
// whose effect is another file — proving output was produced and consumed.
await type(`id -un > ${markerPath}.out`)
await pressEnter()
const outDeadline = Date.now() + 15_000
while (Date.now() < outDeadline && !existsSync(`${markerPath}.out`)) {
await new Promise((r) => setTimeout(r, 200))
}
report.outputRoundTrip = existsSync(`${markerPath}.out`)
report.remoteUser = existsSync(`${markerPath}.out`)
? readFileSync(`${markerPath}.out`, 'utf8').trim()
: null
rmSync(markerPath, { force: true })
rmSync(`${markerPath}.out`, { force: true })
// --- the stored-password path -------------------------------------------
//
// The test server refuses password authentication, so a successful password
// login cannot be demonstrated here. What *can* be demonstrated is that the
// app goes to the trouble of using a saved password instead of asking for one
// — which is the part this project actually implements, and the part the
// user's "remember the password" requirement rests on.
const passwordHostId = await evaluate(`(async () => {
const created = await window.nexusterm.tree.createHost({
parentId: null, label: '${HOST_LABEL}-PW', hostname: '127.0.0.1', port: 2222,
username: ${JSON.stringify(userInfo().username)}, authType: 'password',
keyPath: null, color: null,
})
return created.find(n => n.kind === 'host' && n.label === '${HOST_LABEL}-PW').id
})()`)
const withoutStored = await evaluate(
`(async () => (await window.nexusterm.session.open({ hostId: ${passwordHostId}, password: null, trustFingerprint: null })).status)()`,
)
// With nothing saved, the app must ask rather than fail or guess.
report.asksWhenNoPasswordStored = withoutStored === 'need-password'
await evaluate(`window.nexusterm.credentials.set({
hostId: ${passwordHostId}, kind: 'password', secret: 'stored-but-the-server-refuses-passwords'
})`)
report.credentialStored = await evaluate(
`window.nexusterm.credentials.has(${passwordHostId}, 'password')`,
)
const withStored = await evaluate(
`(async () => (await window.nexusterm.session.open({ hostId: ${passwordHostId}, password: null, trustFingerprint: null })).status)()`,
)
// Reaching `auth-failed` rather than `need-password` means the stored secret
// was read from the vault and handed to SSH — the round trip happened.
report.usedStoredPassword = withStored === 'auth-failed'
report.statusWithStoredPassword = withStored
// There is deliberately no API that returns the secret; only its existence.
report.secretIsNotReadableFromRenderer = await evaluate(
`typeof window.nexusterm.credentials.get === 'undefined'`,
)
// --- clean up ------------------------------------------------------------
await evaluate(`(async () => {
const tree = await window.nexusterm.tree.list()
for (const n of tree) {
if (n.kind === 'host' && n.label.startsWith('${HOST_LABEL}')) await window.nexusterm.tree.deleteNode(n.id)
}
return true
})()`)
report.cleanedUp = true
console.log(JSON.stringify(report, null, 2))
ws.close()