fix: support macOS VNC connections (#1311)

This commit is contained in:
ZacharyZcR
2026-08-24 03:59:37 +08:00
committed by GitHub
parent ad266956cc
commit fa0fa7f836
6 changed files with 225 additions and 20 deletions
@@ -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<VncCompatibilityProxy> {
const sockets = new Set<net.Socket>();
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<number>((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();
},
};
}
+49 -14
View File
@@ -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<typeof resolveJumpTunnelEndpoint> | 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 }
@@ -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<number> {
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<Buffer> {
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");
});
});
@@ -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"));
+6 -4
View File
@@ -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<string | null> {
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;
}
+23 -1
View File
@@ -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;