feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
This commit is contained in:
LukeGus committed 2026-09-21 20:25:52 -05:00
1 parent d440ce9073
commit c6bcfdb80d
50 files changed
+1031 -232

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
# Remote Desktop
RDP, VNC and Telnet remote desktop sessions, packaged as a first-party plugin.
## Why this plugin runs in-process
Every other plugin runs in a `worker_threads` worker and reaches the server
through a structured-clone `postMessage` boundary. That boundary is what makes
the capability gate and the audit trail mean anything: a worker plugin cannot
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
has to go through the broker.
This plugin cannot work that way:
- it owns a `guacamole-lite` WebSocket server on port 30008,
- its `/connect-host/:hostId` route does multi-step credential and tunnel
resolution (SSH jump-host tunnels, a macOS VNC compatibility proxy) using
raw `net.Socket` plumbing,
- it needs plaintext host credentials to connect at all.
None of that can cross a structured-clone boundary, so this plugin runs on the
main thread instead, under the `process:transport-owner` tier, the same as
`ssh-terminal`, `docker` and `host-metrics`.
## guacd is not spawned by this plugin
guacd (the Apache Guacamole proxy daemon that actually speaks RDP/VNC/Telnet)
is a separate, external service reached over plain TCP -- by default a
sibling Docker container, or any network-reachable host configured through the
admin "guacd URL" setting. This plugin does not download, bundle or spawn
guacd as a child process.
That is a deliberate limitation, not an oversight: Apache Guacamole does not
publish prebuilt guacd binaries for any platform (only source tarballs and
their own Docker image), and there is no viable native guacd for Windows at
all. Bundling guacd for Linux and macOS is possible in principle but would
make Termix the ongoing upstream builder and security patcher for guacd plus
FreeRDP/libvncclient/libssh2/libtelnet -- out of scope for this conversion.
guacd's availability is handled as an external dependency with graceful
degradation, exactly as it was before this plugin existed.
## What lives where
Unlike `ssh-terminal` (whose implementation stays in core because
`session-manager.ts` has consumers well beyond the terminal), this plugin's
backend and frontend code is fully relocated here from
`src/backend/hosts/guacamole/` and `src/ui/features/guacamole/`, the same
shape `docker` and `host-metrics` use.
Two pieces of shared infrastructure still reach across the plugin boundary
into this plugin rather than living in core:
- `src/backend/hosts/collab/routes.ts` and
`src/backend/hosts/session-sharing/` import `token-service.ts` and
`guacamole-server.ts` directly to resolve and validate live Guacamole
sessions for collaborative/shared viewing.
- `src/ui/features/collab/` and `src/ui/features/session-sharing/` import
`GuacamoleDisplay.tsx` directly for the same reason.
This is an accepted, temporary coupling: collab and session-sharing are
expected to become plugins of their own later, at which point these become
real inter-plugin references instead of raw relative imports.
## Disabling
Disabling behaves like any other plugin: the rdp/vnc/telnet tabs stop being
offered from the host list, command palette and quick connect, the
`/guacamole` router 404s, and the WebSocket server on port 30008 is closed.
@@ -1,5 +1,5 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../utils/logger.js";
import { guacLogger } from "../../../src/backend/utils/logger.js";
import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
@@ -7,8 +7,8 @@ import {
import {
createCurrentSessionRecordingRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
} from "../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
import fs from "fs";
import path from "path";
@@ -24,7 +24,7 @@ function readGuacdOptions(): { host: string; port: number } {
return resolveGuacdOptions(dbUrl);
}
const GUAC_WS_PORT = 30008;
export const GUAC_WS_PORT = 30008;
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
@@ -250,9 +250,34 @@ function createGuacServer(): GuacamoleLite {
return server;
}
let guacServer = createGuacServer();
let guacServer: GuacamoleLite | null = null;
/**
* Starts the guacamole-lite WebSocket server. Called from this plugin's
* activate() -- see plugins/remote-desktop/backend/index.mjs.
*/
export async function startGuacamoleService(): Promise<void> {
guacServer = createGuacServer();
}
/**
* Stops the guacamole-lite WebSocket server. Called from this plugin's
* deactivate().
*/
export async function stopGuacamoleService(): Promise<void> {
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during shutdown", err as Error);
}
guacServer = null;
}
export async function restartGuacServer(): Promise<void> {
// A no-op while the plugin is disabled: creating a new server here would
// silently resurrect the WS server behind deactivate()'s back.
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
+74
View File
@@ -0,0 +1,74 @@
/**
* Remote Desktop (RDP/VNC/Telnet) - first-party, in-process plugin.
*
* Runs on the main thread rather than in a worker for the same class of
* reason as ssh-terminal/docker (see src/backend/plugins/first-party.ts): it
* owns a WebSocket server (guacamole-lite) on port 30008, and its
* /connect-host/:hostId route does multi-step credential/tunnel resolution
* (jump-host SSH tunnels, a macOS VNC compatibility proxy) involving raw
* net.Socket plumbing the worker's structured-clone postMessage boundary
* cannot carry.
*
* guacd itself is not a process this plugin spawns: it is a separate
* external service (a sibling Docker container by default, or any
* network-reachable guacd) reached over plain TCP. See guacd-config.ts's
* resolveGuacdOptions(). There is nothing to supervise here - guacd's
* availability is handled as an external dependency with graceful
* degradation, same as before this plugin existed.
*
* Its backend logic is real TypeScript physically relocated here (routes.ts,
* guacamole-server.ts, token-service.ts, etc.), compiled by
* scripts/copy-bundled-plugins.cjs like docker's plugin backend, rather than
* kept as a thin wrapper over code left in core -- see that script's own
* comment for why. The one piece that stays in core is
* src/backend/database/routes/guacamole-dispatch.ts, since it is what
* database.ts mounts permanently at /guacamole regardless of whether this
* plugin is installed.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let guacServerModule = null;
let dispatchModule = null;
export async function activate(ctx) {
const [server, routes, dispatch] = await Promise.all([
loadRelative("guacamole-server"),
loadRelative("routes"),
loadRelative("../../../backend/backend/database/routes/guacamole-dispatch"),
]);
guacServerModule = server;
dispatchModule = dispatch;
await server.startGuacamoleService();
dispatch.registerGuacamoleBridge({
router: routes.router,
restart: server.restartGuacServer,
createJoinToken: (guacamoleConnectionId, readOnly) =>
server.tokenService.createJoinToken(guacamoleConnectionId, readOnly),
getSessionInfo: server.getGuacSessionInfo,
});
ctx.log.info(
`Remote Desktop listening on ${server.GUAC_WS_PORT ?? 30008}`,
);
}
export async function deactivate() {
if (dispatchModule) {
dispatchModule.unregisterGuacamoleBridge();
dispatchModule = null;
}
if (guacServerModule) {
await guacServerModule.stopGuacamoleService();
guacServerModule = null;
}
}
@@ -1,32 +1,32 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { getErrorMessage } from "../../../src/backend/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";
import { guacLogger } from "../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
import {
resolveRecipientSharedHostAuthentication,
type RecipientSharedHostAuthResolution,
} from "../../utils/shared-host-auth-resolver.js";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
} from "../../../src/backend/utils/shared-host-auth-resolver.js";
import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import { createJumpHostChain } from "../jump-host-chain.js";
} from "../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { getGuacSessionByConnectId } from "./guacamole-server.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
} from "../../../src/backend/utils/audit-logger.js";
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
import {
buildRdpSettings,
@@ -828,4 +828,4 @@ router.get("/status", async (req, res) => {
}
});
export default router;
export { router };
@@ -1,5 +1,5 @@
import crypto from "crypto";
import { guacLogger } from "../../utils/logger.js";
import { guacLogger } from "../../../src/backend/utils/logger.js";
export interface GuacamoleConnectionSettings {
type?: "rdp" | "vnc" | "telnet";
@@ -1,5 +1,5 @@
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
import { getErrorMessage } from "../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import React, {
useState,
useEffect,
@@ -13,7 +13,7 @@ import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
type GuacamoleTouchMode,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
} from "./GuacamoleDisplay.tsx";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
@@ -21,17 +21,17 @@ import {
logActivity,
isElectron,
} from "@/main-axios.ts";
import { readConfiguredDimension } from "@/features/guacamole/guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "@/api/guacamole-api";
import { readConfiguredDimension } from "./guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
import {
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "@/features/guacamole/GuacamoleFileBrowser.tsx";
import { describeUploadError } from "@/features/guacamole/guacamole-filesystem.ts";
import { canUploadToRdpDrive } from "@/features/guacamole/guacamole-file-drop.ts";
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
import { describeUploadError } from "./guacamole-filesystem.ts";
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
@@ -52,7 +52,7 @@ import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.
import type { SSHHost } from "@/types";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
interface GuacamoleAppProps {
hostId?: string;
@@ -139,7 +139,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
<div className="relative w-full h-full">
<ConnectionScreen
status="disconnected"
message={t("guacamole.hostNotFound")}
message={t("remoteDesktop.hostNotFound")}
/>
</div>
);
@@ -249,11 +249,13 @@ const GuacamoleAppInner = React.forwardRef<
const display = displayRef.current;
if (!display) throw new Error("RDP session is not ready");
await display.uploadFile(file);
toast.success(t("guacamole.files.uploaded", { name: file.name }));
toast.success(
t("remoteDesktop.files.uploaded", { name: file.name }),
);
} catch (error) {
toast.error(
describeUploadError(error, (key) =>
t(`guacamole.files.${key}`, { name: file.name }),
t(`remoteDesktop.files.${key}`, { name: file.name }),
),
);
}
@@ -267,8 +269,8 @@ const GuacamoleAppInner = React.forwardRef<
toast.error(
t(
allowUpload
? "guacamole.files.driveUnavailable"
: "guacamole.files.uploadDisabled",
? "remoteDesktop.files.driveUnavailable"
: "remoteDesktop.files.uploadDisabled",
),
);
}, [allowUpload, t]);
@@ -329,17 +331,17 @@ const GuacamoleAppInner = React.forwardRef<
addLog({
type: "info",
stage: "guac_guacd",
message: t("guacamole.checkingGuacd"),
message: t("remoteDesktop.checkingGuacd"),
});
const status = await getGuacdStatus(resolvedOrigin);
if (status.guacd.status !== "connected") {
throw new Error(t("guacamole.guacdUnavailable"));
throw new Error(t("remoteDesktop.guacdUnavailable"));
}
addLog({
type: "info",
stage: "guac_token",
message: t("guacamole.requestingToken", {
message: t("remoteDesktop.requestingToken", {
type: resolvedProtocolForConnect.toUpperCase(),
}),
});
@@ -408,8 +410,11 @@ const GuacamoleAppInner = React.forwardRef<
await fetchToken();
tokenRetryRef.current.markConnected();
} catch (err: unknown) {
const message = getErrorMessage(err, t("guacamole.failedToConnect"));
setError(message || t("guacamole.failedToConnect"));
const message = getErrorMessage(
err,
t("remoteDesktop.failedToConnect"),
);
setError(message || t("remoteDesktop.failedToConnect"));
addLog({ type: "error", stage: "error", message });
tokenRetryRef.current.markFailed();
}
@@ -471,10 +476,10 @@ const GuacamoleAppInner = React.forwardRef<
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("guacamole.credentialPromptTitle")}
{t("remoteDesktop.credentialPromptTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("guacamole.credentialPromptDescription")}
{t("remoteDesktop.credentialPromptDescription")}
</DialogDescription>
</DialogHeader>
<form
@@ -523,7 +528,7 @@ const GuacamoleAppInner = React.forwardRef<
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button type="submit" variant="outline">
{t("guacamole.connect")}
{t("remoteDesktop.connect")}
</Button>
</div>
</form>
@@ -537,7 +542,7 @@ const GuacamoleAppInner = React.forwardRef<
<div className="relative w-full h-full">
<ConnectionScreen
status={error ? tokenRetry.status : "connecting"}
message={t("guacamole.connecting", {
message={t("remoteDesktop.connecting", {
type: (
protocol ||
hostConfig.connectionType ||
@@ -548,7 +553,7 @@ const GuacamoleAppInner = React.forwardRef<
maxAttempts={tokenRetry.maxAttempts}
nextRetryInMs={tokenRetry.nextRetryInMs}
onManualRetry={handleReconnect}
retryLabel={t("guacamole.retry")}
retryLabel={t("remoteDesktop.retry")}
/>
</div>
);
@@ -564,11 +569,11 @@ const GuacamoleAppInner = React.forwardRef<
{(!isDisplayReady || connectionError) && (
<ConnectionScreen
status={connectionError ? "disconnected" : "connecting"}
message={t("guacamole.connecting", {
message={t("remoteDesktop.connecting", {
type: resolvedProtocol.toUpperCase(),
})}
onManualRetry={handleReconnect}
retryLabel={t("guacamole.reconnect")}
retryLabel={t("remoteDesktop.reconnect")}
className="z-50"
/>
)}
@@ -603,21 +608,21 @@ const GuacamoleAppInner = React.forwardRef<
addLog({
type: "info",
stage,
message: t("guacamole.openingSession", { type }),
message: t("remoteDesktop.openingSession", { type }),
});
break;
case "guac_handshake":
addLog({
type: "info",
stage,
message: t("guacamole.negotiating", { type }),
message: t("remoteDesktop.negotiating", { type }),
});
break;
case "guac_ready":
addLog({
type: "success",
stage,
message: t("guacamole.sessionReady", { type }),
message: t("remoteDesktop.sessionReady", { type }),
});
break;
default:
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import type React from "react";
import {
useEffect,
@@ -613,7 +613,7 @@ export const GuacamoleDisplay = forwardRef<
setHasError(true);
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
onError?.(t("guacamole.connectionError"));
onError?.(t("remoteDesktop.connectionError"));
onDisconnect?.();
break;
}
@@ -621,7 +621,7 @@ export const GuacamoleDisplay = forwardRef<
client.onerror = (error: Guacamole.Status) => {
if (!isMountedRef.current || clientRef.current !== client) return;
const errorMessage = error.message || t("guacamole.connectionError");
const errorMessage = error.message || t("remoteDesktop.connectionError");
setIsReady(false);
setHasError(true);
isConnectingRef.current = false;
@@ -696,7 +696,7 @@ export const GuacamoleDisplay = forwardRef<
if (!isMountedRef.current) return;
setIsReady(false);
setHasError(true);
onError?.(getErrorMessage(error, t("guacamole.connectionError")));
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
}
}, [
getWebSocketConnection,
@@ -958,7 +958,7 @@ export const GuacamoleDisplay = forwardRef<
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none bg-background/70 backdrop-blur-sm">
<div className="flex items-center gap-2 rounded-sm border-2 border-dashed border-border px-4 py-3 text-sm font-semibold">
<Upload className="size-4" />
{t("guacamole.files.dropToUpload")}
{t("remoteDesktop.files.dropToUpload")}
</div>
</div>
)}
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { getErrorMessage } from "@/lib/error-message.js";
import { useCallback, useEffect, useRef, useState } from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
setEntries(await listDirectory(filesystem, target));
} catch (err) {
setEntries([]);
setError(getErrorMessage(err, t("guacamole.files.listFailed")));
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
} finally {
setLoading(false);
}
@@ -74,11 +74,11 @@ export function GuacamoleFileBrowser({
setBusyName(file.name);
try {
await uploadFile(filesystem, path, file);
toast.success(t("guacamole.files.uploaded", { name: file.name }));
toast.success(t("remoteDesktop.files.uploaded", { name: file.name }));
} catch (err) {
toast.error(
describeUploadError(err, (key) =>
t(`guacamole.files.${key}`, { name: file.name }),
t(`remoteDesktop.files.${key}`, { name: file.name }),
),
);
} finally {
@@ -96,7 +96,7 @@ export function GuacamoleFileBrowser({
if (pendingUploads.length === 0) return;
onPendingUploadsHandled();
if (!allowUpload) {
toast.error(t("guacamole.files.uploadDisabled"));
toast.error(t("remoteDesktop.files.uploadDisabled"));
return;
}
void upload(pendingUploads);
@@ -112,7 +112,7 @@ export function GuacamoleFileBrowser({
toast.error(
err instanceof Error
? err.message
: t("guacamole.files.downloadFailed", { name: entry.name }),
: t("remoteDesktop.files.downloadFailed", { name: entry.name }),
);
} finally {
setBusyName(null);
@@ -125,7 +125,7 @@ export function GuacamoleFileBrowser({
<div className="absolute right-2 top-2 bottom-2 z-30 flex w-80 flex-col rounded-sm border border-border bg-background/95 shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<span className="flex-1 truncate text-xs font-semibold">
{t("guacamole.files.title")}
{t("remoteDesktop.files.title")}
</span>
<Button
variant="ghost"
@@ -145,7 +145,7 @@ export function GuacamoleFileBrowser({
className="size-6"
disabled={path === "/"}
onClick={() => setPath(parentPath(path))}
aria-label={t("guacamole.files.parent")}
aria-label={t("remoteDesktop.files.parent")}
>
<ArrowUp className="size-3.5" />
</Button>
@@ -157,7 +157,7 @@ export function GuacamoleFileBrowser({
size="icon"
className="size-6"
onClick={() => void refresh(path)}
aria-label={t("guacamole.files.refresh")}
aria-label={t("remoteDesktop.files.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
@@ -174,7 +174,7 @@ export function GuacamoleFileBrowser({
)}
{!loading && !error && entries.length === 0 && (
<p className="px-2 py-3 text-[11px] text-muted-foreground">
{t("guacamole.files.empty")}
{t("remoteDesktop.files.empty")}
</p>
)}
{!loading &&
@@ -204,7 +204,7 @@ export function GuacamoleFileBrowser({
className="size-5 shrink-0"
disabled={busyName === entry.name}
onClick={() => void download(entry)}
aria-label={t("guacamole.files.download")}
aria-label={t("remoteDesktop.files.download")}
>
<Download className="size-3" />
</Button>
@@ -235,8 +235,8 @@ export function GuacamoleFileBrowser({
>
<Upload className="mr-1 size-3" />
{busyName !== null
? t("guacamole.files.uploading", { name: busyName })
: t("guacamole.files.upload")}
? t("remoteDesktop.files.uploading", { name: busyName })
: t("remoteDesktop.files.upload")}
</Button>
</div>
)}
@@ -1,8 +1,8 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { GuacamoleToolbar } from "../../../features/guacamole/GuacamoleToolbar.js";
import type { GuacamoleDisplayHandle } from "../../../features/guacamole/GuacamoleDisplay.js";
import { GuacamoleToolbar } from "./GuacamoleToolbar.js";
import type { GuacamoleDisplayHandle } from "./GuacamoleDisplay.js";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
@@ -49,7 +49,7 @@ describe("GuacamoleToolbar Windows key", () => {
]);
sendKey.mockClear();
fireEvent.click(getByText("guacamole.toolbar.win"));
fireEvent.click(getByText("remoteDesktop.toolbar.win"));
expect(sendKey).toHaveBeenCalledWith(0xffeb, true);
});
@@ -75,8 +75,8 @@ describe("GuacamoleToolbar Windows key", () => {
<GuacamoleToolbar displayRef={displayRef} protocol="vnc" zoom={1.25} />,
);
fireEvent.click(getByLabelText("guacamole.toolbar.zoomOut"));
fireEvent.click(getByLabelText("guacamole.toolbar.zoomIn"));
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomOut"));
fireEvent.click(getByLabelText("remoteDesktop.toolbar.zoomIn"));
fireEvent.click(getByText("125%"));
expect(zoomOut).toHaveBeenCalledOnce();
expect(zoomIn).toHaveBeenCalledOnce();
@@ -108,7 +108,7 @@ describe("GuacamoleToolbar Windows key", () => {
/>,
);
fireEvent.click(getByLabelText("guacamole.toolbar.hide"));
fireEvent.click(getByLabelText("remoteDesktop.toolbar.hide"));
expect(onHide).toHaveBeenCalledOnce();
});
@@ -30,14 +30,14 @@ import {
import type {
GuacamoleDisplayHandle,
GuacamoleTouchMode,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
} from "./GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
detectRuntimeMetaKeyFamily,
metaKeyLabels,
type MetaKeyFamily,
} from "@/features/guacamole/guacamole-meta-key.ts";
} from "./guacamole-meta-key.ts";
interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
@@ -302,7 +302,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.expand")}
{t("remoteDesktop.toolbar.expand")}
</TooltipContent>
</Tooltip>
) : (
@@ -319,7 +319,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.dragHandle")}
{t("remoteDesktop.toolbar.dragHandle")}
</TooltipContent>
</Tooltip>
@@ -349,8 +349,8 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{touchMode === "touchscreen"
? t("guacamole.toolbar.switchToTrackpad")
: t("guacamole.toolbar.switchToTouch")}
? t("remoteDesktop.toolbar.switchToTrackpad")
: t("remoteDesktop.toolbar.switchToTouch")}
</TooltipContent>
</Tooltip>
</>
@@ -361,7 +361,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
<>
<div className={SEP} />
<TipIconBtn
tooltip={t("guacamole.files.title")}
tooltip={t("remoteDesktop.files.title")}
onClick={onToggleFileBrowser}
className={cn(fileBrowserOpen && "bg-muted text-foreground")}
>
@@ -374,26 +374,26 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
<>
<div className={SEP} />
<TipIconBtn
tooltip={t("guacamole.toolbar.zoomOut")}
tooltip={t("remoteDesktop.toolbar.zoomOut")}
onClick={() => displayRef.current?.zoomOut()}
>
<ZoomOut className="size-3.5" />
</TipIconBtn>
<TipBtn
tooltip={t("guacamole.toolbar.resetZoom")}
tooltip={t("remoteDesktop.toolbar.resetZoom")}
onClick={() => displayRef.current?.resetZoom()}
className="min-w-12 tabular-nums"
>
{Math.round(zoom * 100)}%
</TipBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.zoomIn")}
tooltip={t("remoteDesktop.toolbar.zoomIn")}
onClick={() => displayRef.current?.zoomIn()}
>
<ZoomIn className="size-3.5" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.fitToScreen")}
tooltip={t("remoteDesktop.toolbar.fitToScreen")}
onClick={() => displayRef.current?.resetZoom()}
>
<Scan className="size-3.5" />
@@ -406,7 +406,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
<>
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.ctrlAltDel")}
tooltip={t("remoteDesktop.toolbar.ctrlAltDel")}
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
>
CAD
@@ -435,13 +435,17 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
[
"ctrl",
MODIFIER_KEYSYMS.ctrl,
t("guacamole.toolbar.ctrl"),
t("remoteDesktop.toolbar.ctrl"),
],
[
"alt",
MODIFIER_KEYSYMS.alt,
t("remoteDesktop.toolbar.alt"),
],
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
[
"shift",
MODIFIER_KEYSYMS.shift,
t("guacamole.toolbar.shift"),
t("remoteDesktop.toolbar.shift"),
],
["win", MODIFIER_KEYSYMS.win, t(meta.stickyKey)],
] as [string, number, string][]
@@ -462,8 +466,12 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{stickyKeys[ks]
? t("guacamole.toolbar.stickyActive", { key: label })
: t("guacamole.toolbar.stickyInactive", { key: label })}
? t("remoteDesktop.toolbar.stickyActive", {
key: label,
})
: t("remoteDesktop.toolbar.stickyInactive", {
key: label,
})}
</TooltipContent>
</Tooltip>
))}
@@ -473,7 +481,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
{/* Function key toggle */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.fnToggle")}
tooltip={t("remoteDesktop.toolbar.fnToggle")}
onClick={() => setShowFKeys((v) => !v)}
className={cn(
showFKeys &&
@@ -498,37 +506,37 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
{/* Navigation */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.esc")}
tooltip={t("remoteDesktop.toolbar.esc")}
onClick={() => sendCombo(0xff1b)}
>
Esc
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.tab")}
tooltip={t("remoteDesktop.toolbar.tab")}
onClick={() => sendCombo(0xff09)}
>
Tab
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.home")}
tooltip={t("remoteDesktop.toolbar.home")}
onClick={() => sendCombo(0xff50)}
>
Home
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.end")}
tooltip={t("remoteDesktop.toolbar.end")}
onClick={() => sendCombo(0xff57)}
>
End
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageUp")}
tooltip={t("remoteDesktop.toolbar.pageUp")}
onClick={() => sendCombo(0xff55)}
>
PgUp
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageDown")}
tooltip={t("remoteDesktop.toolbar.pageDown")}
onClick={() => sendCombo(0xff56)}
>
PgDn
@@ -538,7 +546,7 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
<div className="flex flex-col ml-0.5">
<div className="flex justify-center">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowUp")}
tooltip={t("remoteDesktop.toolbar.arrowUp")}
onClick={() => sendCombo(0xff52)}
>
<ChevronUp className="size-3" />
@@ -546,19 +554,19 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</div>
<div className="flex">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowLeft")}
tooltip={t("remoteDesktop.toolbar.arrowLeft")}
onClick={() => sendCombo(0xff51)}
>
<ChevronLeft className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowDown")}
tooltip={t("remoteDesktop.toolbar.arrowDown")}
onClick={() => sendCombo(0xff54)}
>
<ChevronDown className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowRight")}
tooltip={t("remoteDesktop.toolbar.arrowRight")}
onClick={() => sendCombo(0xff53)}
>
<ChevronRight className="size-3" />
@@ -579,12 +587,12 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.collapse")}
{t("remoteDesktop.toolbar.collapse")}
</TooltipContent>
</Tooltip>
{onHide && (
<TipIconBtn
tooltip={t("guacamole.toolbar.hide")}
tooltip={t("remoteDesktop.toolbar.hide")}
onClick={onHide}
>
<X className="size-3.5" />
@@ -25,7 +25,7 @@ import {
getGuacdStatus,
getGuacamoleConnectionId,
getGuacamoleTokenFromHost,
} from "../../api/guacamole-api";
} from "./guacamole-api";
beforeEach(() => {
authApiMock.get.mockClear();
File renamed without changes.
@@ -3,7 +3,7 @@ import {
isPasteShortcut,
pasteTextToRemote,
type GuacamoleClipboardClient,
} from "../../../features/guacamole/guacamole-clipboard.js";
} from "./guacamole-clipboard.js";
describe("Guacamole clipboard paste", () => {
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
getGuacamoleDisplaySize,
readConfiguredDimension,
} from "../../../features/guacamole/guacamole-display-size";
} from "./guacamole-display-size";
describe("getGuacamoleDisplaySize", () => {
it("requests native pixels and matching DPI for HiDPI RDP", () => {
@@ -3,7 +3,7 @@ import {
canUploadToRdpDrive,
getFileDropDisposition,
hasDraggedFiles,
} from "@/features/guacamole/guacamole-file-drop.ts";
} from "./guacamole-file-drop.ts";
describe("Guacamole file drop", () => {
it("recognizes external file drags", () => {
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
detectMetaKeyFamily,
metaKeyLabels,
} from "../../../features/guacamole/guacamole-meta-key.js";
import { detectMetaKeyFamily, metaKeyLabels } from "./guacamole-meta-key.js";
describe("guacamole toolbar meta key labels", () => {
it("keeps Windows labels for RDP regardless of the local device", () => {
@@ -56,9 +56,9 @@ export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
family,
short: "Cmd",
lock: "Cmd+L",
stickyKey: "guacamole.toolbar.cmd",
keyTooltip: "guacamole.toolbar.cmdKey",
lockTooltip: "guacamole.toolbar.cmdL",
stickyKey: "remoteDesktop.toolbar.cmd",
keyTooltip: "remoteDesktop.toolbar.cmdKey",
lockTooltip: "remoteDesktop.toolbar.cmdL",
};
}
if (family === "super") {
@@ -66,17 +66,17 @@ export function metaKeyLabels(family: MetaKeyFamily): MetaKeyLabels {
family,
short: "Super",
lock: "Super+L",
stickyKey: "guacamole.toolbar.super",
keyTooltip: "guacamole.toolbar.superKey",
lockTooltip: "guacamole.toolbar.superL",
stickyKey: "remoteDesktop.toolbar.super",
keyTooltip: "remoteDesktop.toolbar.superKey",
lockTooltip: "remoteDesktop.toolbar.superL",
};
}
return {
family,
short: "Win",
lock: "Win+L",
stickyKey: "guacamole.toolbar.win",
keyTooltip: "guacamole.toolbar.winKey",
lockTooltip: "guacamole.toolbar.winL",
stickyKey: "remoteDesktop.toolbar.win",
keyTooltip: "remoteDesktop.toolbar.winKey",
lockTooltip: "remoteDesktop.toolbar.winL",
};
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { bindPointerInput } from "../../../features/guacamole/guacamole-pointer.js";
import { bindPointerInput } from "./guacamole-pointer.js";
function listenedEvents(): {
element: HTMLElement;
@@ -6,7 +6,7 @@ import {
MAX_SCROLL_CLICKS_PER_FLUSH,
SCROLL_FLUSH_INTERVAL_MS,
takeScrollClicks,
} from "../../../features/guacamole/guacamole-scroll.js";
} from "./guacamole-scroll.js";
describe("guacamole scroll coalescing", () => {
it("caps a click flood so a laggy tunnel cannot queue a backlog", () => {
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fetchId = vi.hoisted(() => vi.fn());
vi.mock("@/api/guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
import { watchGuacamoleConnectionId } from "../../../features/guacamole/guacamole-session-id";
vi.mock("./guacamole-api", () => ({ getGuacamoleConnectionId: fetchId }));
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
beforeEach(() => {
vi.useFakeTimers();
fetchId.mockReset();
@@ -1,4 +1,4 @@
import { getGuacamoleConnectionId } from "@/api/guacamole-api";
import { getGuacamoleConnectionId } from "./guacamole-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
export function watchGuacamoleConnectionId(
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildGuacamoleWebSocketBaseUrl } from "../../../features/guacamole/guacamole-websocket-url.js";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js";
const httpsLocation = {
protocol: "https:",
+28
View File
@@ -0,0 +1,28 @@
/**
* Frontend half of the remote-desktop plugin.
*
* rdp/vnc/telnet are built-in tabs owned by this plugin (see
* BUILT_IN_TABS_BY_PLUGIN in src/ui/shell/pluginLoader.ts), the same
* category ssh-terminal's "terminal" tab is in: they render through
* tabUtils.tsx's hardcoded switch rather than through registerTabComponent,
* because GuacamoleApp needs hostId/tabId/protocol/quickConnectHost/ref --
* more props than the generic { tab } plugin surface can carry. There is
* nothing to register or unregister here; isTabTypeAvailable("rdp"/"vnc"/
* "telnet") is what actually gates them off when this plugin is disabled.
*
* This file exists only so the plugin has a frontend entry point matching
* every other plugin's shape, ready for the day a real frontend loader
* calls register()/unregister() directly instead of pluginLoader.ts's
* hand-duplicated stopgap wiring.
*/
export const id = "remote-desktop";
export const tabIds = ["rdp", "vnc", "telnet"];
export async function register() {
// No-op: see module comment.
}
export async function unregister() {
// No-op: see module comment.
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { needsRdpCredentialPrompt } from "@/features/guacamole/rdp-credential-prompt";
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
describe("needsRdpCredentialPrompt", () => {
it("prompts recipients when RDP has no saved authentication", () => {
+51
View File
@@ -0,0 +1,51 @@
{
"id": "remote-desktop",
"name": "Remote Desktop",
"version": "1.0.0",
"description": "RDP, VNC and Telnet remote desktop sessions, with clipboard, drive redirection, session recording and session sharing.",
"author": { "name": "Termix" },
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
"category": "Terminal",
"icon": "Monitor",
"engine": { "termix": ">=2.9.0", "api": "1" },
"capabilities": {
"backend": true,
"frontend": true,
"electron": true,
"platforms": ["linux", "win32", "darwin"]
},
"permissions": ["process:transport-owner"],
"contributes": {
"tabs": [
{
"id": "rdp",
"titleKey": "hosts.tabRdp",
"icon": "Monitor",
"openFrom": ["host-context-menu", "palette"]
},
{
"id": "vnc",
"titleKey": "hosts.tabVnc",
"icon": "MousePointerClick",
"openFrom": ["host-context-menu", "palette"]
},
{
"id": "telnet",
"titleKey": "hosts.tabTelnet",
"icon": "Terminal",
"openFrom": ["host-context-menu", "palette"]
}
],
"hostCapability": [
{ "key": "enableRdp", "labelKey": "hosts.tabRdp", "editorTab": "rdp" },
{ "key": "enableVnc", "labelKey": "hosts.tabVnc", "editorTab": "vnc" },
{
"key": "enableTelnet",
"labelKey": "hosts.tabTelnet",
"editorTab": "telnet"
}
]
},
"sidecars": []
}
@@ -0,0 +1,113 @@
// Mounted at /guacamole. Remote Desktop ships as a first-party in-process
// plugin (plugins/remote-desktop) rather than a route file imported directly
// here, so this module keeps /guacamole/* working at its original path
// without handing the plugin a reference to the shared app instance.
//
// It also brokers the small surface collab, session-sharing and the admin
// settings route need from the plugin (join tokens, live-session lookups, a
// server restart hook). Those core modules cannot import the plugin's
// TypeScript directly -- plugins/ is compiled by tsconfig.plugins.json as a
// one-directional read of src/backend/ (see copy-bundled-plugins.cjs), and a
// core file under tsconfig.node.json's rootDir cannot statically import
// something outside it. So the plugin registers this bridge once on
// activate, and clears it on deactivate; a disabled/uninstalled plugin falls
// back to the safe defaults below (404 for the router, null for session
// lookups, a no-op restart).
//
// This is an accepted, temporary coupling -- see the plugin's own README.
// collab and session-sharing are expected to become plugins of their own
// later, at which point this becomes a real inter-plugin service reference
// instead of a bridge object living in core.
//
// The router forwards with no auth of its own: it delegates to exactly one
// first-party router, and every route in that router already applies its
// own auth middleware.
import type { Request, Response, NextFunction, Router } from "express";
import { databaseLogger } from "../../utils/logger.js";
export interface GuacamoleSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
export interface GuacamoleBridge {
router: Router;
restart: () => Promise<void>;
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
getSessionInfo: (
guacamoleConnectionId: string,
) => GuacamoleSessionInfo | null;
}
let bridge: GuacamoleBridge | null = null;
export function registerGuacamoleBridge(next: GuacamoleBridge): void {
bridge = next;
databaseLogger.info("Registered Remote Desktop plugin bridge", {
operation: "guacamole_dispatch_register",
});
}
export function unregisterGuacamoleBridge(): void {
if (!bridge) return;
bridge = null;
databaseLogger.info("Unregistered Remote Desktop plugin bridge", {
operation: "guacamole_dispatch_register",
});
}
/**
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits
* the guacd URL setting (see user-settings-routes.ts). A no-op while the
* plugin is disabled/uninstalled -- restarting a server that does not exist
* would otherwise silently resurrect it outside the plugin lifecycle.
*/
export async function restartGuacamoleService(): Promise<void> {
if (!bridge) return;
await bridge.restart();
}
/**
* Mints a read-only/writable join token for an existing live Guacamole
* session, for collab/session-sharing to hand to an invited viewer. Throws
* when the plugin is disabled/uninstalled -- unlike the read-only lookups
* below, a caller asking to mint a join token needs to know it failed rather
* than silently getting an unusable value.
*/
export function createGuacamoleJoinToken(
guacamoleConnectionId: string,
readOnly: boolean,
): string {
if (!bridge) {
throw new Error("Remote Desktop plugin is not available");
}
return bridge.createJoinToken(guacamoleConnectionId, readOnly);
}
/**
* Looks up a live Guacamole session by guacd's own connection id. Returns
* null both when no such session exists and when the plugin is
* disabled/uninstalled -- to a caller checking "is this session live", the
* two cases mean the same thing.
*/
export function getGuacamoleSessionInfo(
guacamoleConnectionId: string,
): GuacamoleSessionInfo | null {
return bridge?.getSessionInfo(guacamoleConnectionId) ?? null;
}
export default function guacamoleDispatch(
req: Request,
res: Response,
next: NextFunction,
): void {
if (!bridge) {
res.status(404).json({ error: "Remote Desktop is not available" });
return;
}
bridge.router(req, res, next);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { withDriveSettings } from "../../../../../plugins/remote-desktop/backend/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);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { resolveJumpTunnelEndpoint } from "../../../../../plugins/remote-desktop/backend/jump-tunnel-endpoint.js";
describe("resolveJumpTunnelEndpoint", () => {
it("keeps an in-process guacd tunnel on loopback", () => {
expect(resolveJumpTunnelEndpoint("localhost")).toEqual({
bindHost: "127.0.0.1",
advertisedHost: "127.0.0.1",
});
});
it("exposes the tunnel to a separate guacd container", () => {
expect(resolveJumpTunnelEndpoint("guacd")).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix",
});
});
it("supports a custom backend hostname for external guacd", () => {
expect(
resolveJumpTunnelEndpoint("guacd.example", "termix-backend"),
).toEqual({
bindHost: "0.0.0.0",
advertisedHost: "termix-backend",
});
});
});
@@ -0,0 +1,115 @@
import net from "net";
import { afterEach, describe, expect, it } from "vitest";
import { createMacosVncCompatibilityProxy } from "../../../../../plugins/remote-desktop/backend/macos-vnc-proxy.js";
const closers: Array<() => void> = [];
afterEach(() => {
for (const close of closers.splice(0)) close();
});
async function listen(server: net.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
resolve((server.address() as net.AddressInfo).port);
});
});
}
async function read(socket: net.Socket, length: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
let received = Buffer.alloc(0);
const onData = (chunk: Buffer) => {
received = Buffer.concat([received, chunk]);
if (received.length < length) return;
socket.off("data", onData);
resolve(received);
};
socket.on("data", onData);
socket.once("error", reject);
});
}
describe("createMacosVncCompatibilityProxy", () => {
it("normalizes Apple's private RFB banner and forces classic VNC auth", async () => {
let clientBanner = "";
let selectedSecurityType = 0;
const target = net.createServer((socket) => {
socket.write("RFB 003.");
socket.write("889\n");
socket.once("data", (data) => {
clientBanner = data.toString("ascii");
socket.write(Buffer.from([5, 30, 33]));
socket.write(Buffer.from([36, 2, 35]));
socket.once("data", (selection) => {
selectedSecurityType = selection[0];
socket.write("desktop-data");
});
});
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe(
"RFB 003.008\n",
);
client.write("RFB 003.008\n");
expect([...(await read(client, 2))]).toEqual([1, 2]);
client.write(Buffer.from([2]));
expect((await read(client, 12)).toString("ascii")).toBe("desktop-data");
expect(clientBanner).toBe("RFB 003.008\n");
expect(selectedSecurityType).toBe(2);
});
it("passes standard RFB negotiation through unchanged", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.008\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 2])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 2]);
});
it("passes Apple's security types through when VNC auth is unavailable", async () => {
const target = net.createServer((socket) => {
socket.write("RFB 003.889\n");
socket.once("data", () => socket.write(Buffer.from([2, 30, 33])));
});
const targetPort = await listen(target);
closers.push(() => target.close());
const proxy = await createMacosVncCompatibilityProxy({
targetHost: "127.0.0.1",
targetPort,
bindHost: "127.0.0.1",
});
closers.push(proxy.close);
const client = net.createConnection(proxy.port, "127.0.0.1");
closers.push(() => client.destroy());
expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n");
client.write("RFB 003.008\n");
expect([...(await read(client, 3))]).toEqual([2, 30, 33]);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../../../../plugins/remote-desktop/backend/rdp-settings.js";
describe("buildRdpSettings", () => {
it("keeps saved RDP settings authoritative over stale advanced config", () => {
expect(
buildRdpSettings({
port: 3390,
domain: "EXAMPLE",
security: "nla",
ignoreCert: true,
guacConfig: {
port: 3389,
domain: "OLD",
security: "rdp",
"ignore-cert": false,
"color-depth": 24,
},
guacdOverrides: { guacdHost: "guacd.internal" },
}),
).toEqual({
port: 3390,
domain: "EXAMPLE",
security: "nla",
"ignore-cert": true,
"color-depth": 24,
guacdHost: "guacd.internal",
});
});
it("preserves an advanced security value when no saved value exists", () => {
expect(
buildRdpSettings({
port: 3389,
ignoreCert: false,
guacConfig: { security: "tls" },
guacdOverrides: {},
}).security,
).toBe("tls");
});
it("uses the prompted domain for prompt-on-connect authentication", () => {
expect(resolveRdpDomain("none", "EXAMPLE", "OLD")).toBe("EXAMPLE");
expect(resolveRdpDomain("none", "", "OLD")).toBe("");
});
it("keeps the stored domain for saved authentication", () => {
expect(resolveRdpDomain("direct", "EXAMPLE", "SAVED")).toBe("SAVED");
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
});
});
describe("resolveRdpAuthTypeForConnect", () => {
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "secretless" },
}),
).toBe("none");
});
it("uses a recipient override instead of prompting", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "personal-override" },
}),
).toBe("credential");
});
it("uses authentication shared by the owner", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "credential",
sharedResolution: { source: "owner-shared", authType: "credential" },
}),
).toBe("credential");
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { withRecordingSettings } from "../../../../../plugins/remote-desktop/backend/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" });
});
});
@@ -0,0 +1,104 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
it("returns the minted token without waiting for a connection that needs that token", async () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/routes.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(s) =>
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression)
.arguments[1] as ts.ArrowFunction;
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
const index = block.tryBlock.statements
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
.filter((i) => i >= 0)
.at(-1)!;
expect(index).toBeGreaterThan(-1);
const tail = block.tryBlock.statements
.slice(index + 1)
.map((s) => s.getText(ast))
.join("\n");
const json = vi.fn();
const wait = vi.fn(() => {
throw new Error("Client has not received the token yet");
});
await runInNewContext(
ts.transpileModule(`(async () => { ${tail} })()`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText,
{
waitForGuacdOpen: wait,
token: "minted-token",
termixConnectId: "correlation-id",
req: {},
getRequestMeta: () => ({}),
logAudit: async () => {},
getAuditUsername: async () => "owner",
userId: "owner",
connectionType: "rdp",
hostId: 7,
hostname: "host",
port: 3389,
res: { json },
},
);
expect(wait).not.toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({
token: "minted-token",
termixConnectId: "correlation-id",
guacamoleConnectionId: null,
});
});
it("only reveals the live session ID to its owner", () => {
const source = readFileSync(
new URL(
"../../../../../plugins/remote-desktop/backend/guacamole-server.ts",
import.meta.url,
),
"utf8",
);
const ast = ts.createSourceFile(
"server.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const fn = ast.statements.find(
(s) =>
ts.isFunctionDeclaration(s) &&
s.name?.text === "getGuacSessionByConnectId",
)!;
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
const context = {
exports: {} as Record<string, (id: string, user: string) => unknown>,
guacSessionByConnectId: new Map([["connect-id", info]]),
};
runInNewContext(
ts.transpileModule(fn.getText(ast), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const lookup = context.exports.getGuacSessionByConnectId;
expect(lookup("connect-id", "owner")).toEqual(info);
expect(lookup("connect-id", "other")).toBeNull();
expect(lookup("pending", "owner")).toBeNull();
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../../../src/backend/utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } =
await import("../../../../../plugins/remote-desktop/backend/token-service.js");
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
port: 3389,
"ignore-cert": true,
"disable-auth": true,
});
expect(decrypted?.connection.settings.username).toBeUndefined();
expect(decrypted?.connection.settings.password).toBeUndefined();
});
it("keeps normal RDP credential authentication unchanged", () => {
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.settings).toMatchObject({
hostname: "windows.example.test",
username: "Administrator",
password: "secret",
port: 3389,
});
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
});
it("preserves recording metadata outside guacd connection settings", () => {
const recording = {
hostId: 42,
userId: "user-1",
protocol: "vnc" as const,
path: "recording.guac",
startedAt: "2026-07-14T00:00:00.000Z",
};
const token = tokenService.createVncToken(
"vnc.example.test",
"user",
"secret",
{},
recording,
);
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
const termixMeta = {
termixConnectId: "connect-1",
hostId: 7,
ownerUserId: "user-1",
protocol: "rdp" as const,
};
const token = tokenService.createRdpToken(
"windows.example.test",
"Administrator",
"secret",
{},
undefined,
termixMeta,
);
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
});
it("createJoinToken sets connection.join, not connection.type", () => {
const token = tokenService.createJoinToken("guacd-conn-123", true);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-123");
expect(decrypted?.connection.type).toBeUndefined();
expect(decrypted?.connection.readOnly).toBe(true);
});
it("createJoinToken round-trips a read-write join through decryptToken", () => {
const token = tokenService.createJoinToken("guacd-conn-456", false);
const decrypted = tokenService.decryptToken(token);
expect(decrypted?.connection.join).toBe("guacd-conn-456");
expect(decrypted?.connection.readOnly).toBe(false);
expect(decrypted?.recording).toBeUndefined();
});
});
@@ -1,107 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
STREAM_INDEX_MIMETYPE,
basename,
isDirectoryMimetype,
joinPath,
parentPath,
parseDirectoryIndex,
createClientFileStream,
type GuacamoleFileStreamClient,
} from "../../../features/guacamole/guacamole-filesystem.js";
import type Guacamole from "guacamole-common-js";
describe("path helpers", () => {
it("joins onto the root without doubling the separator", () => {
expect(joinPath("/", "Users")).toBe("/Users");
expect(joinPath("/Users", "kei")).toBe("/Users/kei");
});
it("stops walking up at the root", () => {
expect(parentPath("/Users/kei")).toBe("/Users");
expect(parentPath("/Users")).toBe("/");
expect(parentPath("/")).toBe("/");
});
it("reads the trailing segment as the name", () => {
expect(basename("/Users/kei/notes.txt")).toBe("notes.txt");
expect(basename("notes.txt")).toBe("notes.txt");
});
});
describe("isDirectoryMimetype", () => {
it("treats only the stream index mimetype as a directory", () => {
expect(isDirectoryMimetype(STREAM_INDEX_MIMETYPE)).toBe(true);
expect(isDirectoryMimetype("text/plain")).toBe(false);
});
});
describe("parseDirectoryIndex", () => {
it("sorts directories before files and alphabetises within each group", () => {
const json = JSON.stringify({
"/report.pdf": "application/pdf",
"/apps": STREAM_INDEX_MIMETYPE,
"/notes.txt": "text/plain",
"/Documents": STREAM_INDEX_MIMETYPE,
});
expect(parseDirectoryIndex(json, "/").map((e) => e.name)).toEqual([
"apps",
"Documents",
"notes.txt",
"report.pdf",
]);
});
it("marks entries carrying the stream index mimetype as directories", () => {
const json = JSON.stringify({
"/apps": STREAM_INDEX_MIMETYPE,
"/notes.txt": "text/plain",
});
const [dir, file] = parseDirectoryIndex(json, "/");
expect(dir).toEqual({
name: "apps",
path: "/apps",
mimetype: STREAM_INDEX_MIMETYPE,
isDirectory: true,
});
expect(file.isDirectory).toBe(false);
});
it("keeps absolute keys as-is when listing a nested directory", () => {
const json = JSON.stringify({ "/Users/kei/notes.txt": "text/plain" });
expect(parseDirectoryIndex(json, "/Users/kei")[0].path).toBe(
"/Users/kei/notes.txt",
);
});
it("resolves a relative key against the directory being listed", () => {
const json = JSON.stringify({ "notes.txt": "text/plain" });
expect(parseDirectoryIndex(json, "/Users/kei")[0].path).toBe(
"/Users/kei/notes.txt",
);
});
it("returns nothing for an empty directory", () => {
expect(parseDirectoryIndex("{}", "/")).toEqual([]);
});
});
describe("direct RDP upload", () => {
it("opens a connection-level file stream when no filesystem object is available", () => {
const stream = {} as Guacamole.OutputStream;
const client = {
createFileStream: vi.fn(() => stream),
} as GuacamoleFileStreamClient;
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
expect(createClientFileStream(client, file)).toBe(stream);
expect(client.createFileStream).toHaveBeenCalledWith(
"text/plain",
"notes.txt",
);
});
});