diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index 52666de..c5b8e07 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -52,7 +52,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter - **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused. - **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect. - Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test. -- ⬜ Phase 1c — not started. +- ✅ **Phase 1c — done, with one documented verification gap.** + - **OPKSSH / certificate auth**: `ssh2` (the npm library) has no support for OpenSSH certificates — confirmed by inspecting its type definitions and README, no certificate-related auth flow exists. Implemented `connectWithCertificate()` in `backend/src/routes/terminal.ts`: writes the stored private key + certificate to a temp dir (mode `0600`) and shells out to the system `ssh` binary (which natively understands `-o CertificateFile=`) under a real `node-pty` pty. Used automatically when an SSH integration has a `certificate` secret configured (new field added to Settings' SSH host form). Does **not** support jump-host chaining (documented limitation, not silently dropped — Termix's own OPKSSH path doesn't generally chain through jump hosts either). **Verification gap**: this sandbox has no `ssh` CLI installed (`apt-get install openssh-client` failed — mirror 404), so this path type-checks and is logically sound but has not been exercised end-to-end. Needs a real test against a cert-auth-enabled host before being considered fully verified; `openssh-client` is near-universal on real deployment targets, so this is a sandbox limitation, not an expected production gap. + - **tmux session monitor/reattach**: new WebSocket message `list_tmux` execs `tmux list-sessions` on the target host and returns session names; `connect` accepts an optional `tmuxSession` (validated against `^[A-Za-z0-9_-]{1,64}$` before being interpolated into a shell command, to prevent injection) which attaches to that tmux session or creates it if missing, via `exec('tmux attach -t || tmux new-session -s ', { pty: ... })` instead of a plain `client.shell()`. `src/pages/Terminal.tsx`'s pane header gained a tmux session picker (plain shell / new session / attach to an existing one). **Verified end-to-end** against a real test SSH server running real `bash`/`tmux` processes (via `node-pty`): listed zero sessions, created a `testsess` tmux session through the WS protocol, confirmed a follow-up `list_tmux` call returned `['testsess']`. + - **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `/_.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk. + - No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked. ### Phase 2 — SSH Tunnels (NOT STARTED) diff --git a/backend/package-lock.json b/backend/package-lock.json index fadea30..828ad90 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "node-pty": "^1.1.0", "ssh2": "^1.17.0", "undici": "^8.5.0", "zod": "^3.24.1" @@ -2038,6 +2039,22 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", diff --git a/backend/package.json b/backend/package.json index 3328c22..b3b2a0d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "node-pty": "^1.1.0", "ssh2": "^1.17.0", "undici": "^8.5.0", "zod": "^3.24.1" diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts index 2a9f34f..2b17d49 100644 --- a/backend/src/routes/terminal.ts +++ b/backend/src/routes/terminal.ts @@ -1,5 +1,9 @@ import type { FastifyInstance } from 'fastify' import { Client, type ClientChannel, type ConnectConfig } from 'ssh2' +import { spawn as spawnPty, type IPty } from 'node-pty' +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { db } from '../db/index.js' import { loadSecrets } from '../db/secrets.js' @@ -10,13 +14,18 @@ interface IntegrationRow { } interface ClientMessage { - type: 'connect' | 'input' | 'resize' | 'disconnect' + type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux' integrationId?: number cols?: number rows?: number data?: string + tmuxSession?: string } +const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/ + +const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs' + function send(socket: { send: (data: string) => void }, payload: Record) { socket.send(JSON.stringify(payload)) } @@ -31,6 +40,8 @@ function loadSshHost(integrationId: number) { return { id: row.id, config, secrets } } +type SshHost = NonNullable> + function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] { return (keyHash: string): boolean => { const row = db @@ -44,7 +55,7 @@ function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] } } -function baseConnectConfig(host: ReturnType extends infer T ? NonNullable : never): ConnectConfig { +function baseConnectConfig(host: SshHost): ConnectConfig { return { host: host.config.host, port: Number(host.config.port) || 22, @@ -58,19 +69,119 @@ function baseConnectConfig(host: ReturnType extends infer T } } +/** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */ +function connectTarget( + target: SshHost, + onReady: (client: Client) => void, + onError: (message: string) => void, +): { conn: Client; jumpConn: Client | null } { + const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null + + if (jumpHostId) { + const jumpHost = loadSshHost(jumpHostId) + if (!jumpHost) { + onError('Jump host integration not found') + return { conn: new Client(), jumpConn: null } + } + const jumpConn = new Client() + jumpConn.on('ready', () => { + jumpConn.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { + if (err) { + onError(`Jump host forward failed: ${err.message}`) + return + } + client.connect({ ...baseConnectConfig(target), sock }) + }) + }) + jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`)) + const client = new Client() + client.on('ready', () => onReady(client)) + client.on('error', (err) => onError(err.message)) + jumpConn.connect(baseConnectConfig(jumpHost)) + return { conn: client, jumpConn } + } + + const client = new Client() + client.on('ready', () => onReady(client)) + client.on('error', (err) => onError(err.message)) + client.connect(baseConnectConfig(target)) + return { conn: client, jumpConn: null } +} + +/** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the + * system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under + * a real pty. Jump-host chaining is not supported on this path. */ +function connectWithCertificate( + target: SshHost, + cols: number, + rows: number, + onReady: (pty: IPty, keyDir: string) => void, + onError: (message: string) => void, +) { + const keyDir = mkdtempSync(join(tmpdir(), 'archnest-ssh-')) + const keyFile = join(keyDir, 'id_key') + const certFile = join(keyDir, 'id_key-cert.pub') + try { + writeFileSync(keyFile, target.secrets.privateKey ?? '', { mode: 0o600 }) + writeFileSync(certFile, target.secrets.certificate ?? '', { mode: 0o600 }) + } catch (err) { + rmSync(keyDir, { recursive: true, force: true }) + onError(err instanceof Error ? err.message : 'Failed to write certificate files') + return + } + + const args = [ + '-tt', + '-p', String(Number(target.config.port) || 22), + '-i', keyFile, + '-o', `CertificateFile=${certFile}`, + '-o', 'StrictHostKeyChecking=accept-new', + '-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`, + `${target.config.username}@${target.config.host}`, + ] + + let pty: IPty + try { + pty = spawnPty('ssh', args, { name: 'xterm-256color', cols, rows }) + } catch (err) { + rmSync(keyDir, { recursive: true, force: true }) + onError(err instanceof Error ? `Failed to spawn ssh: ${err.message}` : 'Failed to spawn ssh') + return + } + onReady(pty, keyDir) +} + +function sessionLogPath(integrationId: number) { + mkdirSync(SESSION_LOG_DIR, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + return join(SESSION_LOG_DIR, `${integrationId}_${stamp}.log`) +} + export async function terminalRoutes(app: FastifyInstance) { app.get('/api/terminal', { websocket: true }, (socket, req) => { let conn: Client | null = null let jumpConn: Client | null = null let stream: ClientChannel | null = null + let pty: IPty | null = null + let ptyKeyDir: string | null = null + let logPath: string | null = null + + const logData = (data: string) => { + if (logPath) appendFileSync(logPath, data) + } const cleanup = () => { stream?.end() conn?.end() jumpConn?.end() + pty?.kill() + if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true }) stream = null conn = null jumpConn = null + pty = null + ptyKeyDir = null + logPath = null } socket.on('close', cleanup) @@ -84,7 +195,7 @@ export async function terminalRoutes(app: FastifyInstance) { return } - if (msg.type === 'connect') { + if (msg.type === 'connect' || msg.type === 'list_tmux') { const query = req.query as { token?: string } try { await app.jwt.verify(query.token ?? '') @@ -100,68 +211,106 @@ export async function terminalRoutes(app: FastifyInstance) { return } - const startShell = (client: Client) => { + if (msg.type === 'list_tmux') { + const { conn: ephemeralConn, jumpConn: ephemeralJump } = connectTarget( + target, + (client) => { + client.exec("command -v tmux >/dev/null && tmux list-sessions -F '#S' 2>/dev/null", (err, ch) => { + if (err) { + send(socket, { type: 'tmux_sessions', sessions: [] }) + client.end() + ephemeralJump?.end() + return + } + let out = '' + ch.on('data', (chunk: Buffer) => (out += chunk.toString('utf8'))) + ch.on('close', () => { + const sessions = out.split('\n').map((s) => s.trim()).filter(Boolean) + send(socket, { type: 'tmux_sessions', sessions }) + client.end() + ephemeralJump?.end() + }) + }) + }, + (message) => send(socket, { type: 'tmux_sessions', sessions: [], error: message }), + ) + void ephemeralConn + return + } + + const cols = msg.cols ?? 80 + const rows = msg.rows ?? 24 + + if (target.secrets.certificate) { + connectWithCertificate( + target, + cols, + rows, + (p, keyDir) => { + pty = p + ptyKeyDir = keyDir + if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id) + send(socket, { type: 'connected' }) + p.onData((data) => { + logData(data) + send(socket, { type: 'data', data }) + }) + p.onExit(() => { + send(socket, { type: 'closed' }) + cleanup() + }) + }, + (message) => send(socket, { type: 'error', message }), + ) + return + } + + const startSession = (client: Client) => { conn = client - client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => { + const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null + const onChannel = (err: Error | undefined, ch: ClientChannel) => { if (err) { send(socket, { type: 'error', message: err.message }) return } stream = ch + if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id) send(socket, { type: 'connected' }) - ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) + ch.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8') + logData(text) + send(socket, { type: 'data', data: text }) + }) ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') })) ch.on('close', () => { send(socket, { type: 'closed' }) cleanup() }) - }) - } - - const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null - - if (jumpHostId) { - const jumpHost = loadSshHost(jumpHostId) - if (!jumpHost) { - send(socket, { type: 'error', message: 'Jump host integration not found' }) - return } - jumpConn = new Client() - jumpConn.on('ready', () => { - jumpConn!.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => { - if (err) { - send(socket, { type: 'error', message: `Jump host forward failed: ${err.message}` }) - return - } - const client = new Client() - client.on('ready', () => startShell(client)) - client.on('error', (err2) => send(socket, { type: 'error', message: err2.message })) - client.connect({ ...baseConnectConfig(target), sock }) - }) - }) - jumpConn.on('error', (err) => { - send(socket, { type: 'error', message: `Jump host error: ${err.message}` }) - }) - jumpConn.connect(baseConnectConfig(jumpHost)) - return + if (tmuxSession) { + client.exec(`tmux attach -t ${tmuxSession} || tmux new-session -s ${tmuxSession}`, { + pty: { cols, rows, term: 'xterm-256color' }, + }, onChannel) + } else { + client.shell({ cols, rows, term: 'xterm-256color' }, onChannel) + } } - const client = new Client() - client.on('ready', () => startShell(client)) - client.on('error', (err) => { - send(socket, { type: 'error', message: err.message }) - }) - client.connect(baseConnectConfig(target)) + const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message })) + conn = result.conn + jumpConn = result.jumpConn return } if (msg.type === 'input') { - stream?.write(msg.data ?? '') + if (pty) pty.write(msg.data ?? '') + else stream?.write(msg.data ?? '') return } if (msg.type === 'resize') { - stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0) + if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24) + else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0) return } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c6ac2c4..936ecc0 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -55,6 +55,7 @@ const sshFields: FieldDef[] = [ { key: 'password', label: 'Password', secret: true }, { key: 'privateKey', label: 'Private Key (PEM)', secret: true }, { key: 'passphrase', label: 'Key Passphrase', secret: true }, + { key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true }, ] const cardBase: React.CSSProperties = { @@ -401,6 +402,7 @@ function SshHostsSection() { const fieldsWithJumpHost = (): FieldDef[] => [ ...sshFields, { key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' }, + { key: 'sessionLogging', label: 'Record session to disk' }, ] function buildPayload(fields: FieldDef[], values: Record) { @@ -498,6 +500,18 @@ function SshHostsSection() { ) } + if (f.key === 'sessionLogging') { + const savedValue = existing?.config[f.key] === 'true' + const value = values[f.key] !== undefined ? values[f.key] === 'true' : savedValue + return ( +
+ +
+ ) + } const isRevealed = revealed.has(key) const savedValue = f.secret ? '' : existing?.config[f.key] ?? '' const value = values[f.key] ?? savedValue diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index 2a5a348..3166c73 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -291,6 +291,8 @@ function TerminalPane({ onFocus: () => void }) { const [connected, setConnected] = useState(false) + const [tmuxSessions, setTmuxSessions] = useState([]) + const [selectedTmux, setSelectedTmux] = useState('') const containerRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) @@ -330,11 +332,28 @@ function TerminalPane({ useEffect(() => { if (hostId === null || hostId === lastHostIdRef.current) return lastHostIdRef.current = hostId + setSelectedTmux('') + fetchTmuxSessions(hostId) connect(hostId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostId]) - function connect(id: number) { + function fetchTmuxSessions(id: number) { + setTmuxSessions([]) + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) + ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id })) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.type === 'tmux_sessions') { + setTmuxSessions(msg.sessions ?? []) + ws.close() + } + } + } + + function connect(id: number, tmuxSession?: string) { wsRef.current?.close() setConnected(false) const term = termRef.current @@ -348,7 +367,7 @@ function TerminalPane({ wsRef.current = ws ws.onopen = () => { - ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows })) + ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) @@ -389,6 +408,29 @@ function TerminalPane({
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'} + {host && ( + + )}