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
+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(() => {});
}
}
+79 -22
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> }) => {
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,
});
},
);
+59 -21
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,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);
});
});
+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 });
});
+106 -50
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,65 +405,51 @@ 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) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(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,
error: err instanceof Error ? err.message : String(err),
});
});
});
}
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({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: logFile,
});
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: 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 }),