route desktop guacd calls to the connected remote server (#1122)

resolveConnectionOrigin() pins RDP/VNC/Telnet to "remote" because the embedded
desktop backend does not bundle guacd, and the Guacamole websocket already
follows that. The status check and both token calls did not: they use the shared
authApi, which in Electron is hard-coded to the embedded backend.

So the desktop app asked the backend without guacd whether guacd was available,
got "disconnected", and refused to connect — while the connected server it would
actually have used reports it as connected and serves the same host fine from the
web client.

Send those three calls through a remote-origin instance in Electron, alongside
the existing file-manager, tunnel and stats ones.

Closes Termix-SSH/Support#1043
This commit is contained in:
ZacharyZcR
2026-07-28 01:49:05 +08:00
committed by GitHub
parent 1d26f820c6
commit 60109be10f
3 changed files with 117 additions and 12 deletions
+24 -5
View File
@@ -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 { export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet"; protocol: "rdp" | "vnc" | "telnet";
@@ -189,7 +205,7 @@ export async function getGuacamoleToken(
try { try {
const guacParams = toGuacamoleParams(request.guacamoleConfig); const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await authApi.post("/guacamole/token", { const response = await guacamoleApi().post("/guacamole/token", {
type: request.protocol, type: request.protocol,
hostname: request.hostname, hostname: request.hostname,
port: request.port, port: request.port,
@@ -212,7 +228,9 @@ export async function getGuacamoleTokenFromHost(
promptedCredentials?: { username?: string; password?: string }, promptedCredentials?: { username?: string; password?: string },
): Promise<GuacamoleTokenResponse> { ): Promise<GuacamoleTokenResponse> {
try { try {
const response = await authApi.post(`/guacamole/connect-host/${hostId}`, { const response = await guacamoleApi().post(
`/guacamole/connect-host/${hostId}`,
{
...(protocol ? { protocol } : {}), ...(protocol ? { protocol } : {}),
...(promptedCredentials?.username ...(promptedCredentials?.username
? { promptedUsername: promptedCredentials.username } ? { promptedUsername: promptedCredentials.username }
@@ -220,7 +238,8 @@ export async function getGuacamoleTokenFromHost(
...(promptedCredentials?.password ...(promptedCredentials?.password
? { promptedPassword: promptedCredentials.password } ? { promptedPassword: promptedCredentials.password }
: {}), : {}),
}); },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "get guacamole token from host"); throw handleApiError(error, "get guacamole token from host");
@@ -230,6 +249,6 @@ export async function getGuacamoleTokenFromHost(
export async function getGuacdStatus(): Promise<{ export async function getGuacdStatus(): Promise<{
guacd: { status: string }; guacd: { status: string };
}> { }> {
const response = await authApi.get("/guacamole/status"); const response = await guacamoleApi().get("/guacamole/status");
return response.data; return response.data;
} }
+8
View File
@@ -763,6 +763,7 @@ function createRemoteOriginApiInstance(path: string): AxiosInstance {
let remoteFileManagerApi: AxiosInstance | null = null; let remoteFileManagerApi: AxiosInstance | null = null;
let remoteTunnelApi: AxiosInstance | null = null; let remoteTunnelApi: AxiosInstance | null = null;
let remoteStatsApi: AxiosInstance | null = null; let remoteStatsApi: AxiosInstance | null = null;
let remoteGuacamoleApi: AxiosInstance | null = null;
export function getRemoteFileManagerApi(): AxiosInstance { export function getRemoteFileManagerApi(): AxiosInstance {
if (!remoteFileManagerApi) { if (!remoteFileManagerApi) {
@@ -785,6 +786,13 @@ export function getRemoteStatsApi(): AxiosInstance {
return remoteStatsApi; 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 // Maps a live SSH session (keyed by sessionId, which today is the host's
// numeric id as a string -- see ensureSSHSessionForHost) to the resolved // numeric id as a string -- see ensureSSHSessionForHost) to the resolved
// origin it was connected through, so every subsequent file-manager call // origin it was connected through, so every subsequent file-manager call
+78
View File
@@ -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",
},
);
});
});