diff --git a/backend/package-lock.json b/backend/package-lock.json index 4ca5241..fadea30 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 3c8f9c4..3328c22 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db/secrets.ts b/backend/src/db/secrets.ts new file mode 100644 index 0000000..5a8d0ed --- /dev/null +++ b/backend/src/db/secrets.ts @@ -0,0 +1,11 @@ +import { db } from './index.js' +import { decryptSecret } from './crypto.js' + +export function loadSecrets(integrationId: number): Record { + const rows = db + .prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?') + .all(integrationId) as { key: string; value_encrypted: string }[] + const out: Record = {} + for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted) + return out +} diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 3c6198c..2f8d05a 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -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 { - const rows = db - .prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?') - .all(integrationId) as { key: string; value_encrypted: string }[] - const out: Record = {} - 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) diff --git a/backend/src/routes/terminal.ts b/backend/src/routes/terminal.ts new file mode 100644 index 0000000..7e3fce1 --- /dev/null +++ b/backend/src/routes/terminal.ts @@ -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) { + 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 + 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() + } + }) + }) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 3ccb11d..d80d285 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 })) diff --git a/package-lock.json b/package-lock.json index 4029398..5aaa875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 897023b..59dbd16 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index e714610..9ea2b22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx new file mode 100644 index 0000000..386a4c0 --- /dev/null +++ b/src/pages/Terminal.tsx @@ -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([]) + const [activeHostId, setActiveHostId] = useState(null) + const [connected, setConnected] = useState(false) + const containerRef = useRef(null) + const termRef = useRef(null) + const fitRef = useRef(null) + const wsRef = useRef(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 ( +
+
+

+ SSH Hosts +

+ {hosts.length === 0 && ( +

+ No SSH integrations configured. Add one in Settings → Integrations. +

+ )} +
+ {hosts.map((h) => ( + + ))} +
+
+ +
+
+ + {activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'} +
+
+
+
+ ) +} diff --git a/vite.config.ts b/vite.config.ts index 5e7eb0f..4050fc6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ '/api': { target: 'http://localhost:4000', changeOrigin: true, + ws: true, }, }, },