feat(session): add recording and replay (#1049)

This commit is contained in:
ZacharyZcR
2026-07-14 01:11:20 +08:00
committed by GitHub
parent a3e615b59c
commit c6a2ac69dc
19 changed files with 943 additions and 155 deletions
+11
View File
@@ -266,6 +266,8 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd
networks:
@@ -277,6 +279,8 @@ services:
restart: unless-stopped
ports:
- "4822:4822"
volumes:
- termix-data:/termix-data
networks:
- termix-net
@@ -289,6 +293,13 @@ networks:
driver: bridge
```
Termix records enabled SSH, RDP, VNC, and Telnet sessions for replay and audit.
When using an external `guacd`, mount the same recording storage into both
services and set `GUACD_RECORDING_PATH` to the path visible to `guacd` and
`GUACD_RECORDING_BACKEND_PATH` to the corresponding path visible to Termix.
Administrators can change the default 30-day retention period from Session Logs
or with `SESSION_RECORDING_RETENTION_DAYS`.
<br />
## Donate
+4
View File
@@ -12,6 +12,8 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd-dev
networks:
@@ -21,6 +23,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
+3
View File
@@ -10,6 +10,7 @@ services:
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd
networks:
@@ -19,6 +20,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+15
View File
@@ -460,6 +460,8 @@ async function initializeCompleteDatabase(): Promise<void> {
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
@@ -713,6 +715,17 @@ const addColumnIfNotExists = (
};
const migrateSchema = () => {
addColumnIfNotExists(
"session_recordings",
"protocol",
"TEXT NOT NULL DEFAULT 'ssh'",
);
addColumnIfNotExists(
"session_recordings",
"format",
"TEXT NOT NULL DEFAULT 'text'",
);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -1575,6 +1588,8 @@ const migrateSchema = () => {
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
+2
View File
@@ -660,6 +660,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: text("protocol").notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false),
+118 -33
View File
@@ -2,24 +2,58 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import fs from "fs";
import path from "path";
import { eq, and, desc, lt } from "drizzle-orm";
import { eq, desc, lt } from "drizzle-orm";
import { db } from "../db/index.js";
import { sessionRecordings, hosts } from "../db/schema.js";
import { sessionRecordings, hosts, users } from "../db/schema.js";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { Request, Response } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
// Delete session log files and DB rows older than this many days
const LOG_RETENTION_DAYS = 30;
const permissionManager = PermissionManager.getInstance();
function isAllowedRecordingPath(filePath: string): boolean {
const resolved = path.resolve(filePath);
return ["session_logs", "session_recordings"].some((directory) => {
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
return resolved.startsWith(base);
});
}
function getRetentionDays(): number {
const envDays = parseInt(
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
10,
);
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_recording_retention_days'",
)
.get() as { value?: string } | undefined;
const configured = parseInt(row?.value || "", 10);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
}
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
}
async function canAccessRecording(
userId: string,
ownerId: string,
): Promise<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
).toISOString();
const old = await db
@@ -33,8 +67,7 @@ async function pruneOldLogs(): Promise<void> {
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
const allowed = path.resolve(DATA_DIR, "session_logs");
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
@@ -81,6 +114,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const isAdmin = await permissionManager.isAdmin(userId);
const rows = await db
.select({
id: sessionRecordings.id,
@@ -90,12 +124,16 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
username: users.username,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.where(eq(sessionRecordings.userId, userId))
.leftJoin(users, eq(sessionRecordings.userId, users.id))
.where(isAdmin ? undefined : eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
const records = rows.map((row) => {
@@ -120,6 +158,46 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
router.get(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
res.json({ retentionDays: getRetentionDays() });
},
);
router.put(
"/retention",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
db.$client
.prepare(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.run("session_recording_retention_days", String(retentionDays));
void pruneOldLogs();
res.json({ retentionDays });
},
);
/**
* @openapi
* /session_logs/{id}:
@@ -153,12 +231,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const rows = await db
.select()
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.where(eq(sessionRecordings.id, id))
.limit(1);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
if (!(await canAccessRecording(userId, rows[0].userId))) {
return res.status(403).json({ error: "Forbidden" });
}
res.json({ log: rows[0] });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
@@ -206,26 +285,27 @@ router.get(
try {
const rows = await db
.select({ recordingPath: sessionRecordings.recordingPath })
.select({
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
format: sessionRecordings.format,
})
.from(sessionRecordings)
.where(
and(
eq(sessionRecordings.id, id),
eq(sessionRecordings.userId, userId),
),
)
.where(eq(sessionRecordings.id, id))
.limit(1);
if (rows.length === 0)
return res.status(404).json({ error: "Not found" });
if (!(await canAccessRecording(userId, rows[0].userId))) {
return res.status(403).json({ error: "Forbidden" });
}
const filePath = rows[0].recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (!resolvedPath.startsWith(allowedBase)) {
if (!isAllowedRecordingPath(resolvedPath)) {
return res.status(403).json({ error: "Forbidden" });
}
@@ -233,8 +313,14 @@ router.get(
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath, "utf-8");
res.type("text/plain").send(content);
const content = await fs.promises.readFile(resolvedPath);
const contentType =
rows[0].format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: rows[0].format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
} catch (error) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
@@ -277,27 +363,26 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
try {
const rows = await db
.select({ recordingPath: sessionRecordings.recordingPath })
.select({
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
})
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.where(eq(sessionRecordings.id, id))
.limit(1);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
if (!(await canAccessRecording(userId, rows[0].userId))) {
return res.status(403).json({ error: "Forbidden" });
}
const filePath = rows[0].recordingPath;
await db
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id));
if (filePath) {
const resolvedPath = path.resolve(filePath);
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
+74 -17
View File
@@ -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> }) => {
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.type,
type: clientConnection.connectionSettings?.connection?.type,
});
});
},
);
server.on(
"close",
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
server.on("close", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.type,
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,
});
},
);
+42 -4
View File
@@ -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,7 +558,11 @@ router.post(
guacConfig["drive-path"] = "/drive";
guacConfig["create-drive-path"] = true;
}
token = tokenService.createRdpToken(hostname, username, password, {
token = tokenService.createRdpToken(
hostname,
username,
password,
{
port,
domain,
security:
@@ -548,7 +577,9 @@ router.post(
: 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, {
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);
});
});
+15
View File
@@ -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);
}
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Stub all external imports before loading the module under test
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]);
const mockInsertValues = vi.fn().mockReturnValue({ returning: mockReturning });
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
vi.mock("../database/db/index.js", () => ({
@@ -29,21 +30,25 @@ vi.mock("../utils/logger.js", () => ({
// Mock individual fs.promises methods via a stub object
const mockMkdir = vi.fn().mockResolvedValue(undefined);
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
const mockUnlink = vi.fn().mockResolvedValue(undefined);
vi.mock("fs", () => ({
default: {
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: vi.fn(),
unlink: mockUnlink,
},
},
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: vi.fn(),
unlink: mockUnlink,
},
}));
@@ -55,7 +60,8 @@ describe("TerminalSessionManager - session logging", () => {
// Re-apply resolved values after clearAllMocks
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockInsertValues.mockResolvedValue(undefined);
mockReturning.mockResolvedValue([{ id: 1 }]);
mockInsertValues.mockReturnValue({ returning: mockReturning });
mockInsert.mockReturnValue({ values: mockInsertValues });
});
+96 -40
View File
@@ -2,6 +2,7 @@ import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { eq } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
import { getDb } from "../database/db/index.js";
import { sessionRecordings } from "../database/db/schema.js";
@@ -36,6 +37,12 @@ export interface TerminalSession {
outputBuffer: string[];
outputBufferBytes: number;
recordingPath: string | null;
recordingHeader: string | null;
recordingBytes: number;
recordingId: number | null;
recordingWriteChain: Promise<void>;
recordingPersistChain: Promise<void>;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
@@ -117,6 +124,19 @@ class TerminalSessionManager {
const id = crypto.randomUUID();
const now = Date.now();
let recordingPath: string | null = null;
let recordingHeader: string | null = null;
if (sessionLoggingEnabled) {
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
recordingPath = path.join(userLogDir, `${id}.cast`);
recordingHeader = `${JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(now / 1000),
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
})}\n`;
}
const session: TerminalSession = {
id,
userId,
@@ -135,6 +155,12 @@ class TerminalSessionManager {
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
recordingPath,
recordingHeader,
recordingBytes: 0,
recordingId: null,
recordingWriteChain: Promise.resolve(),
recordingPersistChain: Promise.resolve(),
tmuxSessionName: null,
sessionLoggingEnabled,
sessionStartedAt: now,
@@ -334,6 +360,9 @@ class TerminalSessionManager {
}
this.maybePersistLog(session, true);
if (session.recordingPath && session.recordingBytes === 0) {
fs.promises.unlink(session.recordingPath).catch(() => {});
}
if (session.sshStream) {
try {
@@ -376,31 +405,14 @@ class TerminalSessionManager {
});
}
private stripAnsi(raw: string): string {
return (
raw
// ESC sequences: CSI, OSC, DCS, PM, APC, SOS
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "")
// Single-char ESC sequences (e.g. ESC M, ESC =)
.replace(/\x1b[^[\]PX^_]/g, "")
// Other C0/C1 control chars except newline, carriage return, tab
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
// Collapse carriage returns used for line overwrites
.replace(/[^\n]*\r(?!\n)/g, "")
);
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.outputBufferBytes === 0) return;
// Only save if new output arrived since last persist (unless forced)
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
return;
const snapshot = session.outputBuffer.join("");
session.lastPersistedBytes = session.outputBufferBytes;
this.persistSessionLog(session, snapshot).catch((err) => {
if (session.recordingBytes === 0) return;
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
session.lastPersistedBytes = session.recordingBytes;
session.recordingPersistChain = session.recordingPersistChain
.then(() => this.persistSessionLog(session))
.catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
@@ -409,32 +421,35 @@ class TerminalSessionManager {
});
}
private async persistSessionLog(
session: TerminalSession,
logContent: string,
): Promise<void> {
const cleaned = this.stripAnsi(logContent);
if (!cleaned.trim()) return;
const userLogDir = path.join(SESSION_LOGS_DIR, session.userId);
await fs.promises.mkdir(userLogDir, { recursive: true });
const logFile = path.join(userLogDir, `${session.id}.log`);
await fs.promises.writeFile(logFile, cleaned, "utf-8");
private async persistSessionLog(session: TerminalSession): Promise<void> {
if (!session.recordingPath) return;
await session.recordingWriteChain;
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
const db = getDb();
await db.insert(sessionRecordings).values({
if (session.recordingId == null) {
const rows = await db
.insert(sessionRecordings)
.values({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: logFile,
});
recordingPath: session.recordingPath,
protocol: "ssh",
format: "asciicast",
})
.returning({ id: sessionRecordings.id });
session.recordingId = rows[0]?.id ?? null;
} else {
await db
.update(sessionRecordings)
.set({ endedAt: new Date(endedAt).toISOString(), duration })
.where(eq(sessionRecordings.id, session.recordingId));
}
} catch (err) {
sshLogger.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
@@ -449,7 +464,7 @@ class TerminalSessionManager {
userId: session.userId,
hostId: session.hostId,
duration,
bytes: cleaned.length,
bytes: session.recordingBytes,
});
}
@@ -477,6 +492,47 @@ class TerminalSessionManager {
const removed = session.outputBuffer.shift();
if (removed) session.outputBufferBytes -= removed.length;
}
this.recordSessionEvent(session, "o", data);
}
bufferInput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "i", data);
}
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
}
private recordSessionEvent(
session: TerminalSession,
type: "i" | "o" | "r",
data: string,
): void {
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
return;
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
const line = `${JSON.stringify([elapsed, type, data])}\n`;
const firstEvent = session.recordingBytes === 0;
session.recordingBytes += Buffer.byteLength(line);
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
if (firstEvent) {
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
recursive: true,
});
await fs.promises.writeFile(
session.recordingPath!,
`${session.recordingHeader}${line}`,
"utf8",
);
return;
}
await fs.promises.appendFile(session.recordingPath!, line, "utf8");
});
}
flushBuffer(session: TerminalSession): string | null {
+4
View File
@@ -539,6 +539,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
case "input": {
const inputData = data as string;
if (currentSessionId) {
sessionManager.bufferInput(currentSessionId, inputData);
}
const inputStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (inputStream) {
@@ -2913,6 +2916,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (session) {
session.cols = data.cols;
session.rows = data.rows;
sessionManager.bufferResize(session.id, data.cols, data.rows);
}
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
+20
View File
@@ -41,6 +41,26 @@ declare module "guacamole-common-js" {
constructor(url: string);
}
class SessionRecording {
constructor(source: Blob | Tunnel, refreshInterval?: number);
onload: (() => void) | null;
onerror: ((message: string) => void) | null;
onprogress: ((duration: number, parsedSize: number) => void) | null;
onplay: (() => void) | null;
onpause: (() => void) | null;
onseek:
| ((position: number, current: number, total: number) => void)
| null;
getDisplay(): Display;
getPosition(): number;
getDuration(): number;
isPlaying(): boolean;
play(): void;
pause(): void;
seek(position: number, callback?: () => void): void;
abort(): void;
}
class Mouse {
constructor(element: HTMLElement);
onmousedown: ((state: Mouse.State) => void) | null;
+33
View File
@@ -11,6 +11,9 @@ export type SessionLogRecord = {
hostName: string | null;
hostIp: string | null;
sizeBytes: number | null;
protocol: "ssh" | "rdp" | "vnc" | "telnet";
format: "text" | "asciicast" | "guacamole";
username: string | null;
};
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
@@ -22,6 +25,36 @@ export async function getSessionLogs(): Promise<SessionLogRecord[]> {
}
}
export async function getSessionLogBlob(id: number): Promise<Blob> {
try {
const response = await authApi.get(`/session_logs/${id}/content`, {
responseType: "blob",
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch session recording");
}
}
export async function getSessionRecordingRetention(): Promise<number> {
try {
const response = await authApi.get("/session_logs/retention");
return response.data.retentionDays;
} catch (error) {
throw handleApiError(error, "fetch session recording retention");
}
}
export async function setSessionRecordingRetention(
retentionDays: number,
): Promise<void> {
try {
await authApi.put("/session_logs/retention", { retentionDays });
} catch (error) {
throw handleApiError(error, "update session recording retention");
}
}
export async function getSessionLogContent(id: number): Promise<string> {
try {
const response = await authApi.get(`/session_logs/${id}/content`, {
@@ -0,0 +1,293 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play } from "lucide-react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import Guacamole from "guacamole-common-js";
import type { SessionLogRecord } from "@/api/session-log-api";
import { parseAsciicast, type Asciicast } from "./asciicast";
import "@xterm/xterm/css/xterm.css";
const SPEEDS = [0.5, 1, 2, 4];
function formatPosition(seconds: number) {
const minutes = Math.floor(seconds / 60);
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
}
function PlaybackControls({
playing,
position,
duration,
speed,
onToggle,
onSeek,
onSpeed,
}: {
playing: boolean;
position: number;
duration: number;
speed: number;
onToggle: () => void;
onSeek: (position: number) => void;
onSpeed: (speed: number) => void;
}) {
return (
<div className="flex items-center gap-2 border-t border-border/60 bg-muted/20 px-3 py-2">
<button
type="button"
onClick={onToggle}
className="flex size-7 items-center justify-center hover:bg-muted"
aria-label={playing ? "Pause recording" : "Play recording"}
>
{playing ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
</button>
<span className="w-10 text-[10px] tabular-nums text-muted-foreground">
{formatPosition(position)}
</span>
<input
type="range"
min={0}
max={Math.max(duration, 0.01)}
step={0.01}
value={Math.min(position, duration)}
onChange={(event) => onSeek(Number(event.target.value))}
className="min-w-0 flex-1 accent-primary"
aria-label="Recording timeline"
/>
<span className="w-10 text-right text-[10px] tabular-nums text-muted-foreground">
{formatPosition(duration)}
</span>
<select
value={speed}
onChange={(event) => onSpeed(Number(event.target.value))}
className="h-7 border border-border bg-background px-1 text-[10px]"
aria-label="Playback speed"
>
{SPEEDS.map((value) => (
<option key={value} value={value}>
{value}×
</option>
))}
</select>
</div>
);
}
function AsciicastPlayer({ blob }: { blob: Blob }) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const recordingRef = useRef<Asciicast | null>(null);
const positionRef = useRef(0);
const eventIndexRef = useRef(0);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
const [speed, setSpeed] = useState(1);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState("");
const renderAt = useCallback((nextPosition: number) => {
const terminal = terminalRef.current;
const recording = recordingRef.current;
if (!terminal || !recording) return;
if (nextPosition < positionRef.current) {
terminal.reset();
terminal.resize(recording.width, recording.height);
eventIndexRef.current = 0;
}
while (eventIndexRef.current < recording.events.length) {
const [time, type, data] = recording.events[eventIndexRef.current];
if (time > nextPosition) break;
if (type === "o") terminal.write(data);
if (type === "r") {
const [cols, rows] = data.split("x").map(Number);
if (cols > 0 && rows > 0) terminal.resize(cols, rows);
}
eventIndexRef.current++;
}
positionRef.current = nextPosition;
setPosition(nextPosition);
}, []);
useEffect(() => {
if (!containerRef.current) return;
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: false,
fontSize: 12,
theme: { background: "#09090b" },
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
terminalRef.current = terminal;
const observer = new ResizeObserver(() => fitAddon.fit());
observer.observe(containerRef.current);
fitAddon.fit();
blob
.text()
.then((source) => {
const recording = parseAsciicast(source);
recordingRef.current = recording;
setDuration(recording.duration);
terminal.resize(recording.width, recording.height);
renderAt(0);
})
.catch((reason) =>
setError(reason instanceof Error ? reason.message : String(reason)),
);
return () => {
observer.disconnect();
terminal.dispose();
terminalRef.current = null;
};
}, [blob, renderAt]);
useEffect(() => {
if (!playing || !recordingRef.current) return;
let frame = 0;
let previous = performance.now();
const tick = (now: number) => {
const next = Math.min(
duration,
positionRef.current + ((now - previous) / 1000) * speed,
);
previous = now;
renderAt(next);
if (next >= duration) setPlaying(false);
else frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [duration, playing, renderAt, speed]);
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
return (
<div className="flex h-full min-h-0 flex-col">
<div ref={containerRef} className="min-h-0 flex-1 bg-[#09090b] p-2" />
<PlaybackControls
playing={playing}
position={position}
duration={duration}
speed={speed}
onToggle={() => {
if (!playing && position >= duration) renderAt(0);
setPlaying((value) => !value);
}}
onSeek={renderAt}
onSpeed={setSpeed}
/>
</div>
);
}
function GuacamolePlayer({ blob }: { blob: Blob }) {
const containerRef = useRef<HTMLDivElement>(null);
const recordingRef = useRef<Guacamole.SessionRecording | null>(null);
const positionRef = useRef(0);
const [position, setPosition] = useState(0);
const [duration, setDuration] = useState(0);
const [speed, setSpeed] = useState(1);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!containerRef.current) return;
const recording = new Guacamole.SessionRecording(blob, 100);
recordingRef.current = recording;
const display = recording.getDisplay();
const element = display.getElement();
containerRef.current.replaceChildren(element);
const fit = () => {
const width = display.getWidth();
if (width && containerRef.current) {
display.scale(containerRef.current.clientWidth / width);
}
};
const observer = new ResizeObserver(fit);
observer.observe(containerRef.current);
recording.onload = () => {
setDuration(recording.getDuration() / 1000);
fit();
};
recording.onprogress = (nextDuration) => setDuration(nextDuration / 1000);
recording.onseek = (nextPosition) => {
positionRef.current = nextPosition / 1000;
setPosition(positionRef.current);
};
recording.onerror = setError;
return () => {
observer.disconnect();
recording.abort();
recordingRef.current = null;
};
}, [blob]);
useEffect(() => {
const recording = recordingRef.current;
if (!recording || !playing) return;
recording.pause();
let previous = performance.now();
let nextPosition = positionRef.current;
const interval = window.setInterval(() => {
const now = performance.now();
nextPosition = Math.min(
duration,
nextPosition + ((now - previous) / 1000) * speed,
);
previous = now;
recording.seek(nextPosition * 1000);
if (nextPosition >= duration) setPlaying(false);
}, 100);
return () => clearInterval(interval);
}, [duration, playing, speed]);
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
return (
<div className="flex h-full min-h-0 flex-col">
<div
ref={containerRef}
className="min-h-0 flex-1 overflow-hidden bg-black"
/>
<PlaybackControls
playing={playing}
position={position}
duration={duration}
speed={speed}
onToggle={() => {
const recording = recordingRef.current;
if (!recording) return;
if (!playing && position >= duration) recording.seek(0);
setPlaying((value) => !value);
}}
onSeek={(nextPosition) => {
recordingRef.current?.seek(nextPosition * 1000);
positionRef.current = nextPosition;
setPosition(nextPosition);
}}
onSpeed={setSpeed}
/>
</div>
);
}
export function SessionRecordingPlayer({
log,
blob,
}: {
log: SessionLogRecord;
blob: Blob;
}) {
return log.format === "guacamole" ? (
<GuacamolePlayer blob={blob} />
) : (
<AsciicastPlayer blob={blob} />
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseAsciicast } from "./asciicast";
describe("parseAsciicast", () => {
it("parses terminal dimensions and timed input/output", () => {
const recording = parseAsciicast(
'{"version":2,"width":120,"height":30}\n[0.1,"o","hello"]\n[1.5,"i","ls\\r"]\n[2,"r","100x40"]\n',
);
expect(recording.width).toBe(120);
expect(recording.height).toBe(30);
expect(recording.duration).toBe(2);
expect(recording.events).toHaveLength(3);
});
it("rejects unsupported recording versions", () => {
expect(() => parseAsciicast('{"version":1}\n')).toThrow(
"Unsupported asciicast version",
);
});
});
@@ -0,0 +1,39 @@
export type AsciicastEvent = [
time: number,
type: "i" | "o" | "r",
data: string,
];
export type Asciicast = {
width: number;
height: number;
duration: number;
events: AsciicastEvent[];
};
export function parseAsciicast(source: string): Asciicast {
const lines = source.trim().split("\n");
const header = JSON.parse(lines.shift() || "{}") as {
version?: number;
width?: number;
height?: number;
};
if (header.version !== 2) throw new Error("Unsupported asciicast version");
const events = lines
.filter(Boolean)
.map((line) => JSON.parse(line) as AsciicastEvent)
.filter(
([time, type, data]) =>
Number.isFinite(time) &&
(type === "i" || type === "o" || type === "r") &&
typeof data === "string",
);
return {
width: header.width || 80,
height: header.height || 24,
duration: events.at(-1)?.[0] || 0,
events,
};
}
+74 -6
View File
@@ -15,6 +15,7 @@ import {
Download,
Eye,
Loader2,
Save,
ScrollText,
Search,
X,
@@ -23,9 +24,13 @@ import { toast } from "sonner";
import {
getSessionLogs,
getSessionLogContent,
getSessionLogBlob,
getSessionRecordingRetention,
setSessionRecordingRetention,
deleteSessionLog,
type SessionLogRecord,
} from "@/api/session-log-api";
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
function formatDuration(seconds: number | null): string {
if (seconds == null) return "--";
@@ -63,7 +68,13 @@ function buildFilename(log: SessionLogRecord): string {
const h = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.log`;
const extension =
log.format === "guacamole"
? "guac"
: log.format === "asciicast"
? "cast"
: "log";
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`;
}
function SectionHeader({ label, count }: { label: string; count: number }) {
@@ -105,6 +116,9 @@ function LogRow({
<span className="text-[10px] text-muted-foreground/60 truncate">
{formatDate(log.startedAt)}
{" · "}
{log.protocol.toUpperCase()}
{log.username ? ` · ${log.username}` : ""}
{" · "}
{formatDuration(log.duration)}
{" · "}
{formatBytes(log.sizeBytes)}
@@ -159,12 +173,15 @@ export function SessionLogsPanel() {
const [filter, setFilter] = useState("");
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
const [viewContent, setViewContent] = useState<string>("");
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
const [viewLoading, setViewLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
null,
);
const [deleting, setDeleting] = useState(false);
const [copied, setCopied] = useState(false);
const [retentionDays, setRetentionDays] = useState<number | null>(null);
const [savingRetention, setSavingRetention] = useState(false);
const load = useCallback(
(initial = false) => {
@@ -192,6 +209,9 @@ export function SessionLogsPanel() {
useEffect(() => {
load(true);
getSessionRecordingRetention()
.then(setRetentionDays)
.catch(() => setRetentionDays(null));
const interval = setInterval(() => load(false), 5000);
return () => clearInterval(interval);
}, [load]);
@@ -199,17 +219,23 @@ export function SessionLogsPanel() {
const q = filter.trim().toLowerCase();
const filtered = q
? logs.filter((l) =>
(l.hostName ?? l.hostIp ?? "").toLowerCase().includes(q),
`${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}`
.toLowerCase()
.includes(q),
)
: logs;
const handleView = async (log: SessionLogRecord) => {
setViewLog(log);
setViewContent("");
setViewBlob(null);
setViewLoading(true);
try {
const content = await getSessionLogContent(log.id);
setViewContent(content);
if (log.format === "text") {
setViewContent(await getSessionLogContent(log.id));
} else {
setViewBlob(await getSessionLogBlob(log.id));
}
} catch {
toast.error(t("sessionLogs.loadError"));
} finally {
@@ -219,8 +245,7 @@ export function SessionLogsPanel() {
const handleDownload = async (log: SessionLogRecord) => {
try {
const content = await getSessionLogContent(log.id);
const blob = new Blob([content], { type: "text/plain" });
const blob = await getSessionLogBlob(log.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -285,6 +310,7 @@ export function SessionLogsPanel() {
</span>
<span className="text-[10px] text-muted-foreground/50">
{formatDate(viewLog.startedAt)}
{` · ${viewLog.protocol.toUpperCase()}`}
{viewLog.duration != null
? ` · ${formatDuration(viewLog.duration)}`
: ""}
@@ -294,6 +320,7 @@ export function SessionLogsPanel() {
</div>
<TooltipProvider>
<div className="flex items-center gap-0.5 shrink-0">
{viewLog.format === "text" && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -313,6 +340,7 @@ export function SessionLogsPanel() {
: t("sessionLogs.copyContent")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -336,6 +364,8 @@ export function SessionLogsPanel() {
<div className="flex items-center justify-center py-16">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : viewBlob ? (
<SessionRecordingPlayer log={viewLog} blob={viewBlob} />
) : (
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
{viewContent || "(empty)"}
@@ -349,6 +379,44 @@ export function SessionLogsPanel() {
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
{retentionDays != null && (
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex-1">Recording retention</span>
<Input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-7 w-20 text-xs"
aria-label="Recording retention days"
/>
<span>days</span>
<button
type="button"
disabled={savingRetention}
onClick={async () => {
setSavingRetention(true);
try {
await setSessionRecordingRetention(retentionDays);
toast.success("Recording retention updated");
} catch {
toast.error("Failed to update recording retention");
} finally {
setSavingRetention(false);
}
}}
className="flex size-7 items-center justify-center hover:bg-muted disabled:opacity-50"
aria-label="Save recording retention"
>
{savingRetention ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
</button>
</div>
)}
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 bg-muted/40 flex items-center justify-center">