feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
This commit is contained in:
LukeGus committed 2026-09-21 20:25:52 -05:00
1 parent d440ce9073
commit c6bcfdb80d
50 files changed
+1031 -232

No files matched your search

@@ -0,0 +1,113 @@
// Mounted at /guacamole. Remote Desktop ships as a first-party in-process
// plugin (plugins/remote-desktop) rather than a route file imported directly
// here, so this module keeps /guacamole/* working at its original path
// without handing the plugin a reference to the shared app instance.
//
// It also brokers the small surface collab, session-sharing and the admin
// settings route need from the plugin (join tokens, live-session lookups, a
// server restart hook). Those core modules cannot import the plugin's
// TypeScript directly -- plugins/ is compiled by tsconfig.plugins.json as a
// one-directional read of src/backend/ (see copy-bundled-plugins.cjs), and a
// core file under tsconfig.node.json's rootDir cannot statically import
// something outside it. So the plugin registers this bridge once on
// activate, and clears it on deactivate; a disabled/uninstalled plugin falls
// back to the safe defaults below (404 for the router, null for session
// lookups, a no-op restart).
//
// This is an accepted, temporary coupling -- see the plugin's own README.
// collab and session-sharing are expected to become plugins of their own
// later, at which point this becomes a real inter-plugin service reference
// instead of a bridge object living in core.
//
// The router forwards with no auth of its own: it delegates to exactly one
// first-party router, and every route in that router already applies its
// own auth middleware.
import type { Request, Response, NextFunction, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
export interface GuacamoleSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
export interface GuacamoleBridge {
router: Router;
restart: () => Promise<void>;
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
getSessionInfo: (
guacamoleConnectionId: string,
) => GuacamoleSessionInfo | null;
}
let bridge: GuacamoleBridge | null = null;
export function registerGuacamoleBridge(next: GuacamoleBridge): void {
bridge = next;
databaseLogger.info("Registered Remote Desktop plugin bridge", {
operation: "guacamole_dispatch_register",
});
}
export function unregisterGuacamoleBridge(): void {
if (!bridge) return;
bridge = null;
databaseLogger.info("Unregistered Remote Desktop plugin bridge", {
operation: "guacamole_dispatch_register",
});
}
/**
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits
* the guacd URL setting (see user-settings-routes.ts). A no-op while the
* plugin is disabled/uninstalled -- restarting a server that does not exist
* would otherwise silently resurrect it outside the plugin lifecycle.
*/
export async function restartGuacamoleService(): Promise<void> {
if (!bridge) return;
await bridge.restart();
}
/**
* Mints a read-only/writable join token for an existing live Guacamole
* session, for collab/session-sharing to hand to an invited viewer. Throws
* when the plugin is disabled/uninstalled -- unlike the read-only lookups
* below, a caller asking to mint a join token needs to know it failed rather
* than silently getting an unusable value.
*/
export function createGuacamoleJoinToken(
guacamoleConnectionId: string,
readOnly: boolean,
): string {
if (!bridge) {
throw new Error("Remote Desktop plugin is not available");
}
return bridge.createJoinToken(guacamoleConnectionId, readOnly);
}
/**
* Looks up a live Guacamole session by guacd's own connection id. Returns
* null both when no such session exists and when the plugin is
* disabled/uninstalled -- to a caller checking "is this session live", the
* two cases mean the same thing.
*/
export function getGuacamoleSessionInfo(
guacamoleConnectionId: string,
): GuacamoleSessionInfo | null {
return bridge?.getSessionInfo(guacamoleConnectionId) ?? null;
}
export default function guacamoleDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!bridge) {
res.status(404).json({ error: "Remote Desktop is not available" });
return;
}
bridge.router(req, res, next);
}
@@ -1,29 +0,0 @@
export const GUACD_DRIVE_PATH_ENV = "GUACD_DRIVE_PATH";
const DEFAULT_DRIVE_ROOT = "/drive";
/**
* Fills in where guacd keeps the files behind RDP drive redirection.
*
* The folder lives on the guacd host, not on Termix's - with the stock
* compose it is a directory in the shared termix-data volume, configured
* through GUACD_DRIVE_PATH. Each user gets a folder of their own underneath:
* a shared drive would show everyone's uploads to everyone else. A host that
* names its own drive-path keeps it.
*/
export function withDriveSettings(
guacConfig: Record<string, unknown>,
userId: string,
env: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
if (!guacConfig["enable-drive"] || guacConfig["drive-path"]) {
return guacConfig;
}
const root = (
env[GUACD_DRIVE_PATH_ENV]?.trim() || DEFAULT_DRIVE_ROOT
).replace(/\/+$/, "");
return {
...guacConfig,
"drive-path": `${root}/${userId}`,
"create-drive-path": true,
};
}
@@ -1,264 +0,0 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../utils/logger.js";
import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
} from "./token-service.js";
import {
createCurrentSessionRecordingRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import fs from "fs";
import path from "path";
const tokenService = GuacamoleTokenService.getInstance();
function readGuacdOptions(): { host: string; port: number } {
let dbUrl: string | undefined;
try {
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
} catch {
// DB not available yet, use env var defaults
}
return resolveGuacdOptions(dbUrl);
}
const GUAC_WS_PORT = 30008;
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
guacamoleConnectionId?: string;
connectionSettings?: {
connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata;
termixMeta?: {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: string;
};
};
};
export interface GuacSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
// primary connection's guacd handshake completes.
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
export function getGuacSessionByConnectId(
connectId: string,
userId: string,
): GuacSessionInfo | null {
const info = guacSessionByConnectId.get(connectId);
return info?.ownerUserId === userId ? info : null;
}
export function getGuacSessionInfo(
guacamoleConnectionId: string,
): GuacSessionInfo | null {
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
}
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise<void> {
const recording = clientConnection.connectionSettings?.recording;
if (!recording) return;
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
if (!resolvedPath.startsWith(allowedBase)) return;
// guacd may flush/rename the recording just after the websocket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!fs.existsSync(resolvedPath)) {
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
guacLogger.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
guacdPath,
hint:
"guacd writes the recording to guacdPath, the backend reads it from path. " +
"When guacd runs in its own container these must be the same volume — set " +
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
});
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await createCurrentSessionRecordingRepository().create({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: Math.max(
0,
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
),
recordingPath: resolvedPath,
protocol: recording.protocol,
format: "guacamole",
});
}
const websocketOptions = {
host: "127.0.0.1",
port: GUAC_WS_PORT,
};
const clientOptions = {
crypt: {
cypher: "AES-256-CBC",
key: tokenService.getEncryptionKey(),
},
log: {
level: "ERRORS",
stdLog: (...args: unknown[]) => {
guacLogger.info(args.join(" "));
},
errorLog: (...args: unknown[]) => {
guacLogger.error(args.join(" "));
},
},
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
vnc: ["width", "height"],
telnet: ["width", "height"],
},
connectionDefaultSettings: {
rdp: {
security: "any",
"ignore-cert": true,
"enable-wallpaper": false,
"enable-font-smoothing": true,
"enable-desktop-composition": false,
"disable-audio": false,
"enable-drive": false,
"resize-method": "display-update",
width: 1280,
height: 720,
dpi: 96,
audio: ["audio/L16"],
},
vnc: {
"swap-red-blue": false,
cursor: "remote",
width: 1280,
height: 720,
// macOS Screen Sharing negotiates its VNC security type over several
// round trips (RFB type 30 -> 33/36/2/35) and can fail the first
// attempt; retrying lets guacd's VNC client re-establish instead of
// guacd giving up immediately (Support#1063).
autoretry: 2,
},
telnet: {
"terminal-type": "xterm-256color",
},
},
};
const _origConsoleLog = console.log;
console.log = (...args: unknown[]) => {
const msg = args[0];
if (typeof msg === "string" && msg.startsWith("New client connection"))
return;
_origConsoleLog(...args);
};
function createGuacServer(): GuacamoleLite {
const guacdOptions = readGuacdOptions();
const server = new GuacamoleLite(
websocketOptions,
guacdOptions,
clientOptions,
);
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
if (!isJoin && termixMeta && guacamoleConnectionId) {
const info: GuacSessionInfo = {
guacamoleConnectionId,
hostId: termixMeta.hostId,
ownerUserId: termixMeta.ownerUserId,
protocol: termixMeta.protocol,
openedAt: Date.now(),
};
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
}
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
if (!isJoin && termixMeta && guacamoleConnectionId) {
guacSessionByConnectId.delete(termixMeta.termixConnectId);
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
}
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
});
});
});
server.on(
"error",
(clientConnection: GuacamoleClientConnection, error: Error) => {
guacLogger.error("Guacamole connection error", error, {
operation: "guac_connection_error",
type: clientConnection.connectionSettings?.connection?.type,
});
},
);
return server;
}
let guacServer = createGuacServer();
export async function restartGuacServer(): Promise<void> {
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during restart", err as Error);
}
guacServer = createGuacServer();
}
export { guacServer, tokenService };
@@ -1,15 +0,0 @@
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
export function resolveJumpTunnelEndpoint(
guacdHost: string,
tunnelHost = process.env.GUACD_TUNNEL_HOST,
): { bindHost: string; advertisedHost: string } {
if (LOOPBACK_HOSTS.has(guacdHost.toLowerCase())) {
return { bindHost: "127.0.0.1", advertisedHost: "127.0.0.1" };
}
return {
bindHost: "0.0.0.0",
advertisedHost: tunnelHost?.trim() || "termix",
};
}
@@ -1,97 +0,0 @@
import net from "net";
const RFB_BANNER_LENGTH = 12;
const VNC_AUTH_SECURITY_TYPE = 2;
const MACOS_RFB_BANNER = Buffer.from("RFB 003.889\n", "ascii");
const STANDARD_RFB_BANNER = Buffer.from("RFB 003.008\n", "ascii");
export interface VncCompatibilityProxy {
port: number;
close: () => void;
}
export async function createMacosVncCompatibilityProxy({
targetHost,
targetPort,
bindHost,
}: {
targetHost: string;
targetPort: number;
bindHost: string;
}): Promise<VncCompatibilityProxy> {
const sockets = new Set<net.Socket>();
const server = net.createServer((client) => {
const upstream = net.createConnection(targetPort, targetHost);
sockets.add(client);
sockets.add(upstream);
const forget = (socket: net.Socket) => sockets.delete(socket);
client.once("close", () => forget(client));
upstream.once("close", () => forget(upstream));
client.once("error", () => upstream.destroy());
upstream.once("error", () => client.destroy());
client.pipe(upstream);
let pending = Buffer.alloc(0);
const forwardMacosSecurityTypes = (chunk: Buffer) => {
pending = Buffer.concat([pending, chunk]);
if (pending.length < 1) return;
const securityTypeCount = pending[0];
if (securityTypeCount === 0) {
upstream.off("data", forwardMacosSecurityTypes);
client.write(pending);
upstream.pipe(client);
return;
}
if (pending.length < 1 + securityTypeCount) return;
upstream.off("data", forwardMacosSecurityTypes);
const securityTypes = pending.subarray(1, 1 + securityTypeCount);
if (securityTypes.includes(VNC_AUTH_SECURITY_TYPE)) {
client.write(Buffer.from([1, VNC_AUTH_SECURITY_TYPE]));
} else {
client.write(pending.subarray(0, 1 + securityTypeCount));
}
client.write(pending.subarray(1 + securityTypeCount));
upstream.pipe(client);
};
const forwardServerBanner = (chunk: Buffer) => {
pending = Buffer.concat([pending, chunk]);
if (pending.length < RFB_BANNER_LENGTH) return;
upstream.off("data", forwardServerBanner);
const banner = pending.subarray(0, RFB_BANNER_LENGTH);
const remainder = pending.subarray(RFB_BANNER_LENGTH);
if (banner.equals(MACOS_RFB_BANNER)) {
client.write(STANDARD_RFB_BANNER);
pending = Buffer.alloc(0);
upstream.on("data", forwardMacosSecurityTypes);
if (remainder.length > 0) forwardMacosSecurityTypes(remainder);
} else {
client.write(banner);
client.write(remainder);
upstream.pipe(client);
}
};
upstream.on("data", forwardServerBanner);
});
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, bindHost, () => {
server.off("error", reject);
resolve((server.address() as net.AddressInfo).port);
});
});
server.unref();
return {
port,
close: () => {
server.close();
for (const socket of sockets) socket.destroy();
},
};
}
@@ -1,64 +0,0 @@
interface RdpSettingsInput {
port: number;
domain?: string;
security?: string;
ignoreCert: boolean;
guacConfig: Record<string, unknown>;
guacdOverrides: Record<string, unknown>;
}
export function buildRdpSettings({
port,
domain,
security,
ignoreCert,
guacConfig,
guacdOverrides,
}: RdpSettingsInput): Record<string, unknown> {
return {
...guacConfig,
port,
domain,
...(security === undefined ? {} : { security }),
"ignore-cert": ignoreCert,
...guacdOverrides,
};
}
export function resolveRdpDomain(
authType: string | null,
promptedDomain: unknown,
storedDomain: string,
): string {
return authType === "none" && typeof promptedDomain === "string"
? promptedDomain
: storedDomain;
}
type SharedRdpAuthResolution =
| { source: "personal-override" }
| { source: "owner-shared"; authType: string }
| { source: "secretless" }
| { source: "required" }
| null;
export function resolveRdpAuthTypeForConnect({
storedAuthType,
credentialId,
sharedResolution,
}: {
storedAuthType?: string | null;
credentialId?: number | null;
sharedResolution?: SharedRdpAuthResolution;
}): string {
if (sharedResolution === undefined) {
return storedAuthType || (credentialId ? "credential" : "direct");
}
if (sharedResolution?.source === "personal-override") return "credential";
if (sharedResolution?.source === "owner-shared") {
return sharedResolution.authType;
}
return sharedResolution?.source === "secretless" && storedAuthType === "none"
? "none"
: "direct";
}
@@ -1,22 +0,0 @@
/**
* Merges Termix's recording bookkeeping into a host's guacd settings.
*
* Location and filename are not the host's to choose: recordings are indexed by
* them for playback, and the backend refuses to read anything outside its
* recordings directory. What a recording *contains* is a host-level decision, so
* those flags are only defaulted, never overwritten.
*/
export function withRecordingSettings(
guacConfig: Record<string, unknown>,
recordingPath: string,
recordingName: string,
): Record<string, unknown> {
return {
...guacConfig,
"recording-path": recordingPath,
"recording-name": recordingName,
"create-recording-path": true,
"recording-exclude-output": guacConfig["recording-exclude-output"] ?? false,
"recording-include-keys": guacConfig["recording-include-keys"] ?? true,
};
}
-831
View File
@@ -1,831 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js";
import { withDriveSettings } from "./drive-settings.js";
import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
resolveRecipientSharedHostAuthentication,
type RecipientSharedHostAuthResolution,
} from "../../utils/shared-host-auth-resolver.js";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { getGuacSessionByConnectId } from "./guacamole-server.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "./rdp-settings.js";
import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
const authManager = AuthManager.getInstance();
const DATA_DIR = process.env.DATA_DIR || "./db/data";
router.use(authManager.createAuthMiddleware());
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
if (!req.userId)
return res.status(401).json({ error: "Authentication required" });
const session = getGuacSessionByConnectId(
String(req.params.connectId),
req.userId,
);
res.json({ guacamoleConnectionId: session?.guacamoleConnectionId ?? null });
});
/**
* @openapi
* /guacamole/token:
* post:
* summary: Generate an encrypted Guacamole connection token
* description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - type
* - hostname
* properties:
* type:
* type: string
* enum: [rdp, vnc, telnet]
* hostname:
* type: string
* port:
* type: integer
* username:
* type: string
* password:
* type: string
* domain:
* type: string
* responses:
* 200:
* description: Encrypted connection token
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* 400:
* description: Invalid request
* 500:
* description: Server error
*/
router.post("/token", async (req, res) => {
try {
const { type, hostname, port, username, password, domain, ...rawOptions } =
req.body;
// Strip "auto" sentinel values before forwarding to guacd
const options: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawOptions)) {
if (value !== "auto") options[key] = value;
}
if (!type || !hostname) {
return res
.status(400)
.json({ error: "Missing required fields: type and hostname" });
}
if (!["rdp", "vnc", "telnet"].includes(type)) {
return res.status(400).json({
error: "Invalid connection type. Must be rdp, vnc, or telnet",
});
}
let token: string;
switch (type) {
case "rdp":
token = tokenService.createRdpToken(
hostname,
username || "",
password || "",
{
port: port || 3389,
domain,
...options,
},
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900,
...options,
},
);
break;
case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, {
port: port || 23,
...options,
});
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
res.json({ token });
} catch (error) {
guacLogger.error("Failed to generate guacamole token", error, {
operation: "guac_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
});
/**
* @openapi
* /guacamole/connect-host/{hostId}:
* post:
* summary: Generate Guacamole connection token from host configuration
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
* tags:
* - Guacamole
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* description: Host ID to connect to
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* protocol:
* type: string
* enum: [rdp, vnc, telnet]
* description: Override the host's default connection type
* promptedUsername:
* type: string
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
* promptedPassword:
* type: string
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
* responses:
* 200:
* description: Connection token generated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* token:
* type: string
* description: Encrypted connection token
* guacamoleConnectionId:
* type: string
* nullable: true
* description: Null until the WebSocket handshake completes. Query /guacamole/connection/{connectId} for the session-sharing ID.
* termixConnectId:
* type: string
* description: Correlation ID for looking up this connection after the WebSocket opens.
* 400:
* description: Invalid request or unsupported connection type
* 403:
* description: Access denied to host
* 404:
* description: Host not found
* 500:
* description: Server error
*/
router.post(
"/connect-host/:hostId",
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const hostResolutionRepository = createCurrentHostResolutionRepository();
// Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields.
const hostOwnerId =
await hostResolutionRepository.findHostOwnerId(hostId);
const host = hostOwnerId
? await hostResolutionRepository.findHostById(hostId, hostOwnerId)
: null;
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (host.userId !== userId) {
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
hostId,
"connect",
);
if (!accessInfo.hasAccess) {
guacLogger.warn("User attempted to access host without permission", {
operation: "guac_access_denied",
userId,
hostId,
});
return res.status(403).json({ error: "Access denied to this host" });
}
}
const requestedProtocol = req.body?.protocol as string | undefined;
const connectionType =
requestedProtocol || (host.connectionType as string);
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
return res.status(400).json({
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
});
}
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
// Apply the same migration fallback used in host.ts GET routes.
const ct = host.connectionType as string;
const rdpRaw = !!host.enableRdp;
const vncRaw = !!host.enableVnc;
const telRaw = !!host.enableTelnet;
const isMigratedNonSsh =
!rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
const protocolEnabledMap: Record<string, boolean> = {
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
};
if (!protocolEnabledMap[connectionType]) {
return res.status(400).json({
error: `${connectionType.toUpperCase()} is not enabled for this host.`,
});
}
let guacConfig: Record<string, unknown> = {};
if (host.guacamoleConfig) {
try {
guacConfig =
typeof host.guacamoleConfig === "string"
? JSON.parse(host.guacamoleConfig as string)
: (host.guacamoleConfig as Record<string, unknown>);
} catch (error) {
guacLogger.warn("Failed to parse guacamole config", {
operation: "guac_config_parse_error",
hostId,
error: getErrorMessage(error),
});
}
}
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
// but guacd doesn't recognise "auto" as a valid parameter value.
for (const key of Object.keys(guacConfig)) {
if (guacConfig[key] === "auto") {
delete guacConfig[key];
}
}
// Extract per-connection guacd proxy settings before passing the rest as connection settings
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
string | undefined;
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
const perConnectionGuacdPort = perConnectionGuacdPortRaw
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
: undefined;
delete guacConfig["guacd-hostname"];
delete guacConfig["guacd-port"];
if (guacConfig.dpi != null) {
const parsed = parseInt(String(guacConfig.dpi), 10);
guacConfig.dpi =
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
const hostRecord = host as Record<string, unknown>;
const hostRepository = hostResolutionRepository;
const isSharedConnection = host.userId !== userId;
let sharedAuthResolution: RecipientSharedHostAuthResolution | null = null;
if (isSharedConnection) {
// Recipients never read the owner's raw secrets; wipe them and use
// the per-recipient snapshot for the requested protocol instead.
host.password = null;
host.rdpUser = null;
host.rdpPassword = null;
host.vncUser = null;
host.vncPassword = null;
host.telnetUser = null;
host.telnetPassword = null;
try {
const resolution = await resolveRecipientSharedHostAuthentication(
host,
hostId,
userId,
connectionType as AuthOverrideProtocol,
);
sharedAuthResolution = resolution;
const auth =
resolution.source === "personal-override"
? { ...resolution.credential, domain: null }
: resolution.source === "owner-shared"
? resolution.secret
: null;
if (auth) {
if (connectionType === "rdp") {
host.rdpUser = auth.username ?? null;
host.rdpPassword = auth.password ?? null;
if (auth.domain) host.rdpDomain = auth.domain;
} else if (connectionType === "vnc") {
host.vncUser = auth.username ?? null;
host.vncPassword = auth.password ?? null;
} else if (connectionType === "telnet") {
host.telnetUser = auth.username ?? null;
host.telnetPassword = auth.password ?? null;
}
}
} catch (e) {
guacLogger.warn("Failed to resolve shared host auth", {
operation: "guac_shared_auth_resolve",
hostId,
protocol: connectionType,
error: getErrorMessage(e, "Unknown"),
});
}
} else {
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
const rdpEffectiveAuthType =
(host.rdpAuthType as string) ||
(host.rdpCredentialId ? "credential" : "direct");
const vncEffectiveAuthType =
(host.vncAuthType as string) ||
(host.vncCredentialId ? "credential" : "direct");
const telnetEffectiveAuthType =
(host.telnetAuthType as string) ||
(hostRecord.telnetCredentialId ? "credential" : "direct");
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
host.rdpCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.username) host.rdpUser = cred.username;
if (cred.password) host.rdpPassword = cred.password;
// domain is never sourced from credential
}
} catch (e) {
guacLogger.warn("Failed to resolve RDP credential", {
operation: "guac_rdp_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
host.vncCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.password) host.vncPassword = cred.password;
if (cred.username) host.vncUser = cred.username;
}
} catch (e) {
guacLogger.warn("Failed to resolve VNC credential", {
operation: "guac_vnc_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
if (
telnetEffectiveAuthType === "credential" &&
hostRecord.telnetCredentialId
) {
try {
const cred = await hostRepository.findCredentialByIdForUser(
hostRecord.telnetCredentialId as number,
host.userId as string,
);
if (cred) {
if (cred.username) host.telnetUser = cred.username;
if (cred.password) host.telnetPassword = cred.password;
}
} catch (e) {
guacLogger.warn("Failed to resolve Telnet credential", {
operation: "guac_telnet_credential_resolve",
hostId,
error: getErrorMessage(e, "Unknown"),
});
}
}
}
let token: string;
let hostname = host.ip as string;
let port = host.port as number;
let username: string;
let password: string;
const rdpAuthTypeForConnect = resolveRdpAuthTypeForConnect({
storedAuthType: host.rdpAuthType as string | null,
credentialId: host.rdpCredentialId as number | null,
sharedResolution: isSharedConnection ? sharedAuthResolution : undefined,
});
switch (connectionType) {
case "rdp":
if (rdpAuthTypeForConnect === "none") {
username = String(req.body?.promptedUsername || "");
password = String(req.body?.promptedPassword || "");
} else {
username =
(host.rdpUser as string) || (host.username as string) || "";
password =
(host.rdpPassword as string) || (host.password as string) || "";
}
port = (host.rdpPort as number) || port || 3389;
break;
case "vnc":
username = (host.vncUser as string) || "";
password =
(host.vncPassword as string) || (host.password as string) || "";
port = (host.vncPort as number) || port || 5900;
break;
case "telnet":
username = (host.telnetUser as string) || "";
password =
(host.telnetPassword as string) || (host.password as string) || "";
port = (host.telnetPort as number) || port || 23;
break;
default:
username = "";
password = "";
}
const storedDomain =
(host.rdpDomain as string) || (host.domain as string) || "";
const domain = resolveRdpDomain(
rdpAuthTypeForConnect,
req.body?.promptedDomain,
storedDomain,
);
// Establish SSH tunnel if jump hosts are configured
let jumpHosts: Array<{ hostId: number }> = [];
if (host.jumpHosts) {
try {
jumpHosts =
typeof host.jumpHosts === "string"
? JSON.parse(host.jumpHosts as string)
: (host.jumpHosts as Array<{ hostId: number }>);
} catch {
jumpHosts = [];
}
}
let tunnelEndpoint: ReturnType<typeof resolveJumpTunnelEndpoint> | null =
null;
if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) {
let guacdUrl: string | undefined;
try {
guacdUrl =
(await createCurrentSettingsRepository().get("guac_url")) ??
undefined;
} catch {
// Environment/default guacd configuration remains available.
}
const guacdHost =
perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host;
tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost);
}
if (jumpHosts.length > 0) {
try {
// The chain dials the first hop through that hop's own SOCKS5
// settings; the target host's proxy config does not apply to it.
const jumpClient = await createJumpHostChain(jumpHosts, userId);
if (!jumpClient) {
guacLogger.error(
"Failed to establish jump host chain for guacamole",
undefined,
{ operation: "guac_ssh_tunnel_error", hostId },
);
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
const targetHostname = hostname;
const targetPort = port;
const tunnelPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer((sock) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
targetHostname,
targetPort,
(err, stream) => {
if (err) {
sock.destroy();
return;
}
sock.pipe(stream).pipe(sock);
},
);
});
server.on("error", reject);
server.listen(0, tunnelEndpoint!.bindHost, () => {
const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour
setTimeout(
() => {
server.close();
jumpClient.end();
},
60 * 60 * 1000,
);
resolve(addr.port);
});
});
hostname = tunnelEndpoint!.advertisedHost;
port = tunnelPort;
guacLogger.info("SSH tunnel established for guacamole", {
operation: "guac_ssh_tunnel",
hostId,
tunnelPort,
});
} catch (tunnelError) {
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
operation: "guac_ssh_tunnel_error",
hostId,
});
return res.status(500).json({
error: "Failed to establish SSH tunnel to remote host",
});
}
}
if (connectionType === "vnc" && !username) {
try {
const proxy = await createMacosVncCompatibilityProxy({
targetHost: hostname,
targetPort: port,
bindHost: tunnelEndpoint!.bindHost,
});
hostname = tunnelEndpoint!.advertisedHost;
port = proxy.port;
setTimeout(proxy.close, 60 * 60 * 1000).unref();
guacLogger.info("VNC compatibility proxy established", {
operation: "guac_vnc_compatibility_proxy",
hostId,
proxyPort: port,
});
} catch (proxyError) {
guacLogger.error(
"Failed to establish VNC compatibility proxy",
proxyError,
{
operation: "guac_vnc_compatibility_proxy_error",
hostId,
},
);
return res.status(500).json({
error: "Failed to establish VNC compatibility proxy",
});
}
}
const guacdOverrides = {
...(perConnectionGuacdHost
? { guacdHost: perConnectionGuacdHost }
: {}),
...(perConnectionGuacdPort
? { guacdPort: perConnectionGuacdPort }
: {}),
};
const recordingEnabled =
connectionType !== "vnc" && host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath =
process.env.GUACD_RECORDING_PATH ||
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
const recordingMetadata = recordingEnabled
? {
hostId,
userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
path: recordingName,
guacdPath: recordingPath,
startedAt: new Date().toISOString(),
}
: undefined;
if (recordingEnabled) {
guacConfig = withRecordingSettings(
guacConfig,
recordingPath,
recordingName,
);
}
const termixConnectId = crypto.randomUUID();
const termixMeta = {
termixConnectId,
hostId,
ownerUserId: userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
};
switch (connectionType) {
case "rdp":
guacConfig = withDriveSettings(guacConfig, userId);
token = tokenService.createRdpToken(
hostname,
username,
password,
buildRdpSettings({
port,
domain,
security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
ignoreCert:
host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
guacConfig,
guacdOverrides,
}),
recordingMetadata,
termixMeta,
);
break;
case "vnc":
token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
termixMeta,
);
break;
case "telnet":
token = tokenService.createTelnetToken(
hostname,
username,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
termixMeta,
);
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: `${connectionType}_connect`,
resourceType: "host",
resourceId: String(hostId),
resourceName: `${hostname}:${port}`,
ipAddress,
userAgent,
success: true,
});
res.json({
token,
termixConnectId,
guacamoleConnectionId: null,
});
} catch (error) {
guacLogger.error("Failed to generate guacamole token for host", error, {
operation: "guac_host_token_error",
});
res.status(500).json({ error: "Failed to generate connection token" });
}
},
);
/**
* GET /guacamole/status
* Check if guacd is reachable
*/
router.get("/status", async (req, res) => {
try {
let dbUrl: string | undefined;
try {
dbUrl =
(await createCurrentSettingsRepository().get("guac_url")) ?? undefined;
} catch {
// Fall back to env vars
}
const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl);
const net = await import("net");
const checkConnection = (): Promise<boolean> => {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(3000);
socket.on("connect", () => {
socket.destroy();
resolve(true);
});
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => {
socket.destroy();
resolve(false);
});
socket.connect(guacdPort, guacdHost);
});
};
const isConnected = await checkConnection();
res.json({
guacd: {
host: guacdHost,
port: guacdPort,
status: isConnected ? "connected" : "disconnected",
},
websocket: {
port: 30008,
status: "running",
},
});
} catch (error) {
guacLogger.error("Failed to check guacamole status", error, {
operation: "guac_status_error",
});
res.status(500).json({ error: "Failed to check status" });
}
});
export default router;
@@ -1,251 +0,0 @@
import crypto from "crypto";
import { guacLogger } from "../../utils/logger.js";
export interface GuacamoleConnectionSettings {
type?: "rdp" | "vnc" | "telnet";
join?: string;
readOnly?: boolean;
guacdHost?: string;
guacdPort?: number;
settings: {
hostname?: string;
port?: number;
username?: string;
password?: string;
domain?: string;
width?: number;
height?: number;
dpi?: number;
security?: string;
"ignore-cert"?: boolean;
"disable-auth"?: boolean;
"enable-wallpaper"?: boolean;
"enable-drive"?: boolean;
"drive-path"?: string;
"create-drive-path"?: boolean;
"swap-red-blue"?: boolean;
cursor?: string;
"terminal-type"?: string;
[key: string]: unknown;
};
}
export interface TermixGuacMeta {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: "rdp" | "vnc" | "telnet";
}
export interface GuacamoleToken {
connection: GuacamoleConnectionSettings;
recording?: GuacamoleRecordingMetadata;
termixMeta?: TermixGuacMeta;
}
export interface GuacamoleRecordingMetadata {
hostId: number;
userId: string;
protocol: "rdp" | "vnc" | "telnet";
path: string;
/** Directory guacd was told to write into; differs from the backend's view
* when guacd runs in its own container. */
guacdPath?: string;
startedAt: string;
}
const CIPHER = "aes-256-cbc";
const KEY_LENGTH = 32;
export class GuacamoleTokenService {
private static instance: GuacamoleTokenService;
private encryptionKey: Buffer;
private constructor() {
this.encryptionKey = this.initializeKey();
}
static getInstance(): GuacamoleTokenService {
if (!GuacamoleTokenService.instance) {
GuacamoleTokenService.instance = new GuacamoleTokenService();
}
return GuacamoleTokenService.instance;
}
private initializeKey(): Buffer {
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
if (existingKey) {
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
return Buffer.from(existingKey, "hex");
}
if (existingKey.length === KEY_LENGTH) {
return Buffer.from(existingKey, "utf8");
}
}
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret) {
return crypto
.createHash("sha256")
.update(jwtSecret + "_guacamole")
.digest();
}
guacLogger.warn(
"No persistent encryption key found, generating random key",
{
operation: "guac_key_generation",
},
);
return crypto.randomBytes(KEY_LENGTH);
}
getEncryptionKey(): Buffer {
return this.encryptionKey;
}
encryptToken(tokenObject: GuacamoleToken): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
let encrypted = cipher.update(
JSON.stringify(tokenObject),
"utf8",
"base64",
);
encrypted += cipher.final("base64");
const data = {
iv: iv.toString("base64"),
value: encrypted,
};
return Buffer.from(JSON.stringify(data)).toString("base64");
}
decryptToken(token: string): GuacamoleToken | null {
try {
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
const iv = Buffer.from(data.iv, "base64");
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
let decrypted = decipher.update(data.value, "base64", "utf8");
decrypted += decipher.final("utf8");
return JSON.parse(decrypted) as GuacamoleToken;
} catch (error) {
guacLogger.error("Failed to decrypt guacamole token", error, {
operation: "guac_token_decrypt_error",
});
return null;
}
}
createRdpToken(
hostname: string,
username: string,
password: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "rdp",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
...(password ? { password } : {}),
port: 3389,
"ignore-cert": true,
...(!username && !password ? { "disable-auth": true } : {}),
...settingsOptions,
},
},
recording,
termixMeta,
};
return this.encryptToken(token);
}
createVncToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "vnc",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
...(username ? { username } : {}),
password,
port: 5900,
...settingsOptions,
},
},
recording,
termixMeta,
};
return this.encryptToken(token);
}
createTelnetToken(
hostname: string,
username?: string,
password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> & {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
connection: {
type: "telnet",
...(guacdHost ? { guacdHost } : {}),
...(guacdPort ? { guacdPort } : {}),
settings: {
hostname,
username,
password,
port: 23,
...settingsOptions,
},
},
recording,
termixMeta,
};
return this.encryptToken(token);
}
// join tokens never carry recording params - only the primary connection's
// token should write recording-path/recording-name to guacd.
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
const token: GuacamoleToken = {
connection: {
join: guacamoleConnectionId,
readOnly,
settings: {},
},
};
return this.encryptToken(token);
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { withDriveSettings } from "../../../../../plugins/remote-desktop/backend/drive-settings.js";
describe("withDriveSettings", () => {
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
}),
).toEqual({
"enable-drive": true,
"drive-path": "/termix-data/rdp-drive/user-1",
"create-drive-path": true,
});
});
it("falls back to /drive when the environment says nothing", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {}),
).toMatchObject({ "drive-path": "/drive/user-1" });
});
it("leaves a host-chosen drive-path alone", () => {
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
expect(
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
).toBe(config);
});
it("does nothing when the drive is not enabled", () => {
const config = { "enable-drive": false };
expect(withDriveSettings(config, "user-1")).toBe(config);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { resolveJumpTunnelEndpoint } from "../../../../../plugins/remote-desktop/backend/jump-tunnel-endpoint.js";
describe("resolveJumpTunnelEndpoint", () => {
it("keeps an in-process guacd tunnel on loopback", () => {
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
bindHost: "127.0.0.1",
advertisedHost: "127.0.0.1",
});
});
it("exposes the tunnel to a separate guacd container", () => {
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix",
});
});
it("supports a custom backend hostname for external guacd", () => {
expect(
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix-backend",
});
});
});
@@ -0,0 +1,115 @@
import net from "net";
import { afterEach, describe, expect, it } from "vitest";
import { createMacosVncCompatibilityProxy } from "../../../../../plugins/remote-desktop/backend/macos-vnc-proxy.js";
const closers: Array<() => void> = [];
afterEach(() => {
for (const close of closers.splice(0)) close();
});
async function listen(server: net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as net.AddressInfo).port);
});
});
}
async function read(socket: net.Socket, length: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
let received = Buffer.alloc(0);
const onData = (chunk: Buffer) => {
received = Buffer.concat([received, chunk]);
if (received.length < length) return;
socket.off("data", onData);
resolve(received);
};
socket.on("data", onData);
socket.once("error", reject);
});
}
describe("createMacosVncCompatibilityProxy", () => {
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
let clientBanner = "";
let selectedSecurityType = 0;
const target = net.createServer((socket) => {
socket.write("RFB 003.");
socket.write("889\n");
socket.once("data", (data) => {
clientBanner = data.toString("ascii");
socket.write(Buffer.from([5, 30, 33]));
socket.write(Buffer.from([36, 2, 35]));
socket.once("data", (selection) => {
selectedSecurityType = selection[0];
socket.write("desktop-data");
});
});
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
"RFB 003.008\n",
);
client.write("RFB 003.008\n");
expect([...(await read(client, 2))]).toEqual([1, 2]);
client.write(Buffer.from([2]));
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
expect(clientBanner).toBe("RFB 003.008\n");
expect(selectedSecurityType).toBe(2);
});
it("passes standard RFB negotiation through unchanged", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.008\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
});
it("passes Apple's security types through when VNC auth is unavailable", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.889\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../../../../plugins/remote-desktop/backend/rdp-settings.js";
describe("buildRdpSettings", () => {
it("keeps saved RDP settings authoritative over stale advanced config", () => {
expect(
buildRdpSettings({
port: 3390,
domain: "EXAMPLE",
security: "nla",
ignoreCert: true,
guacConfig: {
port: 3389,
domain: "OLD",
security: "rdp",
"ignore-cert": false,
"color-depth": 24,
},
guacdOverrides: { guacdHost: "guacd.internal" },
}),
).toEqual({
port: 3390,
domain: "EXAMPLE",
security: "nla",
"ignore-cert": true,
"color-depth": 24,
guacdHost: "guacd.internal",
});
});
it("preserves an advanced security value when no saved value exists", () => {
expect(
buildRdpSettings({
port: 3389,
ignoreCert: false,
guacConfig: { security: "tls" },
guacdOverrides: {},
}).security,
).toBe("tls");
});
it("uses the prompted domain for prompt-on-connect authentication", () => {
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
});
it("keeps the stored domain for saved authentication", () => {
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
});
});
describe("resolveRdpAuthTypeForConnect", () => {
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "secretless" },
}),
).toBe("none");
});
it("uses a recipient override instead of prompting", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "personal-override" },
}),
).toBe("credential");
});
it("uses authentication shared by the owner", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "credential",
sharedResolution: { source: "owner-shared", authType: "credential" },
}),
).toBe("credential");
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { withRecordingSettings } from "../../../../../plugins/remote-desktop/backend/recording-settings.js";
const PATH = "/app/data/session_recordings/guacamole";
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
describe("withRecordingSettings", () => {
it("takes ownership of the location and filename", () => {
const merged = withRecordingSettings(
{
"recording-path": "/var/lib/termix/recordings",
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
"create-recording-path": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-path": PATH,
"recording-name": NAME,
"create-recording-path": true,
});
});
it("defaults the content flags when the host has no opinion", () => {
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
"recording-exclude-output": false,
"recording-include-keys": true,
});
});
it("keeps the host's content flags, including the falsy ones", () => {
const merged = withRecordingSettings(
{
"recording-exclude-output": true,
"recording-include-keys": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-output": true,
"recording-include-keys": false,
});
});
it("leaves unrelated settings alone", () => {
const merged = withRecordingSettings(
{ "recording-exclude-mouse": true, width: "1920" },
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-mouse": true,
width: "1920",
});
});
it("does not mutate the settings it was given", () => {
const original = { "recording-path": "/tmp/mine" };
withRecordingSettings(original, PATH, NAME);
expect(original).toEqual({ "recording-path": "/tmp/mine" });
});
});
@@ -0,0 +1,104 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
it("returns the minted token without waiting for a connection that needs that token", async () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(s) =>
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression)
.arguments[1] as ts.ArrowFunction;
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
const index = block.tryBlock.statements
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
.filter((i) => i >= 0)
.at(-1)!;
expect(index).toBeGreaterThan(-1);
const tail = block.tryBlock.statements
.slice(index + 1)
.map((s) => s.getText(ast))
.join("\n");
const json = vi.fn();
const wait = vi.fn(() => {
throw new Error("Client has not received the token yet");
});
await runInNewContext(
ts.transpileModule(`(async () => { ${tail} })()`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText,
{
waitForGuacdOpen: wait,
token: "minted-token",
termixConnectId: "correlation-id",
req: {},
getRequestMeta: () => ({}),
logAudit: async () => {},
getAuditUsername: async () => "owner",
userId: "owner",
connectionType: "rdp",
hostId: 7,
hostname: "host",
port: 3389,
res: { json },
},
);
expect(wait).not.toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({
token: "minted-token",
termixConnectId: "correlation-id",
guacamoleConnectionId: null,
});
});
it("only reveals the live session ID to its owner", () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/guacamole-server.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"server.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const fn = ast.statements.find(
(s) =>
ts.isFunctionDeclaration(s) &&
s.name?.text === "getGuacSessionByConnectId",
)!;
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
const context = {
exports: {} as Record<string, (id: string, user: string) => unknown>,
guacSessionByConnectId: new Map([["connect-id", info]]),
};
runInNewContext(
ts.transpileModule(fn.getText(ast), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const lookup = context.exports.getGuacSessionByConnectId;
expect(lookup("connect-id", "owner")).toEqual(info);
expect(lookup("connect-id", "other")).toBeNull();
expect(lookup("pending", "owner")).toBeNull();
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../../../src/backend/utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } =
await import("../../../../../plugins/remote-desktop/backend/token-service.js");
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
port: 3389,
"ignore-cert": true,
"disable-auth": true,
});
expect(decrypted?.connection.settings.username).toBeUndefined();
expect(decrypted?.connection.settings.password).toBeUndefined();
});
it("keeps normal RDP credential authentication unchanged", () => {
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
username: "Administrator",
password: "secret",
port: 3389,
});
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
});
it("preserves recording metadata outside guacd connection settings", () => {
const recording = {
hostId: 42,
userId: "user-1",
protocol: "vnc" as const,
path: "recording.guac",
startedAt: "2026-07-14T00:00:00.000Z",
};
const token = tokenService.createVncToken(
"vnc.example.test",
"user",
"secret",
{},
recording,
);
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
const termixMeta = {
termixConnectId: "connect-1",
hostId: 7,
ownerUserId: "user-1",
protocol: "rdp" as const,
};
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
{},
undefined,
termixMeta,
);
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
});
it("createJoinToken sets connection.join, not connection.type", () => {
const token = tokenService.createJoinToken("guacd-conn-123", true);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-123");
expect(decrypted?.connection.type).toBeUndefined();
expect(decrypted?.connection.readOnly).toBe(true);
});
it("createJoinToken round-trips a read-write join through decryptToken", () => {
const token = tokenService.createJoinToken("guacd-conn-456", false);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-456");
expect(decrypted?.connection.readOnly).toBe(false);
expect(decrypted?.recording).toBeUndefined();
});
});