fix: make RDP drive redirection writable on the stock deployment (#1333)

* fix: make RDP drive redirection writable on the stock deployment

The default drive-path was /drive on the guacd side, which the official
guacd image cannot create as its non-root user, so every upload was
refused with guacd's raw "FAIL (CANNOT OPEN)" ack. Default to
GUACD_DRIVE_PATH (set to the shared termix-data volume in compose) with
one folder per user, and explain guacd's refusal in the file browser.

* style: format RDP drive settings
This commit is contained in:
ZacharyZcR
2026-08-25 01:40:20 +08:00
committed by GitHub
parent 8260af2d57
commit 672f5ba80b
9 changed files with 113 additions and 7 deletions
+1
View File
@@ -15,6 +15,7 @@ services:
GUACD_HOST: "guacd-dev" GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev" GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on: depends_on:
- guacd-dev - guacd-dev
networks: networks:
+3
View File
@@ -12,6 +12,9 @@ services:
GUACD_HOST: "guacd" GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix" GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole" GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Hardened deployments can require keys from environment variables or # Hardened deployments can require keys from environment variables or
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE, # Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE. # ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
@@ -0,0 +1,29 @@
export const GUACD_DRIVE_PATH_ENV = "GUACD_DRIVE_PATH";
const DEFAULT_DRIVE_ROOT = "/drive";
/**
* Fills in where guacd keeps the files behind RDP drive redirection.
*
* The folder lives on the guacd host, not on Termix's - with the stock
* compose it is a directory in the shared termix-data volume, configured
* through GUACD_DRIVE_PATH. Each user gets a folder of their own underneath:
* a shared drive would show everyone's uploads to everyone else. A host that
* names its own drive-path keeps it.
*/
export function withDriveSettings(
guacConfig: Record<string, unknown>,
userId: string,
env: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
if (!guacConfig["enable-drive"] || guacConfig["drive-path"]) {
return guacConfig;
}
const root = (
env[GUACD_DRIVE_PATH_ENV]?.trim() || DEFAULT_DRIVE_ROOT
).replace(/\/+$/, "");
return {
...guacConfig,
"drive-path": `${root}/${userId}`,
"create-drive-path": true,
};
}
+2 -4
View File
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import express from "express"; import express from "express";
import { GuacamoleTokenService } from "./token-service.js"; import { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js"; import { withRecordingSettings } from "./recording-settings.js";
import { withDriveSettings } from "./drive-settings.js";
import { guacLogger } from "../../utils/logger.js"; import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js";
@@ -658,10 +659,7 @@ router.post(
switch (connectionType) { switch (connectionType) {
case "rdp": case "rdp":
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) { guacConfig = withDriveSettings(guacConfig, userId);
guacConfig["drive-path"] = "/drive";
guacConfig["create-drive-path"] = true;
}
token = tokenService.createRdpToken( token = tokenService.createRdpToken(
hostname, hostname,
username, username,
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { withDriveSettings } from "../../../hosts/guacamole/drive-settings.js";
describe("withDriveSettings", () => {
it("gives each user a folder under GUACD_DRIVE_PATH and creates it", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {
GUACD_DRIVE_PATH: "/termix-data/rdp-drive/",
}),
).toEqual({
"enable-drive": true,
"drive-path": "/termix-data/rdp-drive/user-1",
"create-drive-path": true,
});
});
it("falls back to /drive when the environment says nothing", () => {
expect(
withDriveSettings({ "enable-drive": true }, "user-1", {}),
).toMatchObject({ "drive-path": "/drive/user-1" });
});
it("leaves a host-chosen drive-path alone", () => {
const config = { "enable-drive": true, "drive-path": "/mnt/share" };
expect(
withDriveSettings(config, "user-1", { GUACD_DRIVE_PATH: "/x" }),
).toBe(config);
});
it("does nothing when the drive is not enabled", () => {
const config = { "enable-drive": false };
expect(withDriveSettings(config, "user-1")).toBe(config);
});
});
@@ -19,6 +19,7 @@ import {
parentPath, parentPath,
saveBlobAs, saveBlobAs,
uploadFile, uploadFile,
describeUploadError,
type RemoteFileEntry, type RemoteFileEntry,
} from "./guacamole-filesystem.ts"; } from "./guacamole-filesystem.ts";
@@ -76,9 +77,9 @@ export function GuacamoleFileBrowser({
toast.success(t("guacamole.files.uploaded", { name: file.name })); toast.success(t("guacamole.files.uploaded", { name: file.name }));
} catch (err) { } catch (err) {
toast.error( toast.error(
err instanceof Error describeUploadError(err, (key) =>
? err.message t(`guacamole.files.${key}`, { name: file.name }),
: t("guacamole.files.uploadFailed", { name: file.name }), ),
); );
} finally { } finally {
setBusyName(null); setBusyName(null);
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { describeUploadError } from "./guacamole-filesystem";
const t = (key: string) => `#${key}`;
describe("describeUploadError", () => {
it("explains guacd's refusal to open the target file as a drive-folder problem", () => {
expect(describeUploadError(new Error("FAIL (CANNOT OPEN)"), t)).toBe(
"#driveNotWritable",
);
expect(describeUploadError(new Error("FAIL (NO FS)"), t)).toBe(
"#driveUnavailable",
);
});
it("passes other messages through and falls back for unknown errors", () => {
expect(describeUploadError(new Error("disk full"), t)).toBe("disk full");
expect(describeUploadError(undefined, t)).toBe("#uploadFailed");
});
});
@@ -163,3 +163,22 @@ export function saveBlobAs(blob: Blob, filename: string): void {
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/**
* Turns an upload failure into something a user can act on. guacd answers a
* refused stream with an ack like "FAIL (CANNOT OPEN)" - accurate, but it
* means "the drive folder isn't writable", which is what people need to hear.
*/
export function describeUploadError(
error: unknown,
t: (key: "driveNotWritable" | "driveUnavailable" | "uploadFailed") => string,
): string {
const message = error instanceof Error ? error.message : "";
if (/cannot open|can't open|permission denied/i.test(message)) {
return t("driveNotWritable");
}
if (/no fs/i.test(message)) {
return t("driveUnavailable");
}
return message || t("uploadFailed");
}
+1
View File
@@ -1952,6 +1952,7 @@
"uploadFailed": "Failed to upload {{name}}", "uploadFailed": "Failed to upload {{name}}",
"uploadDisabled": "Uploads are disabled for this connection", "uploadDisabled": "Uploads are disabled for this connection",
"driveUnavailable": "Enable RDP drive redirection before uploading files", "driveUnavailable": "Enable RDP drive redirection before uploading files",
"driveNotWritable": "guacd cannot write to the drive folder. Check GUACD_DRIVE_PATH (or the host's Drive Path) and its permissions.",
"dropToUpload": "Drop files to upload" "dropToUpload": "Drop files to upload"
}, },
"toolbar": { "toolbar": {