diff --git a/docker/compose-dev.yml b/docker/compose-dev.yml index 3ed77957..4165ad97 100644 --- a/docker/compose-dev.yml +++ b/docker/compose-dev.yml @@ -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: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b0d2a850..fda099a7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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. diff --git a/src/backend/hosts/guacamole/drive-settings.ts b/src/backend/hosts/guacamole/drive-settings.ts new file mode 100644 index 00000000..12baa0ca --- /dev/null +++ b/src/backend/hosts/guacamole/drive-settings.ts @@ -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, + userId: string, + env: NodeJS.ProcessEnv = process.env, +): Record { + 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, + }; +} diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index 46acbe97..e5374eda 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -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, diff --git a/src/backend/tests/hosts/guacamole/drive-settings.test.ts b/src/backend/tests/hosts/guacamole/drive-settings.test.ts new file mode 100644 index 00000000..0110f8ed --- /dev/null +++ b/src/backend/tests/hosts/guacamole/drive-settings.test.ts @@ -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); + }); +}); diff --git a/src/ui/features/guacamole/GuacamoleFileBrowser.tsx b/src/ui/features/guacamole/GuacamoleFileBrowser.tsx index b2d58f48..d612f561 100644 --- a/src/ui/features/guacamole/GuacamoleFileBrowser.tsx +++ b/src/ui/features/guacamole/GuacamoleFileBrowser.tsx @@ -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); diff --git a/src/ui/features/guacamole/guacamole-filesystem.test.ts b/src/ui/features/guacamole/guacamole-filesystem.test.ts new file mode 100644 index 00000000..55d61eff --- /dev/null +++ b/src/ui/features/guacamole/guacamole-filesystem.test.ts @@ -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"); + }); +}); diff --git a/src/ui/features/guacamole/guacamole-filesystem.ts b/src/ui/features/guacamole/guacamole-filesystem.ts index 8ad5be20..5504c047 100644 --- a/src/ui/features/guacamole/guacamole-filesystem.ts +++ b/src/ui/features/guacamole/guacamole-filesystem.ts @@ -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"); +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 4606515f..e5796153 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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": {