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,128 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export function host(
overrides: Partial<PluginHostSummary> = {},
): PluginHostSummary {
return {
id: 7,
userId: "user-1",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
requestBinary: (
method: string,
path: string,
options?: { user?: string },
) => Promise<{ status: number; body: ArrayBuffer; contentType: string }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
permissions?: string[];
users?: string[];
hosts?: PluginHostSummary[];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
for (const h of options.hosts ?? [host()]) {
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(h.id);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["session-recording.view"],
hosts: options.hosts ?? [host()],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async requestBinary(method, path, { user = "user-1" } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: { "x-test-user": user },
});
return {
status: response.status,
body: await response.arrayBuffer(),
contentType: response.headers.get("content-type") ?? "",
};
},
async close() {
for (const dispose of [...mock.disposals].reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// session_recordings as core's SQLite bootstrap created it before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT,
username TEXT,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting session_recordings", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO session_recordings (host_id, user_id, username, recording_path, protocol, format) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(
7,
"user-1",
"user-1",
"/data/session_logs/user-1/a.cast",
"ssh",
"asciicast",
);
},
});
expect(db.applied).toEqual(["0001_adopt_session_recordings"]);
expect(tableExists("session_recordings")).toBe(false);
expect(tableExists("p_session_recording_session_recordings")).toBe(true);
expect(
db.sqlite
.prepare(
"SELECT host_id, user_id, recording_path, protocol, format FROM p_session_recording_session_recordings",
)
.all(),
).toEqual([
{
host_id: 7,
user_id: "user-1",
recording_path: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
},
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("session_recordings")).toBe(false);
expect(tableExists("p_session_recording_session_recordings")).toBe(true);
expect(
db.sqlite
.prepare("SELECT * FROM p_session_recording_session_recordings")
.all(),
).toEqual([]);
});
it("cascades on host delete", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_session_recording_session_recordings (host_id, user_id, protocol, format) VALUES (?, ?, ?, ?)",
)
.run(7, "user-1", "ssh", "asciicast");
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 7");
expect(
db.sqlite
.prepare("SELECT * FROM p_session_recording_session_recordings")
.all(),
).toEqual([]);
});
it("keeps a row whose user was deleted, with userId cleared", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_session_recording_session_recordings (host_id, user_id, protocol, format) VALUES (?, ?, ?, ?)",
)
.run(7, "user-1", "ssh", "asciicast");
// userId is a plain column, not refUser(): deleting the user must not
// cascade delete the row (the plugin clears userId itself on
// user.deleted instead).
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
expect(
db.sqlite
.prepare("SELECT host_id FROM p_session_recording_session_recordings")
.all(),
).toEqual([{ host_id: 7 }]);
});
});
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
import { createSessionRecordingRepository } from "../../src/backend/repository";
import { sessionRecordings } from "../../src/backend/tables";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const fakeHosts = {
list: async () => [
{
id: 7,
userId: "user-1",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
},
],
get: async () => null,
checkAccess: async () => ({
hasAccess: false,
isOwner: false,
isShared: false,
}),
create: async () => {
throw new Error("not used");
},
update: async () => null,
listOwned: async () => [],
share: async () => ({ hostId: 0, shared: false }),
listUsers: async () => [],
listRoles: async () => [],
trackSession: () => () => {},
recordActivity: async () => {},
};
async function setup() {
db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("user-1", "alice");
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
const table = await db.database.define(sessionRecordings);
return createSessionRecordingRepository(db.database, table, fakeHosts);
}
describe("session recording repository", () => {
it("creates a row and reads it back by recordingPath", async () => {
const repo = await setup();
const row = await repo.create({
hostId: 7,
userId: "user-1",
username: "alice",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
expect(row.id).toBeGreaterThan(0);
expect(row.recordingPath).toBe("/data/session_logs/user-1/a.cast");
expect(db!.persisted).toBeGreaterThan(0);
});
it("lists a user's recordings with host name and ip attached", async () => {
const repo = await setup();
await repo.create({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
const rows = await repo.listByUserIdWithHost("user-1");
expect(rows).toHaveLength(1);
expect(rows[0].hostName).toBe("web");
expect(rows[0].hostIp).toBe("10.0.0.5");
});
it("finds and deletes only the owner's row", async () => {
const repo = await setup();
const row = await repo.create({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
expect(await repo.findByIdForUser("user-2", row.id)).toBeNull();
expect(await repo.deleteForUser("user-2", row.id)).toBe(false);
expect(await repo.deleteForUser("user-1", row.id)).toBe(true);
expect(await repo.findByIdForUser("user-1", row.id)).toBeNull();
});
it("anonymizes a user's rows instead of deleting them", async () => {
const repo = await setup();
const row = await repo.create({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
recordingPath: "/data/session_logs/user-1/a.cast",
protocol: "ssh",
format: "asciicast",
});
await repo.anonymizeByUserId("user-1");
const rows = await repo.listPathsOlderThan(
new Date(Date.now() + 60_000).toISOString(),
);
expect(rows.map((r) => r.id)).toContain(row.id);
expect(await repo.findByIdForUser("user-1", row.id)).toBeNull();
});
it("lists recordings older than a cutoff for retention pruning", async () => {
const repo = await setup();
await repo.create({
hostId: 7,
userId: "user-1",
startedAt: "2020-01-01T00:00:00.000Z",
recordingPath: "/data/session_logs/user-1/old.cast",
protocol: "ssh",
format: "asciicast",
});
const old = await repo.listPathsOlderThan("2021-01-01T00:00:00.000Z");
expect(old).toHaveLength(1);
const none = await repo.listPathsOlderThan("2019-01-01T00:00:00.000Z");
expect(none).toHaveLength(0);
});
});
@@ -0,0 +1,179 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
let dataDir: string;
beforeEach(async () => {
dataDir = await fs.mkdtemp(
path.join(os.tmpdir(), "session-recording-routes-"),
);
});
afterEach(async () => {
await server?.close();
server = null;
await fs.rm(dataDir, { recursive: true, force: true });
});
describe("session recording routes", () => {
it("401s without an authenticated actor", async () => {
server = await startServer();
const res = await server.request("GET", "/", { user: "" });
expect(res.status).toBe(401);
});
it("lists only the caller's own recordings", async () => {
server = await startServer();
const table = await server.mock.ctx.db.client<{
insert: (t: unknown) => {
values: (v: unknown) => Promise<unknown>;
};
}>();
void table;
// Seed through the repository the way activate() wired it: easiest is a
// direct HTTP round trip isn't available for writes (only ssh-terminal
// and remote-desktop write through the service), so seed the table
// directly.
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-1",
"/tmp/a.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-2",
"/tmp/b.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
const res = await server.request("GET", "/", { user: "user-1" });
expect(res.status).toBe(200);
expect(res.body.logs).toHaveLength(1);
expect(res.body.logs[0].userId).toBe("user-1");
});
it("404s getting another user's recording", async () => {
server = await startServer();
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-2",
"/tmp/b.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
const res = await server.request("GET", "/1", { user: "user-1" });
expect(res.status).toBe(404);
});
it("serves file content within the plugin's data directory", async () => {
server = await startServer();
const filePath = path.join(dataDir, "session_logs", "user-1", "a.cast");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{"version":2}\n[0,"o","hi"]\n');
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(7, "user-1", filePath, "ssh", "asciicast", new Date().toISOString());
// Point ctx.files.dataDir() at the temp dir for this request.
(
server.mock.ctx as unknown as {
files: { dataDir: () => Promise<string> };
}
).files = { dataDir: async () => dataDir };
const res = await server.requestBinary("GET", "/1/content", {
user: "user-1",
});
expect(res.status).toBe(200);
expect(res.contentType).toContain("asciicast");
});
it("refuses a path outside the plugin's data directory", async () => {
server = await startServer();
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-1",
"/etc/passwd",
"ssh",
"asciicast",
new Date().toISOString(),
);
(
server.mock.ctx as unknown as {
files: { dataDir: () => Promise<string> };
}
).files = { dataDir: async () => dataDir };
const res = await server.requestBinary("GET", "/1/content", {
user: "user-1",
});
expect(res.status).toBe(403);
});
it("deletes only the caller's own recording", async () => {
server = await startServer();
server.db.sqlite
.prepare(
`INSERT INTO p_session_recording_session_recordings
(host_id, user_id, recording_path, protocol, format, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
7,
"user-1",
"/tmp/a.cast",
"ssh",
"asciicast",
new Date().toISOString(),
);
const denied = await server.request("DELETE", "/1", { user: "user-2" });
expect(denied.status).toBe(404);
const ok = await server.request("DELETE", "/1", { user: "user-1" });
expect(ok.status).toBe(200);
});
});
@@ -0,0 +1,174 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import manifestJson from "../../manifest.json";
import { pluginDir } from "./helpers";
import { createSessionRecordingRepository } from "../../src/backend/repository";
import { sessionRecordings } from "../../src/backend/tables";
import { createRecordingsWriter } from "../../src/backend/service";
const manifest =
manifestJson as unknown as import("@termix/plugin-sdk/manifest").PluginManifest;
let db: TestDb | null = null;
let dataDir: string;
beforeEach(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-recording-"));
});
afterEach(async () => {
db?.close();
db = null;
await fs.rm(dataDir, { recursive: true, force: true });
});
async function setup(): Promise<{
mock: MockPluginContext;
writer: ReturnType<typeof createRecordingsWriter>;
}> {
db = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("user-1", "alice");
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
const table = await db.database.define(sessionRecordings);
const hosts = {
list: async () => [],
get: async () => null,
checkAccess: async () => ({
hasAccess: false,
isOwner: false,
isShared: false,
}),
create: async () => {
throw new Error("not used");
},
update: async () => null,
listOwned: async () => [],
share: async () => ({ hostId: 0, shared: false }),
listUsers: async () => [],
listRoles: async () => [],
trackSession: () => () => {},
recordActivity: async () => {},
};
const repo = createSessionRecordingRepository(db.database, table, hosts);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
});
// Point ctx.files.dataDir() at a real temp dir for this test.
(mock.ctx as unknown as { files: { dataDir: () => Promise<string> } }).files =
{
dataDir: async () => dataDir,
};
const writer = createRecordingsWriter(mock.ctx, repo);
return { mock, writer };
}
describe("recordings.writer", () => {
it("is null when the host has recording switched off", async () => {
const { mock, writer } = await setup();
await mock.ctx.settings.setHost(7, "enableSessionRecording", false);
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
expect(sink).toBeNull();
});
it("writes the first append with writeFile and later ones with appendFile", async () => {
const { writer } = await setup();
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
expect(sink).not.toBeNull();
await sink!.append("chunk-one\n");
await sink!.append("chunk-two\n");
const filePath = path.join(dataDir, "session_logs", "user-1", "s1.cast");
const content = await fs.readFile(filePath, "utf-8");
expect(content).toBe("chunk-one\nchunk-two\n");
});
it("persist writes the summary onto the row", async () => {
const { writer } = await setup();
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
await sink!.append("x\n");
const endedAt = Date.now();
await sink!.persist({
endedAt,
durationSeconds: 42,
terminatedByOwner: true,
terminationReason: null,
});
// Not thrown means it wrote successfully; deeper row assertions belong
// to repository.test.ts.
});
it("discard removes the row and further writes are no-ops", async () => {
const { writer } = await setup();
const sink = await writer.open({
sessionId: "s1",
hostId: 7,
userId: "user-1",
protocol: "ssh",
format: "asciicast",
startedAt: Date.now(),
});
sink!.discard();
await sink!.append("should not write");
const filePath = path.join(dataDir, "session_logs", "user-1", "s1.cast");
await expect(fs.access(filePath)).rejects.toThrow();
});
it("createFinished inserts a row for an already-written recording", async () => {
const { writer } = await setup();
const row = await writer.createFinished({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
duration: 10,
recordingPath: "/data/session_recordings/guacamole/x.guac",
protocol: "rdp",
format: "guacamole",
});
expect(row.id).toBeGreaterThan(0);
});
});
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
return {
get: vi.fn(async () => ({ data: { logs: [] } })),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
...overrides,
} as PluginApiClient;
}
describe(`${manifest.id} activate`, () => {
it("registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
expect(rendered.registered.tabs()).toEqual(["session-logs"]);
expect(rendered.registered.panels()).toEqual(["session-logs"]);
expect(rendered.registered.railItems()).toEqual([
expect.objectContaining({ id: "session-logs" }),
]);
expect(rendered.registered.slot("terminal.toolbarStatus")).toEqual([
"session-recording.terminalStatus",
]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
});
it("renders the panel without crashing", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
const element = rendered.renderPanel("session-logs");
expect(element).toBeTruthy();
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseAsciicast } from "../../src/frontend/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",
);
});
});