From fa0fa7f836a2001d95615591c26a52b436a907e6 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 24 Aug 2026 03:59:37 +0800 Subject: [PATCH] fix: support macOS VNC connections (#1311) --- .../hosts/guacamole/macos-vnc-proxy.ts | 66 +++++++++++++++ src/backend/hosts/guacamole/routes.ts | 63 +++++++++++---- .../hosts/guacamole/macos-vnc-proxy.test.ts | 80 +++++++++++++++++++ .../features/guacamole/GuacamoleDisplay.tsx | 2 +- src/ui/lib/connection-origin.ts | 10 ++- src/ui/tests/lib/connection-origin.test.ts | 24 +++++- 6 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 src/backend/hosts/guacamole/macos-vnc-proxy.ts create mode 100644 src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts diff --git a/src/backend/hosts/guacamole/macos-vnc-proxy.ts b/src/backend/hosts/guacamole/macos-vnc-proxy.ts new file mode 100644 index 00000000..215b2989 --- /dev/null +++ b/src/backend/hosts/guacamole/macos-vnc-proxy.ts @@ -0,0 +1,66 @@ +import net from "net"; + +const RFB_BANNER_LENGTH = 12; +const MACOS_RFB_BANNER = Buffer.from("RFB 003.889\n", "ascii"); +const STANDARD_RFB_BANNER = Buffer.from("RFB 003.008\n", "ascii"); + +export interface VncCompatibilityProxy { + port: number; + close: () => void; +} + +export async function createMacosVncCompatibilityProxy({ + targetHost, + targetPort, + bindHost, +}: { + targetHost: string; + targetPort: number; + bindHost: string; +}): Promise { + const sockets = new Set(); + const server = net.createServer((client) => { + const upstream = net.createConnection(targetPort, targetHost); + sockets.add(client); + sockets.add(upstream); + + const forget = (socket: net.Socket) => sockets.delete(socket); + client.once("close", () => forget(client)); + upstream.once("close", () => forget(upstream)); + client.once("error", () => upstream.destroy()); + upstream.once("error", () => client.destroy()); + client.pipe(upstream); + + let pending = Buffer.alloc(0); + const forwardServerBanner = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + if (pending.length < RFB_BANNER_LENGTH) return; + + upstream.off("data", forwardServerBanner); + const banner = pending.subarray(0, RFB_BANNER_LENGTH); + client.write( + banner.equals(MACOS_RFB_BANNER) ? STANDARD_RFB_BANNER : banner, + ); + client.write(pending.subarray(RFB_BANNER_LENGTH)); + upstream.pipe(client); + }; + upstream.on("data", forwardServerBanner); + }); + + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, bindHost, () => { + server.off("error", reject); + resolve((server.address() as net.AddressInfo).port); + }); + }); + server.unref(); + + return { + port, + close: () => { + server.close(); + for (const socket of sockets) socket.destroy(); + }, + }; +} diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index 628f5061..62a2a78a 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -23,6 +23,7 @@ import { } from "../../utils/audit-logger.js"; import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js"; import { buildRdpSettings, resolveRdpDomain } from "./rdp-settings.js"; +import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); @@ -495,20 +496,24 @@ router.post( } } + let tunnelEndpoint: ReturnType | null = + null; + if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) { + let guacdUrl: string | undefined; + try { + guacdUrl = + (await createCurrentSettingsRepository().get("guac_url")) ?? + undefined; + } catch { + // Environment/default guacd configuration remains available. + } + const guacdHost = + perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host; + tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost); + } + if (jumpHosts.length > 0) { try { - let guacdUrl: string | undefined; - try { - guacdUrl = - (await createCurrentSettingsRepository().get("guac_url")) ?? - undefined; - } catch { - // Environment/default guacd configuration remains available. - } - const guacdHost = - perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host; - const tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost); - // The chain dials the first hop through that hop's own SOCKS5 // settings; the target host's proxy config does not apply to it. const jumpClient = await createJumpHostChain(jumpHosts, userId); @@ -543,7 +548,7 @@ router.post( ); }); server.on("error", reject); - server.listen(0, tunnelEndpoint.bindHost, () => { + server.listen(0, tunnelEndpoint!.bindHost, () => { const addr = server.address() as net.AddressInfo; // Auto-cleanup after 1 hour setTimeout( @@ -556,7 +561,7 @@ router.post( resolve(addr.port); }); }); - hostname = tunnelEndpoint.advertisedHost; + hostname = tunnelEndpoint!.advertisedHost; port = tunnelPort; guacLogger.info("SSH tunnel established for guacamole", { operation: "guac_ssh_tunnel", @@ -574,6 +579,36 @@ router.post( } } + if (connectionType === "vnc" && !username) { + try { + const proxy = await createMacosVncCompatibilityProxy({ + targetHost: hostname, + targetPort: port, + bindHost: tunnelEndpoint!.bindHost, + }); + hostname = tunnelEndpoint!.advertisedHost; + port = proxy.port; + setTimeout(proxy.close, 60 * 60 * 1000).unref(); + guacLogger.info("VNC compatibility proxy established", { + operation: "guac_vnc_compatibility_proxy", + hostId, + proxyPort: port, + }); + } catch (proxyError) { + guacLogger.error( + "Failed to establish VNC compatibility proxy", + proxyError, + { + operation: "guac_vnc_compatibility_proxy_error", + hostId, + }, + ); + return res.status(500).json({ + error: "Failed to establish VNC compatibility proxy", + }); + } + } + const guacdOverrides = { ...(perConnectionGuacdHost ? { guacdHost: perConnectionGuacdHost } diff --git a/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts b/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts new file mode 100644 index 00000000..b9e3f493 --- /dev/null +++ b/src/backend/tests/hosts/guacamole/macos-vnc-proxy.test.ts @@ -0,0 +1,80 @@ +import net from "net"; +import { afterEach, describe, expect, it } from "vitest"; +import { createMacosVncCompatibilityProxy } from "../../../hosts/guacamole/macos-vnc-proxy.js"; + +const closers: Array<() => void> = []; + +afterEach(() => { + for (const close of closers.splice(0)) close(); +}); + +async function listen(server: net.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as net.AddressInfo).port); + }); + }); +} + +async function read(socket: net.Socket, length: number): Promise { + return new Promise((resolve, reject) => { + let received = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + received = Buffer.concat([received, chunk]); + if (received.length < length) return; + socket.off("data", onData); + resolve(received); + }; + socket.on("data", onData); + socket.once("error", reject); + }); +} + +describe("createMacosVncCompatibilityProxy", () => { + it("normalizes Apple's private RFB banner and preserves later traffic", async () => { + let clientBanner = ""; + const target = net.createServer((socket) => { + socket.write("RFB 003."); + socket.write("889\n"); + socket.once("data", (data) => { + clientBanner = data.toString("ascii"); + socket.write("security-types"); + }); + }); + const targetPort = await listen(target); + closers.push(() => target.close()); + + const proxy = await createMacosVncCompatibilityProxy({ + targetHost: "127.0.0.1", + targetPort, + bindHost: "127.0.0.1", + }); + closers.push(proxy.close); + + const client = net.createConnection(proxy.port, "127.0.0.1"); + closers.push(() => client.destroy()); + expect((await read(client, 12)).subarray(0, 12).toString("ascii")).toBe( + "RFB 003.008\n", + ); + client.write("RFB 003.008\n"); + expect((await read(client, 14)).toString("ascii")).toBe("security-types"); + expect(clientBanner).toBe("RFB 003.008\n"); + }); + + it("passes standard RFB banners through unchanged", async () => { + const target = net.createServer((socket) => socket.write("RFB 003.008\n")); + const targetPort = await listen(target); + closers.push(() => target.close()); + const proxy = await createMacosVncCompatibilityProxy({ + targetHost: "127.0.0.1", + targetPort, + bindHost: "127.0.0.1", + }); + closers.push(proxy.close); + + const client = net.createConnection(proxy.port, "127.0.0.1"); + closers.push(() => client.destroy()); + expect((await read(client, 12)).toString("ascii")).toBe("RFB 003.008\n"); + }); +}); diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 481f5451..9cf6cde5 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -226,7 +226,7 @@ export const GuacamoleDisplay = forwardRef< localPort: 30008, localPath: "/guacamole/websocket/", remotePath: "/guacamole/websocket/", - includeLocalJwt: false, + includeJwt: false, }); if (!wsBase) { onError?.(t("errors.remoteServerRequired")); diff --git a/src/ui/lib/connection-origin.ts b/src/ui/lib/connection-origin.ts index 3cc08045..fb268d39 100644 --- a/src/ui/lib/connection-origin.ts +++ b/src/ui/lib/connection-origin.ts @@ -80,17 +80,17 @@ export async function buildOriginWsUrl({ localPort, localPath, remotePath, - includeLocalJwt = true, + includeJwt = true, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; - includeLocalJwt?: boolean; + includeJwt?: boolean; }): Promise { if (origin === "local") { let url = `ws://127.0.0.1:${localPort}${localPath}`; - if (includeLocalJwt) { + if (includeJwt) { const token = localStorage.getItem("jwt"); if (token) url += `?token=${encodeURIComponent(token)}`; } @@ -107,6 +107,8 @@ export async function buildOriginWsUrl({ .replace(/^https?:\/\//, "") .replace(/\/$/, ""); let url = `${wsProtocol}${wsHost}${remotePath}`; - if (remote.jwt) url += `?token=${encodeURIComponent(remote.jwt)}`; + if (includeJwt && remote.jwt) { + url += `?token=${encodeURIComponent(remote.jwt)}`; + } return url; } diff --git a/src/ui/tests/lib/connection-origin.test.ts b/src/ui/tests/lib/connection-origin.test.ts index c5f18f06..41bb90f2 100644 --- a/src/ui/tests/lib/connection-origin.test.ts +++ b/src/ui/tests/lib/connection-origin.test.ts @@ -132,12 +132,34 @@ describe("buildOriginWsUrl", () => { localPort: 30009, localPath: "/docker/console/", remotePath: "/docker/console/", - includeLocalJwt: false, + includeJwt: false, }); expect(url).toBe("ws://127.0.0.1:30009/docker/console/"); }); + it("does not duplicate the Guacamole token on remote connections", async () => { + win.electronAPI = { + invoke: async (channel: string) => { + if (channel === "get-remote-sync-config") { + return { serverUrl: "https://termix.example" }; + } + if (channel === "get-remote-sync-jwt") return "remote-jwt"; + return null; + }, + }; + + const url = await buildOriginWsUrl({ + origin: "remote", + localPort: 30008, + localPath: "/guacamole/websocket/", + remotePath: "/guacamole/websocket/", + includeJwt: false, + }); + + expect(url).toBe("wss://termix.example/guacamole/websocket/"); + }); + it("leaves the URL alone when there is no token stored", async () => { delete store.jwt;