mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
feat(session): add recording and replay (#1049)
This commit is contained in:
@@ -3,6 +3,10 @@ import { guacLogger } from "../utils/logger.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { sessionRecordings } from "../database/db/schema.js";
|
||||
import type { GuacamoleRecordingMetadata } from "./token-service.js";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
@@ -21,6 +25,63 @@ function readGuacdOptions(): { host: string; port: number } {
|
||||
}
|
||||
|
||||
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 = {
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
};
|
||||
};
|
||||
|
||||
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)) {
|
||||
guacLogger.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await getDb()
|
||||
.insert(sessionRecordings)
|
||||
.values({
|
||||
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 = {
|
||||
port: GUAC_WS_PORT,
|
||||
@@ -89,35 +150,31 @@ function createGuacServer(): GuacamoleLite {
|
||||
clientOptions,
|
||||
);
|
||||
|
||||
server.on(
|
||||
"open",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
});
|
||||
|
||||
server.on(
|
||||
"close",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(
|
||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
||||
error: Error,
|
||||
) => {
|
||||
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,12 +9,15 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { Client } from "ssh2";
|
||||
import net from "net";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.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());
|
||||
|
||||
@@ -526,6 +529,28 @@ router.post(
|
||||
? { guacdPort: perConnectionGuacdPort }
|
||||
: {}),
|
||||
};
|
||||
const recordingEnabled = 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,
|
||||
startedAt: new Date().toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
if (recordingEnabled) {
|
||||
guacConfig["recording-path"] = recordingPath;
|
||||
guacConfig["recording-name"] = recordingName;
|
||||
guacConfig["create-recording-path"] = true;
|
||||
guacConfig["recording-exclude-output"] = false;
|
||||
guacConfig["recording-include-keys"] = true;
|
||||
}
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
@@ -533,22 +558,28 @@ router.post(
|
||||
guacConfig["drive-path"] = "/drive";
|
||||
guacConfig["create-drive-path"] = true;
|
||||
}
|
||||
token = tokenService.createRdpToken(hostname, username, password, {
|
||||
port,
|
||||
domain,
|
||||
security:
|
||||
(host.rdpSecurity as string) ||
|
||||
(host.security as string) ||
|
||||
undefined,
|
||||
"ignore-cert":
|
||||
host.rdpIgnoreCert !== undefined
|
||||
? !!host.rdpIgnoreCert
|
||||
: host.ignoreCert !== undefined
|
||||
? !!host.ignoreCert
|
||||
: true,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
});
|
||||
token = tokenService.createRdpToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
{
|
||||
port,
|
||||
domain,
|
||||
security:
|
||||
(host.rdpSecurity as string) ||
|
||||
(host.security as string) ||
|
||||
undefined,
|
||||
"ignore-cert":
|
||||
host.rdpIgnoreCert !== undefined
|
||||
? !!host.rdpIgnoreCert
|
||||
: host.ignoreCert !== undefined
|
||||
? !!host.ignoreCert
|
||||
: true,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(
|
||||
@@ -561,14 +592,21 @@ router.post(
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
});
|
||||
token = tokenService.createTelnetToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
{
|
||||
port,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
|
||||
@@ -45,4 +45,23 @@ describe("GuacamoleTokenService", () => {
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,15 @@ export interface GuacamoleConnectionSettings {
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
path: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
@@ -127,6 +136,7 @@ export class GuacamoleTokenService {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -144,6 +154,7 @@ export class GuacamoleTokenService {
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -156,6 +167,7 @@ export class GuacamoleTokenService {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -171,6 +183,7 @@ export class GuacamoleTokenService {
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -183,6 +196,7 @@ export class GuacamoleTokenService {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -198,6 +212,7 @@ export class GuacamoleTokenService {
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user