feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
normalizeImportedHost,
userDefaultParams,
} from "../../src/backend/host-settings.js";
describe("normalizeImportedHost", () => {
it("reads a Termix export's pluginSettings", () => {
expect(
normalizeImportedHost({
pluginSettings: {
"remote-desktop": {
enableVnc: true,
vncPort: 5901,
guacamoleConfig: { cursor: "local" },
},
},
}),
).toEqual({
enableVnc: true,
vncPort: 5901,
guacamoleConfig: { cursor: "local" },
});
});
it("reads the flat fields hosts had before 2.9.0", () => {
expect(
normalizeImportedHost({
enableRdp: true,
rdpPort: "3390",
security: "nla",
ignoreCert: 1,
guacamoleConfig: JSON.stringify({ dpi: 120 }),
enableTerminalToolbar: false,
}),
).toEqual({
enableRdp: true,
rdpPort: 3390,
rdpSecurity: "nla",
rdpIgnoreCert: true,
guacamoleConfig: { dpi: 120 },
enableToolbar: false,
});
});
it("turns a connectionType-only host into its switch and port", () => {
expect(
normalizeImportedHost({ connectionType: "telnet", port: 2323 }),
).toEqual({ enableTelnet: true, telnetPort: 2323 });
});
it("writes nothing for a plain SSH row", () => {
expect(
normalizeImportedHost({ connectionType: "ssh", port: 22 }),
).toBeNull();
});
it("drops a port out of range", () => {
expect(normalizeImportedHost({ enableRdp: true, rdpPort: 70000 })).toEqual({
enableRdp: true,
});
});
});
describe("userDefaultParams", () => {
it("maps the user settings onto guacd parameters, skipping inherit", () => {
expect(
userDefaultParams({
colorDepth: "24",
resizeMethod: "reconnect",
disableCopy: "on",
enableWallpaper: "off",
enableDrive: "inherit",
}),
).toEqual({
"color-depth": 24,
"resize-method": "reconnect",
"disable-copy": true,
"enable-wallpaper": false,
});
});
});
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../src/backend/rdp-settings.js";
@@ -53,32 +52,3 @@ describe("buildRdpSettings", () => {
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,318 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type Router } from "express";
import { afterEach, describe, expect, it } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type {
PluginContext,
PluginProtocolTarget,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
const manifest = manifestJson as unknown as PluginManifest;
// The token key comes from JWT_SECRET; without one each service picks its own.
process.env.JWT_SECRET = "remote-desktop-test-secret";
function target(
overrides: Partial<PluginProtocolTarget["auth"]> = {},
): PluginProtocolTarget {
return {
host: {
id: 7,
name: "Win box",
ip: "10.0.0.7",
port: 22,
ownerUserId: "user-1",
jumpHosts: [],
},
shared: false,
auth: {
authType: "direct",
username: "admin",
password: "secret",
domain: "CORP",
...overrides,
},
};
}
interface Server {
mock: MockPluginContext;
request: (
method: string,
path: string,
body?: unknown,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
let server: Server | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function start(
options: {
protocolTargets?: Record<string, PluginProtocolTarget>;
services?: Record<string, object>;
capabilities?: string[];
} = {},
): Promise<Server> {
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
router: () => (router = express.Router()),
protocolTargets: options.protocolTargets,
services: options.services,
});
await activate(mock.ctx as PluginContext);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? "user-1");
next();
});
app.use((req, res, next) => router!(req, res, next));
const httpServer = http.createServer(app);
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
const { port } = httpServer.address() as AddressInfo;
return {
mock,
async request(method, path, body) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
};
}
const tokens = new GuacamoleTokenService();
describe("POST /connect-host/:hostId", () => {
it("mints a token from the resolved login and the host's own settings", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
await server.mock.ctx.settings.setHost(7, "rdpPort", 3390);
await server.mock.ctx.settings.setHost(7, "rdpSecurity", "nla");
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
"color-depth": 16,
"enable-wallpaper": "auto",
});
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
expect(response.status).toBe(200);
expect(response.body.termixConnectId).toEqual(expect.any(String));
const token = tokens.decryptToken(response.body.token);
expect(token?.connection.settings).toMatchObject({
hostname: "10.0.0.7",
port: 3390,
username: "admin",
password: "secret",
domain: "CORP",
security: "nla",
"color-depth": 16,
});
expect(token?.connection.settings).not.toHaveProperty("enable-wallpaper");
expect(token?.termixMeta).toMatchObject({
hostId: 7,
hostName: "Win box",
protocol: "rdp",
});
expect(server.mock.audits.at(-1)).toMatchObject({
action: "rdp_connect",
success: true,
});
});
it("puts the user's RDP defaults under the host's own values", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
"color-depth": 32,
});
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
const settings = tokens.decryptToken(response.body.token)?.connection
.settings;
expect(settings?.["color-depth"]).toBe(32);
expect(settings?.["disable-copy"]).toBe(true);
});
it("uses the prompted login when the host asks at connect time", async () => {
server = await start({
protocolTargets: { "7:rdp": target({ authType: "none", username: "" }) },
});
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
promptedUsername: "bob",
promptedPassword: "pw",
promptedDomain: "",
});
const settings = tokens.decryptToken(response.body.token)?.connection
.settings;
expect(settings).toMatchObject({ username: "bob", password: "pw" });
});
it("refuses a protocol the host has switched off", async () => {
server = await start({ protocolTargets: { "7:vnc": target() } });
const response = await server.request("POST", "/connect-host/7", {
protocol: "vnc",
});
expect(response.status).toBe(400);
});
it("answers 404 when the caller cannot reach the host", async () => {
server = await start();
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
expect(response.status).toBe(404);
});
it("refuses everything while an admin has turned it off", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.set("enabled", false);
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
expect(
(await server.request("POST", "/connect-host/7", { protocol: "rdp" }))
.status,
).toBe(403);
expect(
(
await server.request("POST", "/token", {
type: "rdp",
hostname: "10.0.0.9",
})
).status,
).toBe(403);
const status = await server.request("GET", "/status?probe=0");
expect(status.body.enabled).toBe(false);
});
it("records only when session recording is on for the host", async () => {
const enabledFor = async (hostId: number) => hostId === 7;
server = await start({
protocolTargets: { "7:rdp": target() },
services: {
"recordings.writer": {
enabledFor,
createFinished: async () => ({ id: 1 }),
},
},
});
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
const token = tokens.decryptToken(response.body.token);
expect(token?.recording).toMatchObject({ hostId: 7, protocol: "rdp" });
expect(token?.connection.settings["recording-name"]).toEqual(
expect.any(String),
);
});
});
describe("the rest of the routes", () => {
it("mints a quick connect token with the caller's defaults", async () => {
server = await start();
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "24");
const response = await server.request("POST", "/token", {
type: "rdp",
hostname: "10.0.0.9",
username: "u",
password: "p",
});
expect(response.status).toBe(200);
expect(
tokens.decryptToken(response.body.token)?.connection.settings,
).toMatchObject({ hostname: "10.0.0.9", port: 3389, "color-depth": 24 });
});
it("offers native RDP only in the desktop app", async () => {
server = await start();
expect((await server.request("GET", "/native-rdp")).body).toEqual({
available: false,
});
expect(
(await server.request("POST", "/native-rdp", { host: "10.0.0.7" }))
.status,
).toBe(404);
});
it("does not hand out another user's session id", async () => {
server = await start();
const response = await server.request("GET", "/connection/unknown");
expect(response.body).toEqual({ guacamoleConnectionId: null });
});
});
describe("activate", () => {
it("fails closed without network:serve", async () => {
await expect(
start({
capabilities: manifest.capabilities.filter(
(capability) => capability !== "network:serve",
),
}),
).rejects.toThrow(/network:serve/);
});
it("cannot read a host's login without credentials:read", async () => {
server = await start({
protocolTargets: { "7:rdp": target() },
capabilities: manifest.capabilities.filter(
(capability) => capability !== "credentials:read",
),
});
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
expect(response.status).toBe(500);
expect(server.mock.checked).toContain("credentials:read");
});
it("provides sessions.live for rdp, vnc and telnet", async () => {
server = await start();
expect([...server.mock.services.keys()].sort()).toEqual(
expect.arrayContaining([
"sessions.live#rdp",
"sessions.live#telnet",
"sessions.live#vnc",
]),
);
});
});
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RemoteSessions } from "../../src/backend/sessions.js";
import { createLiveSessions } from "../../src/backend/live-sessions.js";
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
const meta = {
termixConnectId: "c1",
hostId: 7,
hostName: "Win box",
ownerUserId: "user-1",
protocol: "rdp" as const,
tabInstanceId: "tab-1",
};
afterEach(() => {
vi.useRealTimers();
});
describe("RemoteSessions", () => {
it("moves parked resources onto the session and releases them on close", () => {
const sessions = new RemoteSessions();
const tunnel = vi.fn();
const tracking = vi.fn();
sessions.park("c1", [tunnel]);
sessions.opened(meta, "g1", [tracking]);
expect(sessions.byConnect("c1")?.guacamoleConnectionId).toBe("g1");
expect(sessions.byGuacamole("g1")?.hostName).toBe("Win box");
expect(tunnel).not.toHaveBeenCalled();
sessions.closed("c1");
expect(tunnel).toHaveBeenCalledOnce();
expect(tracking).toHaveBeenCalledOnce();
expect(sessions.byGuacamole("g1")).toBeNull();
});
it("releases a tunnel guacd never used", () => {
vi.useFakeTimers();
const sessions = new RemoteSessions(1000);
const tunnel = vi.fn();
sessions.park("c1", [tunnel]);
vi.advanceTimersByTime(1000);
expect(tunnel).toHaveBeenCalledOnce();
});
it("releases everything on clear, and one failing close does not stop the rest", () => {
const sessions = new RemoteSessions();
const failing = vi.fn(() => {
throw new Error("already closed");
});
const pending = vi.fn();
const open = vi.fn();
sessions.park("c2", [failing, pending]);
sessions.park("c1", [open]);
sessions.opened(meta, "g1");
sessions.clear();
expect(pending).toHaveBeenCalledOnce();
expect(open).toHaveBeenCalledOnce();
});
});
describe("sessions.live providers", () => {
const tokens = new GuacamoleTokenService();
it("describes a live session of its own protocol only", () => {
const sessions = new RemoteSessions();
sessions.opened(meta, "g1");
const deps = { sessions, tokens, endSession: vi.fn(() => true) };
const rdp = createLiveSessions("rdp", deps);
const vnc = createLiveSessions("vnc", deps);
expect(rdp.getSession("g1")).toMatchObject({
id: "g1",
userId: "user-1",
hostId: 7,
hostName: "Win box",
isConnected: true,
tabInstanceId: "tab-1",
});
expect(vnc.getSession("g1")).toBeNull();
});
it("mints a join token for a viewer and refuses an unknown session", () => {
const sessions = new RemoteSessions();
sessions.opened(meta, "g1");
const rdp = createLiveSessions("rdp", {
sessions,
tokens,
endSession: vi.fn(() => true),
});
const token = tokens.decryptToken(rdp.createViewerToken("g1", true));
expect(token?.connection).toMatchObject({ join: "g1", readOnly: true });
expect(token?.recording).toBeUndefined();
expect(() => rdp.createViewerToken("nope", false)).toThrow();
});
it("ends the session for its owner", () => {
const sessions = new RemoteSessions();
sessions.opened(meta, "g1");
const endSession = vi.fn(() => true);
const rdp = createLiveSessions("rdp", { sessions, tokens, endSession });
rdp.ownerEndSession("g1", "owner ended");
expect(endSession).toHaveBeenCalledWith("g1");
});
});
@@ -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("../../src/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("../../src/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();
});
@@ -1,20 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } 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("../../src/backend/token-service.js");
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
const tokenService = new GuacamoleTokenService();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");