feat: turn session recording into a plugin (B13)

This commit is contained in:
LukeGus committed 2026-09-24 00:09:48 -05:00
1 parent a276f6898c
commit 0af2c94983
147 files changed
+25915 -2074

No files matched your search

@@ -0,0 +1,42 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { sessionRecordings } from "./tables.js";
import { createSessionRecordingRepository } from "./repository.js";
import { createRecordingsWriter, type RecordingsWriterV1 } from "./service.js";
import { registerSessionRecordingRoutes } from "./routes.js";
import { startRetentionSweep } from "./retention.js";
export type { RecordingsWriterV1, RecordingSink } from "./service.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(sessionRecordings);
const repository = createSessionRecordingRepository(ctx.db, table, ctx.hosts);
ctx.http
.router<Router>()
.use(
registerSessionRecordingRoutes(ctx, repository, () =>
ctx.files.dataDir(),
),
);
ctx.services.provide<RecordingsWriterV1>(
"recordings.writer",
createRecordingsWriter(ctx, repository),
);
// A recording is evidence about the host as much as the person, so it
// outlives the account: the row's userId is cleared rather than cascading.
ctx.events.on("user.deleted", (payload) => {
const { userId } = payload as { userId: string };
void repository.anonymizeByUserId(userId);
});
startRetentionSweep(ctx, repository, () => ctx.files.dataDir());
ctx.log.info(
"Session Recording routes mounted at /plugin-api/session-recording",
);
}
export async function deactivate() {}
@@ -0,0 +1,240 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import type { PluginDatabase, PluginHosts } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface SessionRecordingRecord {
id: number;
hostId: number;
userId: string | null;
username: string | null;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
terminatedByOwner: boolean | null;
terminationReason: string | null;
}
export interface SessionRecordingListRecord {
id: number;
hostId: number;
userId: string | null;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
username: string | null;
hostName: string | null;
hostIp: string | null;
}
export interface SessionRecordingCreateInput {
hostId: number;
userId: string;
username?: string | null;
startedAt: string;
endedAt?: string | null;
duration?: number | null;
recordingPath?: string | null;
protocol?: string | null;
format?: string | null;
terminatedByOwner?: boolean | null;
terminationReason?: string | null;
}
export type SessionRecordingRepository = ReturnType<
typeof createSessionRecordingRepository
>;
/**
* Written without RETURNING so it runs the same on all three engines: an
* insert is read back by a marker key it was given, an update by its id.
*/
export function createSessionRecordingRepository(
db: PluginDatabase,
table: Table,
hosts: PluginHosts,
) {
const client = () => db.client<Drizzle>();
async function usernameFor(userId: string): Promise<string | null> {
const { users } = await db.refs<{ users: Table }>();
const drizzle = await client();
const rows = await drizzle
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId));
return rows[0]?.username ?? null;
}
async function withHostNames(
rows: SessionRecordingListRecord[],
): Promise<SessionRecordingListRecord[]> {
const list = await hosts.list();
const byId = new Map(list.map((host) => [host.id, host]));
return rows.map((row) => {
const host = byId.get(row.hostId);
return { ...row, hostName: host?.name ?? null, hostIp: host?.ip ?? null };
});
}
return {
usernameFor,
/**
* Inserts a row and returns it. recordingPath is the caller's read-back
* key: every caller sets it to something unique (the file path or a
* session id) before insert, since MySQL has no RETURNING to read a new
* id back with.
*/
async create(
input: SessionRecordingCreateInput & { recordingPath: string },
): Promise<SessionRecordingRecord> {
const drizzle = await client();
await drizzle.insert(table).values(input);
const rows = await drizzle
.select()
.from(table)
.where(eq(table.recordingPath, input.recordingPath))
.orderBy(desc(table.id))
.limit(1);
await db.persist();
return rows[0];
},
async updateEnded(
id: number,
input: {
endedAt: string;
duration: number | null;
terminatedByOwner?: boolean;
terminationReason?: string | null;
},
): Promise<void> {
const drizzle = await client();
await drizzle.update(table).set(input).where(eq(table.id, id));
await db.persist();
},
async findByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return rows[0] ?? null;
},
async findPathByIdForUser(
userId: string,
id: number,
): Promise<{
id: number;
recordingPath: string | null;
format: string | null;
} | null> {
const drizzle = await client();
const rows = await drizzle
.select({
id: table.id,
recordingPath: table.recordingPath,
format: table.format,
})
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return rows[0] ?? null;
},
async listByUserIdWithHost(
userId: string,
): Promise<SessionRecordingListRecord[]> {
const drizzle = await client();
const rows = (await drizzle
.select()
.from(table)
.where(eq(table.userId, userId))
.orderBy(desc(table.startedAt))) as SessionRecordingListRecord[];
return withHostNames(rows);
},
async listPathsOlderThan(
cutoff: string,
): Promise<Array<{ id: number; recordingPath: string | null }>> {
const drizzle = await client();
return drizzle
.select({ id: table.id, recordingPath: table.recordingPath })
.from(table)
.where(lt(table.startedAt, cutoff));
},
async deleteForUser(userId: string, id: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: table.id })
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
if (existing.length === 0) return false;
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return true;
},
async deleteById(id: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: table.id })
.from(table)
.where(eq(table.id, id))
.limit(1);
if (existing.length === 0) return false;
await drizzle.delete(table).where(eq(table.id, id));
await db.persist();
return true;
},
async deleteByHostId(hostId: number): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.hostId, hostId));
await db.persist();
},
async deleteByHostIds(hostIds: number[]): Promise<void> {
if (hostIds.length === 0) return;
const drizzle = await client();
await drizzle.delete(table).where(inArray(table.hostId, hostIds));
await db.persist();
},
/**
* Detaches recordings from a user being deleted instead of removing them.
* A recording is evidence about the host as much as about the person, and
* the file stays on disk regardless of the row.
*/
async anonymizeByUserId(userId: string): Promise<void> {
const drizzle = await client();
await drizzle
.update(table)
.set({ userId: null })
.where(eq(table.userId, userId));
await db.persist();
},
};
}
@@ -0,0 +1,64 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
const DEFAULT_RETENTION_DAYS = 30;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
async function getRetentionDays(ctx: PluginContext): Promise<number> {
const configured = await ctx.settings.get<number>("retentionDays");
if (typeof configured === "number" && configured >= 1 && configured <= 3650) {
return configured;
}
return DEFAULT_RETENTION_DAYS;
}
async function pruneOldRecordings(
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): Promise<void> {
try {
const retentionDays = await getRetentionDays(ctx);
const cutoff = new Date(
Date.now() - retentionDays * 24 * 60 * 60 * 1000,
).toISOString();
const old = await repository.listPathsOlderThan(cutoff);
if (old.length === 0) return;
const base = `${path.resolve(await dataDir())}${path.sep}`;
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
if (resolved.startsWith(base)) {
await fs.unlink(resolved).catch(() => {});
}
}
await repository.deleteById(row.id);
}
ctx.log.info(`Pruned ${old.length} old session recording(s)`);
} catch (error) {
ctx.log.warn(
`Failed to prune old session recordings: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/** Runs the retention sweep at startup and every 24 hours. */
export function startRetentionSweep(
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): void {
void pruneOldRecordings(ctx, repository, dataDir);
const timer = setInterval(
() => void pruneOldRecordings(ctx, repository, dataDir),
PRUNE_INTERVAL_MS,
);
timer.unref?.();
ctx.disposables.add(() => clearInterval(timer));
}
@@ -0,0 +1,253 @@
import fs from "node:fs";
import path from "node:path";
import express, { type Request, type Response, type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
function actorId(ctx: PluginContext): string | undefined {
return ctx.currentActor();
}
/**
* @openapi
* /plugin-api/session-recording/:
* get:
* summary: List session recordings
* description: Returns every terminal session recording for the authenticated user.
* tags:
* - Session Recording
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session recordings.
*/
function registerList(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
): void {
router.get("/", async (_req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
try {
const rows = await repository.listByUserIdWithHost(userId);
const records = rows.map((row) => {
let sizeBytes: number | null = null;
if (row.recordingPath) {
try {
sizeBytes = fs.statSync(row.recordingPath).size;
} catch {
// file may have been removed
}
}
return { ...row, sizeBytes };
});
res.json({ logs: records });
} catch (error) {
ctx.log.error(
"Failed to fetch session recordings",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to fetch session recordings" });
}
});
}
/**
* @openapi
* /plugin-api/session-recording/{id}:
* get:
* summary: Get session recording metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Recording
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 404:
* description: Session recording not found.
* 500:
* description: Failed to fetch session recording.
*/
function registerGet(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
): void {
router.get("/:id", async (req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findByIdForUser(userId, id);
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
ctx.log.error(
"Failed to fetch session recording",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to fetch session recording" });
}
});
}
/**
* @openapi
* /plugin-api/session-recording/{id}/content:
* get:
* summary: Get session recording content
* description: Returns the raw content of a session recording file.
* tags:
* - Session Recording
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw recording content.
* 404:
* description: Session recording or file not found.
* 500:
* description: Failed to read session recording.
*/
function registerContent(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): void {
router.get("/:id/content", async (req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findPathByIdForUser(userId, id);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
const base = `${path.resolve(await dataDir())}${path.sep}`;
if (!resolvedPath.startsWith(base)) {
return res.status(403).json({ error: "Forbidden" });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath);
const format =
row.format ?? (filePath.endsWith(".cast") ? "asciicast" : "text");
const contentType =
format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
} catch (error) {
ctx.log.error(
"Failed to read session recording content",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to read session recording" });
}
});
}
/**
* @openapi
* /plugin-api/session-recording/{id}:
* delete:
* summary: Delete session recording
* description: Deletes a session recording and its file.
* tags:
* - Session Recording
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording deleted.
* 404:
* description: Session recording not found.
* 500:
* description: Failed to delete session recording.
*/
function registerDelete(
router: Router,
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): void {
router.delete("/:id", async (req: Request, res: Response) => {
const userId = actorId(ctx);
if (!userId) return res.status(401).json({ error: "Not authenticated" });
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const row = await repository.findPathByIdForUser(userId, id);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
const deleted = await repository.deleteForUser(userId, id);
if (!deleted) return res.status(404).json({ error: "Not found" });
if (filePath) {
const resolvedPath = path.resolve(filePath);
const base = `${path.resolve(await dataDir())}${path.sep}`;
if (resolvedPath.startsWith(base) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
ctx.log.error(
"Failed to delete session recording",
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: "Failed to delete session recording" });
}
});
}
export function registerSessionRecordingRoutes(
ctx: PluginContext,
repository: SessionRecordingRepository,
dataDir: () => Promise<string>,
): Router {
const router = express.Router();
registerList(router, ctx, repository);
registerGet(router, ctx, repository);
registerContent(router, ctx, repository, dataDir);
registerDelete(router, ctx, repository, dataDir);
return router;
}
@@ -0,0 +1,116 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
/**
* recordings.writer v1, the service ssh-terminal calls to record sessions.
*
* One call per already-batched chunk (ssh-terminal coalesces on a 300ms
* trailing edge, issue #1049): mkdir and writeFile on the first append,
* appendFile after that, never per chunk.
*/
export interface RecordingSink {
append: (chunk: string) => Promise<void>;
persist: (summary: {
endedAt: number;
durationSeconds: number;
terminatedByOwner: boolean;
terminationReason: string | null;
}) => Promise<void>;
discard: () => void;
}
export interface RecordingsWriterV1 {
/** Null when recording is off for this user or host. */
open: (meta: {
sessionId: string;
hostId: number;
userId: string;
protocol: "ssh";
format: "asciicast";
startedAt: number;
}) => Promise<RecordingSink | null>;
/**
* Inserts a row for a recording a caller already wrote to disk itself
* (remote desktop's guacd recordings), rather than streaming through
* open()/append(). The caller already decided to record; this only writes
* the row.
*/
createFinished: (input: {
hostId: number;
userId: string;
startedAt: string;
endedAt: string;
duration: number | null;
recordingPath: string;
protocol: string;
format: string;
}) => Promise<{ id: number }>;
}
export function createRecordingsWriter(
ctx: PluginContext,
repository: SessionRecordingRepository,
): RecordingsWriterV1 {
return {
async open(meta) {
const enabled = await ctx.settings.getHost<boolean>(
meta.hostId,
"enableSessionRecording",
);
if (enabled === false) return null;
const dataDir = await ctx.files.dataDir();
const dir = path.join(dataDir, "session_logs", meta.userId);
const filePath = path.join(dir, `${meta.sessionId}.cast`);
let wroteFirst = false;
let discarded = false;
const username = await repository.usernameFor(meta.userId);
const row = await repository.create({
hostId: meta.hostId,
userId: meta.userId,
username,
startedAt: new Date(meta.startedAt).toISOString(),
recordingPath: filePath,
protocol: meta.protocol,
format: meta.format,
});
return {
async append(chunk: string) {
if (discarded) return;
if (!wroteFirst) {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, chunk);
wroteFirst = true;
} else {
await fs.appendFile(filePath, chunk);
}
},
async persist(summary) {
if (discarded) return;
await repository.updateEnded(row.id, {
endedAt: new Date(summary.endedAt).toISOString(),
duration: summary.durationSeconds,
terminatedByOwner: summary.terminatedByOwner,
terminationReason: summary.terminationReason,
});
},
discard() {
discarded = true;
void repository.deleteById(row.id).catch(() => {});
},
};
},
async createFinished(input) {
const username = await repository.usernameFor(input.userId);
const row = await repository.create({ ...input, username });
return { id: row.id };
},
};
}
@@ -0,0 +1,52 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refHost,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* userId is a plain column, not refUser(): a recording is evidence about the
* host as much as the person, so it outlives the account. The plugin listens
* for user.deleted and clears userId itself instead of cascading. username
* keeps an anonymized row attributable.
*/
export const sessionRecordings = adoptLegacyTable(
"session_recordings",
defineTable(
"session_recordings",
{
id: id(),
hostId: refHost(),
userId: varchar(),
username: text(),
accessId: integer(),
startedAt: timestamp().notNull().defaultNow(),
endedAt: timestamp(),
duration: integer(),
commands: text(),
dangerousActions: text(),
recordingPath: text(),
protocol: text().notNull().default("ssh"),
format: text().notNull().default("text"),
terminatedByOwner: boolean().default(false),
terminationReason: text(),
},
{
indexes: [
{
name: "idx_session_recordings_user_started",
columns: ["userId", "startedAt"],
},
{ name: "idx_session_recordings_host", columns: ["hostId"] },
],
},
),
);
export const tables = [sessionRecordings];