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_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd-dev
networks:
+3
View File
@@ -12,6 +12,9 @@ services:
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
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
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_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 { GuacamoleTokenService } from "./token-service.js";
import { withRecordingSettings } from "./recording-settings.js";
import { withDriveSettings } from "./drive-settings.js";
import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -658,10 +659,7 @@ router.post(
switch (connectionType) {
case "rdp":
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
guacConfig["drive-path"] = "/drive";
guacConfig["create-drive-path"] = true;
}
guacConfig = withDriveSettings(guacConfig, userId);
token = tokenService.createRdpToken(
hostname,
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,
saveBlobAs,
uploadFile,
describeUploadError,
type RemoteFileEntry,
} from "./guacamole-filesystem.ts";
@@ -76,9 +77,9 @@ export function GuacamoleFileBrowser({
toast.success(t("guacamole.files.uploaded", { name: file.name }));
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t("guacamole.files.uploadFailed", { name: file.name }),
describeUploadError(err, (key) =>
t(`guacamole.files.${key}`, { name: file.name }),
),
);
} finally {
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();
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}}",
"uploadDisabled": "Uploads are disabled for this connection",
"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"
},
"toolbar": {