mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: turn session recording into a plugin (B13)
This commit is contained in:
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];
|
||||
Reference in new issue
Block a user