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:
parent
52646d866d
commit
c37ad3d0d4
18 changed files with 326 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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!!
|
||||
|
|
|
|||
14
backend/package-lock.json
generated
14
backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
43
backend/src/integrations/remoteDesktop.ts
Normal file
43
backend/src/integrations/remoteDesktop.ts
Normal 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' }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export type IntegrationType =
|
|||
| 'uptime_kuma'
|
||||
| 'weather'
|
||||
| 'ssh'
|
||||
| 'remote_desktop'
|
||||
|
||||
export interface IntegrationConfig {
|
||||
[key: string]: string
|
||||
|
|
|
|||
95
backend/src/routes/guacamole.ts
Normal file
95
backend/src/routes/guacamole.ts
Normal 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 })
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ const integrationTypes = [
|
|||
'uptime_kuma',
|
||||
'weather',
|
||||
'ssh',
|
||||
'remote_desktop',
|
||||
] as const
|
||||
|
||||
const createSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -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
21
backend/src/types/guacamole-lite.d.ts
vendored
Normal 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
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
103
src/pages/RemoteDesktop.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
4
src/types/guacamole-common-js.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module 'guacamole-common-js' {
|
||||
const Guacamole: any
|
||||
export default Guacamole
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue