honour per-host recording flags and explain a missing recording (#1121)

The session recording section offers a recording path, a filename template and
four content toggles, but the backend overwrote five of the six on every
connection. A host could set none of them and get no indication why.

Location and filename genuinely are not the host's to choose — recordings are
indexed by them for playback and the backend refuses to read outside its
recordings directory — so drop those two inputs rather than keep pretending they
apply. The content flags are a host-level decision, so default them instead of
forcing them.

That still leaves the reported case, where guacd writes the file somewhere the
backend cannot see it. The warning now reports both paths and names the two env
vars that align them, which is otherwise guesswork for a split-container setup.

Closes Termix-SSH/Support#1041
This commit is contained in:
ZacharyZcR
2026-07-28 01:49:01 +08:00
committed by GitHub
parent 94a072b76d
commit 1d26f820c6
6 changed files with 106 additions and 65 deletions
@@ -104,10 +104,16 @@ async function persistGuacamoleRecording(
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!fs.existsSync(resolvedPath)) {
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
guacLogger.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
guacdPath,
hint:
"guacd writes the recording to guacdPath, the backend reads it from path. " +
"When guacd runs in its own container these must be the same volume — set " +
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
});
return;
}
@@ -0,0 +1,22 @@
/**
* Merges Termix's recording bookkeeping into a host's guacd settings.
*
* Location and filename are not the host's to choose: recordings are indexed by
* them for playback, and the backend refuses to read anything outside its
* recordings directory. What a recording *contains* is a host-level decision, so
* those flags are only defaulted, never overwritten.
*/
export function withRecordingSettings(
guacConfig: Record<string, unknown>,
recordingPath: string,
recordingName: string,
): Record<string, unknown> {
return {
...guacConfig,
"recording-path": recordingPath,
"recording-name": recordingName,
"create-recording-path": true,
"recording-exclude-output": guacConfig["recording-exclude-output"] ?? false,
"recording-include-keys": guacConfig["recording-include-keys"] ?? true,
};
}
+7 -5
View File
@@ -1,5 +1,6 @@
import express from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js";
import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -615,15 +616,16 @@ router.post(
userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
path: recordingName,
guacdPath: recordingPath,
startedAt: new Date().toISOString(),
}
: undefined;
if (recordingEnabled) {
guacConfig["recording-path"] = recordingPath;
guacConfig["recording-name"] = recordingName;
guacConfig["create-recording-path"] = true;
guacConfig["recording-exclude-output"] = false;
guacConfig["recording-include-keys"] = true;
guacConfig = withRecordingSettings(
guacConfig,
recordingPath,
recordingName,
);
}
const termixConnectId = crypto.randomUUID();
@@ -48,6 +48,9 @@ export interface GuacamoleRecordingMetadata {
userId: string;
protocol: "rdp" | "vnc" | "telnet";
path: string;
/** Directory guacd was told to write into; differs from the backend's view
* when guacd runs in its own container. */
guacdPath?: string;
startedAt: string;
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { withRecordingSettings } from "../../../hosts/guacamole/recording-settings.js";
const PATH = "/app/data/session_recordings/guacamole";
const NAME = "b7e6c0f2-0000-4000-8000-000000000000.guac";
describe("withRecordingSettings", () => {
it("takes ownership of the location and filename", () => {
const merged = withRecordingSettings(
{
"recording-path": "/var/lib/termix/recordings",
"recording-name": "${GUAC_USERNAME}-${GUAC_DATE}",
"create-recording-path": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-path": PATH,
"recording-name": NAME,
"create-recording-path": true,
});
});
it("defaults the content flags when the host has no opinion", () => {
expect(withRecordingSettings({}, PATH, NAME)).toMatchObject({
"recording-exclude-output": false,
"recording-include-keys": true,
});
});
it("keeps the host's content flags, including the falsy ones", () => {
const merged = withRecordingSettings(
{
"recording-exclude-output": true,
"recording-include-keys": false,
},
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-output": true,
"recording-include-keys": false,
});
});
it("leaves unrelated settings alone", () => {
const merged = withRecordingSettings(
{ "recording-exclude-mouse": true, width: "1920" },
PATH,
NAME,
);
expect(merged).toMatchObject({
"recording-exclude-mouse": true,
width: "1920",
});
});
it("does not mutate the settings it was given", () => {
const original = { "recording-path": "/tmp/mine" };
withRecordingSettings(original, PATH, NAME);
expect(original).toEqual({ "recording-path": "/tmp/mine" });
});
});
@@ -859,26 +859,6 @@ export function HostEditorRdpTab({
}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.recordingPath")}
</label>
<Input
placeholder="/var/lib/termix/recordings"
value={form.guacamoleConfig["recording-path"] ?? ""}
onChange={(e) => setGuacField("recording-path", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.recordingName")}
</label>
<Input
placeholder="${GUAC_USERNAME}-${GUAC_DATE}-${GUAC_TIME}"
value={form.guacamoleConfig["recording-name"] ?? ""}
onChange={(e) => setGuacField("recording-name", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.guac.createPathIfMissing")}
description={t("hosts.guac.createPathIfMissingDesc")}
@@ -1418,26 +1398,6 @@ export function HostEditorVncTab({
}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.recordingPath")}
</label>
<Input
placeholder="/var/lib/termix/recordings"
value={form.guacamoleConfig["recording-path"] ?? ""}
onChange={(e) => setGuacField("recording-path", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.recordingName")}
</label>
<Input
placeholder="${GUAC_USERNAME}-${GUAC_DATE}-${GUAC_TIME}"
value={form.guacamoleConfig["recording-name"] ?? ""}
onChange={(e) => setGuacField("recording-name", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.guac.createPathIfMissing")}
description={t("hosts.guac.createPathIfMissingDesc")}
@@ -1889,26 +1849,6 @@ export function HostEditorTelnetTab({
}
>
<div className="flex flex-col gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.recordingPath")}
</label>
<Input
placeholder="/var/lib/termix/recordings"
value={form.guacamoleConfig["recording-path"] ?? ""}
onChange={(e) => setGuacField("recording-path", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.recordingName")}
</label>
<Input
placeholder="${GUAC_USERNAME}-${GUAC_DATE}-${GUAC_TIME}"
value={form.guacamoleConfig["recording-name"] ?? ""}
onChange={(e) => setGuacField("recording-name", e.target.value)}
/>
</div>
<SettingRow
label={t("hosts.guac.createPathIfMissing")}
description={t("hosts.guac.createPathIfMissingDesc")}