diff --git a/plugins/remote-desktop/src/backend/routes.ts b/plugins/remote-desktop/src/backend/routes.ts index 4fce49175..40f4863b2 100644 --- a/plugins/remote-desktop/src/backend/routes.ts +++ b/plugins/remote-desktop/src/backend/routes.ts @@ -501,6 +501,16 @@ export function registerRoutes(router: Router, deps: RouteDeps): void { * schema: * type: string * description: "0" skips dialing guacd. + * - in: query + * name: hostId + * schema: + * type: integer + * description: Saved host whose guacd settings should be checked; requires protocol and access to that host. + * - in: query + * name: protocol + * schema: + * type: string + * enum: [rdp, vnc, telnet] * responses: * 200: * description: The status. @@ -510,7 +520,39 @@ export function registerRoutes(router: Router, deps: RouteDeps): void { router.get("/status", async (req: Request, res: Response) => { try { const enabled = await deps.enabled(); - const guacd = deps.guacd(); + let guacd = deps.guacd(); + if (req.query.hostId !== undefined) { + const userId = actor(ctx, res); + if (!userId) return; + const hostId = Number(req.query.hostId); + const protocol = req.query.protocol; + if ( + !Number.isSafeInteger(hostId) || + hostId <= 0 || + !isRemoteProtocol(protocol) + ) { + return res.status(400).json({ error: "Invalid host ID or protocol" }); + } + const target = await ctx.credentials.resolveHostProtocol( + hostId, + protocol, + ); + if (!target) return res.status(404).json({ error: "Host not found" }); + const settings = await readHostSettings(ctx, hostId); + if (!settings[ENABLE_KEY[protocol]]) { + return res + .status(400) + .json({ error: "Protocol is not enabled for this host" }); + } + const { guacdOverrides } = cleanGuacConfig({ + ...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}), + ...settings.guacamoleConfig, + }); + guacd = { + host: guacdOverrides.guacdHost || guacd.host, + port: guacdOverrides.guacdPort || guacd.port, + }; + } const probe = enabled && req.query.probe !== "0"; const reachable = probe ? await probeGuacd(guacd) : false; res.json({ diff --git a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx index d48226c84..3848ae0d4 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleApp.tsx @@ -321,7 +321,11 @@ const GuacamoleAppInner = React.forwardRef< stage: "guac_guacd", message: t("remoteDesktop.checkingGuacd"), }); - const status = await getGuacdStatus(resolvedOrigin); + const status = await getGuacdStatus(resolvedOrigin, { + hostId, + protocol: resolvedProtocolForConnect, + syncId: hostConfig.syncId, + }); if (status.enabled === false) { throw new Error(t("remoteDesktop.disabled")); } diff --git a/plugins/remote-desktop/src/frontend/guacamole-api.ts b/plugins/remote-desktop/src/frontend/guacamole-api.ts index 1b06464bc..9ec59a906 100644 --- a/plugins/remote-desktop/src/frontend/guacamole-api.ts +++ b/plugins/remote-desktop/src/frontend/guacamole-api.ts @@ -244,10 +244,29 @@ export interface RemoteDesktopStatus { /** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */ export async function getGuacdStatus( origin: ConnectionOrigin, - options: { probe?: boolean } = {}, + options: { + probe?: boolean; + hostId?: number; + protocol?: "rdp" | "vnc" | "telnet"; + syncId?: string | null; + } = {}, ): Promise { + const params = new URLSearchParams(); + if (options.probe === false) params.set("probe", "0"); + if (options.hostId) { + const useRemote = isElectron() && origin === "remote"; + const remoteHostId = useRemote + ? await resolveRemoteHostId(options.syncId) + : null; + if (useRemote && options.syncId && remoteHostId === null) { + throw new Error("The synced host does not exist on the remote server"); + } + params.set("hostId", String(remoteHostId ?? options.hostId)); + if (options.protocol) params.set("protocol", options.protocol); + } + const query = params.toString(); const response = await guacamoleApi(origin).get( - options.probe === false ? "/status?probe=0" : "/status", + query ? `/status?${query}` : "/status", ); return response.data; } diff --git a/plugins/remote-desktop/tests/backend/routes.test.ts b/plugins/remote-desktop/tests/backend/routes.test.ts index f1cc4d141..ee66ecdb0 100644 --- a/plugins/remote-desktop/tests/backend/routes.test.ts +++ b/plugins/remote-desktop/tests/backend/routes.test.ts @@ -1,5 +1,5 @@ import http from "node:http"; -import type { AddressInfo } from "node:net"; +import net, { type AddressInfo } from "node:net"; import express, { type Router } from "express"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -336,3 +336,84 @@ describe("activate", () => { ); }); }); + +describe("saved host guacd status", () => { + it("probes the same override used by the connection token", async () => { + const guacd = net.createServer((socket) => socket.end()); + await new Promise((resolve) => guacd.listen(0, "127.0.0.1", resolve)); + try { + const port = (guacd.address() as AddressInfo).port; + server = await start({ protocolTargets: { "7:rdp": target() } }); + await server.mock.ctx.settings.setHost(7, "enableRdp", true); + await server.mock.ctx.settings.setHost(7, "guacamoleConfig", { + "guacd-hostname": "127.0.0.1", + "guacd-port": String(port), + }); + const status = await server.request( + "GET", + "/status?hostId=7&protocol=rdp", + ); + expect(status.status).toBe(200); + expect(status.body.guacd).toEqual({ + host: "127.0.0.1", + port, + status: "connected", + }); + const result = await server.request("POST", "/connect-host/7", { + protocol: "rdp", + }); + expect(tokens.decryptToken(result.body.token)?.connection).toMatchObject({ + guacdHost: status.body.guacd.host, + guacdPort: status.body.guacd.port, + }); + } finally { + await new Promise((resolve) => guacd.close(() => resolve())); + } + }); + + it("keeps the global port for a host-only override and global config when absent", async () => { + server = await start({ protocolTargets: { "7:rdp": target() } }); + await server.mock.ctx.settings.setHost(7, "enableRdp", true); + const global = await server.request("GET", "/status?probe=0"); + const inherited = await server.request( + "GET", + "/status?probe=0&hostId=7&protocol=rdp", + ); + expect(inherited.body.guacd).toEqual(global.body.guacd); + await server.mock.ctx.settings.setHost(7, "guacamoleConfig", { + "guacd-hostname": "custom.example", + }); + const overridden = await server.request( + "GET", + "/status?probe=0&hostId=7&protocol=rdp", + ); + expect(overridden.body.guacd).toEqual({ + ...global.body.guacd, + host: "custom.example", + }); + }); + + it("refuses a host the caller cannot resolve", async () => { + server = await start(); + await server.mock.ctx.settings.setHost(7, "enableRdp", true); + const result = await server.request("GET", "/status?hostId=7&protocol=rdp"); + expect(result.status).toBe(404); + expect(result.body.guacd).toBeUndefined(); + }); + + it("refuses disabled protocols", async () => { + server = await start({ protocolTargets: { "7:rdp": target() } }); + const result = await server.request("GET", "/status?hostId=7&protocol=rdp"); + expect(result.status).toBe(400); + }); + + it.each([ + "hostId=bad&protocol=rdp", + "hostId=0&protocol=rdp", + "hostId=7&protocol=ssh", + "hostId=7", + ])("rejects invalid queries: %s", async (query) => { + server = await start(); + expect((await server.request("GET", `/status?${query}`)).status).toBe(400); + }); +}); diff --git a/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts index eaf8d1004..bfab4c050 100644 --- a/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts @@ -172,3 +172,46 @@ it("looks up the session on the backend that issued its token", async () => { }); expect(authApiMock.get).not.toHaveBeenCalled(); }); + +describe("saved host status routing", () => { + it("maps the local host ID before probing the remote backend", async () => { + isElectronMock.mockReturnValue(true); + resolveRemoteHostIdMock.mockResolvedValue(41); + await getGuacdStatus("remote", { + hostId: 9, + protocol: "rdp", + syncId: "sync-9", + }); + expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("sync-9"); + expect(remoteApiMock.get).toHaveBeenCalledWith( + "/status?hostId=41&protocol=rdp", + ); + expect(authApiMock.get).not.toHaveBeenCalled(); + }); + + it("keeps local IDs for local connections", async () => { + isElectronMock.mockReturnValue(true); + await getGuacdStatus("local", { + hostId: 9, + protocol: "vnc", + syncId: "sync-9", + }); + expect(authApiMock.get).toHaveBeenCalledWith( + "/status?hostId=9&protocol=vnc", + ); + expect(resolveRemoteHostIdMock).not.toHaveBeenCalled(); + }); + + it("does not probe an unrelated remote ID when a synced host is missing", async () => { + isElectronMock.mockReturnValue(true); + resolveRemoteHostIdMock.mockResolvedValue(null); + await expect( + getGuacdStatus("remote", { + hostId: 9, + protocol: "rdp", + syncId: "missing", + }), + ).rejects.toThrow("does not exist"); + expect(remoteApiMock.get).not.toHaveBeenCalled(); + }); +});