mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint * test(css): exercise the configured PostCSS plugin chain * test(identity): preserve full Ed25519 public keys in fixtures
This commit is contained in:
1 parent
c98003f90e
commit
3b9a1bacf0
5 files changed
+194
-5
No files matched your search
@@ -501,6 +501,16 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
|||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* description: "0" skips dialing guacd.
|
* 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:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The status.
|
* description: The status.
|
||||||
@@ -510,7 +520,39 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
|||||||
router.get("/status", async (req: Request, res: Response) => {
|
router.get("/status", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const enabled = await deps.enabled();
|
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 probe = enabled && req.query.probe !== "0";
|
||||||
const reachable = probe ? await probeGuacd(guacd) : false;
|
const reachable = probe ? await probeGuacd(guacd) : false;
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -321,7 +321,11 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
stage: "guac_guacd",
|
stage: "guac_guacd",
|
||||||
message: t("remoteDesktop.checkingGuacd"),
|
message: t("remoteDesktop.checkingGuacd"),
|
||||||
});
|
});
|
||||||
const status = await getGuacdStatus(resolvedOrigin);
|
const status = await getGuacdStatus(resolvedOrigin, {
|
||||||
|
hostId,
|
||||||
|
protocol: resolvedProtocolForConnect,
|
||||||
|
syncId: hostConfig.syncId,
|
||||||
|
});
|
||||||
if (status.enabled === false) {
|
if (status.enabled === false) {
|
||||||
throw new Error(t("remoteDesktop.disabled"));
|
throw new Error(t("remoteDesktop.disabled"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,10 +244,29 @@ export interface RemoteDesktopStatus {
|
|||||||
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
|
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
|
||||||
export async function getGuacdStatus(
|
export async function getGuacdStatus(
|
||||||
origin: ConnectionOrigin,
|
origin: ConnectionOrigin,
|
||||||
options: { probe?: boolean } = {},
|
options: {
|
||||||
|
probe?: boolean;
|
||||||
|
hostId?: number;
|
||||||
|
protocol?: "rdp" | "vnc" | "telnet";
|
||||||
|
syncId?: string | null;
|
||||||
|
} = {},
|
||||||
): Promise<RemoteDesktopStatus> {
|
): Promise<RemoteDesktopStatus> {
|
||||||
|
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<RemoteDesktopStatus>(
|
const response = await guacamoleApi(origin).get<RemoteDesktopStatus>(
|
||||||
options.probe === false ? "/status?probe=0" : "/status",
|
query ? `/status?${query}` : "/status",
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import http from "node:http";
|
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 express, { type Router } from "express";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
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<void>((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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -172,3 +172,46 @@ it("looks up the session on the backend that issued its token", async () => {
|
|||||||
});
|
});
|
||||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user