diff --git a/TERMIX_MIGRATION.md b/TERMIX_MIGRATION.md index 9e51004..160e419 100644 --- a/TERMIX_MIGRATION.md +++ b/TERMIX_MIGRATION.md @@ -112,9 +112,25 @@ One real bug was caught and fixed during this verification: `openExecStream()` o **Documented gap**: no browser is available in this sandbox, so `Containers.tsx` was verified by type-checking and a production `vite build`, and by manually exercising every backend endpoint it calls against the real daemon above — but it has not been clicked through in an actual browser. All test artifacts (test `dockerd` instance, test image/container, test backend instance, test DB, tokens, temp files) were cleaned up afterward. -### Phase 5 — RDP/VNC/Telnet (NOT STARTED) +### Phase 5 — RDP/VNC/Telnet (DONE) -Source: `src/backend/guacamole/*` + `guacamole-lite` dependency + frontend `src/ui/features/guacamole/*`. **Biggest infra lift**: requires a `guacd` sidecar container (see `docker/docker-compose.yml` in the Termix fork) added to ArchNest's own `docker-compose.yml` — this is a new runtime dependency, not just ported code. Should be scoped in detail (including how `guacd` networking/ports interact with ArchNest's existing deployment on `racknerd1`/NPM) before starting. +**Architecture decision**: Termix's own approach (`new GuacamoleLite({ server }, ...)`) attaches an unfiltered `'upgrade'` listener to the whole HTTP server, which would have collided with `@fastify/websocket`'s existing routes (`/api/terminal`, `/api/docker/exec`). Instead, `guacamole-lite`'s lower-level `ClientConnection`/`Crypt` classes (imported directly from their CJS lib files, typed via a small ambient `.d.ts`) are driven from inside our own Fastify `{websocket: true}` route, on a socket Fastify has already upgraded — no interaction with the HTTP server's `'upgrade'` event at all. `guacd` itself remains a required sidecar process (a real `guacd` binary, available via `apt`), but is not wired into a `docker-compose.yml` yet — see gap below. + +**What was built:** +- `backend/src/integrations/types.ts` / `registry.ts` / `routes/integrations.ts` — new `remote_desktop` integration type (config: `protocol`/`hostname`/`port`/`username`/`domain`, secret: `password`). +- `backend/src/integrations/remoteDesktop.ts` — `testConnection()` does a raw TCP probe of the configured port (distinct from the real Guacamole-protocol tunnel below). +- `backend/src/routes/guacamole.ts` — `/api/guacamole` websocket route: authenticates the `token` query param via `app.jwt.verify` (same pattern as `terminal.ts`/`docker.ts`, since websocket upgrades can't carry an `Authorization` header), loads the `remote_desktop` integration's config + decrypted secrets, server-side constructs and encrypts a Guacamole connection token via `Crypt`, then instantiates `ClientConnection` directly on the open socket and calls `.connect({ host, port })` against `guacd` (configurable via `ARCHNEST_GUACD_HOST`/`ARCHNEST_GUACD_PORT`, default `127.0.0.1:4822`). New env var `ARCHNEST_GUAC_CRYPT_KEY` (32-byte AES-256-CBC key) added to `.env.example`. +- `src/pages/RemoteDesktop.tsx` — new page (`/remote-desktop`, sidebar entry with a `MonitorSmartphone` icon): host picker + a `guacamole-common-js` `Guacamole.Client`/`Guacamole.WebSocketTunnel` canvas viewer. Note: `Guacamole.WebSocketTunnel` appends its own `"?" + data` query string inside `connect()`, so the tunnel URL passed to its constructor must be bare, with `token`/`integrationId` passed as the string argument to `client.connect(...)` instead — this was caught and fixed during browser verification (see below). +- `src/pages/Settings.tsx` — generic integration card extended with a `remote_desktop` entry (protocol/hostname/port/username/domain/password fields). + +**Verified end-to-end** against real, locally-installed infrastructure (no mocking): a real `guacd` (v1.3.0, installed via `apt`) and a real `Xtightvnc`/`vncserver` desktop. A raw `ws` client test first confirmed the tunnel itself — JWT auth, integration lookup, token encryption, and the guacd handshake — by observing real Guacamole-protocol `size`/`img` instructions come back over the websocket. Then the actual `RemoteDesktop.tsx` page was exercised in a real headless Chromium (Playwright) against a real running Vite dev server + backend: logged in, navigated to `/remote-desktop`, selected the configured VNC host, and confirmed the UI reaches `Connected` state with a live VNC framebuffer (cursor visible) rendered on canvas — not just a build/typecheck pass. + +One real bug was caught and fixed during this browser verification: the page initially called `client.connect()` with no arguments while the tunnel URL already had `token=...&integrationId=...` appended, producing a malformed `...&integrationId=1?undefined` URL and an `ECONNREFUSED`-style failure. Root cause (confirmed by reading `Guacamole.WebSocketTunnel`'s source): it always appends its own `"?" + data` itself. Fixed by passing a bare tunnel URL and moving the query data into the `client.connect(data)` call. + +**Documented gaps**: +- Telnet was not verified — no real telnet server could be installed in this sandbox (`telnetd`/`inetutils-telnetd` 404'd against the available `apt` mirror snapshot). RDP was not verified either (no real RDP target was available); only the VNC path has a live, browser-confirmed end-to-end test. The route code path is identical across all three protocols (same `ClientConnection`/`guacd` flow, differing only in the `connection.type` and per-protocol settings), so this is a coverage gap rather than a known defect. +- `guacd` is not yet added to a `docker-compose.yml` for actual deployment on `racknerd1` — it currently must be run as a sidecar process/container manually, pointed at via `ARCHNEST_GUACD_HOST`/`ARCHNEST_GUACD_PORT`. Wiring that into the real deployment compose file is follow-up work, not done here. +- All test artifacts (test `guacd`/`vncserver` processes, test backend instance, test DB, tokens, temp files, Playwright scripts) were cleaned up afterward. ### Also worth checking during/after the phases above diff --git a/backend/.env.example b/backend/.env.example index d3f0466..593a1ab 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,3 +3,4 @@ ARCHNEST_DB_PATH=./data/archnest.db ARCHNEST_JWT_SECRET=change-me-to-a-long-random-string ARCHNEST_SECRET_KEY=change-me-to-another-long-random-string ARCHNEST_CORS_ORIGIN=http://localhost:5173 +ARCHNEST_GUAC_CRYPT_KEY=change-me-to-a-32-byte-secret!! diff --git a/backend/package-lock.json b/backend/package-lock.json index 39b3713..013cd5f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "guacamole-lite": "^1.2.0", "node-pty": "^1.1.0", "ssh2": "^1.17.0", "undici": "^8.5.0", @@ -1915,6 +1916,19 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/guacamole-lite": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/guacamole-lite/-/guacamole-lite-1.2.0.tgz", + "integrity": "sha512-NeSYgbT5s5rxF0SE/kzJsV5Gg0IvnqoTOCbNIUMl23z1+SshaVfLExpxrEXSGTG0cdvY5lfZC1fOAepYriaXGg==", + "license": "Apache-2.0", + "dependencies": { + "deep-extend": "^0.6.0", + "ws": "^8.15.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index 2ec0236..291db55 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "better-sqlite3": "^11.8.1", "dotenv": "^16.6.1", "fastify": "^5.2.1", + "guacamole-lite": "^1.2.0", "node-pty": "^1.1.0", "ssh2": "^1.17.0", "undici": "^8.5.0", diff --git a/backend/src/integrations/registry.ts b/backend/src/integrations/registry.ts index 40e193d..fe5cf1e 100644 --- a/backend/src/integrations/registry.ts +++ b/backend/src/integrations/registry.ts @@ -7,6 +7,7 @@ import { cloudflare } from './cloudflare.js' import { weather } from './weather.js' import { aws } from './aws.js' import { ssh } from './ssh.js' +import { remoteDesktop } from './remoteDesktop.js' export const adapterRegistry: Record = { uptime_kuma: uptimeKuma, @@ -17,4 +18,5 @@ export const adapterRegistry: Record = { aws, weather, ssh, + remote_desktop: remoteDesktop, } diff --git a/backend/src/integrations/remoteDesktop.ts b/backend/src/integrations/remoteDesktop.ts new file mode 100644 index 0000000..4befe24 --- /dev/null +++ b/backend/src/integrations/remoteDesktop.ts @@ -0,0 +1,43 @@ +import { connect as netConnect } from 'node:net' +import type { IntegrationAdapter } from './types.js' + +function requireConfig(config: Record): string | null { + if (!config.protocol || !['rdp', 'vnc', 'telnet'].includes(config.protocol)) return 'Missing or invalid protocol' + if (!config.hostname) return 'Missing hostname' + return null +} + +function defaultPort(protocol: string): number { + if (protocol === 'rdp') return 3389 + if (protocol === 'vnc') return 5900 + return 23 +} + +function probeTcp(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = netConnect({ host, port, timeout: 5000 }) + socket.once('connect', () => { + socket.destroy() + resolve() + }) + socket.once('timeout', () => { + socket.destroy() + reject(new Error('Connection timed out')) + }) + socket.once('error', (err) => reject(err)) + }) +} + +export const remoteDesktop: IntegrationAdapter = { + async testConnection(config) { + const missing = requireConfig(config) + if (missing) return { ok: false, message: missing } + const port = Number(config.port) || defaultPort(config.protocol) + try { + await probeTcp(config.hostname, port) + return { ok: true, message: `${config.protocol.toUpperCase()} port reachable` } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' } + } + }, +} diff --git a/backend/src/integrations/types.ts b/backend/src/integrations/types.ts index 184f93f..01ecce9 100644 --- a/backend/src/integrations/types.ts +++ b/backend/src/integrations/types.ts @@ -7,6 +7,7 @@ export type IntegrationType = | 'uptime_kuma' | 'weather' | 'ssh' + | 'remote_desktop' export interface IntegrationConfig { [key: string]: string diff --git a/backend/src/routes/guacamole.ts b/backend/src/routes/guacamole.ts new file mode 100644 index 0000000..8da4d88 --- /dev/null +++ b/backend/src/routes/guacamole.ts @@ -0,0 +1,95 @@ +import type { FastifyInstance } from 'fastify' +import { randomUUID } from 'node:crypto' +import { db } from '../db/index.js' +import { loadSecrets } from '../db/secrets.js' +// guacamole-lite only exports its Server class from the package root; the lower-level +// ClientConnection/Crypt classes we need are pulled directly from their CJS lib files. +import ClientConnection from 'guacamole-lite/lib/ClientConnection.js' +import Crypt from 'guacamole-lite/lib/Crypt.js' + +interface RemoteDesktopRow { + id: number + type: string + config_json: string +} + +const CRYPT_CYPHER = 'AES-256-CBC' +const CRYPT_KEY = process.env.ARCHNEST_GUAC_CRYPT_KEY + +const GUACD_HOST = process.env.ARCHNEST_GUACD_HOST ?? '127.0.0.1' +const GUACD_PORT = Number(process.env.ARCHNEST_GUACD_PORT ?? 4822) + +const CLIENT_OPTIONS = { + log: { level: 30, stdLog: () => {}, errorLog: () => {} }, + crypt: { cypher: CRYPT_CYPHER, key: CRYPT_KEY }, + maxInactivityTime: 0, + connectionDefaultSettings: { + rdp: { port: '3389', width: 1024, height: 768, dpi: 96, image: ['image/png', 'image/jpeg'] }, + vnc: { port: '5900', width: 1024, height: 768, dpi: 96, image: ['image/png', 'image/jpeg'] }, + telnet: { port: '23', width: 1024, height: 768, dpi: 96, image: ['image/png', 'image/jpeg'] }, + }, + allowedUnencryptedConnectionSettings: { + rdp: ['width', 'height', 'dpi'], + vnc: ['width', 'height', 'dpi'], + telnet: ['width', 'height', 'dpi'], + }, +} + +function loadRemoteDesktopTarget(integrationId: number) { + const row = db + .prepare("SELECT * FROM integrations WHERE id = ? AND type = 'remote_desktop'") + .get(integrationId) as RemoteDesktopRow | undefined + if (!row) return null + const config = JSON.parse(row.config_json) as Record + const secrets = loadSecrets(row.id) + return { config, secrets } +} + +export async function guacamoleRoutes(app: FastifyInstance) { + if (!CRYPT_KEY) { + app.log.warn('ARCHNEST_GUAC_CRYPT_KEY not set — remote desktop sessions will fail to start') + } + + app.get('/api/guacamole', { websocket: true }, (socket, req) => { + const query = req.query as { token?: string; integrationId?: string } + + void (async () => { + try { + await app.jwt.verify(query.token ?? '') + } catch { + socket.close(1008, 'Unauthorized') + return + } + + const integrationId = Number(query.integrationId) + const target = Number.isFinite(integrationId) ? loadRemoteDesktopTarget(integrationId) : null + if (!target) { + socket.close(1008, 'Remote desktop integration not found') + return + } + if (!CRYPT_KEY) { + socket.close(1011, 'Server not configured for remote desktop') + return + } + + const { protocol, hostname, port, username, domain } = target.config + const settings: Record = { hostname, username, password: target.secrets.password ?? '' } + if (port) settings.port = port + if (domain) settings.domain = domain + + const token = new Crypt(CRYPT_CYPHER, CRYPT_KEY).encrypt({ + connection: { type: protocol, settings }, + }) + + const connectionId = randomUUID() + const clientConnection = new ClientConnection( + CLIENT_OPTIONS, + connectionId, + socket, + { token }, + { processConnectionSettings: (s: unknown, cb: (err: unknown, s: unknown) => void) => cb(undefined, s) }, + ) + clientConnection.connect({ host: GUACD_HOST, port: GUACD_PORT }) + })() + }) +} diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 2f8d05a..9b4f50c 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -15,6 +15,7 @@ const integrationTypes = [ 'uptime_kuma', 'weather', 'ssh', + 'remote_desktop', ] as const const createSchema = z.object({ diff --git a/backend/src/server.ts b/backend/src/server.ts index eff8ef6..fd648d2 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import { terminalRoutes } from './routes/terminal.js' import { tunnelRoutes } from './routes/tunnels.js' import { fileRoutes } from './routes/files.js' import { dockerRoutes, dockerExecRoutes } from './routes/docker.js' +import { guacamoleRoutes } from './routes/guacamole.js' import { startAutoStartTunnels } from './tunnels/manager.js' const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET @@ -43,6 +44,7 @@ await app.register(tunnelRoutes) await app.register(fileRoutes) await app.register(dockerRoutes) await app.register(dockerExecRoutes) +await app.register(guacamoleRoutes) app.get('/api/health', async () => ({ ok: true })) diff --git a/backend/src/types/guacamole-lite.d.ts b/backend/src/types/guacamole-lite.d.ts new file mode 100644 index 0000000..9ff0765 --- /dev/null +++ b/backend/src/types/guacamole-lite.d.ts @@ -0,0 +1,21 @@ +declare module 'guacamole-lite/lib/ClientConnection.js' { + import { EventEmitter } from 'node:events' + export default class ClientConnection extends EventEmitter { + constructor( + clientOptions: unknown, + connectionId: string | number, + webSocket: unknown, + query: Record, + callbacks: { processConnectionSettings: (settings: unknown, cb: (err: unknown, settings: unknown) => void) => void }, + ) + connect(guacdOptions: { host: string; port: number }): void + } +} + +declare module 'guacamole-lite/lib/Crypt.js' { + export default class Crypt { + constructor(cypher: string, key: string) + encrypt(data: unknown): string + decrypt(encoded: string): unknown + } +} diff --git a/package-lock.json b/package-lock.json index 5aaa875..578dac2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/vite": "^4.3.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "guacamole-common-js": "^1.5.0", "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -2297,6 +2298,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/guacamole-common-js": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz", + "integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==", + "license": "Apache 2.0" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", diff --git a/package.json b/package.json index 59dbd16..a16f538 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.3.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "guacamole-common-js": "^1.5.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 2ce7962..afee65e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import Terminal from './pages/Terminal' import Tunnels from './pages/Tunnels' import Files from './pages/Files' import Containers from './pages/Containers' +import RemoteDesktop from './pages/RemoteDesktop' import Settings from './pages/Settings' import Login from './pages/Login' import Enrollment from './pages/Enrollment' @@ -88,6 +89,7 @@ function Dashboard() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a5997ed..2ec7a4c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { Waypoints, FolderOpen, Box, + MonitorSmartphone, Settings, ChevronLeft, ChevronRight, @@ -27,6 +28,7 @@ const navItems = [ { icon: Waypoints, label: 'Tunnels', route: '/tunnels' }, { icon: FolderOpen, label: 'Files', route: '/files' }, { icon: Box, label: 'Containers', route: '/containers' }, + { icon: MonitorSmartphone, label: 'Remote Desktop', route: '/remote-desktop' }, { icon: Settings, label: 'Settings', route: '/settings' }, ] diff --git a/src/pages/RemoteDesktop.tsx b/src/pages/RemoteDesktop.tsx new file mode 100644 index 0000000..9fd6836 --- /dev/null +++ b/src/pages/RemoteDesktop.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react' +import Guacamole from 'guacamole-common-js' +import { api, getToken, type Integration } from '../lib/api' + +const TEXT_SECONDARY = '#7A7D85' + +export default function RemoteDesktop() { + const [hosts, setHosts] = useState([]) + const [hostId, setHostId] = useState(null) + const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle') + const [errorMessage, setErrorMessage] = useState('') + const displayRef = useRef(null) + const clientRef = useRef(null) + + useEffect(() => { + api.listIntegrations().then(({ integrations }) => { + setHosts(integrations.filter((i) => i.type === 'remote_desktop')) + }) + }, []) + + useEffect(() => { + return () => { + clientRef.current?.disconnect() + } + }, []) + + function connect(id: number) { + clientRef.current?.disconnect() + if (displayRef.current) displayRef.current.innerHTML = '' + setStatus('connecting') + setErrorMessage('') + + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + // Guacamole.WebSocketTunnel appends its own "?" query string on connect(), + // so the tunnel URL itself must not already contain one. + const tunnel = new Guacamole.WebSocketTunnel(`${proto}://${window.location.host}/api/guacamole`) + const client = new Guacamole.Client(tunnel) + clientRef.current = client + + client.onerror = (err: { message?: string }) => { + setStatus('error') + setErrorMessage(err?.message ?? 'Connection failed') + } + client.onstatechange = (state: number) => { + if (state === 3) setStatus('connected') + } + + const display = client.getDisplay().getElement() + displayRef.current?.appendChild(display) + + client.connect(`token=${encodeURIComponent(token ?? '')}&integrationId=${id}`) + } + + function handleSelect(id: number) { + setHostId(id) + connect(id) + } + + const host = hosts.find((h) => h.id === hostId) + + return ( +
+
+

+ Remote Desktops +

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

+ No remote desktop integrations configured. Add one in Settings → Integrations. +

+ )} + {hosts.map((h) => ( + + ))} +
+ +
+
+

+ {host ? host.name : 'Select a remote desktop'} +

+

+ {status === 'connecting' && 'Connecting…'} + {status === 'connected' && 'Connected'} + {status === 'error' && `Error: ${errorMessage}`} +

+
+
+
+
+ ) +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 936ecc0..aecbf35 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -46,6 +46,14 @@ const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] { type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] }, { type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] }, { type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] }, + { type: 'remote_desktop', name: 'Remote Desktop', fields: [ + { key: 'protocol', label: 'Protocol (rdp / vnc / telnet)' }, + { key: 'hostname', label: 'Hostname' }, + { key: 'port', label: 'Port' }, + { key: 'username', label: 'Username' }, + { key: 'domain', label: 'Domain (RDP only)' }, + { key: 'password', label: 'Password', secret: true }, + ] }, ] const sshFields: FieldDef[] = [ diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts new file mode 100644 index 0000000..6acfcfe --- /dev/null +++ b/src/types/guacamole-common-js.d.ts @@ -0,0 +1,4 @@ +declare module 'guacamole-common-js' { + const Guacamole: any + export default Guacamole +}