mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-25 15:36:58 +00:00
fix: support macOS VNC connections (#1311)
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user