feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

@@ -1,18 +1,20 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../../../src/backend/utils/logger.js";
import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
} from "./token-service.js";
import { getCurrentSettingValue } from "../../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
import fs from "fs";
import path from "path";
const tokenService = GuacamoleTokenService.getInstance();
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type {
GuacamoleRecordingMetadata,
GuacamoleTokenService,
TermixGuacMeta,
} from "./token-service.js";
import type { GuacdOptions } from "./guacd-config.js";
import type { RemoteSessions } from "./sessions.js";
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
/** The recordings.writer service, when the session-recording plugin provides it. */
export interface RecordingsWriter {
enabledFor: (hostId: number) => Promise<boolean>;
createFinished: (input: {
hostId: number;
userId: string;
@@ -25,147 +27,38 @@ export interface RecordingsWriter {
}) => Promise<{ id: number }>;
}
let pluginRecordings: RecordingsWriter | null = null;
export function setPluginRecordings(recordings: RecordingsWriter | null): void {
pluginRecordings = recordings;
}
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 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 = {
/** Where the backend reads guacd's recordings from. */
export function recordingsDir(env: NodeJS.ProcessEnv = process.env): string {
return (
env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole")
);
}
type ClientConnection = {
guacamoleConnectionId?: string;
connectionSettings?: {
connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata;
termixMeta?: {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: string;
};
termixMeta?: TermixGuacMeta;
};
close?: () => void;
};
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;
}
if (!pluginRecordings) {
guacLogger.warn(
"Session Recording plugin is off; guacamole recording not saved to the log",
{ operation: "guac_recording_no_writer", hostId: recording.hostId },
);
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await pluginRecordings.createFinished({
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",
});
}
// noServer: core owns the listening socket. guacamole-lite passes this object
// straight to ws, and exposes the WebSocketServer it builds, so the plugin can
// hand it an upgrade from /plugin-ws/remote-desktop/display. There is no
// guacamole port any more.
const websocketOptions = {
noServer: true,
};
const clientOptions = {
const clientOptions = (
tokens: GuacamoleTokenService,
log: RemoteDesktopLogger,
) => ({
crypt: {
cypher: "AES-256-CBC",
key: tokenService.getEncryptionKey(),
key: tokens.getEncryptionKey(),
},
log: {
level: "ERRORS",
stdLog: (...args: unknown[]) => {
guacLogger.info(args.join(" "));
},
errorLog: (...args: unknown[]) => {
guacLogger.error(args.join(" "));
},
stdLog: (...args: unknown[]) => log.info(args.join(" ")),
errorLog: (...args: unknown[]) => log.error(args.join(" ")),
},
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
@@ -202,150 +95,224 @@ const clientOptions = {
"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);
};
export interface GuacamoleServerDeps {
log: RemoteDesktopLogger;
tokens: GuacamoleTokenService;
sessions: RemoteSessions;
guacd: () => GuacdOptions;
recordings: () => RecordingsWriter | null;
/** A socket event has no request behind it, so the row is written as the session's user. */
asUser: <T>(userId: string, fn: () => Promise<T>) => Promise<T>;
/** Called when a primary session opens; returns what to release on close. */
onOpen?: (hostId: number) => () => void;
}
function createGuacServer(): GuacamoleLite {
const guacdOptions = readGuacdOptions();
const server = new GuacamoleLite(
websocketOptions,
guacdOptions,
clientOptions,
);
export interface GuacamoleServer {
handleUpgrade: (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
) => void;
/** Rebuilds the server so a changed guacd address takes effect. */
restart: () => void;
/** Closes a session and everyone watching it. */
endSession: (guacamoleConnectionId: string) => boolean;
close: () => void;
}
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
/**
* guacamole-lite in noServer mode: core owns the listening socket and hands
* upgrades from /plugin-ws/remote-desktop/display to its WebSocketServer.
*/
export function createGuacamoleServer(
deps: GuacamoleServerDeps,
): GuacamoleServer {
const { log, sessions } = deps;
let server: GuacamoleLite | null = null;
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
const persistRecording = async (connection: ClientConnection) => {
const recording = connection.connectionSettings?.recording;
if (!recording) return;
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);
const dir = recordingsDir();
const resolvedPath = path.resolve(dir, recording.path);
if (!resolvedPath.startsWith(`${path.resolve(dir)}${path.sep}`)) return;
// guacd may flush or rename the recording just after the socket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
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);
if (!fs.existsSync(resolvedPath)) {
log.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
guacdPath: recording.guacdPath ?? dir,
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;
}
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
const writer = await deps.asUser(recording.userId, async () =>
deps.recordings(),
);
if (!writer) {
log.warn(
"Session Recording is off; the recording was kept on disk only",
{
operation: "guac_recording_no_writer",
hostId: recording.hostId,
},
);
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await deps.asUser(recording.userId, () =>
writer.createFinished({
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 build = (): GuacamoleLite => {
// guacamole-lite fills in port 8080 unless it sees a server key, and ws
// refuses a port next to noServer, so the port is cleared explicitly.
const guacamole = new GuacamoleLite(
{ noServer: true, port: undefined },
deps.guacd(),
clientOptions(deps.tokens, log),
);
// It also installs its own SIGTERM and SIGINT handlers. Core decides
// when the process stops and closes this through deactivate instead.
const handlers = guacamole as unknown as {
sigTermHandler?: () => void;
sigIntHandler?: () => void;
};
if (handlers.sigTermHandler) {
process.off("SIGTERM", handlers.sigTermHandler);
}
if (handlers.sigIntHandler) {
process.off("SIGINT", handlers.sigIntHandler);
}
guacamole.on("open", (connection: ClientConnection) => {
const meta = connection.connectionSettings?.termixMeta;
const guacamoleConnectionId = connection.guacamoleConnectionId;
const isJoin = !!connection.connectionSettings?.connection?.join;
if (isJoin || !meta || !guacamoleConnectionId) return;
const release = deps.onOpen?.(meta.hostId);
sessions.opened(meta, guacamoleConnectionId, release ? [release] : []);
});
guacamole.on("close", (connection: ClientConnection) => {
const meta = connection.connectionSettings?.termixMeta;
const isJoin = !!connection.connectionSettings?.connection?.join;
if (!isJoin && meta) sessions.closed(meta.termixConnectId);
persistRecording(connection).catch((error) => {
log.error("Failed to persist Guacamole recording", {
operation: "guac_recording_persist_error",
error: errorMessage(error),
});
});
});
});
server.on(
"error",
(clientConnection: GuacamoleClientConnection, error: Error) => {
guacLogger.error("Guacamole connection error", error, {
guacamole.on("error", (connection: ClientConnection, error: Error) => {
log.error("Guacamole connection error", {
operation: "guac_connection_error",
type: clientConnection.connectionSettings?.connection?.type,
type: connection.connectionSettings?.connection?.type,
error: errorMessage(error),
});
});
return guacamole;
};
const closeServer = () => {
if (!server) return;
try {
server.close();
} catch (error) {
log.error("Error closing the Guacamole server", {
error: errorMessage(error),
});
}
server = null;
};
server = build();
return {
handleUpgrade(request, socket, head) {
const wss = (
server as unknown as {
webSocketServer?: {
handleUpgrade: (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
done: (ws: unknown) => void,
) => void;
emit: (event: string, ...args: unknown[]) => void;
};
} | null
)?.webSocketServer;
if (!wss) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
},
);
return server;
restart() {
if (!server) return;
closeServer();
server = build();
},
endSession(guacamoleConnectionId) {
const active = (
server as unknown as {
activeConnections?: Map<unknown, ClientConnection>;
} | null
)?.activeConnections;
if (!active) return false;
let closed = false;
for (const connection of [...active.values()]) {
const join = connection.connectionSettings?.connection?.join;
if (
connection.guacamoleConnectionId === guacamoleConnectionId ||
join === guacamoleConnectionId
) {
connection.close?.();
closed = true;
}
}
return closed;
},
close: closeServer,
};
}
let guacServer: GuacamoleLite | null = null;
/**
* Builds the guacamole-lite server. Called from this plugin's activate().
* Nothing binds a port: handleGuacamoleUpgrade below feeds it connections.
*/
export async function startGuacamoleService(): Promise<void> {
guacServer = createGuacServer();
}
/**
* Hands one upgrade to guacamole-lite's own WebSocketServer.
*
* guacamole-lite constructs that server internally and has no "attach to this
* http server" option, so giving it the upgrade is the only way in. Core has
* already authenticated the request by the time this runs.
*/
export function handleGuacamoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = guacServer as unknown as {
webSocketServer?: {
handleUpgrade: (
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
done: (ws: unknown) => void,
) => void;
emit: (event: string, ...args: unknown[]) => void;
};
} | null;
const wss = server?.webSocketServer;
if (!wss) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
}
/**
* Stops the guacamole-lite WebSocket server. Called from this plugin's
* deactivate().
*/
export async function stopGuacamoleService(): Promise<void> {
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during shutdown", err as Error);
}
guacServer = null;
}
export async function restartGuacServer(): Promise<void> {
// A no-op while the plugin is disabled: creating a new server here would
// silently resurrect the WS server behind deactivate()'s back.
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during restart", err as Error);
}
guacServer = createGuacServer();
}
export { guacServer, tokenService };
@@ -0,0 +1,86 @@
export type GuacdOptions = {
host: string;
port: number;
};
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
host: "localhost",
port: 4822,
};
function parsePort(value: string | undefined, fallback: number) {
const port = parseInt(value || "", 10);
return Number.isFinite(port) ? port : fallback;
}
export function parseGuacdUrl(
value: string,
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
): GuacdOptions {
const raw = value.trim();
if (!raw) {
return fallback;
}
if (raw.includes("://")) {
try {
const url = new URL(raw);
return {
host: url.hostname || fallback.host,
port: parsePort(url.port, fallback.port),
};
} catch {
return fallback;
}
}
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
if (bracketedIpv6) {
return {
host: bracketedIpv6[1],
port: parsePort(bracketedIpv6[2], fallback.port),
};
}
const [host, port] = raw.split(":");
return {
host: host || fallback.host,
port: parsePort(port, fallback.port),
};
}
export function getGuacdEnvOptions(): GuacdOptions | null {
if (process.env.GUACD_URL) {
return parseGuacdUrl(process.env.GUACD_URL);
}
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
return null;
}
return {
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
};
}
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
const envOptions = getGuacdEnvOptions();
if (envOptions) {
return envOptions;
}
if (dbUrl) {
return parseGuacdUrl(dbUrl);
}
return DEFAULT_GUACD_OPTIONS;
}
export function formatGuacdOptions(options: GuacdOptions): string {
return `${options.host}:${options.port}`;
}
export function getDefaultGuacdUrl(): string {
return formatGuacdOptions(resolveGuacdOptions());
}
@@ -0,0 +1,187 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
export type RemoteProtocol = "rdp" | "vnc" | "telnet";
export const PLUGIN_ID = "remote-desktop";
export interface RemoteDesktopHostSettings {
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
rdpPort: number;
vncPort: number;
telnetPort: number;
rdpSecurity: string;
rdpIgnoreCert: boolean;
guacamoleConfig: Record<string, unknown>;
enableToolbar: boolean;
}
export const ENABLE_KEY: Record<
RemoteProtocol,
keyof RemoteDesktopHostSettings
> = { rdp: "enableRdp", vnc: "enableVnc", telnet: "enableTelnet" };
export const PORT_KEY: Record<RemoteProtocol, keyof RemoteDesktopHostSettings> =
{ rdp: "rdpPort", vnc: "vncPort", telnet: "telnetPort" };
export const DEFAULT_PORT: Record<RemoteProtocol, number> = {
rdp: 3389,
vnc: 5900,
telnet: 23,
};
export function isRemoteProtocol(value: unknown): value is RemoteProtocol {
return value === "rdp" || value === "vnc" || value === "telnet";
}
function asPort(value: unknown): number | undefined {
const port = typeof value === "string" ? Number(value) : value;
return typeof port === "number" &&
Number.isInteger(port) &&
port > 0 &&
port <= 65535
? port
: undefined;
}
function asObject(value: unknown): Record<string, unknown> | undefined {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
}
function asBool(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1" || value === "true") return true;
if (value === 0 || value === "0" || value === "false") return false;
return undefined;
}
export async function readHostSettings(
ctx: PluginContext,
hostId: number,
): Promise<RemoteDesktopHostSettings> {
const values = await ctx.settings.getAll("host", hostId);
return {
enableRdp: values.enableRdp === true,
enableVnc: values.enableVnc === true,
enableTelnet: values.enableTelnet === true,
rdpPort: asPort(values.rdpPort) ?? DEFAULT_PORT.rdp,
vncPort: asPort(values.vncPort) ?? DEFAULT_PORT.vnc,
telnetPort: asPort(values.telnetPort) ?? DEFAULT_PORT.telnet,
rdpSecurity:
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
rdpIgnoreCert: values.rdpIgnoreCert === true,
guacamoleConfig: asObject(values.guacamoleConfig) ?? {},
enableToolbar: values.enableToolbar !== false,
};
}
/**
* Turns an imported host row into this plugin's host settings.
*
* A Termix export carries them under pluginSettings["remote-desktop"]. Older
* exports, other tools and a plugin creating a host (proxmox) use the flat
* fields the host row had before 2.9.0, including the connectionType-only
* shape of hosts from before the per-protocol switches existed.
*/
export function normalizeImportedHost(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const nested =
asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ??
({} as Record<string, unknown>);
const pick = (key: string, legacy?: string) =>
nested[key] !== undefined
? nested[key]
: raw[key] !== undefined
? raw[key]
: legacy !== undefined
? raw[legacy]
: undefined;
const out: Record<string, unknown> = {};
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
const key = ENABLE_KEY[protocol];
const flag = asBool(pick(key));
if (flag !== undefined) out[key] = flag;
const port = asPort(pick(PORT_KEY[protocol]));
if (port !== undefined) out[PORT_KEY[protocol]] = port;
}
const anyFlag = ["enableRdp", "enableVnc", "enableTelnet"].some(
(key) => out[key] === true,
);
if (!anyFlag && isRemoteProtocol(raw.connectionType)) {
out[ENABLE_KEY[raw.connectionType]] = true;
const port = asPort(raw.port);
if (port !== undefined && out[PORT_KEY[raw.connectionType]] === undefined) {
out[PORT_KEY[raw.connectionType]] = port;
}
}
const security = pick("rdpSecurity", "security");
if (typeof security === "string" && security) out.rdpSecurity = security;
const ignoreCert = asBool(pick("rdpIgnoreCert", "ignoreCert"));
if (ignoreCert !== undefined) out.rdpIgnoreCert = ignoreCert;
const config = asObject(pick("guacamoleConfig"));
if (config && Object.keys(config).length > 0) out.guacamoleConfig = config;
const toolbar = asBool(pick("enableToolbar", "enableTerminalToolbar"));
if (toolbar !== undefined) out.enableToolbar = toolbar;
return Object.keys(out).length > 0 ? out : null;
}
/** User setting key to the guacd parameter it sets. */
const USER_DEFAULT_PARAMS: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
enableWallpaper: "enable-wallpaper",
enableFontSmoothing: "enable-font-smoothing",
enableDesktopComposition: "enable-desktop-composition",
disableAudio: "disable-audio",
enablePrinting: "enable-printing",
enableDrive: "enable-drive",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
};
/**
* The user's RDP defaults as guacd parameters. "inherit" leaves a parameter
* to the host and guacd; on and off become booleans.
*/
export function userDefaultParams(
values: Record<string, unknown>,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, param] of Object.entries(USER_DEFAULT_PARAMS)) {
const value = values[key];
if (value === undefined || value === null || value === "inherit") continue;
if (key === "colorDepth") {
const depth = Number(value);
if (Number.isInteger(depth) && depth > 0) params[param] = depth;
} else if (key === "resizeMethod") {
if (typeof value === "string" && value) params[param] = value;
} else if (value === "on" || value === "off") {
params[param] = value === "on";
}
}
return params;
}
export async function readUserDefaults(
ctx: PluginContext,
userId: string,
): Promise<Record<string, unknown>> {
return userDefaultParams(await ctx.settings.getAll("user", userId));
}
+85 -40
View File
@@ -1,63 +1,108 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { GuacamoleTokenService } from "./token-service.js";
import { RemoteSessions } from "./sessions.js";
import {
getGuacSessionInfo,
handleGuacamoleUpgrade,
restartGuacServer,
setPluginRecordings,
startGuacamoleService,
stopGuacamoleService,
tokenService,
createGuacamoleServer,
type RecordingsWriter,
} from "./guacamole-server.js";
import { startRemoteDesktopService } from "./routes.js";
import { resolveGuacdOptions } from "./guacd-config.js";
import { registerRoutes } from "./routes.js";
import { createLiveSessions } from "./live-sessions.js";
import { normalizeImportedHost } from "./host-settings.js";
import { createLogger } from "./log.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
const log = createLogger(ctx.log);
const tokens = new GuacamoleTokenService(log);
const sessions = new RemoteSessions();
ctx.disposables.add(() => sessions.clear());
// Optional: without the session-recording plugin, a guacd recording is
// still made on disk but never gets a row, so it never shows in the list.
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer");
setPluginRecordings("createFinished" in recordings ? recordings : null);
ctx.disposables.add(() => setPluginRecordings(null));
// The admin setting, unless GUACD_URL / GUACD_HOST / GUACD_PORT override it.
let guacdUrl = (await ctx.settings.get<string>("guacdUrl")) ?? "";
const guacd = () => resolveGuacdOptions(guacdUrl || undefined);
const enabled = async () =>
(await ctx.settings.get<boolean>("enabled")) !== false;
await startGuacamoleService();
// Optional: without session-recording nothing is recorded.
const recordings = (): RecordingsWriter | null => {
const service =
ctx.services.get<Partial<RecordingsWriter>>("recordings.writer");
return typeof service.createFinished === "function" &&
typeof service.enabledFor === "function"
? (service as RecordingsWriter)
: null;
};
startRemoteDesktopService(ctx.http.router());
// guacamole-lite prints every new connection to stdout.
const consoleLog = console.log;
console.log = (...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("New client connection")
)
return;
consoleLog(...args);
};
ctx.disposables.add(() => {
console.log = consoleLog;
});
const server = createGuacamoleServer({
log,
tokens,
sessions,
guacd,
recordings,
asUser: (userId, fn) => ctx.asUser(userId, fn),
onOpen: (hostId) => ctx.hosts.trackSession(hostId),
});
ctx.disposables.add(() => server.close());
ctx.settings.onChange("guacdUrl", (value) => {
guacdUrl = typeof value === "string" ? value : "";
server.restart();
});
registerRoutes(ctx.http.router(), {
ctx,
log,
tokens,
sessions,
guacd,
enabled,
recordings,
});
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
//
// Public because a Guacamole display authenticates with the encrypted,
// single-use connection token minted by POST /token, not with a session
// JWT. guacamole-lite decrypts and validates that token itself, and the
// route that mints one is authenticated normally.
// Public because a display authenticates with the encrypted, single-use
// token minted by an authenticated route, not with a session JWT.
ctx.ws.upgrade(
"/display",
(request, socket, head) =>
handleGuacamoleUpgrade(
request as Parameters<typeof handleGuacamoleUpgrade>[0],
socket as Parameters<typeof handleGuacamoleUpgrade>[1],
server.handleUpgrade(
request as Parameters<typeof server.handleUpgrade>[0],
socket as Parameters<typeof server.handleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
// What collab, session-sharing and the admin guacd setting need from a
// running Guacamole server. Revoked automatically on deactivate, and
// hosts/guacamole-sessions.ts degrades safely while it is gone.
ctx.registry.provide("remote-desktop.sessions", {
restart: restartGuacServer,
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
getSessionInfo: getGuacSessionInfo,
});
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
ctx.services.provide(
"sessions.live",
createLiveSessions(protocol, {
sessions,
tokens,
endSession: server.endSession,
}),
{ name: protocol },
);
}
ctx.log.info(
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display",
ctx.registry.provide(
"remote-desktop.hostImportNormalizer",
normalizeImportedHost,
);
}
export async function deactivate() {
await stopGuacamoleService();
}
export async function deactivate() {}
@@ -0,0 +1,145 @@
import net from "net";
import type { Client } from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { RemoteDesktopLogger } from "./log.js";
/** The tunnels plugin's service, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
forward(
sourceHostId: number,
target: { targetHost: string; targetPort: number; bindHost?: string },
options?: { name?: string; idleTimeoutMs?: number },
): Promise<{
bindHost: string;
bindPort: number;
close: () => Promise<void>;
}>;
}
/** The tunnels plugin keeps these names for on-demand forwards. */
const TUNNEL_NAME_PREFIX = "web:";
/** A forward nothing is using closes itself after this long. */
const FORWARD_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
export interface JumpTunnel {
port: number;
close: () => void;
}
export interface JumpTunnelRequest {
jumpHosts: Array<{ hostId: number }>;
targetHost: string;
targetPort: number;
bindHost: string;
/** Names the forward, so each session gets one of its own. */
connectId: string;
}
/**
* A local port guacd can dial that reaches the target through the host's
* jump hosts.
*
* One hop is exactly what the tunnels plugin's forward does, so that is used
* when the plugin is running and the user may use it. A chain, or no tunnels
* plugin, goes through ctx.ssh.jumpChain with a listener of our own. Either
* way the caller closes it when the session ends.
*/
export async function openJumpTunnel(
ctx: PluginContext,
log: RemoteDesktopLogger,
request: JumpTunnelRequest,
): Promise<JumpTunnel> {
if (request.jumpHosts.length === 1) {
const viaTunnels = await forwardThroughTunnels(ctx, log, request);
if (viaTunnels) return viaTunnels;
}
return forwardThroughChain(ctx, request);
}
async function forwardThroughTunnels(
ctx: PluginContext,
log: RemoteDesktopLogger,
request: JumpTunnelRequest,
): Promise<JumpTunnel | null> {
const tunnels = ctx.services.get<Partial<TunnelsAccess>>("tunnels.access");
if (typeof tunnels.forward !== "function") return null;
const hopId = request.jumpHosts[0].hostId;
try {
const handle = await tunnels.forward(
hopId,
{
targetHost: request.targetHost,
targetPort: request.targetPort,
bindHost: request.bindHost,
},
{
name: `${TUNNEL_NAME_PREFIX}${hopId}:rd-${request.connectId}`,
idleTimeoutMs: FORWARD_IDLE_TIMEOUT_MS,
},
);
return {
port: handle.bindPort,
close: () => void handle.close().catch(() => undefined),
};
} catch (error) {
// A user without the tunnels permission still reaches their host.
log.warn("Tunnels forward failed, using a jump chain instead", {
operation: "guac_tunnels_forward",
hopId,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
async function forwardThroughChain(
ctx: PluginContext,
request: JumpTunnelRequest,
): Promise<JumpTunnel> {
// 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 { client, dispose } = await ctx.ssh.jumpChain<Client>(
request.jumpHosts,
);
const sockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
client.forwardOut(
"127.0.0.1",
0,
request.targetHost,
request.targetPort,
(error, stream) => {
if (error) {
socket.destroy();
return;
}
socket.pipe(stream).pipe(socket);
},
);
});
let port: number;
try {
port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, request.bindHost, () =>
resolve((server.address() as net.AddressInfo).port),
);
});
} catch (error) {
await dispose();
throw error;
}
return {
port,
close: () => {
for (const socket of sockets) socket.destroy();
server.close();
void dispose();
},
};
}
@@ -0,0 +1,65 @@
import type { GuacamoleTokenService } from "./token-service.js";
import type { RemoteSessions } from "./sessions.js";
import type { RemoteProtocol } from "./host-settings.js";
/** sessions.live v1, as session-sharing reads it (plugins/session-sharing/src/backend/live.ts). */
export interface LiveSessionInfo {
id: string;
userId: string;
hostId: number;
hostName: string;
isConnected: boolean;
createdAt: number;
tabInstanceId: string | null;
}
export interface RemoteLiveSessions {
getSession: (sessionId: string) => LiveSessionInfo | null;
ownerEndSession: (sessionId: string, reason: string) => void;
createViewerToken: (sessionId: string, readOnly: boolean) => string;
}
/**
* One provider per protocol. A session id is guacd's own connection id,
* which is what a viewer's join token names.
*/
export function createLiveSessions(
protocol: RemoteProtocol,
deps: {
sessions: RemoteSessions;
tokens: GuacamoleTokenService;
endSession: (guacamoleConnectionId: string) => boolean;
},
): RemoteLiveSessions {
const find = (sessionId: string) => {
const session = deps.sessions.byGuacamole(sessionId);
return session && session.protocol === protocol ? session : null;
};
return {
getSession(sessionId) {
const session = find(sessionId);
if (!session) return null;
return {
id: session.guacamoleConnectionId,
userId: session.ownerUserId,
hostId: session.hostId,
hostName: session.hostName,
isConnected: true,
createdAt: session.openedAt,
tabInstanceId: session.tabInstanceId,
};
},
ownerEndSession(sessionId) {
if (find(sessionId)) deps.endSession(sessionId);
},
createViewerToken(sessionId, readOnly) {
if (!find(sessionId)) {
throw new Error(`No live ${protocol} session ${sessionId}`);
}
return deps.tokens.createJoinToken(sessionId, readOnly);
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type Meta = Record<string, unknown>;
export interface RemoteDesktopLogger {
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
return meta && Object.keys(meta).length > 0
? `${message} ${JSON.stringify(meta)}`
: message;
}
/** ctx.log with the structured context the old guac logger carried. */
export function createLogger(log: PluginLogger): RemoteDesktopLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, meta) => log.error(withMeta(message, meta)),
};
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
@@ -34,31 +34,3 @@ export function resolveRdpDomain(
? 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";
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,116 @@
import type { TermixGuacMeta } from "./token-service.js";
export interface RemoteSession {
guacamoleConnectionId: string;
termixConnectId: string;
hostId: number;
hostName: string;
ownerUserId: string;
protocol: TermixGuacMeta["protocol"];
tabInstanceId: string | null;
openedAt: number;
}
type Cleanup = () => void;
/** How long a minted token's tunnel waits for guacd to connect through it. */
export const PENDING_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Live guacd sessions and what each one holds open.
*
* A connect request builds its jump tunnel or VNC proxy before guacd dials
* in, so those resources are parked under the request's connect id and move
* onto the session when it opens. They are released when the session
* closes, when guacd never shows up, or when the plugin stops.
*/
export class RemoteSessions {
private readonly pending = new Map<
string,
{ cleanups: Cleanup[]; timer: ReturnType<typeof setTimeout> }
>();
private readonly byConnectId = new Map<string, RemoteSession>();
private readonly byGuacamoleId = new Map<string, RemoteSession>();
private readonly cleanupsByConnectId = new Map<string, Cleanup[]>();
constructor(private readonly pendingTimeoutMs = PENDING_TIMEOUT_MS) {}
/** Resources for a connection that guacd has not opened yet. */
park(termixConnectId: string, cleanups: Cleanup[]): void {
if (cleanups.length === 0) return;
const timer = setTimeout(() => {
this.pending.delete(termixConnectId);
run(cleanups);
}, this.pendingTimeoutMs);
timer.unref?.();
this.pending.set(termixConnectId, { cleanups, timer });
}
opened(
meta: TermixGuacMeta,
guacamoleConnectionId: string,
extra: Cleanup[] = [],
): RemoteSession {
const session: RemoteSession = {
guacamoleConnectionId,
termixConnectId: meta.termixConnectId,
hostId: meta.hostId,
hostName: meta.hostName,
ownerUserId: meta.ownerUserId,
protocol: meta.protocol,
tabInstanceId: meta.tabInstanceId ?? null,
openedAt: Date.now(),
};
const parked = this.pending.get(meta.termixConnectId);
if (parked) {
clearTimeout(parked.timer);
this.pending.delete(meta.termixConnectId);
}
this.cleanupsByConnectId.set(meta.termixConnectId, [
...(parked?.cleanups ?? []),
...extra,
]);
this.byConnectId.set(meta.termixConnectId, session);
this.byGuacamoleId.set(guacamoleConnectionId, session);
return session;
}
closed(termixConnectId: string): void {
const session = this.byConnectId.get(termixConnectId);
if (session) this.byGuacamoleId.delete(session.guacamoleConnectionId);
this.byConnectId.delete(termixConnectId);
run(this.cleanupsByConnectId.get(termixConnectId) ?? []);
this.cleanupsByConnectId.delete(termixConnectId);
}
byConnect(termixConnectId: string): RemoteSession | null {
return this.byConnectId.get(termixConnectId) ?? null;
}
byGuacamole(guacamoleConnectionId: string): RemoteSession | null {
return this.byGuacamoleId.get(guacamoleConnectionId) ?? null;
}
/** Releases everything, for deactivate. */
clear(): void {
for (const { cleanups, timer } of this.pending.values()) {
clearTimeout(timer);
run(cleanups);
}
this.pending.clear();
for (const cleanups of this.cleanupsByConnectId.values()) run(cleanups);
this.cleanupsByConnectId.clear();
this.byConnectId.clear();
this.byGuacamoleId.clear();
}
}
function run(cleanups: Cleanup[]): void {
for (const cleanup of cleanups) {
try {
cleanup();
} catch {
// One failed close must not keep the rest open.
}
}
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
@@ -1,5 +1,9 @@
import crypto from "crypto";
import { guacLogger } from "../../../../src/backend/utils/logger.js";
interface TokenLogger {
warn: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, meta?: Record<string, unknown>) => void;
}
export interface GuacamoleConnectionSettings {
type?: "rdp" | "vnc" | "telnet";
@@ -33,8 +37,10 @@ export interface GuacamoleConnectionSettings {
export interface TermixGuacMeta {
termixConnectId: string;
hostId: number;
hostName: string;
ownerUserId: string;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId: string | null;
}
export interface GuacamoleToken {
@@ -57,21 +63,19 @@ export interface GuacamoleRecordingMetadata {
const CIPHER = "aes-256-cbc";
const KEY_LENGTH = 32;
const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
/**
* Encrypts the connection tokens guacamole-lite decrypts. Built in activate;
* the key is derived from the environment, so a restart keeps it.
*/
export class GuacamoleTokenService {
private static instance: GuacamoleTokenService;
private encryptionKey: Buffer;
private constructor() {
constructor(private readonly log: TokenLogger = silentLogger) {
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) {
@@ -91,12 +95,9 @@ export class GuacamoleTokenService {
.digest();
}
guacLogger.warn(
"No persistent encryption key found, generating random key",
{
operation: "guac_key_generation",
},
);
this.log.warn("No persistent encryption key found, generating random key", {
operation: "guac_key_generation",
});
return crypto.randomBytes(KEY_LENGTH);
}
@@ -134,8 +135,9 @@ export class GuacamoleTokenService {
return JSON.parse(decrypted) as GuacamoleToken;
} catch (error) {
guacLogger.error("Failed to decrypt guacamole token", error, {
this.log.error("Failed to decrypt guacamole token", {
operation: "guac_token_decrypt_error",
error: error instanceof Error ? error.message : String(error),
});
return null;
}