diff --git a/src/ui/api/guacamole-api.ts b/src/ui/api/guacamole-api.ts index f47dd455..310241b5 100644 --- a/src/ui/api/guacamole-api.ts +++ b/src/ui/api/guacamole-api.ts @@ -1,4 +1,20 @@ -import { authApi, handleApiError } from "@/main-axios"; +import { + authApi, + getRemoteGuacamoleApi, + handleApiError, + isElectron, +} from "@/main-axios"; +import type { AxiosInstance } from "axios"; + +/** + * The embedded desktop backend does not bundle guacd, which is why + * resolveConnectionOrigin() pins RDP/VNC/Telnet to "remote". These calls have to + * follow: asking the embedded backend reports the guacd *it* cannot reach, + * rather than the one on the connected server that serves the session. + */ +function guacamoleApi(): AxiosInstance { + return isElectron() ? getRemoteGuacamoleApi() : authApi; +} export interface GuacamoleTokenRequest { protocol: "rdp" | "vnc" | "telnet"; @@ -189,7 +205,7 @@ export async function getGuacamoleToken( try { const guacParams = toGuacamoleParams(request.guacamoleConfig); - const response = await authApi.post("/guacamole/token", { + const response = await guacamoleApi().post("/guacamole/token", { type: request.protocol, hostname: request.hostname, port: request.port, @@ -212,15 +228,18 @@ export async function getGuacamoleTokenFromHost( promptedCredentials?: { username?: string; password?: string }, ): Promise { try { - const response = await authApi.post(`/guacamole/connect-host/${hostId}`, { - ...(protocol ? { protocol } : {}), - ...(promptedCredentials?.username - ? { promptedUsername: promptedCredentials.username } - : {}), - ...(promptedCredentials?.password - ? { promptedPassword: promptedCredentials.password } - : {}), - }); + const response = await guacamoleApi().post( + `/guacamole/connect-host/${hostId}`, + { + ...(protocol ? { protocol } : {}), + ...(promptedCredentials?.username + ? { promptedUsername: promptedCredentials.username } + : {}), + ...(promptedCredentials?.password + ? { promptedPassword: promptedCredentials.password } + : {}), + }, + ); return response.data; } catch (error) { throw handleApiError(error, "get guacamole token from host"); @@ -230,6 +249,6 @@ export async function getGuacamoleTokenFromHost( export async function getGuacdStatus(): Promise<{ guacd: { status: string }; }> { - const response = await authApi.get("/guacamole/status"); + const response = await guacamoleApi().get("/guacamole/status"); return response.data; } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 0579404f..e7da418a 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -763,6 +763,7 @@ function createRemoteOriginApiInstance(path: string): AxiosInstance { let remoteFileManagerApi: AxiosInstance | null = null; let remoteTunnelApi: AxiosInstance | null = null; let remoteStatsApi: AxiosInstance | null = null; +let remoteGuacamoleApi: AxiosInstance | null = null; export function getRemoteFileManagerApi(): AxiosInstance { if (!remoteFileManagerApi) { @@ -785,6 +786,13 @@ export function getRemoteStatsApi(): AxiosInstance { return remoteStatsApi; } +export function getRemoteGuacamoleApi(): AxiosInstance { + if (!remoteGuacamoleApi) { + remoteGuacamoleApi = createRemoteOriginApiInstance(""); + } + return remoteGuacamoleApi; +} + // Maps a live SSH session (keyed by sessionId, which today is the host's // numeric id as a string -- see ensureSSHSessionForHost) to the resolved // origin it was connected through, so every subsequent file-manager call diff --git a/src/ui/tests/api/guacamole-api.test.ts b/src/ui/tests/api/guacamole-api.test.ts new file mode 100644 index 00000000..0fd1d372 --- /dev/null +++ b/src/ui/tests/api/guacamole-api.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const authApiMock = vi.hoisted(() => ({ + get: vi.fn(async () => ({ data: { guacd: { status: "disconnected" } } })), + post: vi.fn(async () => ({ data: { token: "local-token" } })), +})); +const remoteApiMock = vi.hoisted(() => ({ + get: vi.fn(async () => ({ data: { guacd: { status: "connected" } } })), + post: vi.fn(async () => ({ data: { token: "remote-token" } })), +})); +const isElectronMock = vi.hoisted(() => vi.fn(() => false)); + +vi.mock("@/main-axios", () => ({ + authApi: authApiMock, + getRemoteGuacamoleApi: () => remoteApiMock, + isElectron: isElectronMock, + handleApiError: (error: unknown) => error, +})); + +import { + getGuacdStatus, + getGuacamoleTokenFromHost, +} from "../../api/guacamole-api"; + +beforeEach(() => { + authApiMock.get.mockClear(); + authApiMock.post.mockClear(); + remoteApiMock.get.mockClear(); + remoteApiMock.post.mockClear(); +}); + +describe("guacamole API origin", () => { + it("uses the shared instance in the browser", async () => { + isElectronMock.mockReturnValue(false); + + await getGuacdStatus(); + await getGuacamoleTokenFromHost(9, "vnc"); + + expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status"); + expect(authApiMock.post).toHaveBeenCalledOnce(); + expect(remoteApiMock.get).not.toHaveBeenCalled(); + expect(remoteApiMock.post).not.toHaveBeenCalled(); + }); + + it("uses the connected remote server in the desktop app", async () => { + isElectronMock.mockReturnValue(true); + + // The embedded backend has no guacd, so asking it reports "disconnected" + // even when the connected server can serve the session. + const status = await getGuacdStatus(); + const token = await getGuacamoleTokenFromHost(9, "vnc"); + + expect(status.guacd.status).toBe("connected"); + expect(token.token).toBe("remote-token"); + expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status"); + expect(remoteApiMock.post).toHaveBeenCalledOnce(); + expect(authApiMock.get).not.toHaveBeenCalled(); + expect(authApiMock.post).not.toHaveBeenCalled(); + }); + + it("sends the connect-host payload unchanged to the remote server", async () => { + isElectronMock.mockReturnValue(true); + + await getGuacamoleTokenFromHost(9, "rdp", { + username: "admin", + password: "secret", + }); + + expect(remoteApiMock.post).toHaveBeenCalledWith( + "/guacamole/connect-host/9", + { + protocol: "rdp", + promptedUsername: "admin", + promptedPassword: "secret", + }, + ); + }); +});