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
This commit is contained in:
Claude 2026-06-19 10:52:04 +00:00
parent f2629a22f8
commit 71f49e0700
No known key found for this signature in database
11 changed files with 347 additions and 10 deletions

View file

@ -12,6 +12,7 @@
"@aws-sdk/client-sts": "^3.1072.0",
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^10.1.0",
"@fastify/websocket": "^11.2.0",
"@types/ssh2": "^1.15.5",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",
@ -1017,6 +1018,27 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/websocket": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.3",
"fastify-plugin": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
@ -1514,6 +1536,18 @@
"url": "https://dotenvx.com"
}
},
"node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@ -2395,6 +2429,12 @@
"reusify": "^1.0.0"
}
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -2568,6 +2608,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",

View file

@ -13,6 +13,7 @@
"@aws-sdk/client-sts": "^3.1072.0",
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^10.1.0",
"@fastify/websocket": "^11.2.0",
"@types/ssh2": "^1.15.5",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",

11
backend/src/db/secrets.ts Normal file
View file

@ -0,0 +1,11 @@
import { db } from './index.js'
import { decryptSecret } from './crypto.js'
export function loadSecrets(integrationId: number): Record<string, string> {
const rows = db
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
.all(integrationId) as { key: string; value_encrypted: string }[]
const out: Record<string, string> = {}
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
return out
}

View file

@ -1,7 +1,8 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
import { encryptSecret, decryptSecret } from '../db/crypto.js'
import { encryptSecret } from '../db/crypto.js'
import { loadSecrets } from '../db/secrets.js'
import { adapterRegistry } from '../integrations/registry.js'
import type { IntegrationType, Resource } from '../integrations/types.js'
@ -47,15 +48,6 @@ function serialize(row: IntegrationRow) {
}
}
function loadSecrets(integrationId: number): Record<string, string> {
const rows = db
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
.all(integrationId) as { key: string; value_encrypted: string }[]
const out: Record<string, string> = {}
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
return out
}
export async function integrationRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)

View file

@ -0,0 +1,115 @@
import type { FastifyInstance } from 'fastify'
import { Client } from 'ssh2'
import type { ClientChannel } from 'ssh2'
import { db } from '../db/index.js'
import { loadSecrets } from '../db/secrets.js'
interface IntegrationRow {
id: number
type: string
config_json: string
}
interface ClientMessage {
type: 'connect' | 'input' | 'resize' | 'disconnect'
integrationId?: number
cols?: number
rows?: number
data?: string
}
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
socket.send(JSON.stringify(payload))
}
export async function terminalRoutes(app: FastifyInstance) {
app.get('/api/terminal', { websocket: true }, (socket, req) => {
let conn: Client | null = null
let stream: ClientChannel | null = null
const cleanup = () => {
stream?.end()
conn?.end()
stream = null
conn = null
}
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
}
if (msg.type === 'connect') {
const query = req.query as { token?: string }
try {
await app.jwt.verify(query.token ?? '')
} catch {
send(socket, { type: 'error', message: 'Unauthorized' })
socket.close()
return
}
const row = db
.prepare('SELECT id, type, config_json FROM integrations WHERE id = ?')
.get(msg.integrationId) as IntegrationRow | undefined
if (!row || row.type !== 'ssh') {
send(socket, { type: 'error', message: 'SSH integration not found' })
return
}
const config = JSON.parse(row.config_json) as Record<string, string>
const secrets = loadSecrets(row.id)
conn = new Client()
conn.on('ready', () => {
conn!.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
if (err) {
send(socket, { type: 'error', message: err.message })
return
}
stream = ch
send(socket, { type: 'connected' })
ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
ch.on('close', () => {
send(socket, { type: 'closed' })
cleanup()
})
})
})
conn.on('error', (err) => {
send(socket, { type: 'error', message: err.message })
})
conn.connect({
host: config.host,
port: Number(config.port) || 22,
username: config.username,
password: secrets.password || undefined,
privateKey: secrets.privateKey || undefined,
passphrase: secrets.passphrase || undefined,
readyTimeout: 8000,
})
return
}
if (msg.type === 'input') {
stream?.write(msg.data ?? '')
return
}
if (msg.type === 'resize') {
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
return
}
if (msg.type === 'disconnect') {
cleanup()
}
})
})
}

View file

@ -2,10 +2,12 @@ import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import websocket from '@fastify/websocket'
import { authRoutes } from './routes/auth.js'
import { integrationRoutes } from './routes/integrations.js'
import { bookmarkRoutes } from './routes/bookmarks.js'
import { eventRoutes } from './routes/events.js'
import { terminalRoutes } from './routes/terminal.js'
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
if (!JWT_SECRET) {
@ -16,6 +18,7 @@ const app = Fastify({ logger: true })
await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true })
await app.register(jwt, { secret: JWT_SECRET })
await app.register(websocket)
app.decorate('authenticate', async function (req, reply) {
try {
@ -29,6 +32,7 @@ await app.register(authRoutes)
await app.register(integrationRoutes)
await app.register(bookmarkRoutes)
await app.register(eventRoutes)
await app.register(terminalRoutes)
app.get('/api/health', async () => ({ ok: true }))

17
package-lock.json generated
View file

@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@ -1551,6 +1553,21 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",

View file

@ -11,6 +11,8 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",

View file

@ -5,6 +5,7 @@ import TopBar from './components/TopBar'
import Glance from './pages/Glance'
import Infrastructure from './pages/Infrastructure'
import BookNest from './pages/BookNest'
import Terminal from './pages/Terminal'
import Settings from './pages/Settings'
import Login from './pages/Login'
import Enrollment from './pages/Enrollment'
@ -80,6 +81,7 @@ function Dashboard() {
<Route path="/" element={<Glance />} />
<Route path="/infrastructure" element={<Infrastructure />} />
<Route path="/booknest" element={<BookNest />} />
<Route path="/terminal" element={<Terminal />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</section>

131
src/pages/Terminal.tsx Normal file
View file

@ -0,0 +1,131 @@
import { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { api, getToken, type Integration } from '../lib/api'
const GOLD = '#C8A434'
const TEXT_SECONDARY = '#7A7D85'
export default function Terminal() {
const [hosts, setHosts] = useState<Integration[]>([])
const [activeHostId, setActiveHostId] = useState<number | null>(null)
const [connected, setConnected] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
api.listIntegrations().then(({ integrations }) => {
setHosts(integrations.filter((i) => i.type === 'ssh'))
})
}, [])
useEffect(() => {
if (!containerRef.current) return
const term = new XTerm({
cursorBlink: true,
fontSize: 13,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(containerRef.current)
fit.fit()
termRef.current = term
fitRef.current = fit
const onResize = () => fit.fit()
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
term.dispose()
wsRef.current?.close()
}
}, [])
function connect(hostId: number) {
wsRef.current?.close()
setActiveHostId(hostId)
setConnected(false)
const term = termRef.current
if (!term) return
term.reset()
term.writeln('Connecting…')
const token = getToken()
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
wsRef.current = ws
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'connect', integrationId: hostId, cols: term.cols, rows: term.rows }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
setConnected(true)
term.reset()
} else if (msg.type === 'data') {
term.write(msg.data)
} else if (msg.type === 'error') {
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
setConnected(false)
} else if (msg.type === 'closed') {
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
setConnected(false)
}
}
ws.onclose = () => setConnected(false)
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
})
term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
})
}
return (
<div className="flex h-full gap-4">
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
SSH Hosts
</p>
{hosts.length === 0 && (
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
No SSH integrations configured. Add one in Settings Integrations.
</p>
)}
<div className="flex flex-col gap-1">
{hosts.map((h) => (
<button
key={h.id}
onClick={() => connect(h.id)}
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
style={{
background: activeHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: activeHostId === h.id ? GOLD : '#E8E6E0',
}}
>
{h.name}
</button>
))}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col rounded-lg border border-white/10 bg-[#15161A] p-2">
<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' }}
/>
{activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'}
</div>
<div ref={containerRef} className="min-h-0 flex-1" />
</div>
</div>
)
}

View file

@ -9,6 +9,7 @@ export default defineConfig({
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
ws: true,
},
},
},