Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
import type { FastifyInstance } from 'fastify'
|
2026-06-19 11:04:46 +00:00
|
|
|
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
2026-06-19 11:28:51 +00:00
|
|
|
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'
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
import { db } from '../db/index.js'
|
|
|
|
|
import { loadSecrets } from '../db/secrets.js'
|
|
|
|
|
|
|
|
|
|
interface IntegrationRow {
|
|
|
|
|
id: number
|
|
|
|
|
type: string
|
|
|
|
|
config_json: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ClientMessage {
|
2026-06-19 11:28:51 +00:00
|
|
|
type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux'
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
integrationId?: number
|
|
|
|
|
cols?: number
|
|
|
|
|
rows?: number
|
|
|
|
|
data?: string
|
2026-06-19 11:28:51 +00:00
|
|
|
tmuxSession?: string
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
|
|
|
|
|
|
|
|
|
|
const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'
|
|
|
|
|
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
|
|
|
|
socket.send(JSON.stringify(payload))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:04:46 +00:00
|
|
|
function loadSshHost(integrationId: number) {
|
|
|
|
|
const row = db
|
|
|
|
|
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
|
|
|
|
|
.get(integrationId) as IntegrationRow | undefined
|
|
|
|
|
if (!row || row.type !== 'ssh') return null
|
|
|
|
|
const config = JSON.parse(row.config_json) as Record<string, string>
|
|
|
|
|
const secrets = loadSecrets(row.id)
|
|
|
|
|
return { id: row.id, config, secrets }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
type SshHost = NonNullable<ReturnType<typeof loadSshHost>>
|
|
|
|
|
|
2026-06-19 11:04:46 +00:00
|
|
|
function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] {
|
|
|
|
|
return (keyHash: string): boolean => {
|
|
|
|
|
const row = db
|
|
|
|
|
.prepare('SELECT fingerprint FROM ssh_host_keys WHERE integration_id = ?')
|
|
|
|
|
.get(integrationId) as { fingerprint: string } | undefined
|
|
|
|
|
if (!row) {
|
|
|
|
|
db.prepare('INSERT INTO ssh_host_keys (integration_id, fingerprint) VALUES (?, ?)').run(integrationId, keyHash)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return row.fingerprint === keyHash
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
function baseConnectConfig(host: SshHost): ConnectConfig {
|
2026-06-19 11:04:46 +00:00
|
|
|
return {
|
|
|
|
|
host: host.config.host,
|
|
|
|
|
port: Number(host.config.port) || 22,
|
|
|
|
|
username: host.config.username,
|
|
|
|
|
password: host.secrets.password || undefined,
|
|
|
|
|
privateKey: host.secrets.privateKey || undefined,
|
|
|
|
|
passphrase: host.secrets.passphrase || undefined,
|
|
|
|
|
readyTimeout: 8000,
|
|
|
|
|
hostHash: 'sha256',
|
|
|
|
|
hostVerifier: makeHostVerifier(host.id),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
/** 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`)
|
|
|
|
|
}
|
|
|
|
|
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
export async function terminalRoutes(app: FastifyInstance) {
|
|
|
|
|
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
|
|
|
|
let conn: Client | null = null
|
2026-06-19 11:04:46 +00:00
|
|
|
let jumpConn: Client | null = null
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
let stream: ClientChannel | null = null
|
2026-06-19 11:28:51 +00:00
|
|
|
let pty: IPty | null = null
|
|
|
|
|
let ptyKeyDir: string | null = null
|
|
|
|
|
let logPath: string | null = null
|
|
|
|
|
|
|
|
|
|
const logData = (data: string) => {
|
|
|
|
|
if (logPath) appendFileSync(logPath, data)
|
|
|
|
|
}
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
stream?.end()
|
|
|
|
|
conn?.end()
|
2026-06-19 11:04:46 +00:00
|
|
|
jumpConn?.end()
|
2026-06-19 11:28:51 +00:00
|
|
|
pty?.kill()
|
|
|
|
|
if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true })
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
stream = null
|
|
|
|
|
conn = null
|
2026-06-19 11:04:46 +00:00
|
|
|
jumpConn = null
|
2026-06-19 11:28:51 +00:00
|
|
|
pty = null
|
|
|
|
|
ptyKeyDir = null
|
|
|
|
|
logPath = null
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket.on('close', cleanup)
|
|
|
|
|
|
|
|
|
|
socket.on('message', async (raw: Buffer) => {
|
|
|
|
|
let msg: ClientMessage
|
|
|
|
|
try {
|
|
|
|
|
msg = JSON.parse(raw.toString())
|
|
|
|
|
} catch {
|
|
|
|
|
send(socket, { type: 'error', message: 'Invalid JSON' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
if (msg.type === 'connect' || msg.type === 'list_tmux') {
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
const query = req.query as { token?: string }
|
|
|
|
|
try {
|
|
|
|
|
await app.jwt.verify(query.token ?? '')
|
|
|
|
|
} catch {
|
|
|
|
|
send(socket, { type: 'error', message: 'Unauthorized' })
|
|
|
|
|
socket.close()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:04:46 +00:00
|
|
|
const target = msg.integrationId !== undefined ? loadSshHost(msg.integrationId) : null
|
|
|
|
|
if (!target) {
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
send(socket, { type: 'error', message: 'SSH integration not found' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
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) => {
|
2026-06-19 11:04:46 +00:00
|
|
|
conn = client
|
2026-06-19 11:28:51 +00:00
|
|
|
const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null
|
|
|
|
|
const onChannel = (err: Error | undefined, ch: ClientChannel) => {
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
if (err) {
|
|
|
|
|
send(socket, { type: 'error', message: err.message })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
stream = ch
|
2026-06-19 11:28:51 +00:00
|
|
|
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
send(socket, { type: 'connected' })
|
2026-06-19 11:28:51 +00:00
|
|
|
ch.on('data', (chunk: Buffer) => {
|
|
|
|
|
const text = chunk.toString('utf8')
|
|
|
|
|
logData(text)
|
|
|
|
|
send(socket, { type: 'data', data: text })
|
|
|
|
|
})
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
|
|
|
|
ch.on('close', () => {
|
|
|
|
|
send(socket, { type: 'closed' })
|
|
|
|
|
cleanup()
|
|
|
|
|
})
|
2026-06-19 11:04:46 +00:00
|
|
|
}
|
2026-06-19 11:28:51 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-19 11:04:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 11:28:51 +00:00
|
|
|
const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message }))
|
|
|
|
|
conn = result.conn
|
|
|
|
|
jumpConn = result.jumpConn
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'input') {
|
2026-06-19 11:28:51 +00:00
|
|
|
if (pty) pty.write(msg.data ?? '')
|
|
|
|
|
else stream?.write(msg.data ?? '')
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'resize') {
|
2026-06-19 11:28:51 +00:00
|
|
|
if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24)
|
|
|
|
|
else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
Add Phase 1a: core SSH terminal (Termix migration)
Implements the minimal-viable terminal described in TERMIX_MIGRATION.md
Phase 1a: a real interactive SSH session in the browser over a
WebSocket, using xterm.js on the frontend and ssh2 on the backend.
Reuses ArchNest's existing SSH integrations (host/port/username/
password/privateKey/passphrase) instead of introducing a second,
duplicate host-management system the way Termix has one.
Backend: new /api/terminal WebSocket route (registered via
@fastify/websocket) handling connect/input/resize/disconnect messages,
authenticated via a JWT passed as a query param (browsers can't set
custom headers on the WS handshake). Extracted the integration secret
loader out of routes/integrations.ts into db/secrets.ts so the new
terminal route can reuse it without duplicating the decrypt logic.
Frontend: new Terminal.tsx page listing configured SSH hosts and
rendering an xterm.js terminal wired to the WebSocket; wired into
App.tsx at /terminal. vite.config.ts's dev proxy now forwards
WebSocket upgrades (ws: true) so this works under `npm run dev`.
Verified end-to-end against a real (test) ssh2-based SSH server:
connect, shell banner, keystroke echo, and prompt redraw all worked
correctly over the actual WebSocket protocol.
Deliberately deferred to Phase 1b/1c per the migration doc: jump-host
chaining, tab/split-pane UI, terminal theme/font settings, OPKSSH cert
auth, tmux session monitor, session recording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 10:52:04 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'disconnect') {
|
|
|
|
|
cleanup()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|