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

+2 -9
View File
@@ -519,17 +519,10 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location /session_logs/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved # File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved
# to the file-manager plugin, served under /plugin-api/file-manager/. # to the file-manager plugin, served under /plugin-api/file-manager/.
# Session logs moved to the session-recording plugin, served under
# /plugin-api/session-recording/.
location /health { location /health {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
+2 -9
View File
@@ -497,17 +497,10 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location /session_logs/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
# File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved # File manager routes (recent/pinned/shortcuts, SFTP, transfers) moved
# to the file-manager plugin, served under /plugin-api/file-manager/. # to the file-manager plugin, served under /plugin-api/file-manager/.
# Session logs moved to the session-recording plugin, served under
# /plugin-api/session-recording/.
location /health { location /health {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
+5
View File
@@ -0,0 +1,5 @@
-- session_recordings moved to the session-recording plugin, whose first
-- migration renames it. Dropping it here would lose every recording, so
-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -288,6 +288,13 @@
"when": 1790224430728, "when": 1790224430728,
"tag": "0040_misty_skin", "tag": "0040_misty_skin",
"breakpoints": true "breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1790225600497,
"tag": "0041_wonderful_cargill",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,5 @@
-- session_recordings moved to the session-recording plugin, whose first
-- migration renames it. Dropping it here would lose every recording, so
-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -295,6 +295,13 @@
"when": 1790224430003, "when": 1790224430003,
"tag": "0041_watery_zeigeist", "tag": "0041_watery_zeigeist",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1790225599928,
"tag": "0042_strange_shocker",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -0,0 +1,5 @@
-- session_recordings moved to the session-recording plugin, whose first
-- migration renames it. Dropping it here would lose every recording, so
-- this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -267,6 +267,13 @@
"when": 1790224429343, "when": 1790224429343,
"tag": "0037_burly_enchantress", "tag": "0037_burly_enchantress",
"breakpoints": true "breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1790225599344,
"tag": "0038_broad_avengers",
"breakpoints": true
} }
] ]
} }
+30 -4
View File
@@ -118,7 +118,11 @@ Plugins own their tables. A plugin declares them with `defineTable()` from
`@termix/plugin-sdk/db` and ships migrations for `sqlite`, `postgres` and `@termix/plugin-sdk/db` and ships migrations for `sqlite`, `postgres` and
`mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed `mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed
`p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small `p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small
key/value state. Core `schema.ts` ends up holding only core tables. key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a
per-plugin folder under `DATA_DIR`, created on first call, for state too
large for `ctx.kv` (session recordings, uploaded files); a plugin lays out
its own subdirectories underneath it. Core `schema.ts` ends up holding only
core tables.
**One table object, three DDL emitters.** The sqlite-core definitions already **One table object, three DDL emitters.** The sqlite-core definitions already
encode correctly on every engine at query time, which is why core's own 44 encode correctly on every engine at query time, which is why core's own 44
@@ -608,6 +612,22 @@ the asciicast formatting and the 300 ms batched flush (issue #1049) and hands
the provider one `append` per batch. Service names need a dot, so "tmux" and the provider one `append` per batch. Service names need a dot, so "tmux" and
"recordings" became `tmux.sessions` and `recordings.writer`. "recordings" became `tmux.sessions` and `recordings.writer`.
**B13** is the session-recording plugin, which provides `recordings.writer`.
It adopts `session_recordings`, keeping `userId` a plain column rather than
`refUser()` (a recording is evidence about the host as much as the person,
so it outlives the account) and clearing it on the `user.deleted` event
instead of cascading. `open(meta)` returns null when the host's
`enableSessionRecording` setting is off; otherwise a `RecordingSink` with
`append(chunk)` (one call per already-batched chunk, so it never writes
per-chunk itself), `persist(summary)` and `discard()` for a session that
recorded nothing. `createFinished(input)` is the second half of the
contract, for a caller that already wrote its own recording to disk and only
needs the database row: remote-desktop's guacd recordings use it instead of
importing the old repository directly, and without the plugin enabled a
guacd recording still lands on disk but gets no row. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
A service call runs a permission check for its actor, so a share-link guest, A service call runs a permission check for its actor, so a share-link guest,
who has no user, cannot make one. Guest link resolution is therefore who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
@@ -1161,6 +1181,7 @@ deciding, not the mechanism.
| `hosts:read` | low | See the host list it can already see | | `hosts:read` | low | See the host list it can already see |
| `db:own` | low | Own tables | | `db:own` | low | Own tables |
| `kv:own` | low | Small key/value state | | `kv:own` | low | Small key/value state |
| `files:own` | low | Its own files on disk |
| `secrets:own` | low | Its own encrypted secrets | | `secrets:own` | low | Its own encrypted secrets |
| `settings:read-core` | low | Read core server settings | | `settings:read-core` | low | Read core server settings |
| `ui:surface` | low | Contribute tabs, panels and settings | | `ui:surface` | low | Contribute tabs, panels and settings |
@@ -1190,6 +1211,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.log.*` | none | **A1** | | `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | | `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | | `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** | | `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** | | `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | | `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
@@ -1668,9 +1690,13 @@ upgrade on all three engines, and the checks in step 15 are green.
- `src/backend/database/routes/delete-user-data.ts` and the user/host - `src/backend/database/routes/delete-user-data.ts` and the user/host
repositories: explicit deletes of the table. A `refUser`/`refHost` column repositories: explicit deletes of the table. A `refUser`/`refHost` column
cascades on every engine, because the foreign key survives the rename. cascades on every engine, because the foreign key survives the rename.
Prefer that cascade. A table that cannot carry such a column needs a Prefer that cascade. A table that cannot carry such a column (evidence
core "user deleted" or "host deleted" event on `ctx.events`, added to that has to outlive the account, like a recording) subscribes to
the SDK the step 3 way; none exists yet because no plugin needed one. `ctx.events.on("user.deleted", ...)` or `"host.deleted"` instead, added
to the SDK the step 3 way; **B13** added `user.deleted`
(`TOPICS.userDeleted` in `src/backend/plugins/events.ts`), emitted from
`deleteUserAndRelatedData` right where the old direct repository call
used to sit. `host.deleted` already existed (B7).
- `sync-entities.ts`, if core synced it (step 9). - `sync-entities.ts`, if core synced it (step 9).
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including - Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
core tests and `src/types`. core tests and `src/types`.
+39
View File
@@ -282,6 +282,40 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
desktop toolbars now; the tab bar's share button is gone, so a host with the desktop toolbars now; the tab bar's share button is gone, so a host with the
terminal toolbar switched off has no share button. A generic tab-menu slot terminal toolbar switched off has no share button. A generic tab-menu slot
would bring it back if that matters. Owner: D1, if wanted. would bring it back if that matters. Owner: D1, if wanted.
- **B13 (session-recording):** `enable_session_logging` is still a live
`ssh_data` column; only the copy-into-plugin-settings migration shipped
(`session-recording-settings-migration.ts`). The legacy default was `true`;
the plugin's enable switch defaults to `false` like every other plugin's, so
the migration copies every host's value rather than only the ones that
turned it off - a fresh host created after 2.9.0 defaults to recording off,
a real behaviour change from the old column default, made once here rather
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
off the resolved SSH host instead of `ctx.settings.getHost`, and
`plugins/remote-desktop/src/backend/routes.ts`'s
`host.enableSessionLogging !== false` check, `HostEditorData.ts`,
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
all still read the column directly. The General tab's checkbox was removed
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
data plumbing above was not switched over. Dropping the column in lockstep
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
`schema:generate`) needs every one of those switched first. Owner: a
dedicated follow-up step, or D0.
- **B13 (session-recording):** `remote-desktop`'s guacd recordings now insert
through the optional `recordings.writer` service's `createFinished(input)`
(added alongside `open()`/`RecordingSink` for ssh-terminal's incremental
writer) instead of importing `createCurrentSessionRecordingRepository`
directly. Without the session-recording plugin enabled, a guacd recording
is still written to disk but gets no `session_recordings` row and so never
appears in the list - a warning is logged. Owner: none needed unless a
second consumer of `recordings.writer` appears.
- **B13 (session-recording):** `accessId` (the legacy `access_id` column,
referencing the now-core-only `host_access` table) is carried in the
adopted table's shape as a plain unreferenced integer, since the SDK's
`defineTable` has no builder for a foreign key into an arbitrary core
table beyond `refUser()`/`refHost()`. Nothing has ever populated it (true
in core before this step too). Owner: none needed unless a future step
wants to actually use it.
## Manual checks after 2.9.0 ## Manual checks after 2.9.0
@@ -295,3 +329,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
window, a user share joined from Active Connections, revoke and "end for window, a user share joined from Active Connections, revoke and "end for
everyone", and a collab room with two members, control hand-off, a guest everyone", and a collab room with two members, control hand-off, a guest
link, rotate and end. link, rotate and end.
- Session recording: an SSH session recorded end to end (playback matches what
happened, including a resize mid-session), a host with recording switched
off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them.
+14
View File
@@ -74,6 +74,19 @@ export interface PluginKeyValue {
list: () => Promise<string[]>; list: () => Promise<string[]>;
} }
/**
* A folder on disk this plugin owns, for state too large for ctx.kv (session
* recordings, uploaded files). Requires files:own.
*/
export interface PluginFiles {
/**
* The plugin's own folder under DATA_DIR, created if it does not exist yet.
* Always the same path for this plugin; a plugin lays out its own
* subdirectories underneath it.
*/
dataDir: () => Promise<string>;
}
/** /**
* The plugin's own tables. * The plugin's own tables.
* *
@@ -1147,6 +1160,7 @@ export interface PluginContext {
readonly log: PluginLogger; readonly log: PluginLogger;
readonly events: PluginEvents; readonly events: PluginEvents;
readonly kv: PluginKeyValue; readonly kv: PluginKeyValue;
readonly files: PluginFiles;
readonly db: PluginDatabase; readonly db: PluginDatabase;
readonly sync: PluginSync; readonly sync: PluginSync;
readonly registry: PluginRegistry; readonly registry: PluginRegistry;
+1
View File
@@ -60,6 +60,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:read", "low"), entry("hosts:read", "low"),
entry("db:own", "low"), entry("db:own", "low"),
entry("kv:own", "low"), entry("kv:own", "low"),
entry("files:own", "low"),
entry("secrets:own", "low"), entry("secrets:own", "low"),
entry("settings:read-core", "low"), entry("settings:read-core", "low"),
entry("ui:surface", "low"), entry("ui:surface", "low"),
+13
View File
@@ -300,6 +300,12 @@ export function createFakeContext(
list: async () => [...kv.keys()], list: async () => [...kv.keys()],
}, },
files: {
// A fake path; a test that needs a real folder uses the OS temp dir
// itself, not this double.
dataDir: async () => `/tmp/${pluginId}`,
},
db: options.db db: options.db
? { ? {
...options.db, ...options.db,
@@ -798,6 +804,13 @@ export function createMockCtx(
}, },
}, },
files: {
dataDir: async () => {
require("files:own");
return ctx.files.dataDir();
},
},
events: { events: {
emit: (topic, payload) => { emit: (topic, payload) => {
if ( if (
+7
View File
@@ -22,6 +22,13 @@
"audit:read", "audit:read",
"ui:surface" "ui:surface"
], ],
"requires": [
{
"service": "recordings.writer",
"versionRange": "^1.0.0",
"optional": true
}
],
"contributes": { "contributes": {
"tabs": [ "tabs": [
{ {
@@ -4,16 +4,33 @@ import {
GuacamoleTokenService, GuacamoleTokenService,
type GuacamoleRecordingMetadata, type GuacamoleRecordingMetadata,
} from "./token-service.js"; } from "./token-service.js";
import { import { getCurrentSettingValue } from "../../../../src/backend/database/repositories/factory.js";
createCurrentSessionRecordingRepository,
getCurrentSettingValue,
} from "../../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js"; import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
const tokenService = GuacamoleTokenService.getInstance(); const tokenService = GuacamoleTokenService.getInstance();
/** The recordings.writer service, when the session-recording plugin provides it. */
export interface RecordingsWriter {
createFinished: (input: {
hostId: number;
userId: string;
startedAt: string;
endedAt: string;
duration: number | null;
recordingPath: string;
protocol: string;
format: string;
}) => Promise<{ id: number }>;
}
let pluginRecordings: RecordingsWriter | null = null;
export function setPluginRecordings(recordings: RecordingsWriter | null): void {
pluginRecordings = recordings;
}
function readGuacdOptions(): { host: string; port: number } { function readGuacdOptions(): { host: string; port: number } {
let dbUrl: string | undefined; let dbUrl: string | undefined;
try { try {
@@ -103,9 +120,17 @@ async function persistGuacamoleRecording(
return; return;
} }
if (!pluginRecordings) {
guacLogger.warn(
"Session Recording plugin is off; guacamole recording not saved to the log",
{ operation: "guac_recording_no_writer", hostId: recording.hostId },
);
return;
}
const endedAt = new Date(); const endedAt = new Date();
const startedAt = new Date(recording.startedAt); const startedAt = new Date(recording.startedAt);
await createCurrentSessionRecordingRepository().create({ await pluginRecordings.createFinished({
hostId: recording.hostId, hostId: recording.hostId,
userId: recording.userId, userId: recording.userId,
startedAt: startedAt.toISOString(), startedAt: startedAt.toISOString(),
@@ -4,15 +4,24 @@ import {
getGuacSessionInfo, getGuacSessionInfo,
handleGuacamoleUpgrade, handleGuacamoleUpgrade,
restartGuacServer, restartGuacServer,
setPluginRecordings,
startGuacamoleService, startGuacamoleService,
stopGuacamoleService, stopGuacamoleService,
tokenService, tokenService,
type RecordingsWriter,
} from "./guacamole-server.js"; } from "./guacamole-server.js";
import { startRemoteDesktopService } from "./routes.js"; import { startRemoteDesktopService } from "./routes.js";
export async function activate(ctx: PluginContext) { export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh); setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null)); ctx.disposables.add(() => setPluginSsh(null));
// Optional: without the session-recording plugin, a guacd recording is
// still made on disk but never gets a row, so it never shows in the list.
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer");
setPluginRecordings("createFinished" in recordings ? recordings : null);
ctx.disposables.add(() => setPluginRecordings(null));
await startGuacamoleService(); await startGuacamoleService();
startRemoteDesktopService(ctx.http.router()); startRemoteDesktopService(ctx.http.router());
+16
View File
@@ -0,0 +1,16 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Session recording moved out of core's `/session_logs` routes and now serves
under `/plugin-api/session-recording` through core's connect pipeline.
- Owns its data: core's `session_recordings` table is renamed to
`p_session_recording_session_recordings` on first activation.
- New `session-recording.view` permission, given to the admin and user roles.
- Offers `recordings.writer` to other plugins: `open()`/`append()`/`persist()`
for an incremental writer (ssh-terminal), `createFinished()` for a recording
already written to disk (remote desktop's guacd recordings).
- Retention days becomes an admin plugin setting; per-host recording becomes
a host plugin setting.
+24
View File
@@ -0,0 +1,24 @@
# Session Recording
Records terminal sessions and lets you play them back or download them.
Bundled with Termix and enabled by default. It imports only
`@termix/plugin-sdk`.
## What it does
- **Recording**: an SSH terminal session is recorded as an asciicast, written
incrementally in 300ms batches as it happens (issue #1049).
- **Playback**: view a recording inline, scrub through it, copy its plain
text, or download it as a recording file or plain text.
- **Retention**: an admin setting prunes recordings older than a configured
number of days, checked at startup and every 24 hours.
- **Per-host switch**: recording can be turned off for a host.
- **`recordings.writer`**: a service other plugins consume. `open(meta)`
starts an incremental recording (ssh-terminal); `createFinished(input)`
inserts a row for a recording the caller already wrote to disk itself
(remote desktop's guacd recordings).
Recordings outlive the account they were made under: deleting a user
anonymizes their recordings rather than deleting them, since a recording is
evidence about the host as much as about the person.
+48
View File
@@ -0,0 +1,48 @@
{
"nav": {
"sessionLogs": "Session Logs"
},
"hosts": {
"enableSessionRecording": "Session Recording",
"enableSessionRecordingDesc": "Record terminal session output for later review"
},
"permissions": {
"view": {
"title": "View session recordings",
"description": "See and play back your own session recordings."
}
},
"settings": {
"admin": {
"retentionDays": {
"label": "Retention (days)",
"description": "How long a session recording is kept before it is deleted automatically."
}
},
"host": {
"enable": {
"label": "Enable session recording"
}
}
},
"terminalStatus": {
"recording": "Recording"
},
"sessionLogs": {
"title": "Session Logs",
"noLogs": "No session logs yet",
"noLogsDesc": "Enable session logging on a host to start recording",
"duration": "Duration",
"viewLog": "View log",
"downloadLog": "Download",
"downloadAsText": "Download as text",
"deleteLog": "Delete",
"confirmDelete": "Delete this session log?",
"confirmDeleteDesc": "This action cannot be undone.",
"copyContent": "Copy",
"copied": "Copied!",
"loadError": "Failed to load session logs",
"deleteError": "Failed to delete session log",
"filterByHost": "Filter by host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessielogboeke"
},
"hosts": {
"enableSessionRecording": "Sessie-logging",
"enableSessionRecordingDesc": "Neem terminaalsessie-uitvoer op vir latere hersiening"
},
"sessionLogs": {
"title": "Sessielogboeke",
"noLogs": "Geen sessielogboeke nog nie",
"noLogsDesc": "Aktiveer sessie-aanmelding op 'n gasheer om opname te begin",
"duration": "Duur",
"viewLog": "Bekyk logboek",
"downloadLog": "Laai af",
"downloadAsText": "Laai af as teks",
"deleteLog": "Vee uit",
"confirmDelete": "Vee hierdie sessielog uit?",
"confirmDeleteDesc": "Hierdie aksie kan nie ongedaan gemaak word nie.",
"copyContent": "Kopieer",
"copied": "Gekopieer!",
"loadError": "Kon nie sessielogboeke laai nie",
"deleteError": "Kon nie sessielogboek uitvee nie",
"filterByHost": "Filtreer volgens gasheer..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "سجلات الجلسات"
},
"hosts": {
"enableSessionRecording": "تسجيل الجلسات",
"enableSessionRecordingDesc": "تسجيل مخرجات الجلسة الطرفية للمراجعة لاحقاً"
},
"sessionLogs": {
"title": "سجلات الجلسات",
"noLogs": "لا توجد سجلات جلسات بعد",
"noLogsDesc": "فعّل تسجيل الجلسات على مضيف لبدء التسجيل",
"duration": "المدة",
"viewLog": "عرض السجل",
"downloadLog": "تنزيل",
"downloadAsText": "تنزيل كنص",
"deleteLog": "حذف",
"confirmDelete": "حذف سجل الجلسة هذا؟",
"confirmDeleteDesc": "لا يمكن التراجع عن هذا الإجراء.",
"copyContent": "نسخ",
"copied": "تم النسخ!",
"loadError": "فشل في تحميل سجلات الجلسات",
"deleteError": "فشل في حذف سجل الجلسة",
"filterByHost": "تصفية حسب المضيف..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Дневници на сесиите"
},
"hosts": {
"enableSessionRecording": "Записване на сесии",
"enableSessionRecordingDesc": "Записване на изхода от терминалната сесия за по-късен преглед"
},
"sessionLogs": {
"title": "Дневници на сесии",
"noLogs": "Все още няма логове на сесии",
"noLogsDesc": "Активирайте регистрирането на сесията на хост, за да започнете записа",
"duration": "Продължителност",
"viewLog": "Преглед на дневника",
"downloadLog": "Изтегляне",
"downloadAsText": "Изтегляне като текст",
"deleteLog": "Изтриване",
"confirmDelete": "Да се изтрие ли този дневник на сесията?",
"confirmDeleteDesc": "Това действие не може да бъде отменено.",
"copyContent": "Копиране",
"copied": "Копирано!",
"loadError": "Зареждането на лог файловете на сесията не бе успешно",
"deleteError": "Неуспешно изтриване на дневника на сесията",
"filterByHost": "Филтриране по хост..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "সেশন লগ"
},
"hosts": {
"enableSessionRecording": "সেশন লগিং",
"enableSessionRecordingDesc": "পরবর্তী পর্যালোচনার জন্য টার্মিনাল সেশনের আউটপুট রেকর্ড করুন।"
},
"sessionLogs": {
"title": "সেশন লগ",
"noLogs": "এখনো কোনো সেশন লগ নেই",
"noLogsDesc": "রেকর্ডিং শুরু করতে কোনো হোস্টে সেশন লগিং চালু করুন।",
"duration": "সময়কাল",
"viewLog": "লগ দেখুন",
"downloadLog": "ডাউনলোড করুন",
"downloadAsText": "টেক্সট হিসেবে ডাউনলোড করুন",
"deleteLog": "মুছে ফেলুন",
"confirmDelete": "এই সেশন লগটি মুছে ফেলবেন?",
"confirmDeleteDesc": "এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।",
"copyContent": "অনুলিপি",
"copied": "অনুলিপি করা হয়েছে!",
"loadError": "সেশন লগ লোড করতে ব্যর্থ হয়েছে",
"deleteError": "সেশন লগ মুছে ফেলতে ব্যর্থ হয়েছে",
"filterByHost": "হোস্ট দ্বারা ফিল্টার করুন..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registres de sessió"
},
"hosts": {
"enableSessionRecording": "Registre de sessions",
"enableSessionRecordingDesc": "Enregistra la sortida de la sessió de terminal per a una revisió posterior"
},
"sessionLogs": {
"title": "Registres de sessió",
"noLogs": "Encara no hi ha registres de sessió",
"noLogsDesc": "Habilita el registre de sessions en un amfitrió per iniciar l'enregistrament",
"duration": "Durada",
"viewLog": "Veure registre",
"downloadLog": "Descarrega",
"downloadAsText": "Descarrega com a text",
"deleteLog": "Suprimeix",
"confirmDelete": "Voleu suprimir aquest registre de sessió?",
"confirmDeleteDesc": "Aquesta acció no es pot desfer.",
"copyContent": "Còpia",
"copied": "Copiat!",
"loadError": "No s'han pogut carregar els registres de sessió",
"deleteError": "No s'ha pogut suprimir el registre de sessió",
"filterByHost": "Filtra per amfitrió..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Protokoly relací"
},
"hosts": {
"enableSessionRecording": "Protokolování relací",
"enableSessionRecordingDesc": "Zaznamenejte výstup terminálové relace pro pozdější kontrolu"
},
"sessionLogs": {
"title": "Protokoly relací",
"noLogs": "Zatím žádné protokoly relací",
"noLogsDesc": "Povolte protokolování relace na hostiteli pro zahájení nahrávání",
"duration": "Trvání",
"viewLog": "Zobrazit protokol",
"downloadLog": "Stáhnout",
"downloadAsText": "Stáhnout jako text",
"deleteLog": "Vymazat",
"confirmDelete": "Smazat tento protokol relace?",
"confirmDeleteDesc": "Tuto akci nelze vrátit zpět.",
"copyContent": "Kopie",
"copied": "Zkopírováno!",
"loadError": "Nepodařilo se načíst protokoly relací",
"deleteError": "Nepodařilo se smazat protokol relace",
"filterByHost": "Filtrovat podle hostitele..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessionslogfiler"
},
"hosts": {
"enableSessionRecording": "Sessionslogning",
"enableSessionRecordingDesc": "Optag output fra terminalsessionen til senere gennemgang"
},
"sessionLogs": {
"title": "Sessionslogfiler",
"noLogs": "Ingen sessionslogfiler endnu",
"noLogsDesc": "Aktivér sessionslogning på en vært for at starte optagelse",
"duration": "Varighed",
"viewLog": "Se log",
"downloadLog": "Download",
"downloadAsText": "Download som tekst",
"deleteLog": "Slet",
"confirmDelete": "Slet denne sessionslog?",
"confirmDeleteDesc": "Denne handling kan ikke fortrydes.",
"copyContent": "Kopi",
"copied": "Kopieret!",
"loadError": "Kunne ikke indlæse sessionslogfiler",
"deleteError": "Kunne ikke slette sessionsloggen",
"filterByHost": "Filtrer efter vært..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sitzungsprotokolle"
},
"hosts": {
"enableSessionRecording": "Sitzungsprotokollierung",
"enableSessionRecordingDesc": "Terminal-Sitzungsausgabe zur späteren Überprüfung aufzeichnen"
},
"sessionLogs": {
"title": "Sitzungsprotokolle",
"noLogs": "Noch keine Sitzungsprotokolle",
"noLogsDesc": "Aktivieren Sie die Sitzungsprotokollierung auf einem Host, um die Aufzeichnung zu starten",
"duration": "Dauer",
"viewLog": "Protokoll anzeigen",
"downloadLog": "Herunterladen",
"downloadAsText": "Als Text herunterladen",
"deleteLog": "Löschen",
"confirmDelete": "Dieses Sitzungsprotokoll löschen?",
"confirmDeleteDesc": "Diese Aktion kann nicht rückgängig gemacht werden.",
"copyContent": "Kopieren",
"copied": "Kopiert!",
"loadError": "Fehler beim Laden der Sitzungsprotokolle",
"deleteError": "Fehler beim Löschen des Sitzungsprotokolls",
"filterByHost": "Nach Host filtern..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Αρχεία καταγραφής συνεδρίας"
},
"hosts": {
"enableSessionRecording": "Καταγραφή συνεδρίας",
"enableSessionRecordingDesc": "Καταγραφή εξόδου συνεδρίας τερματικού για μελλοντική αναθεώρηση"
},
"sessionLogs": {
"title": "Αρχεία καταγραφής συνεδρίας",
"noLogs": "Δεν υπάρχουν ακόμη αρχεία καταγραφής συνεδρίας",
"noLogsDesc": "Ενεργοποίηση καταγραφής περιόδου λειτουργίας σε έναν κεντρικό υπολογιστή για έναρξη εγγραφής",
"duration": "Διάρκεια",
"viewLog": "Προβολή αρχείου καταγραφής",
"downloadLog": "Λήψη",
"downloadAsText": "Λήψη ως κείμενο",
"deleteLog": "Διαγράφω",
"confirmDelete": "Διαγραφή αυτού του αρχείου καταγραφής περιόδου σύνδεσης;",
"confirmDeleteDesc": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"copyContent": "Αντίγραφο",
"copied": "Αντιγράφηκε!",
"loadError": "Αποτυχία φόρτωσης αρχείων καταγραφής περιόδου λειτουργίας",
"deleteError": "Η διαγραφή του αρχείου καταγραφής περιόδου λειτουργίας απέτυχε",
"filterByHost": "Φιλτράρισμα κατά κεντρικό υπολογιστή..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registros de sesión"
},
"hosts": {
"enableSessionRecording": "Registro de sesión",
"enableSessionRecordingDesc": "Registrar la salida de la sesión de terminal para su revisión posterior"
},
"sessionLogs": {
"title": "Registros de sesión",
"noLogs": "Aún no hay registros de sesión",
"noLogsDesc": "Activa el registro de sesiones en un host para empezar a grabar",
"duration": "Duración",
"viewLog": "Ver registro",
"downloadLog": "Descargar",
"downloadAsText": "Descargar como texto",
"deleteLog": "Eliminar",
"confirmDelete": "¿Eliminar este registro de sesión?",
"confirmDeleteDesc": "Esta acción no se puede deshacer.",
"copyContent": "Copiar",
"copied": "¡Copiado!",
"loadError": "Error al cargar los registros de sesión",
"deleteError": "Error al eliminar el registro de sesión",
"filterByHost": "Filtrar por host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Istuntolokit"
},
"hosts": {
"enableSessionRecording": "Istuntojen lokitiedot",
"enableSessionRecordingDesc": "Tallenna pääteistunnon tuloste myöhempää tarkastelua varten"
},
"sessionLogs": {
"title": "Istuntolokit",
"noLogs": "Ei vielä istuntolokeja",
"noLogsDesc": "Ota istunnon lokikirjaus käyttöön isännässä aloittaaksesi tallennuksen",
"duration": "Kesto",
"viewLog": "Näytä loki",
"downloadLog": "Lataa",
"downloadAsText": "Lataa tekstinä",
"deleteLog": "Poistaa",
"confirmDelete": "Poistetaanko tämä istuntoloki?",
"confirmDeleteDesc": "Tätä toimintoa ei voi perua.",
"copyContent": "Kopioida",
"copied": "Kopioitu!",
"loadError": "Istuntolokien lataaminen epäonnistui",
"deleteError": "Istuntolokitietojen poistaminen epäonnistui",
"filterByHost": "Suodata isännän mukaan..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Journaux de session"
},
"hosts": {
"enableSessionRecording": "Journalisation de session",
"enableSessionRecordingDesc": "Enregistrer la sortie de la session terminal pour une consultation ultérieure"
},
"sessionLogs": {
"title": "Journaux de session",
"noLogs": "Aucun journal de session pour l’instant",
"noLogsDesc": "Activez la journalisation des sessions sur un hôte pour démarrer l’enregistrement",
"duration": "Durée",
"viewLog": "Afficher le journal",
"downloadLog": "Télécharger",
"downloadAsText": "Télécharger au format texte",
"deleteLog": "Supprimer",
"confirmDelete": "Supprimer ce journal de session ?",
"confirmDeleteDesc": "Cette action est irréversible.",
"copyContent": "Copier",
"copied": "Copié !",
"loadError": "Échec du chargement des journaux de session",
"deleteError": "Échec de la suppression du journal de session",
"filterByHost": "Filtrer par hôte..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "יומני סשן"
},
"hosts": {
"enableSessionRecording": "רישום מושבים",
"enableSessionRecordingDesc": "הקלט את פלט הפעלת הטרמינל לצורך סקירה מאוחרת יותר"
},
"sessionLogs": {
"title": "יומני סשן",
"noLogs": "אין עדיין יומני סשן",
"noLogsDesc": "הפעל רישום סשן במארח כדי להתחיל להקליט",
"duration": "מֶשֶׁך",
"viewLog": "הצג יומן",
"downloadLog": "הורדה",
"downloadAsText": "הורד כטקסט",
"deleteLog": "לִמְחוֹק",
"confirmDelete": "למחוק את יומן הפעילויות הזה?",
"confirmDeleteDesc": "לא ניתן לבטל פעולה זו.",
"copyContent": "לְהַעְתִיק",
"copied": "מוּעֲתָק!",
"loadError": "טעינת יומני הסשן נכשלה",
"deleteError": "נכשלה מחיקת יומן ההפעלה",
"filterByHost": "סנן לפי מארח..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "सत्र लॉग्स"
},
"hosts": {
"enableSessionRecording": "सत्र लॉगिंग",
"enableSessionRecordingDesc": "बाद में समीक्षा के लिए टर्मिनल सत्र का आउटपुट रिकॉर्ड करें"
},
"sessionLogs": {
"title": "सेशन लॉग्स",
"noLogs": "अभी तक कोई सेशन लॉग नहीं",
"noLogsDesc": "रिकॉर्डिंग शुरू करने के लिए किसी होस्ट पर सेशन लॉगिंग सक्षम करें",
"duration": "अवधि",
"viewLog": "लॉग देखें",
"downloadLog": "डाउनलोड करें",
"downloadAsText": "टेक्स्ट के रूप में डाउनलोड करें",
"deleteLog": "हटाएं",
"confirmDelete": "क्या इस सेशन लॉग को हटाएं?",
"confirmDeleteDesc": "यह क्रिया वापस नहीं ली जा सकती।",
"copyContent": "कॉपी करें",
"copied": "कॉपी हो गया!",
"loadError": "सेशन लॉग लोड करने में विफल",
"deleteError": "सेशन लॉग हटाने में विफल",
"filterByHost": "होस्ट द्वारा फ़िल्टर करें..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Munkamenet-naplók"
},
"hosts": {
"enableSessionRecording": "Munkamenet-naplózás",
"enableSessionRecordingDesc": "Terminál munkamenet kimenetének rögzítése későbbi áttekintés céljából"
},
"sessionLogs": {
"title": "Munkamenet-naplók",
"noLogs": "Még nincsenek munkamenet-naplók",
"noLogsDesc": "A felvétel megkezdéséhez engedélyezze a munkamenet-naplózást egy gazdagépen",
"duration": "Időtartam",
"viewLog": "Napló megtekintése",
"downloadLog": "Letöltés",
"downloadAsText": "Letöltés szövegként",
"deleteLog": "Töröl",
"confirmDelete": "Törli ezt a munkamenet-naplót?",
"confirmDeleteDesc": "Ez a művelet nem vonható vissza.",
"copyContent": "Másolat",
"copied": "Másolva!",
"loadError": "Nem sikerült betölteni a munkamenet-naplókat",
"deleteError": "Nem sikerült törölni a munkamenet-naplót",
"filterByHost": "Szűrés hoszt szerint..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Log Sesi"
},
"hosts": {
"enableSessionRecording": "Pencatatan Sesi",
"enableSessionRecordingDesc": "Rekam output sesi terminal untuk ditinjau nanti."
},
"sessionLogs": {
"title": "Log Sesi",
"noLogs": "Belum ada log sesi.",
"noLogsDesc": "Aktifkan pencatatan sesi pada host untuk memulai perekaman.",
"duration": "Lamanya",
"viewLog": "Lihat log",
"downloadLog": "Unduh",
"downloadAsText": "Unduh sebagai teks",
"deleteLog": "Menghapus",
"confirmDelete": "Hapus log sesi ini?",
"confirmDeleteDesc": "Tindakan ini tidak dapat dibatalkan.",
"copyContent": "Menyalin",
"copied": "Disalin!",
"loadError": "Gagal memuat log sesi",
"deleteError": "Gagal menghapus log sesi",
"filterByHost": "Saring berdasarkan host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Log di sessione"
},
"hosts": {
"enableSessionRecording": "Registrazione sessione",
"enableSessionRecordingDesc": "Registra l'output della sessione del terminale per la revisione successiva"
},
"sessionLogs": {
"title": "Registri di sessione",
"noLogs": "Ancora nessun registro di sessione",
"noLogsDesc": "Abilita la registrazione delle sessioni su un host per iniziare a registrare",
"duration": "Durata",
"viewLog": "Visualizza registro",
"downloadLog": "Scarica",
"downloadAsText": "Scarica come testo",
"deleteLog": "Elimina",
"confirmDelete": "Eliminare questo registro di sessione?",
"confirmDeleteDesc": "Questa azione non può essere annullata.",
"copyContent": "Copia",
"copied": "Copiato!",
"loadError": "Impossibile caricare i registri di sessione",
"deleteError": "Impossibile eliminare il registro di sessione",
"filterByHost": "Filtra per host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "セッションログ"
},
"hosts": {
"enableSessionRecording": "セッションログ",
"enableSessionRecordingDesc": "後で確認できるようにターミナルセッションの出力を記録します"
},
"sessionLogs": {
"title": "セッションログ",
"noLogs": "セッションログはまだありません",
"noLogsDesc": "ホストでセッションログを有効にして記録を開始してください",
"duration": "所要時間",
"viewLog": "ログを表示",
"downloadLog": "ダウンロード",
"downloadAsText": "テキストとしてダウンロード",
"deleteLog": "削除",
"confirmDelete": "このセッションログを削除しますか?",
"confirmDeleteDesc": "この操作は元に戻せません。",
"copyContent": "コピー",
"copied": "コピーしました!",
"loadError": "セッションログの読み込みに失敗しました",
"deleteError": "セッションログの削除に失敗しました",
"filterByHost": "ホストでフィルター..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "세션 로그"
},
"hosts": {
"enableSessionRecording": "세션 로깅",
"enableSessionRecordingDesc": "나중에 검토할 수 있도록 터미널 세션 출력을 기록합니다"
},
"sessionLogs": {
"title": "세션 로그",
"noLogs": "아직 세션 로그 없음",
"noLogsDesc": "호스트에서 세션 로깅을 활성화하여 기록 시작",
"duration": "지속 시간",
"viewLog": "로그 보기",
"downloadLog": "다운로드",
"downloadAsText": "텍스트 파일로 다운로드",
"deleteLog": "삭제",
"confirmDelete": "이 세션 로그를 삭제하시겠습니까?",
"confirmDeleteDesc": "이 작업은 취소할 수 없습니다.",
"copyContent": "복사",
"copied": "복사됨!",
"loadError": "세션 로그 로드 실패",
"deleteError": "세션 로그 삭제 실패",
"filterByHost": "호스트로 필터링..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessielogboeken"
},
"hosts": {
"enableSessionRecording": "Sessieregistratie",
"enableSessionRecordingDesc": "Sla de uitvoer van de terminalsessie op voor latere beoordeling."
},
"sessionLogs": {
"title": "Sessielogboeken",
"noLogs": "Nog geen sessielogboeken",
"noLogsDesc": "Schakel sessielogging in op een host om te beginnen met opnemen.",
"duration": "Duur",
"viewLog": "Logboek bekijken",
"downloadLog": "Download",
"downloadAsText": "Downloaden als tekst",
"deleteLog": "Verwijderen",
"confirmDelete": "Dit sessielogboek verwijderen?",
"confirmDeleteDesc": "Deze actie kan niet ongedaan gemaakt worden.",
"copyContent": "Kopiëren",
"copied": "Gekopieerd!",
"loadError": "Het laden van de sessielogboeken is mislukt.",
"deleteError": "Het verwijderen van het sessielogboek is mislukt.",
"filterByHost": "Filteren op host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Øktlogger"
},
"hosts": {
"enableSessionRecording": "Øktlogging",
"enableSessionRecordingDesc": "Ta opp utdata fra terminaløkten for senere gjennomgang"
},
"sessionLogs": {
"title": "Øktlogger",
"noLogs": "Ingen øktlogger ennå",
"noLogsDesc": "Aktiver øktlogging på en vert for å starte opptaket",
"duration": "Varighet",
"viewLog": "Vis logg",
"downloadLog": "Last ned",
"downloadAsText": "Last ned som tekst",
"deleteLog": "Slett",
"confirmDelete": "Slette denne øktloggen?",
"confirmDeleteDesc": "Denne handlingen kan ikke angres.",
"copyContent": "Kopiere",
"copied": "Kopiert!",
"loadError": "Kunne ikke laste inn øktlogger",
"deleteError": "Kunne ikke slette øktloggen",
"filterByHost": "Filtrer etter vert..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Logi sesji"
},
"hosts": {
"enableSessionRecording": "Rejestrowanie sesji",
"enableSessionRecordingDesc": "Zapisuj wyjście sesji terminala do późniejszego przejrzenia"
},
"sessionLogs": {
"title": "Logi sesji",
"noLogs": "Brak logów sesji",
"noLogsDesc": "Włącz rejestrowanie sesji na hoście, aby rozpocząć nagrywanie",
"duration": "Czas trwania",
"viewLog": "Wyświetl log",
"downloadLog": "Pobierz",
"downloadAsText": "Pobierz jako tekst",
"deleteLog": "Usuń",
"confirmDelete": "Usunąć ten log sesji?",
"confirmDeleteDesc": "Tej czynności nie można cofnąć.",
"copyContent": "Kopiuj",
"copied": "Skopiowano!",
"loadError": "Nie udało się załadować logów sesji",
"deleteError": "Nie udało się usunąć logu sesji",
"filterByHost": "Filtruj według hosta..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registros de sessão"
},
"hosts": {
"enableSessionRecording": "Registro de sessão",
"enableSessionRecordingDesc": "Registre a saída da sessão do terminal para revisão posterior."
},
"sessionLogs": {
"title": "Registros de sessão",
"noLogs": "Ainda não há registros de sessão.",
"noLogsDesc": "Habilite o registro de sessão em um host para iniciar a gravação.",
"duration": "Duração",
"viewLog": "Ver registro",
"downloadLog": "Download",
"downloadAsText": "Baixar como texto",
"deleteLog": "Excluir",
"confirmDelete": "Excluir este registro de sessão?",
"confirmDeleteDesc": "Esta ação não pode ser desfeita.",
"copyContent": "Cópia",
"copied": "Copiado!",
"loadError": "Falha ao carregar os registros da sessão.",
"deleteError": "Falha ao excluir o registro da sessão",
"filterByHost": "Filtrar por host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Registos de Sessão"
},
"hosts": {
"enableSessionRecording": "Registo de sessão",
"enableSessionRecordingDesc": "Gravar a saída da sessão de terminal para revisão posterior"
},
"sessionLogs": {
"title": "Registos de Sessão",
"noLogs": "Ainda sem registos de sessão",
"noLogsDesc": "Ative o registo de sessão num host para começar a gravar",
"duration": "Duração",
"viewLog": "Ver registo",
"downloadLog": "Transferir",
"downloadAsText": "Descarregar como texto",
"deleteLog": "Eliminar",
"confirmDelete": "Eliminar este registo de sessão?",
"confirmDeleteDesc": "Esta ação não pode ser desfeita.",
"copyContent": "Copiar",
"copied": "Copiado!",
"loadError": "Falha ao carregar registos de sessão",
"deleteError": "Falha ao eliminar registo de sessão",
"filterByHost": "Filtrar por host..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Jurnalele de sesiune"
},
"hosts": {
"enableSessionRecording": "Înregistrarea sesiunilor",
"enableSessionRecordingDesc": "Înregistrați ieșirea sesiunii terminale pentru o revizuire ulterioară"
},
"sessionLogs": {
"title": "Jurnalele de sesiune",
"noLogs": "Încă nu există jurnale de sesiune",
"noLogsDesc": "Activați înregistrarea în jurnal pe o gazdă pentru a începe înregistrarea",
"duration": "Durată",
"viewLog": "Vizualizare jurnal",
"downloadLog": "Descărcare",
"downloadAsText": "Descărcați ca text",
"deleteLog": "Şterge",
"confirmDelete": "Ștergeți acest jurnal de sesiune?",
"confirmDeleteDesc": "Această acțiune nu poate fi anulată.",
"copyContent": "Copie",
"copied": "Copiat!",
"loadError": "Nu s-au putut încărca jurnalele de sesiune",
"deleteError": "Nu s-a putut șterge jurnalul sesiunii",
"filterByHost": "Filtrare după gazdă..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Журналы сессий"
},
"hosts": {
"enableSessionRecording": "Логирование сессии",
"enableSessionRecordingDesc": "Записывать вывод терминальной сессии для последующего просмотра"
},
"sessionLogs": {
"title": "Журналы сеансов",
"noLogs": "Пока нет журналов сеансов",
"noLogsDesc": "Включите ведение журнала сеансов на хосте, чтобы начать запись",
"duration": "Продолжительность",
"viewLog": "Просмотреть журнал",
"downloadLog": "Скачать",
"downloadAsText": "Скачать как текст",
"deleteLog": "Удалить",
"confirmDelete": "Удалить этот журнал сеансов?",
"confirmDeleteDesc": "Это действие нельзя отменить.",
"copyContent": "Копировать",
"copied": "Скопировано!",
"loadError": "Не удалось загрузить журналы сеансов",
"deleteError": "Не удалось удалить журнал сеанса",
"filterByHost": "Фильтровать по хосту..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Записи сесија"
},
"hosts": {
"enableSessionRecording": "Записивање сесије",
"enableSessionRecordingDesc": "Снимите излаз терминалне сесије за каснији преглед"
},
"sessionLogs": {
"title": "Записи сесија",
"noLogs": "Још нема евиденција сесија",
"noLogsDesc": "Омогућите евидентирање сесије на хосту да бисте започели снимање",
"duration": "Трајање",
"viewLog": "Прикажи дневник",
"downloadLog": "Преузми",
"downloadAsText": "Преузми као текст",
"deleteLog": "Обриши",
"confirmDelete": "Да ли желите да обришете овај дневник сесије?",
"confirmDeleteDesc": "Ова радња се не може поништити.",
"copyContent": "Копија",
"copied": "Копирано!",
"loadError": "Учитавање евиденције сесије није успело",
"deleteError": "Брисање дневника сесије није успело",
"filterByHost": "Филтрирај по хосту..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Sessionsloggar"
},
"hosts": {
"enableSessionRecording": "Sessionsloggning",
"enableSessionRecordingDesc": "Spela in utdata från terminalsessionen för senare granskning"
},
"sessionLogs": {
"title": "Sessionsloggar",
"noLogs": "Inga sessionsloggar ännu",
"noLogsDesc": "Aktivera sessionsloggning på en värd för att starta inspelning",
"duration": "Varaktighet",
"viewLog": "Visa logg",
"downloadLog": "Ladda ner",
"downloadAsText": "Ladda ner som text",
"deleteLog": "Radera",
"confirmDelete": "Radera den här sessionsloggen?",
"confirmDeleteDesc": "Den här åtgärden kan inte ångras.",
"copyContent": "Kopiera",
"copied": "Kopierad!",
"loadError": "Misslyckades med att läsa in sessionsloggar",
"deleteError": "Misslyckades med att radera sessionsloggen",
"filterByHost": "Filtrera efter värd..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "บันทึกเซสชัน"
},
"hosts": {
"enableSessionRecording": "การบันทึกเซสชัน",
"enableSessionRecordingDesc": "บันทึกผลลัพธ์จากเทอร์มินัลเพื่อตรวจสอบในภายหลัง"
},
"sessionLogs": {
"title": "บันทึกเซสชัน",
"noLogs": "ยังไม่มีบันทึกการใช้งานใดๆ",
"noLogsDesc": "เปิดใช้งานการบันทึกเซสชันบนโฮสต์เพื่อเริ่มการบันทึก",
"duration": "ระยะเวลา",
"viewLog": "ดูบันทึก",
"downloadLog": "ดาวน์โหลด",
"downloadAsText": "ดาวน์โหลดเป็นข้อความ",
"deleteLog": "ลบ",
"confirmDelete": "ลบบันทึกการใช้งานนี้หรือไม่?",
"confirmDeleteDesc": "การกระทำนี้ไม่สามารถย้อนกลับได้",
"copyContent": "สำเนา",
"copied": "คัดลอกแล้ว!",
"loadError": "ไม่สามารถโหลดบันทึกเซสชันได้",
"deleteError": "ไม่สามารถลบบันทึกเซสชันได้",
"filterByHost": "กรองตามโฮสต์..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Oturum Kayıtları"
},
"hosts": {
"enableSessionRecording": "Oturum Kaydı",
"enableSessionRecordingDesc": "Daha sonra gözden geçirmek için terminal oturumu çıktısını kaydet"
},
"sessionLogs": {
"title": "Oturum Günlükleri",
"noLogs": "Henüz oturum günlüğü yok",
"noLogsDesc": "Kayda başlamak için bir sunucuda oturum günlüğünü etkinleştirin",
"duration": "Süre",
"viewLog": "Günlüğü görüntüle",
"downloadLog": "İndir",
"downloadAsText": "Metin olarak indir",
"deleteLog": "Sil",
"confirmDelete": "Bu oturum günlüğü silinsin mi?",
"confirmDeleteDesc": "Bu işlem geri alınamaz.",
"copyContent": "Kopyala",
"copied": "Kopyalandı!",
"loadError": "Oturum günlükleri yüklenemedi",
"deleteError": "Oturum günlüğü silinemedi",
"filterByHost": "Sunucuya göre filtrele..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Журнали сеансів"
},
"hosts": {
"enableSessionRecording": "Ведення журналу сеансу",
"enableSessionRecordingDesc": "Запис виводу сеансу терміналу для подальшого перегляду"
},
"sessionLogs": {
"title": "Журнали сеансів",
"noLogs": "Журналів сеансів поки що немає",
"noLogsDesc": "Увімкнути ведення журналу сеансу на хості, щоб розпочати запис",
"duration": "Тривалість",
"viewLog": "Переглянути журнал",
"downloadLog": "Завантажити",
"downloadAsText": "Завантажити як текст",
"deleteLog": "Видалити",
"confirmDelete": "Видалити цей журнал сеансу?",
"confirmDeleteDesc": "Цю дію не можна скасувати.",
"copyContent": "Копіювати",
"copied": "Скопійовано!",
"loadError": "Не вдалося завантажити журнали сеансу",
"deleteError": "Не вдалося видалити журнал сеансу",
"filterByHost": "Фільтрувати за хостом..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "Nhật ký phiên"
},
"hosts": {
"enableSessionRecording": "Ghi nhật ký phiên",
"enableSessionRecordingDesc": "Ghi lại đầu ra phiên terminal để xem lại sau"
},
"sessionLogs": {
"title": "Nhật ký phiên",
"noLogs": "Chưa có nhật ký phiên nào",
"noLogsDesc": "Bật ghi nhật ký phiên trên máy chủ để bắt đầu ghi",
"duration": "Thời lượng",
"viewLog": "Xem nhật ký",
"downloadLog": "Tải xuống",
"downloadAsText": "Tải xuống dưới dạng văn bản",
"deleteLog": "Xóa",
"confirmDelete": "Xóa nhật ký phiên này?",
"confirmDeleteDesc": "Hành động này không thể hoàn tác.",
"copyContent": "Sao chép",
"copied": "Đã sao chép!",
"loadError": "Tải nhật ký phiên thất bại",
"deleteError": "Xóa nhật ký phiên thất bại",
"filterByHost": "Lọc theo máy chủ..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "会话日志"
},
"hosts": {
"enableSessionRecording": "会话日志记录",
"enableSessionRecordingDesc": "记录终端会话输出以供日后查看"
},
"sessionLogs": {
"title": "会话日志",
"noLogs": "暂无会话日志",
"noLogsDesc": "在主机上启用会话日志以开始记录",
"duration": "持续时间",
"viewLog": "查看日志",
"downloadLog": "下载",
"downloadAsText": "下载为文本",
"deleteLog": "删除",
"confirmDelete": "删除此会话日志?",
"confirmDeleteDesc": "此操作无法撤消。",
"copyContent": "复制",
"copied": "已复制!",
"loadError": "加载会话日志失败",
"deleteError": "删除会话日志失败",
"filterByHost": "按主机筛选..."
}
}
@@ -0,0 +1,26 @@
{
"nav": {
"sessionLogs": "工作階段記錄"
},
"hosts": {
"enableSessionRecording": "工作階段記錄",
"enableSessionRecordingDesc": "記錄終端機工作階段輸出以供日後檢視"
},
"sessionLogs": {
"title": "工作階段記錄",
"noLogs": "尚無任何工作階段記錄",
"noLogsDesc": "在主機上啟用工作階段記錄以開始錄製",
"duration": "持續時間",
"viewLog": "檢視記錄",
"downloadLog": "下載",
"downloadAsText": "下載為文字",
"deleteLog": "刪除",
"confirmDelete": "刪除此工作階段記錄?",
"confirmDeleteDesc": "此動作無法復原。",
"copyContent": "複製",
"copied": "已複製!",
"loadError": "載入工作階段記錄失敗",
"deleteError": "刪除工作階段記錄失敗",
"filterByHost": "依主機篩選..."
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"id": "session-recording",
"name": "Session Recording",
"version": "1.0.0",
"description": "Records terminal sessions and lets you play them back or download them.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "ScrollText",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"db:own",
"files:own",
"network:serve",
"ui:surface"
],
"provides": [
{
"service": "recordings.writer",
"version": "1.0.0",
"permission": "session-recording.view"
}
],
"contributes": {
"tabs": [
{
"id": "session-logs",
"titleKey": "nav.sessionLogs",
"icon": "ScrollText",
"openFrom": ["rail"]
}
],
"permissions": [
{
"name": "view",
"titleKey": "permissions.view.title",
"descriptionKey": "permissions.view.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"admin": [
{
"key": "retentionDays",
"type": "number",
"labelKey": "settings.admin.retentionDays.label",
"descriptionKey": "settings.admin.retentionDays.description",
"min": 1,
"max": 3650,
"default": 30
}
],
"host": {
"enableKey": "enableSessionRecording",
"enableLabelKey": "settings.host.enable.label",
"fields": []
}
},
"hostCapability": {
"key": "enableSessionRecording",
"labelKey": "hosts.enableSessionRecording",
"editorTab": "general"
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,22 @@
-- session-recording 0001: adopt_session_recordings
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `session_recordings` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`host_id` int NOT NULL,
`user_id` varchar(255),
`username` text,
`access_id` int,
`started_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`ended_at` text,
`duration` int,
`commands` text,
`dangerous_actions` text,
`recording_path` text,
`protocol` text NOT NULL DEFAULT ('ssh'),
`format` text NOT NULL DEFAULT ('text'),
`terminated_by_owner` boolean DEFAULT false,
`termination_reason` text,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `session_recordings` TO `p_session_recording_session_recordings`;
@@ -0,0 +1,24 @@
-- session-recording 0001: adopt_session_recordings
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "session_recordings" (
"id" serial PRIMARY KEY,
"host_id" integer NOT NULL,
"user_id" varchar(255),
"username" text,
"access_id" integer,
"started_at" varchar(255) 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" boolean DEFAULT false,
"termination_reason" text,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_session_recordings_user_started" ON "session_recordings" ("user_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_session_recordings_host" ON "session_recordings" ("host_id");
ALTER TABLE "session_recordings" RENAME TO "p_session_recording_session_recordings";
@@ -0,0 +1,76 @@
{
"version": 1,
"tables": {
"session_recordings": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"userId": {
"type": "varchar",
"length": 255
},
"username": {
"type": "text"
},
"accessId": {
"type": "integer"
},
"startedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"endedAt": {
"type": "timestamp"
},
"duration": {
"type": "integer"
},
"commands": {
"type": "text"
},
"dangerousActions": {
"type": "text"
},
"recordingPath": {
"type": "text"
},
"protocol": {
"type": "text",
"notNull": true,
"defaultValue": "ssh"
},
"format": {
"type": "text",
"notNull": true,
"defaultValue": "text"
},
"terminatedByOwner": {
"type": "boolean",
"defaultValue": false
},
"terminationReason": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_session_recordings_user_started",
"columns": ["userId", "startedAt"]
},
{
"name": "idx_session_recordings_host",
"columns": ["hostId"]
}
],
"adopts": "session_recordings"
}
}
}
@@ -0,0 +1,24 @@
-- session-recording 0001: adopt_session_recordings
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "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
);
CREATE INDEX IF NOT EXISTS "idx_session_recordings_user_started" ON "session_recordings" ("user_id", "started_at");
CREATE INDEX IF NOT EXISTS "idx_session_recordings_host" ON "session_recordings" ("host_id");
ALTER TABLE "session_recordings" RENAME TO "p_session_recording_session_recordings";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/session-recording",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -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];
@@ -0,0 +1,21 @@
import { Circle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
/** What ssh-terminal hands a "terminal.toolbarStatus" component. */
interface TerminalToolbarStatusProps {
host?: { pluginSettings?: Record<string, Record<string, unknown>> };
isConnected?: boolean;
}
export function RecordingStatus(props: Record<string, unknown>) {
const { isConnected } = props as TerminalToolbarStatusProps;
const { t } = useTranslation();
if (!isConnected) return null;
return (
<div className="flex items-center gap-1 px-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Circle className="size-2 fill-red-500 text-red-500" />
{t("terminalStatus.recording")}
</div>
);
}
@@ -1,14 +1,18 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { Input } from "@/components/input";
import { import {
useTranslation,
useToast,
usePluginApi,
usePermission,
} from "@termix/plugin-sdk/frontend";
import {
Input,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/tooltip"; copyToClipboard,
} from "@termix/plugin-sdk/ui";
import { import {
ArrowLeft, ArrowLeft,
Check, Check,
@@ -17,26 +21,58 @@ import {
Eye, Eye,
FileText, FileText,
Loader2, Loader2,
Save,
ScrollText, ScrollText,
Search, Search,
X, X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { SessionRecordingPlayer } from "./SessionRecordingPlayer";
import { asciicastToPlainText, parseAsciicast } from "./asciicast";
import { import {
getSessionLogs, createSessionRecordingApi,
getSessionLogContent,
getSessionLogBlob,
getSessionRecordingRetention,
setSessionRecordingRetention,
deleteSessionLog,
type SessionLogRecord, type SessionLogRecord,
} from "@/api/session-log-api"; } from "./session-recording-api";
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
import { function useAdaptivePolling(
asciicastToPlainText, fn: () => Promise<boolean | void>,
parseAsciicast, options: { minIntervalMs: number; maxIntervalMs: number },
} from "@/features/session-recording/asciicast"; enabled: boolean,
) {
const fnRef = useRef(fn);
fnRef.current = fn;
useEffect(() => {
if (!enabled) return;
let interval = options.minIntervalMs;
let stable = 0;
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
const tick = async () => {
if (cancelled) return;
try {
const changed = await fnRef.current();
if (changed) {
interval = options.minIntervalMs;
stable = 0;
} else {
stable++;
if (stable >= 3) {
interval = Math.min(interval * 1.5, options.maxIntervalMs);
}
}
} catch {
// keep polling
}
if (!cancelled) timer = setTimeout(tick, interval);
};
timer = setTimeout(tick, interval);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, options.minIntervalMs, options.maxIntervalMs]);
}
function formatDuration(seconds: number | null): string { function formatDuration(seconds: number | null): string {
if (seconds == null) return "--"; if (seconds == null) return "--";
@@ -206,6 +242,9 @@ function LogRow({
export function SessionLogsPanel() { export function SessionLogsPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast();
const api = createSessionRecordingApi(usePluginApi());
const canView = usePermission("view");
const [logs, setLogs] = useState<SessionLogRecord[]>([]); const [logs, setLogs] = useState<SessionLogRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
@@ -219,8 +258,6 @@ export function SessionLogsPanel() {
); );
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 logsRef = useRef(logs); const logsRef = useRef(logs);
logsRef.current = logs; logsRef.current = logs;
@@ -228,7 +265,7 @@ export function SessionLogsPanel() {
async (initial = false) => { async (initial = false) => {
if (initial) setLoading(true); if (initial) setLoading(true);
try { try {
const fresh = await getSessionLogs(); const fresh = await api.list();
const changed = const changed =
logsRef.current.length !== fresh.length || logsRef.current.length !== fresh.length ||
logsRef.current.some((log, i) => log.id !== fresh[i]?.id); logsRef.current.some((log, i) => log.id !== fresh[i]?.id);
@@ -247,25 +284,21 @@ export function SessionLogsPanel() {
if (initial) setLoading(false); if (initial) setLoading(false);
} }
}, },
[t], [api, t, toast],
); );
useEffect(() => { useEffect(() => {
if (!canView) return;
void load(true); void load(true);
getSessionRecordingRetention() }, [canView, load]);
.then(setRetentionDays)
.catch(() => setRetentionDays(null));
}, [load]);
useAdaptivePolling( useAdaptivePolling(
() => load(false), () => load(false),
{ {
minIntervalMs: 5_000, minIntervalMs: 5_000,
maxIntervalMs: 30_000, maxIntervalMs: 30_000,
stablePollsPerStep: 3,
}, },
true, canView,
{ runImmediately: false },
); );
const q = filter.trim().toLowerCase(); const q = filter.trim().toLowerCase();
@@ -285,11 +318,11 @@ export function SessionLogsPanel() {
setViewLoading(true); setViewLoading(true);
try { try {
if (log.format === "text") { if (log.format === "text") {
const text = await getSessionLogContent(log.id); const text = await api.getContent(log.id);
setViewContent(text); setViewContent(text);
setViewText(text); setViewText(text);
} else { } else {
const blob = await getSessionLogBlob(log.id); const blob = await api.getBlob(log.id);
setViewBlob(blob); setViewBlob(blob);
setViewText(await extractPlainText(log, blob)); setViewText(await extractPlainText(log, blob));
} }
@@ -311,7 +344,7 @@ export function SessionLogsPanel() {
const handleDownload = async (log: SessionLogRecord) => { const handleDownload = async (log: SessionLogRecord) => {
try { try {
downloadBlob(await getSessionLogBlob(log.id), buildFilename(log)); downloadBlob(await api.getBlob(log.id), buildFilename(log));
} catch { } catch {
toast.error(t("sessionLogs.loadError")); toast.error(t("sessionLogs.loadError"));
} }
@@ -322,7 +355,7 @@ export function SessionLogsPanel() {
const text = const text =
log.id === viewLog?.id && viewText != null log.id === viewLog?.id && viewText != null
? viewText ? viewText
: await extractPlainText(log, await getSessionLogBlob(log.id)); : await extractPlainText(log, await api.getBlob(log.id));
if (text == null) { if (text == null) {
toast.error(t("sessionLogs.loadError")); toast.error(t("sessionLogs.loadError"));
return; return;
@@ -340,7 +373,7 @@ export function SessionLogsPanel() {
if (!deleteTarget) return; if (!deleteTarget) return;
setDeleting(true); setDeleting(true);
try { try {
await deleteSessionLog(deleteTarget.id); await api.delete(deleteTarget.id);
setLogs((prev) => prev.filter((l) => l.id !== deleteTarget.id)); setLogs((prev) => prev.filter((l) => l.id !== deleteTarget.id));
setDeleteTarget(null); setDeleteTarget(null);
} catch { } catch {
@@ -473,44 +506,6 @@ 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">
@@ -1,13 +1,12 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play } from "lucide-react"; import { Pause, Play } from "lucide-react";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import Guacamole from "guacamole-common-js"; import Guacamole from "guacamole-common-js";
import type { SessionLogRecord } from "@/api/session-log-api"; import { Select2 } from "@termix/plugin-sdk/ui";
import { parseAsciicast, type Asciicast } from "./asciicast";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { Select2 } from "@/components/select2"; import { parseAsciicast, type Asciicast } from "./asciicast";
import type { SessionLogRecord } from "./session-recording-api";
const SPEEDS = [0.5, 1, 2, 4]; const SPEEDS = [0.5, 1, 2, 4];
@@ -16,6 +15,10 @@ function formatPosition(seconds: number) {
return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`; return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
} }
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function PlaybackControls({ function PlaybackControls({
playing, playing,
position, position,
@@ -0,0 +1,42 @@
import { ScrollText } from "lucide-react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { SessionLogsPanel } from "./SessionLogsPanel";
import { RecordingStatus } from "./RecordingStatus";
export function activate(app: TermixApp): void {
app.registerRailItem({
id: "session-logs",
icon: ScrollText,
titleKey: "nav.sessionLogs",
kind: "panel",
promotable: true,
rightDockable: true,
separatorAfter: true,
permission: "view",
});
app.registerPanel("session-logs", () => <SessionLogsPanel />);
app.registerTab("session-logs", () => <SessionLogsPanel />, {
icon: ScrollText,
titleKey: "nav.sessionLogs",
hostless: true,
});
app.registerSlotContribution("terminal.toolbarStatus", {
actionId: "session-recording.terminalStatus",
titleKey: "nav.sessionLogs",
kind: "component",
component: RecordingStatus,
when: (context) => {
const host = context.host as
| { pluginSettings?: Record<string, Record<string, unknown>> }
| undefined;
const enabled =
host?.pluginSettings?.["session-recording"]?.enableSessionRecording;
return enabled !== false;
},
});
}
export function deactivate(): void {}
@@ -0,0 +1,44 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type SessionLogRecord = {
id: number;
hostId: number;
userId: string | null;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
hostName: string | null;
hostIp: string | null;
sizeBytes: number | null;
protocol: "ssh" | "rdp" | "vnc" | "telnet";
format: "text" | "asciicast" | "guacamole";
username: string | null;
};
export function createSessionRecordingApi(api: PluginApiClient) {
return {
async list(): Promise<SessionLogRecord[]> {
const response = await api.get<{ logs: SessionLogRecord[] }>("/");
return response.data.logs;
},
async getBlob(id: number): Promise<Blob> {
const response = await api.get<Blob>(`/${id}/content`, {
responseType: "blob",
});
return response.data;
},
async getContent(id: number): Promise<string> {
const response = await api.get<string>(`/${id}/content`, {
responseType: "text",
});
return response.data;
},
async delete(id: number): Promise<void> {
await api.delete(`/${id}`);
},
};
}
@@ -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();
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseAsciicast } from "../../../features/session-recording/asciicast"; import { parseAsciicast } from "../../src/frontend/asciicast";
describe("parseAsciicast", () => { describe("parseAsciicast", () => {
it("parses terminal dimensions and timed input/output", () => { it("parses terminal dimensions and timed input/output", () => {
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
-4
View File
@@ -1,10 +1,6 @@
{ {
"$comment": "Literals src/ui may still spell that name a plugin id or a plugin-owned view. Each is data, not knowledge of a plugin. Checked by scripts/check-shell-plugin-ids.cjs; D1 and Phase B empty it.", "$comment": "Literals src/ui may still spell that name a plugin id or a plugin-owned view. Each is data, not knowledge of a plugin. Checked by scripts/check-shell-plugin-ids.cjs; D1 and Phase B empty it.",
"files": { "files": {
"src/ui/api/session-log-api.ts": {
"literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
},
"src/ui/lib/connection-origin.ts": { "src/ui/lib/connection-origin.ts": {
"literals": ["rdp", "telnet", "vnc"], "literals": ["rdp", "telnet", "vnc"],
"reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it." "reason": "Host protocol value (connectionType, enableRdp, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableTelnet, session protocol), a host column until Phase B moves it. Host protocol value (connectionType, enableVnc, session protocol), a host column until Phase B moves it."
-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js"; import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js"; import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js"; import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import rbacRoutes from "./routes/rbac.js"; import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js"; import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js"; import userPreferencesRoutes from "./routes/user-preferences.js";
@@ -1805,7 +1804,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes); app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes); app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes); app.use("/ssh-auth", sshAuthRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/rbac", rbacRoutes); app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes); app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes); app.use("/user-preferences", userPreferencesRoutes);
-32
View File
@@ -479,27 +479,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS 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,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS secret_sources ( CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -937,17 +916,6 @@ const relaxPluginGrantGrantedBy = () => {
}; };
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");
@@ -124,17 +124,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id", columns: "user_id",
}, },
// Recordings and live session sharing.
{
name: "idx_session_recordings_user_started",
table: "session_recordings",
columns: "user_id, started_at",
},
{
name: "idx_session_recordings_host",
table: "session_recordings",
columns: "host_id",
},
// Automations. // Automations.
{ {
-42
View File
@@ -719,48 +719,6 @@ export const auditLogs = mysqlTable(
], ],
); );
export const sessionRecordings = mysqlTable(
"session_recordings",
{
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: int("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
endedAt: text("ended_at"),
duration: int("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: boolean("terminated_by_owner").default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = mysqlTable( export const opksshTokens = mysqlTable(
"opkssh_tokens", "opkssh_tokens",
{ {
-42
View File
@@ -720,48 +720,6 @@ export const auditLogs = pgTable(
], ],
); );
export const sessionRecordings = pgTable(
"session_recordings",
{
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: varchar("started_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: boolean("terminated_by_owner").default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = pgTable( export const opksshTokens = pgTable(
"opkssh_tokens", "opkssh_tokens",
{ {
-44
View File
@@ -712,50 +712,6 @@ export const auditLogs = sqliteTable(
], ],
); );
export const sessionRecordings = sqliteTable(
"session_recordings",
{
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
startedAt: text("started_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
duration: integer("duration"),
commands: text("commands"),
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: text("protocol").notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: integer("terminated_by_owner", {
mode: "boolean",
}).default(false),
terminationReason: text("termination_reason"),
},
// Listed newest-first per user, and audited per host.
(table) => [
index("idx_session_recordings_user_started").on(
table.userId,
table.startedAt,
),
index("idx_session_recordings_host").on(table.hostId),
],
);
export const opksshTokens = sqliteTable( export const opksshTokens = sqliteTable(
"opkssh_tokens", "opkssh_tokens",
{ {
@@ -37,7 +37,6 @@ import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js"; import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js"; import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js"; import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js"; import { SessionRepository } from "./session-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js"; import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js"; import { CredentialAccessRepository } from "./credential-access-repository.js";
@@ -400,13 +399,6 @@ export function createCurrentRoleRepository(): RoleRepository {
); );
} }
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
return new SessionRecordingRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("session_recording_repository_write"),
);
}
export function createCurrentSessionRepository(): SessionRepository { export function createCurrentSessionRepository(): SessionRepository {
return new SessionRepository( return new SessionRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -1,262 +0,0 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import { hosts, sessionRecordings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
export interface SessionRecordingCreateInput {
hostId: number;
userId: string;
startedAt: string;
endedAt?: string | null;
duration?: number | null;
commands?: string | null;
dangerousActions?: string | null;
recordingPath?: string | null;
protocol?: string | null;
format?: string | null;
terminatedByOwner?: boolean | null;
terminationReason?: string | null;
}
export interface SessionRecordingListRecord {
id: number;
hostId: number;
userId: string;
startedAt: string;
endedAt: string | null;
duration: number | null;
recordingPath: string | null;
protocol: string;
format: string | null;
hostName: string | null;
hostIp: string | null;
}
export interface SessionRecordingPathRecord {
id: number;
recordingPath: string | null;
}
export class SessionRecordingRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(
input: SessionRecordingCreateInput,
): Promise<SessionRecordingRecord> {
const [created] = await insertReturning(
this.context,
sessionRecordings,
input,
);
await this.afterWrite();
return created;
}
async updateEnded(
id: number,
input: {
endedAt: string;
duration: number | null;
terminatedByOwner?: boolean;
terminationReason?: string;
},
): Promise<void> {
await this.context.drizzle
.update(sessionRecordings)
.set(input)
.where(eq(sessionRecordings.id, id));
await this.afterWrite();
}
async findById(id: number): Promise<SessionRecordingRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.limit(1);
return rows[0] ?? null;
}
async findPathById(id: number): Promise<
| (SessionRecordingPathRecord & {
userId: string;
format?: string | null;
})
| null
> {
const rows = await this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
format: sessionRecordings.format,
})
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.limit(1);
return rows[0] ?? null;
}
async listByUserIdWithHost(
userId: string,
): Promise<SessionRecordingListRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
hostId: sessionRecordings.hostId,
userId: sessionRecordings.userId,
startedAt: sessionRecordings.startedAt,
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.where(eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
}
async findByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async findPathByIdForUser(
userId: string,
id: number,
): Promise<SessionRecordingPathRecord | null> {
const rows = await this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async listPathsOlderThan(
cutoff: string,
): Promise<SessionRecordingPathRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(lt(sessionRecordings.startedAt, cutoff));
}
async deleteById(id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, id));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result) > 0;
}
/**
* 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 — deleting only the row would orphan it.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.update(sessionRecordings)
.set({ userId: null })
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -22,7 +22,6 @@ import {
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentSessionRepository, createCurrentSessionRepository,
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository, createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository, createCurrentSharedHostSecretsRepository,
createCurrentSshCredentialUsageRepository, createCurrentSshCredentialUsageRepository,
@@ -61,9 +60,11 @@ export async function deleteUserAndRelatedData(
userId, userId,
); );
// Retained rather than deleted: these outlive the account by design. // session_recordings is retained rather than deleted, by design: it
// See anonymizeByUserId on each repository. // outlives the account. The session-recording plugin listens for
await createCurrentSessionRecordingRepository().anonymizeByUserId(userId); // user.deleted and anonymizes its own rows instead of cascading.
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
pluginEvents.emit(TOPICS.userDeleted, { userId });
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences( await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
userId, userId,
@@ -9,7 +9,6 @@ import {
createCurrentRecentActivityRepository, createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository, createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentSyncTombstoneRepository, createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js"; import { isNonEmptyString } from "./host-normalizers.js";
@@ -397,9 +396,10 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id); const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) { if (hostIds.length > 0) {
// file manager recent/pinned/shortcuts, transfer_recent and command // file manager recent/pinned/shortcuts, transfer_recent, command
// history cascade on the host's refHost() foreign key, as the // history and session recordings cascade on the host's refHost()
// file-manager and ssh-terminal plugins' adopted tables. // foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostIds( await createCurrentSshCredentialUsageRepository().deleteByHostIds(
hostIds, hostIds,
@@ -412,10 +412,6 @@ export function registerHostFolderRoutes(
await createCurrentRbacAccessRepository().deleteHostAccessForHosts( await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
hostIds, hostIds,
); );
await createCurrentSessionRecordingRepository().deleteByHostIds(
hostIds,
);
} }
const { hostSyncIds, folderSyncIds } = const { hostSyncIds, folderSyncIds } =
+4 -8
View File
@@ -20,7 +20,6 @@ import {
createCurrentOpksshTokenRepository, createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository, createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository, createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentRbacAccessRepository, createCurrentRbacAccessRepository,
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
@@ -2524,9 +2523,10 @@ router.delete(
const numericHostId = Number(hostId); const numericHostId = Number(hostId);
// file manager recent/pinned/shortcuts, transfer_recent and command // file manager recent/pinned/shortcuts, transfer_recent, command
// history cascade on the host's refHost() foreign key, as the // history and session recordings cascade on the host's refHost()
// file-manager and ssh-terminal plugins' adopted tables. // foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostId( await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId, numericHostId,
@@ -2540,10 +2540,6 @@ router.delete(
numericHostId, numericHostId,
); );
await createCurrentSessionRecordingRepository().deleteByHostId(
numericHostId,
);
await createCurrentHostRepository().deleteForUser(userId, numericHostId); await createCurrentHostRepository().deleteForUser(userId, numericHostId);
if (hostToDelete.syncId) { if (hostToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record( await createCurrentSyncTombstoneRepository().record(
@@ -1,362 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import fs from "fs";
import path from "path";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../repositories/factory.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const permissionManager = PermissionManager.getInstance();
function isAllowedRecordingPath(filePath: string): boolean {
const resolved = path.resolve(filePath);
return ["session_logs", "session_recordings"].some((directory) => {
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
return resolved.startsWith(base);
});
}
function getRetentionDays(): number {
const envDays = parseInt(
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
10,
);
try {
const configured = parseInt(
getCurrentSettingValue("session_recording_retention_days") || "",
10,
);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
}
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
}
async function canAccessRecording(
userId: string,
ownerId: string,
): Promise<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
).toISOString();
const sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const old = await sessionRecordingRepository.listPathsOlderThan(cutoff);
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
await sessionRecordingRepository.deleteById(row.id);
}
if (old.length > 0) {
apiLogger.info(`Pruned ${old.length} old session log(s)`, {
operation: "session_log_prune",
count: old.length,
});
}
} catch (err) {
apiLogger.warn("Failed to prune old session logs", {
operation: "session_log_prune_error",
error: err instanceof Error ? err.message : String(err),
});
}
}
// Run prune once at startup, then every 24 hours
pruneOldLogs();
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000).unref();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /session_logs:
* get:
* summary: List session logs
* description: Returns all terminal session recordings for the authenticated user.
* tags:
* - Session Logs
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session logs.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows =
await createCurrentSessionRecordingRepository().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) {
apiLogger.error("Failed to fetch session logs", error, {
operation: "session_logs_list",
userId,
});
res.status(500).json({ error: "Failed to fetch session logs" });
}
});
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" });
}
await createCurrentSettingsRepository().upsert(
"session_recording_retention_days",
String(retentionDays),
);
void pruneOldLogs();
res.json({ retentionDays });
},
);
/**
* @openapi
* /session_logs/{id}:
* get:
* summary: Get session log metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to fetch session log.
*/
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
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 createCurrentSessionRecordingRepository().findByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
res.json({ log: row });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
operation: "session_log_get",
userId,
id,
});
res.status(500).json({ error: "Failed to fetch session log" });
}
});
/**
* @openapi
* /session_logs/{id}/content:
* get:
* summary: Get session log content
* description: Returns the raw text content of a session log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw log text.
* 403:
* description: Not authorized.
* 404:
* description: Session log or file not found.
* 500:
* description: Failed to read session log.
*/
router.get(
"/:id/content",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
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 createCurrentSessionRecordingRepository().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);
if (!isAllowedRecordingPath(resolvedPath)) {
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 as { format?: string | null }).format ??
(row.recordingPath?.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) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
userId,
id,
});
res.status(500).json({ error: "Failed to read session log" });
}
},
);
/**
* @openapi
* /session_logs/{id}:
* delete:
* summary: Delete session log
* description: Deletes a session recording and its log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session log deleted.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to delete session log.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
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 sessionRecordingRepository =
createCurrentSessionRecordingRepository();
const row = await sessionRecordingRepository.findPathByIdForUser(
userId,
id,
);
if (!row) return res.status(404).json({ error: "Not found" });
const filePath = row.recordingPath;
await sessionRecordingRepository.deleteForUser(userId, id);
if (filePath) {
const resolvedPath = path.resolve(filePath);
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
apiLogger.error("Failed to delete session log", error, {
operation: "session_log_delete",
userId,
id,
});
res.status(500).json({ error: "Failed to delete session log" });
}
});
export default router;
+18
View File
@@ -250,6 +250,20 @@ export function createPluginContext(
{ action: "kv_list" }, { action: "kv_list" },
); );
const filesDataDir = guarded(
manifest,
"files:own",
async () => {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const base = process.env.DATA_DIR ?? "./db/data";
const dir = path.join(base, "plugins", pluginId);
await fs.mkdir(dir, { recursive: true });
return dir;
},
{ action: "files_data_dir" },
);
const dbDefine = guarded( const dbDefine = guarded(
manifest, manifest,
"db:own", "db:own",
@@ -422,6 +436,10 @@ export function createPluginContext(
list: () => kvList(), list: () => kvList(),
}, },
files: {
dataDir: () => filesDataDir(),
},
registry: { registry: {
provide: (key, value) => { provide: (key, value) => {
registry.provide(key, value); registry.provide(key, value);
+7
View File
@@ -47,6 +47,13 @@ export const TOPICS = {
* the row is not gone, only unrecoverable, and should go with it. * the row is not gone, only unrecoverable, and should go with it.
*/ */
userDataWiped: "user.data_wiped", userDataWiped: "user.data_wiped",
/**
* A user account was deleted. For a plugin whose rows should outlive the
* account (evidence, audit trail) rather than cascade with a refUser()
* column: it keeps a plain userId column and anonymizes its own rows here,
* the way core's own session recordings did before this event existed.
*/
userDeleted: "user.deleted",
} as const; } as const;
export type EventListener = (payload: unknown) => void; export type EventListener = (payload: unknown) => void;
+4
View File
@@ -373,6 +373,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runSessionSharingSettingsMigration } = const { runSessionSharingSettingsMigration } =
await import("./utils/crypto-migration/session-sharing-settings-migration.js"); await import("./utils/crypto-migration/session-sharing-settings-migration.js");
await runSessionSharingSettingsMigration(); await runSessionSharingSettingsMigration();
const { runSessionRecordingSettingsMigration } =
await import("./utils/crypto-migration/session-recording-settings-migration.js");
await runSessionRecordingSettingsMigration();
} catch (error) { } catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", { systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init", operation: "plugin_init",
@@ -7,10 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a * `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
* number of tables that the primary bootstrap already creates. The probe never * number of tables that the primary bootstrap already creates. The probe never
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and two * threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
* of those unreachable copies had drifted away from the real definition * of those unreachable copies had drifted away from the real definition
* (`sessions` had lost `ON DELETE CASCADE`; `session_recordings` still had the * (`sessions` had lost `ON DELETE CASCADE`).
* pre-#1128 `user_id NOT NULL` with `ON DELETE CASCADE` and no `username`).
* *
* They are gone now. What has to stay true is that the bootstrap alone * They are gone now. What has to stay true is that the bootstrap alone
* produces every one of those tables, from an empty database and from a * produces every one of those tables, from an empty database and from a
@@ -27,7 +26,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
"roles", "roles",
"user_roles", "user_roles",
"audit_logs", "audit_logs",
"session_recordings",
"api_keys", "api_keys",
]; ];
@@ -99,16 +97,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
await db.initializeDatabase(); await db.initializeDatabase();
const sqlite = db.getSqlite(); const sqlite = db.getSqlite();
// session_recordings: nullable user_id with the attribution kept, per
// "audit trails survive the account" — not the NOT NULL + CASCADE the
// dead copy still carried.
const columns = sqlite
.prepare("PRAGMA table_info(session_recordings)")
.all() as Array<{ name: string; notnull: number }>;
const userId = columns.find((c) => c.name === "user_id");
expect(userId?.notnull).toBe(0);
expect(columns.some((c) => c.name === "username")).toBe(true);
// sessions: the dead copy had dropped ON DELETE CASCADE. // sessions: the dead copy had dropped ON DELETE CASCADE.
const sql = sqlite const sql = sqlite
.prepare( .prepare(
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more