mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
feat(session): add recording and replay (#1049)
This commit is contained in:
@@ -266,6 +266,8 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -277,6 +279,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4822:4822"
|
- "4822:4822"
|
||||||
|
volumes:
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
@@ -289,6 +293,13 @@ networks:
|
|||||||
driver: bridge
|
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 />
|
<br />
|
||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
GUACD_HOST: "guacd-dev"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd-dev
|
- guacd-dev
|
||||||
networks:
|
networks:
|
||||||
@@ -21,6 +23,8 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd-dev
|
container_name: guacd-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- termix-dev-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-dev-net
|
- termix-dev-net
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
GUACD_HOST: "guacd"
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -19,6 +20,8 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd
|
container_name: guacd
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
|
|||||||
@@ -460,6 +460,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
commands TEXT,
|
commands TEXT,
|
||||||
dangerous_actions TEXT,
|
dangerous_actions TEXT,
|
||||||
recording_path TEXT,
|
recording_path TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
format TEXT NOT NULL DEFAULT 'text',
|
||||||
terminated_by_owner INTEGER DEFAULT 0,
|
terminated_by_owner INTEGER DEFAULT 0,
|
||||||
termination_reason TEXT,
|
termination_reason TEXT,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
@@ -713,6 +715,17 @@ const addColumnIfNotExists = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
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", "theme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||||
@@ -1575,6 +1588,8 @@ const migrateSchema = () => {
|
|||||||
commands TEXT,
|
commands TEXT,
|
||||||
dangerous_actions TEXT,
|
dangerous_actions TEXT,
|
||||||
recording_path TEXT,
|
recording_path TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
format TEXT NOT NULL DEFAULT 'text',
|
||||||
terminated_by_owner INTEGER DEFAULT 0,
|
terminated_by_owner INTEGER DEFAULT 0,
|
||||||
termination_reason TEXT,
|
termination_reason TEXT,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -660,6 +660,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
|||||||
dangerousActions: text("dangerous_actions"),
|
dangerousActions: text("dangerous_actions"),
|
||||||
|
|
||||||
recordingPath: text("recording_path"),
|
recordingPath: text("recording_path"),
|
||||||
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
|
format: text("format").notNull().default("text"),
|
||||||
|
|
||||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -2,24 +2,58 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
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 { 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 { apiLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
||||||
|
|
||||||
// Delete session log files and DB rows older than this many days
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const LOG_RETENTION_DAYS = 30;
|
|
||||||
|
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> {
|
async function pruneOldLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(
|
const cutoff = new Date(
|
||||||
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
|
||||||
).toISOString();
|
).toISOString();
|
||||||
|
|
||||||
const old = await db
|
const old = await db
|
||||||
@@ -33,8 +67,7 @@ async function pruneOldLogs(): Promise<void> {
|
|||||||
for (const row of old) {
|
for (const row of old) {
|
||||||
if (row.recordingPath) {
|
if (row.recordingPath) {
|
||||||
const resolved = path.resolve(row.recordingPath);
|
const resolved = path.resolve(row.recordingPath);
|
||||||
const allowed = path.resolve(DATA_DIR, "session_logs");
|
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
|
||||||
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
|
|
||||||
await fs.promises.unlink(resolved).catch(() => {});
|
await fs.promises.unlink(resolved).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +114,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
|||||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
|
const isAdmin = await permissionManager.isAdmin(userId);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: sessionRecordings.id,
|
id: sessionRecordings.id,
|
||||||
@@ -90,12 +124,16 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
endedAt: sessionRecordings.endedAt,
|
endedAt: sessionRecordings.endedAt,
|
||||||
duration: sessionRecordings.duration,
|
duration: sessionRecordings.duration,
|
||||||
recordingPath: sessionRecordings.recordingPath,
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
protocol: sessionRecordings.protocol,
|
||||||
|
format: sessionRecordings.format,
|
||||||
|
username: users.username,
|
||||||
hostName: hosts.name,
|
hostName: hosts.name,
|
||||||
hostIp: hosts.ip,
|
hostIp: hosts.ip,
|
||||||
})
|
})
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
|
.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));
|
.orderBy(desc(sessionRecordings.startedAt));
|
||||||
|
|
||||||
const records = rows.map((row) => {
|
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
|
* @openapi
|
||||||
* /session_logs/{id}:
|
* /session_logs/{id}:
|
||||||
@@ -153,12 +231,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(
|
.where(eq(sessionRecordings.id, id))
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
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] });
|
res.json({ log: rows[0] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
apiLogger.error("Failed to fetch session log", error, {
|
apiLogger.error("Failed to fetch session log", error, {
|
||||||
@@ -206,26 +285,27 @@ router.get(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
.select({
|
||||||
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
userId: sessionRecordings.userId,
|
||||||
|
format: sessionRecordings.format,
|
||||||
|
})
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(
|
.where(eq(sessionRecordings.id, id))
|
||||||
and(
|
|
||||||
eq(sessionRecordings.id, id),
|
|
||||||
eq(sessionRecordings.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (rows.length === 0)
|
if (rows.length === 0)
|
||||||
return res.status(404).json({ error: "Not found" });
|
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;
|
const filePath = rows[0].recordingPath;
|
||||||
if (!filePath)
|
if (!filePath)
|
||||||
return res.status(404).json({ error: "No recording file" });
|
return res.status(404).json({ error: "No recording file" });
|
||||||
|
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
if (!isAllowedRecordingPath(resolvedPath)) {
|
||||||
if (!resolvedPath.startsWith(allowedBase)) {
|
|
||||||
return res.status(403).json({ error: "Forbidden" });
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +313,14 @@ router.get(
|
|||||||
return res.status(404).json({ error: "File not found" });
|
return res.status(404).json({ error: "File not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await fs.promises.readFile(resolvedPath, "utf-8");
|
const content = await fs.promises.readFile(resolvedPath);
|
||||||
res.type("text/plain").send(content);
|
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) {
|
} catch (error) {
|
||||||
apiLogger.error("Failed to read session log content", error, {
|
apiLogger.error("Failed to read session log content", error, {
|
||||||
operation: "session_log_content",
|
operation: "session_log_content",
|
||||||
@@ -277,27 +363,26 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
.select({
|
||||||
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
userId: sessionRecordings.userId,
|
||||||
|
})
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(
|
.where(eq(sessionRecordings.id, id))
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
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;
|
const filePath = rows[0].recordingPath;
|
||||||
|
|
||||||
await db
|
await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id));
|
||||||
.delete(sessionRecordings)
|
|
||||||
.where(
|
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
|
||||||
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
|
|
||||||
await fs.promises.unlink(resolvedPath).catch(() => {});
|
await fs.promises.unlink(resolvedPath).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { guacLogger } from "../utils/logger.js";
|
|||||||
import { GuacamoleTokenService } from "./token-service.js";
|
import { GuacamoleTokenService } from "./token-service.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { resolveGuacdOptions } from "../utils/guacd-config.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();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
|
|
||||||
@@ -21,6 +25,63 @@ function readGuacdOptions(): { host: string; port: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GUAC_WS_PORT = 30008;
|
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 = {
|
const websocketOptions = {
|
||||||
port: GUAC_WS_PORT,
|
port: GUAC_WS_PORT,
|
||||||
@@ -89,35 +150,31 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
clientOptions,
|
clientOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
server.on(
|
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||||
"open",
|
guacLogger.info("Guacamole connection opened", {
|
||||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
operation: "guac_connection_open",
|
||||||
guacLogger.info("Guacamole connection opened", {
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
operation: "guac_connection_open",
|
});
|
||||||
type: clientConnection.connectionSettings?.type,
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.on(
|
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||||
"close",
|
guacLogger.info("Guacamole connection closed", {
|
||||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
operation: "guac_connection_close",
|
||||||
guacLogger.info("Guacamole connection closed", {
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
operation: "guac_connection_close",
|
});
|
||||||
type: clientConnection.connectionSettings?.type,
|
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||||
|
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||||
|
operation: "guac_recording_persist_error",
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
server.on(
|
server.on(
|
||||||
"error",
|
"error",
|
||||||
(
|
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
|
||||||
error: Error,
|
|
||||||
) => {
|
|
||||||
guacLogger.error("Guacamole connection error", error, {
|
guacLogger.error("Guacamole connection error", error, {
|
||||||
operation: "guac_connection_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 { eq, and } from "drizzle-orm";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import path from "path";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
|
|
||||||
router.use(authManager.createAuthMiddleware());
|
router.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
@@ -526,6 +529,28 @@ router.post(
|
|||||||
? { guacdPort: perConnectionGuacdPort }
|
? { 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) {
|
switch (connectionType) {
|
||||||
case "rdp":
|
case "rdp":
|
||||||
@@ -533,22 +558,28 @@ router.post(
|
|||||||
guacConfig["drive-path"] = "/drive";
|
guacConfig["drive-path"] = "/drive";
|
||||||
guacConfig["create-drive-path"] = true;
|
guacConfig["create-drive-path"] = true;
|
||||||
}
|
}
|
||||||
token = tokenService.createRdpToken(hostname, username, password, {
|
token = tokenService.createRdpToken(
|
||||||
port,
|
hostname,
|
||||||
domain,
|
username,
|
||||||
security:
|
password,
|
||||||
(host.rdpSecurity as string) ||
|
{
|
||||||
(host.security as string) ||
|
port,
|
||||||
undefined,
|
domain,
|
||||||
"ignore-cert":
|
security:
|
||||||
host.rdpIgnoreCert !== undefined
|
(host.rdpSecurity as string) ||
|
||||||
? !!host.rdpIgnoreCert
|
(host.security as string) ||
|
||||||
: host.ignoreCert !== undefined
|
undefined,
|
||||||
? !!host.ignoreCert
|
"ignore-cert":
|
||||||
: true,
|
host.rdpIgnoreCert !== undefined
|
||||||
...guacConfig,
|
? !!host.rdpIgnoreCert
|
||||||
...guacdOverrides,
|
: host.ignoreCert !== undefined
|
||||||
});
|
? !!host.ignoreCert
|
||||||
|
: true,
|
||||||
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
|
},
|
||||||
|
recordingMetadata,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
token = tokenService.createVncToken(
|
token = tokenService.createVncToken(
|
||||||
@@ -561,14 +592,21 @@ router.post(
|
|||||||
...guacConfig,
|
...guacConfig,
|
||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
|
recordingMetadata,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "telnet":
|
case "telnet":
|
||||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
token = tokenService.createTelnetToken(
|
||||||
port,
|
hostname,
|
||||||
...guacConfig,
|
username,
|
||||||
...guacdOverrides,
|
password,
|
||||||
});
|
{
|
||||||
|
port,
|
||||||
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
|
},
|
||||||
|
recordingMetadata,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return res.status(400).json({ error: "Invalid connection type" });
|
return res.status(400).json({ error: "Invalid connection type" });
|
||||||
|
|||||||
@@ -45,4 +45,23 @@ describe("GuacamoleTokenService", () => {
|
|||||||
});
|
});
|
||||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
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 {
|
export interface GuacamoleToken {
|
||||||
connection: GuacamoleConnectionSettings;
|
connection: GuacamoleConnectionSettings;
|
||||||
|
recording?: GuacamoleRecordingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleRecordingMetadata {
|
||||||
|
hostId: number;
|
||||||
|
userId: string;
|
||||||
|
protocol: "rdp" | "vnc" | "telnet";
|
||||||
|
path: string;
|
||||||
|
startedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CIPHER = "aes-256-cbc";
|
const CIPHER = "aes-256-cbc";
|
||||||
@@ -127,6 +136,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
|
recording?: GuacamoleRecordingMetadata,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -144,6 +154,7 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -156,6 +167,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
|
recording?: GuacamoleRecordingMetadata,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -171,6 +183,7 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -183,6 +196,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
|
recording?: GuacamoleRecordingMetadata,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -198,6 +212,7 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
// Stub all external imports before loading the module under test
|
// 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 });
|
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
|
||||||
|
|
||||||
vi.mock("../database/db/index.js", () => ({
|
vi.mock("../database/db/index.js", () => ({
|
||||||
@@ -29,21 +30,25 @@ vi.mock("../utils/logger.js", () => ({
|
|||||||
// Mock individual fs.promises methods via a stub object
|
// Mock individual fs.promises methods via a stub object
|
||||||
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockWriteFile = 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", () => ({
|
vi.mock("fs", () => ({
|
||||||
default: {
|
default: {
|
||||||
promises: {
|
promises: {
|
||||||
mkdir: mockMkdir,
|
mkdir: mockMkdir,
|
||||||
writeFile: mockWriteFile,
|
writeFile: mockWriteFile,
|
||||||
|
appendFile: mockAppendFile,
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
unlink: vi.fn(),
|
unlink: mockUnlink,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
promises: {
|
promises: {
|
||||||
mkdir: mockMkdir,
|
mkdir: mockMkdir,
|
||||||
writeFile: mockWriteFile,
|
writeFile: mockWriteFile,
|
||||||
|
appendFile: mockAppendFile,
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
unlink: vi.fn(),
|
unlink: mockUnlink,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -55,7 +60,8 @@ describe("TerminalSessionManager - session logging", () => {
|
|||||||
// Re-apply resolved values after clearAllMocks
|
// Re-apply resolved values after clearAllMocks
|
||||||
mockMkdir.mockResolvedValue(undefined);
|
mockMkdir.mockResolvedValue(undefined);
|
||||||
mockWriteFile.mockResolvedValue(undefined);
|
mockWriteFile.mockResolvedValue(undefined);
|
||||||
mockInsertValues.mockResolvedValue(undefined);
|
mockReturning.mockResolvedValue([{ id: 1 }]);
|
||||||
|
mockInsertValues.mockReturnValue({ returning: mockReturning });
|
||||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type Client, type ClientChannel } from "ssh2";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sessionRecordings } from "../database/db/schema.js";
|
import { sessionRecordings } from "../database/db/schema.js";
|
||||||
@@ -36,6 +37,12 @@ export interface TerminalSession {
|
|||||||
|
|
||||||
outputBuffer: string[];
|
outputBuffer: string[];
|
||||||
outputBufferBytes: number;
|
outputBufferBytes: number;
|
||||||
|
recordingPath: string | null;
|
||||||
|
recordingHeader: string | null;
|
||||||
|
recordingBytes: number;
|
||||||
|
recordingId: number | null;
|
||||||
|
recordingWriteChain: Promise<void>;
|
||||||
|
recordingPersistChain: Promise<void>;
|
||||||
tmuxSessionName: string | null;
|
tmuxSessionName: string | null;
|
||||||
sessionLoggingEnabled: boolean;
|
sessionLoggingEnabled: boolean;
|
||||||
sessionStartedAt: number;
|
sessionStartedAt: number;
|
||||||
@@ -117,6 +124,19 @@ class TerminalSessionManager {
|
|||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = Date.now();
|
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 = {
|
const session: TerminalSession = {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
@@ -135,6 +155,12 @@ class TerminalSessionManager {
|
|||||||
detachTimeout: null,
|
detachTimeout: null,
|
||||||
outputBuffer: [],
|
outputBuffer: [],
|
||||||
outputBufferBytes: 0,
|
outputBufferBytes: 0,
|
||||||
|
recordingPath,
|
||||||
|
recordingHeader,
|
||||||
|
recordingBytes: 0,
|
||||||
|
recordingId: null,
|
||||||
|
recordingWriteChain: Promise.resolve(),
|
||||||
|
recordingPersistChain: Promise.resolve(),
|
||||||
tmuxSessionName: null,
|
tmuxSessionName: null,
|
||||||
sessionLoggingEnabled,
|
sessionLoggingEnabled,
|
||||||
sessionStartedAt: now,
|
sessionStartedAt: now,
|
||||||
@@ -334,6 +360,9 @@ class TerminalSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.maybePersistLog(session, true);
|
this.maybePersistLog(session, true);
|
||||||
|
if (session.recordingPath && session.recordingBytes === 0) {
|
||||||
|
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (session.sshStream) {
|
if (session.sshStream) {
|
||||||
try {
|
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 {
|
private maybePersistLog(session: TerminalSession, force = false): void {
|
||||||
if (!session.sessionLoggingEnabled) return;
|
if (!session.sessionLoggingEnabled) return;
|
||||||
if (session.outputBufferBytes === 0) return;
|
if (session.recordingBytes === 0) return;
|
||||||
// Only save if new output arrived since last persist (unless forced)
|
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
|
||||||
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
|
session.lastPersistedBytes = session.recordingBytes;
|
||||||
return;
|
session.recordingPersistChain = session.recordingPersistChain
|
||||||
const snapshot = session.outputBuffer.join("");
|
.then(() => this.persistSessionLog(session))
|
||||||
session.lastPersistedBytes = session.outputBufferBytes;
|
.catch((err) => {
|
||||||
this.persistSessionLog(session, snapshot).catch((err) => {
|
sshLogger.warn("Failed to persist session log", {
|
||||||
sshLogger.warn("Failed to persist session log", {
|
operation: "session_log_persist_error",
|
||||||
operation: "session_log_persist_error",
|
sessionId: session.id,
|
||||||
sessionId: session.id,
|
error: err instanceof Error ? err.message : String(err),
|
||||||
error: err instanceof Error ? err.message : String(err),
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistSessionLog(
|
private async persistSessionLog(session: TerminalSession): Promise<void> {
|
||||||
session: TerminalSession,
|
if (!session.recordingPath) return;
|
||||||
logContent: string,
|
await session.recordingWriteChain;
|
||||||
): 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");
|
|
||||||
|
|
||||||
const endedAt = Date.now();
|
const endedAt = Date.now();
|
||||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
await db.insert(sessionRecordings).values({
|
if (session.recordingId == null) {
|
||||||
hostId: session.hostId,
|
const rows = await db
|
||||||
userId: session.userId,
|
.insert(sessionRecordings)
|
||||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
.values({
|
||||||
endedAt: new Date(endedAt).toISOString(),
|
hostId: session.hostId,
|
||||||
duration,
|
userId: session.userId,
|
||||||
recordingPath: logFile,
|
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) {
|
} catch (err) {
|
||||||
sshLogger.warn("Failed to insert session recording row", {
|
sshLogger.warn("Failed to insert session recording row", {
|
||||||
operation: "session_recording_insert_error",
|
operation: "session_recording_insert_error",
|
||||||
@@ -449,7 +464,7 @@ class TerminalSessionManager {
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
hostId: session.hostId,
|
hostId: session.hostId,
|
||||||
duration,
|
duration,
|
||||||
bytes: cleaned.length,
|
bytes: session.recordingBytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +492,47 @@ class TerminalSessionManager {
|
|||||||
const removed = session.outputBuffer.shift();
|
const removed = session.outputBuffer.shift();
|
||||||
if (removed) session.outputBufferBytes -= removed.length;
|
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 {
|
flushBuffer(session: TerminalSession): string | null {
|
||||||
|
|||||||
@@ -539,6 +539,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
case "input": {
|
case "input": {
|
||||||
const inputData = data as string;
|
const inputData = data as string;
|
||||||
|
if (currentSessionId) {
|
||||||
|
sessionManager.bufferInput(currentSessionId, inputData);
|
||||||
|
}
|
||||||
const inputStream =
|
const inputStream =
|
||||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||||||
if (inputStream) {
|
if (inputStream) {
|
||||||
@@ -2913,6 +2916,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (session) {
|
if (session) {
|
||||||
session.cols = data.cols;
|
session.cols = data.cols;
|
||||||
session.rows = data.rows;
|
session.rows = data.rows;
|
||||||
|
sessionManager.bufferResize(session.id, data.cols, data.rows);
|
||||||
}
|
}
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
||||||
|
|||||||
Vendored
+20
@@ -41,6 +41,26 @@ declare module "guacamole-common-js" {
|
|||||||
constructor(url: string);
|
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 {
|
class Mouse {
|
||||||
constructor(element: HTMLElement);
|
constructor(element: HTMLElement);
|
||||||
onmousedown: ((state: Mouse.State) => void) | null;
|
onmousedown: ((state: Mouse.State) => void) | null;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export type SessionLogRecord = {
|
|||||||
hostName: string | null;
|
hostName: string | null;
|
||||||
hostIp: string | null;
|
hostIp: string | null;
|
||||||
sizeBytes: number | null;
|
sizeBytes: number | null;
|
||||||
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
format: "text" | "asciicast" | "guacamole";
|
||||||
|
username: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
|
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> {
|
export async function getSessionLogContent(id: number): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/session_logs/${id}/content`, {
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Save,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
@@ -23,9 +24,13 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
getSessionLogs,
|
getSessionLogs,
|
||||||
getSessionLogContent,
|
getSessionLogContent,
|
||||||
|
getSessionLogBlob,
|
||||||
|
getSessionRecordingRetention,
|
||||||
|
setSessionRecordingRetention,
|
||||||
deleteSessionLog,
|
deleteSessionLog,
|
||||||
type SessionLogRecord,
|
type SessionLogRecord,
|
||||||
} from "@/api/session-log-api";
|
} from "@/api/session-log-api";
|
||||||
|
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
|
||||||
|
|
||||||
function formatDuration(seconds: number | null): string {
|
function formatDuration(seconds: number | null): string {
|
||||||
if (seconds == null) return "--";
|
if (seconds == null) return "--";
|
||||||
@@ -63,7 +68,13 @@ function buildFilename(log: SessionLogRecord): string {
|
|||||||
const h = String(d.getHours()).padStart(2, "0");
|
const h = String(d.getHours()).padStart(2, "0");
|
||||||
const min = String(d.getMinutes()).padStart(2, "0");
|
const min = String(d.getMinutes()).padStart(2, "0");
|
||||||
const s = String(d.getSeconds()).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 }) {
|
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||||
@@ -105,6 +116,9 @@ function LogRow({
|
|||||||
<span className="text-[10px] text-muted-foreground/60 truncate">
|
<span className="text-[10px] text-muted-foreground/60 truncate">
|
||||||
{formatDate(log.startedAt)}
|
{formatDate(log.startedAt)}
|
||||||
{" · "}
|
{" · "}
|
||||||
|
{log.protocol.toUpperCase()}
|
||||||
|
{log.username ? ` · ${log.username}` : ""}
|
||||||
|
{" · "}
|
||||||
{formatDuration(log.duration)}
|
{formatDuration(log.duration)}
|
||||||
{" · "}
|
{" · "}
|
||||||
{formatBytes(log.sizeBytes)}
|
{formatBytes(log.sizeBytes)}
|
||||||
@@ -159,12 +173,15 @@ export function SessionLogsPanel() {
|
|||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
|
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
|
||||||
const [viewContent, setViewContent] = useState<string>("");
|
const [viewContent, setViewContent] = useState<string>("");
|
||||||
|
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
|
||||||
const [viewLoading, setViewLoading] = useState(false);
|
const [viewLoading, setViewLoading] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
|
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [retentionDays, setRetentionDays] = useState<number | null>(null);
|
||||||
|
const [savingRetention, setSavingRetention] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(initial = false) => {
|
(initial = false) => {
|
||||||
@@ -192,6 +209,9 @@ export function SessionLogsPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(true);
|
load(true);
|
||||||
|
getSessionRecordingRetention()
|
||||||
|
.then(setRetentionDays)
|
||||||
|
.catch(() => setRetentionDays(null));
|
||||||
const interval = setInterval(() => load(false), 5000);
|
const interval = setInterval(() => load(false), 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -199,17 +219,23 @@ export function SessionLogsPanel() {
|
|||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
const filtered = q
|
const filtered = q
|
||||||
? logs.filter((l) =>
|
? logs.filter((l) =>
|
||||||
(l.hostName ?? l.hostIp ?? "").toLowerCase().includes(q),
|
`${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q),
|
||||||
)
|
)
|
||||||
: logs;
|
: logs;
|
||||||
|
|
||||||
const handleView = async (log: SessionLogRecord) => {
|
const handleView = async (log: SessionLogRecord) => {
|
||||||
setViewLog(log);
|
setViewLog(log);
|
||||||
setViewContent("");
|
setViewContent("");
|
||||||
|
setViewBlob(null);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try {
|
try {
|
||||||
const content = await getSessionLogContent(log.id);
|
if (log.format === "text") {
|
||||||
setViewContent(content);
|
setViewContent(await getSessionLogContent(log.id));
|
||||||
|
} else {
|
||||||
|
setViewBlob(await getSessionLogBlob(log.id));
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("sessionLogs.loadError"));
|
toast.error(t("sessionLogs.loadError"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -219,8 +245,7 @@ export function SessionLogsPanel() {
|
|||||||
|
|
||||||
const handleDownload = async (log: SessionLogRecord) => {
|
const handleDownload = async (log: SessionLogRecord) => {
|
||||||
try {
|
try {
|
||||||
const content = await getSessionLogContent(log.id);
|
const blob = await getSessionLogBlob(log.id);
|
||||||
const blob = new Blob([content], { type: "text/plain" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -285,6 +310,7 @@ export function SessionLogsPanel() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
{formatDate(viewLog.startedAt)}
|
{formatDate(viewLog.startedAt)}
|
||||||
|
{` · ${viewLog.protocol.toUpperCase()}`}
|
||||||
{viewLog.duration != null
|
{viewLog.duration != null
|
||||||
? ` · ${formatDuration(viewLog.duration)}`
|
? ` · ${formatDuration(viewLog.duration)}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -294,25 +320,27 @@ export function SessionLogsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
<Tooltip>
|
{viewLog.format === "text" && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<button
|
<TooltipTrigger asChild>
|
||||||
onClick={handleCopy}
|
<button
|
||||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
onClick={handleCopy}
|
||||||
>
|
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||||
{copied ? (
|
>
|
||||||
<Check className="size-3 text-green-500" />
|
{copied ? (
|
||||||
) : (
|
<Check className="size-3 text-green-500" />
|
||||||
<Copy className="size-3" />
|
) : (
|
||||||
)}
|
<Copy className="size-3" />
|
||||||
</button>
|
)}
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent side="left">
|
</TooltipTrigger>
|
||||||
{copied
|
<TooltipContent side="left">
|
||||||
? t("sessionLogs.copied")
|
{copied
|
||||||
: t("sessionLogs.copyContent")}
|
? t("sessionLogs.copied")
|
||||||
</TooltipContent>
|
: t("sessionLogs.copyContent")}
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -336,6 +364,8 @@ export function SessionLogsPanel() {
|
|||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</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">
|
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
|
||||||
{viewContent || "(empty)"}
|
{viewContent || "(empty)"}
|
||||||
@@ -349,6 +379,44 @@ export function SessionLogsPanel() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<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 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
|
<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">
|
<div className="size-10 bg-muted/40 flex items-center justify-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user