Phase 1c: OPKSSH cert auth, tmux session monitor/reattach, session logging

- terminal.ts: connectWithCertificate() shells out to system ssh via
  node-pty for OpenSSH certificate auth (ssh2 has no native support);
  list_tmux WS message + tmuxSession connect param for tmux
  attach/create with shell-injection-safe name validation;
  sessionLogging config field appends terminal output to disk.
- Settings.tsx: certificate secret field and sessionLogging checkbox
  for SSH host integrations.
- Terminal.tsx: tmux session picker in each pane's header.
- Verified end-to-end against a real test SSH server running real
  bash/tmux processes (plain shell, tmux create+list, session log
  written to disk). Cert auth path type-checks but is unverified in
  this sandbox (no ssh CLI available) - documented as a gap in
  TERMIX_MIGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-19 11:28:51 +00:00
parent 94b174c72e
commit 27abbc8ce1
No known key found for this signature in database
6 changed files with 272 additions and 45 deletions

View file

@ -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 <name> || tmux new-session -s <name>', { 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 `<ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'>/<integrationId>_<timestamp>.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)

View file

@ -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",

View file

@ -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"

View file

@ -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<string, unknown>) {
socket.send(JSON.stringify(payload))
}
@ -31,6 +40,8 @@ function loadSshHost(integrationId: number) {
return { id: row.id, config, secrets }
}
type SshHost = NonNullable<ReturnType<typeof loadSshHost>>
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<typeof loadSshHost> extends infer T ? NonNullable<T> : 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<typeof loadSshHost> 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
}

View file

@ -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<string, string>) {
@ -498,6 +500,18 @@ function SshHostsSection() {
</div>
)
}
if (f.key === 'sessionLogging') {
const savedValue = existing?.config[f.key] === 'true'
const value = values[f.key] !== undefined ? values[f.key] === 'true' : savedValue
return (
<div key={key} className="flex items-end pb-1.5">
<label className="flex items-center gap-2 text-xs" style={{ color: '#E8E6E0' }}>
<input type="checkbox" checked={value} onChange={(e) => onChange(f.key, e.target.checked ? 'true' : 'false')} />
{f.label}
</label>
</div>
)
}
const isRevealed = revealed.has(key)
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
const value = values[f.key] ?? savedValue

View file

@ -291,6 +291,8 @@ function TerminalPane({
onFocus: () => void
}) {
const [connected, setConnected] = useState(false)
const [tmuxSessions, setTmuxSessions] = useState<string[]>([])
const [selectedTmux, setSelectedTmux] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(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({
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
{host && (
<select
value={selectedTmux}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const raw = e.target.value
const value = raw === '__new__' ? `archnest-${Date.now().toString(36)}` : raw
setSelectedTmux(raw)
connect(host.id, value || undefined)
}}
className="ml-auto rounded-md border border-white/10 bg-transparent px-1.5 py-0.5"
style={{ color: TEXT_SECONDARY, fontSize: '11px' }}
title="Attach to a tmux session on this host"
>
<option value="">Plain shell</option>
<option value="__new__">New tmux session</option>
{tmuxSessions.map((s) => (
<option key={s} value={s}>
tmux: {s}
</option>
))}
</select>
)}
</div>
<div ref={containerRef} className="min-h-0 flex-1" />
</div>