mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
319 lines
9.6 KiB
TypeScript
319 lines
9.6 KiB
TypeScript
import GuacamoleLite from "guacamole-lite";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
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;
|
|
startedAt: string;
|
|
endedAt: string;
|
|
duration: number | null;
|
|
recordingPath: string;
|
|
protocol: string;
|
|
format: string;
|
|
}) => Promise<{ id: number }>;
|
|
}
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
|
|
|
/** 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?: TermixGuacMeta;
|
|
};
|
|
close?: () => void;
|
|
};
|
|
|
|
const clientOptions = (
|
|
tokens: GuacamoleTokenService,
|
|
log: RemoteDesktopLogger,
|
|
) => ({
|
|
crypt: {
|
|
cypher: "AES-256-CBC",
|
|
key: tokens.getEncryptionKey(),
|
|
},
|
|
log: {
|
|
level: "ERRORS",
|
|
stdLog: (...args: unknown[]) => log.info(args.join(" ")),
|
|
errorLog: (...args: unknown[]) => log.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",
|
|
},
|
|
},
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 persistRecording = async (connection: ClientConnection) => {
|
|
const recording = connection.connectionSettings?.recording;
|
|
if (!recording) return;
|
|
|
|
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));
|
|
}
|
|
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;
|
|
}
|
|
|
|
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),
|
|
});
|
|
});
|
|
});
|
|
|
|
guacamole.on("error", (connection: ClientConnection, error: Error) => {
|
|
log.error("Guacamole connection error", {
|
|
operation: "guac_connection_error",
|
|
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);
|
|
});
|
|
},
|
|
|
|
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,
|
|
};
|
|
}
|