Phase 5: RDP/VNC/Telnet remote desktop via guacamole-lite + guacd

Adds a remote_desktop integration type and a /api/guacamole websocket
route that drives guacamole-lite's ClientConnection directly (bypassing
its Server class, which would otherwise attach an unfiltered upgrade
listener that conflicts with the existing @fastify/websocket routes).
The frontend RemoteDesktop page renders the Guacamole protocol stream
via guacamole-common-js. Verified end-to-end against a real guacd and
VNC server, including in an actual browser session.

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 15:25:10 +00:00
parent 52646d866d
commit c37ad3d0d4
No known key found for this signature in database
18 changed files with 326 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<IntegrationType, IntegrationAdapter> = {
uptime_kuma: uptimeKuma,
@ -17,4 +18,5 @@ export const adapterRegistry: Record<IntegrationType, IntegrationAdapter> = {
aws,
weather,
ssh,
remote_desktop: remoteDesktop,
}

View file

@ -0,0 +1,43 @@
import { connect as netConnect } from 'node:net'
import type { IntegrationAdapter } from './types.js'
function requireConfig(config: Record<string, string>): 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<void> {
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' }
}
},
}

View file

@ -7,6 +7,7 @@ export type IntegrationType =
| 'uptime_kuma'
| 'weather'
| 'ssh'
| 'remote_desktop'
export interface IntegrationConfig {
[key: string]: string

View file

@ -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<string, string>
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<string, unknown> = { 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 })
})()
})
}

View file

@ -15,6 +15,7 @@ const integrationTypes = [
'uptime_kuma',
'weather',
'ssh',
'remote_desktop',
] as const
const createSchema = z.object({

View file

@ -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 }))

21
backend/src/types/guacamole-lite.d.ts vendored Normal file
View file

@ -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<string, unknown>,
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
}
}

7
package-lock.json generated
View file

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

View file

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

View file

@ -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() {
<Route path="/tunnels" element={<Tunnels />} />
<Route path="/files" element={<Files />} />
<Route path="/containers" element={<Containers />} />
<Route path="/remote-desktop" element={<RemoteDesktop />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</section>

View file

@ -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' },
]

103
src/pages/RemoteDesktop.tsx Normal file
View file

@ -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<Integration[]>([])
const [hostId, setHostId] = useState<number | null>(null)
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const displayRef = useRef<HTMLDivElement>(null)
const clientRef = useRef<any>(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 "?<data>" 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 (
<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 }}>
Remote Desktops
</p>
{hosts.length === 0 && (
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
No remote desktop integrations configured. Add one in Settings Integrations.
</p>
)}
{hosts.map((h) => (
<button
key={h.id}
onClick={() => handleSelect(h.id)}
className="mb-1 w-full rounded-md px-2 py-1.5 text-left text-sm transition"
style={{
background: hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
color: hostId === h.id ? '#C8A434' : '#E8E6E0',
}}
>
{h.name}
</button>
))}
</div>
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-white/10 bg-black">
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
{host ? host.name : 'Select a remote desktop'}
</p>
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
{status === 'connecting' && 'Connecting…'}
{status === 'connected' && 'Connected'}
{status === 'error' && `Error: ${errorMessage}`}
</p>
</div>
<div ref={displayRef} className="flex-1 overflow-auto" />
</div>
</div>
)
}

View file

@ -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[] = [

4
src/types/guacamole-common-js.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module 'guacamole-common-js' {
const Guacamole: any
export default Guacamole
}