feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
This commit is contained in:
LukeGus committed 2026-09-21 20:26:09 -05:00
1 parent c6bcfdb80d
commit 76b2e5739a
41 files changed
+361 -686

No files matched your search

@@ -56,13 +56,9 @@ vi.mock("../../../utils/audit-logger.js", () => ({
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
GuacamoleTokenService: {
getInstance: () => ({
createJoinToken: (id: string, readOnly: boolean) =>
`join:${id}:${readOnly}`,
}),
},
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
createGuacamoleJoinToken: (id: string, readOnly: boolean) =>
`join:${id}:${readOnly}`,
}));
vi.mock("../../../hosts/collab/room-hub.js", () => ({
collabRoomHub: {
@@ -1,34 +0,0 @@
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);
});
});
@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveJumpTunnelEndpoint } from "../../../hosts/guacamole/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",
});
});
});
@@ -1,115 +0,0 @@
import net from "net";
import { afterEach, describe, expect, it } from "vitest";
import { createMacosVncCompatibilityProxy } from "../../../hosts/guacamole/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]);
});
});
@@ -1,84 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../../hosts/guacamole/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");
});
});
@@ -1,68 +0,0 @@
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" });
});
});
@@ -1,98 +0,0 @@
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("../../../hosts/guacamole/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("../../../hosts/guacamole/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();
});
@@ -1,105 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } =
await import("../../../hosts/guacamole/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();
});
});
@@ -63,18 +63,13 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({
},
}));
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
getGuacSessionInfo: (guacamoleConnectionId: string) =>
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
state.guacSessions.get(guacamoleConnectionId) ?? null,
}));
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
GuacamoleTokenService: {
getInstance: () => ({
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
`join-token:${guacamoleConnectionId}:${readOnly}`,
}),
},
createGuacamoleJoinToken: (
guacamoleConnectionId: string,
readOnly: boolean,
) => `join-token:${guacamoleConnectionId}:${readOnly}`,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
@@ -186,3 +186,61 @@ describe("bundled host-metrics plugin", () => {
expect(source).toMatch(/export async function deactivate/);
});
});
describe("bundled remote-desktop plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "remote-desktop");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("remote-desktop"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("remote-desktop");
expect(manifest?.category).toBe("Terminal");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(runsInProcess("remote-desktop", manifest!.permissions)).toBe(true);
});
it("contributes the rdp/vnc/telnet tabs the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const tabIds = manifest?.contributes?.tabs?.map((tab) => tab.id);
expect(tabIds).toEqual(["rdp", "vnc", "telnet"]);
});
it("declares three host capabilities as an array", () => {
const { manifest } = parseManifest(readBundledManifest("remote-desktop"));
const hostCapability = manifest?.contributes?.hostCapability;
expect(Array.isArray(hostCapability)).toBe(true);
expect(
(hostCapability as Array<{ key: string }>).map((entry) => entry.key),
).toEqual(["enableRdp", "enableVnc", "enableTelnet"]);
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"remote-desktop",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
@@ -39,22 +39,24 @@ function manifest(overrides: Record<string, unknown> = {}) {
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal, docker, host-metrics, ai and proxmox", () => {
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop", () => {
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
"ai",
"docker",
"host-metrics",
"proxmox",
"remote-desktop",
"ssh-terminal",
]);
});
it("recognises ssh-terminal, docker, host-metrics, ai and proxmox and nothing else", () => {
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox and remote-desktop and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("docker")).toBe(true);
expect(isFirstParty("host-metrics")).toBe(true);
expect(isFirstParty("ai")).toBe(true);
expect(isFirstParty("proxmox")).toBe(true);
expect(isFirstParty("remote-desktop")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
@@ -72,6 +74,9 @@ describe("runsInProcess", () => {
);
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("proxmox", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
expect(runsInProcess("remote-desktop", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
@@ -80,6 +85,7 @@ describe("runsInProcess", () => {
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
expect(runsInProcess("proxmox", ["hosts.read"])).toBe(false);
expect(runsInProcess("remote-desktop", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(