mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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";
|
} from "../../utils/audit-logger.js";
|
||||||
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js";
|
||||||
import { buildRdpSettings, resolveRdpDomain } from "./rdp-settings.js";
|
import { buildRdpSettings, resolveRdpDomain } from "./rdp-settings.js";
|
||||||
|
import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
@@ -495,8 +496,9 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jumpHosts.length > 0) {
|
let tunnelEndpoint: ReturnType<typeof resolveJumpTunnelEndpoint> | null =
|
||||||
try {
|
null;
|
||||||
|
if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) {
|
||||||
let guacdUrl: string | undefined;
|
let guacdUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
guacdUrl =
|
guacdUrl =
|
||||||
@@ -507,8 +509,11 @@ router.post(
|
|||||||
}
|
}
|
||||||
const guacdHost =
|
const guacdHost =
|
||||||
perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host;
|
perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host;
|
||||||
const tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost);
|
tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jumpHosts.length > 0) {
|
||||||
|
try {
|
||||||
// The chain dials the first hop through that hop's own SOCKS5
|
// The chain dials the first hop through that hop's own SOCKS5
|
||||||
// settings; the target host's proxy config does not apply to it.
|
// settings; the target host's proxy config does not apply to it.
|
||||||
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||||
@@ -543,7 +548,7 @@ router.post(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
server.on("error", reject);
|
server.on("error", reject);
|
||||||
server.listen(0, tunnelEndpoint.bindHost, () => {
|
server.listen(0, tunnelEndpoint!.bindHost, () => {
|
||||||
const addr = server.address() as net.AddressInfo;
|
const addr = server.address() as net.AddressInfo;
|
||||||
// Auto-cleanup after 1 hour
|
// Auto-cleanup after 1 hour
|
||||||
setTimeout(
|
setTimeout(
|
||||||
@@ -556,7 +561,7 @@ router.post(
|
|||||||
resolve(addr.port);
|
resolve(addr.port);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
hostname = tunnelEndpoint.advertisedHost;
|
hostname = tunnelEndpoint!.advertisedHost;
|
||||||
port = tunnelPort;
|
port = tunnelPort;
|
||||||
guacLogger.info("SSH tunnel established for guacamole", {
|
guacLogger.info("SSH tunnel established for guacamole", {
|
||||||
operation: "guac_ssh_tunnel",
|
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 = {
|
const guacdOverrides = {
|
||||||
...(perConnectionGuacdHost
|
...(perConnectionGuacdHost
|
||||||
? { guacdHost: 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,
|
localPort: 30008,
|
||||||
localPath: "/guacamole/websocket/",
|
localPath: "/guacamole/websocket/",
|
||||||
remotePath: "/guacamole/websocket/",
|
remotePath: "/guacamole/websocket/",
|
||||||
includeLocalJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
if (!wsBase) {
|
if (!wsBase) {
|
||||||
onError?.(t("errors.remoteServerRequired"));
|
onError?.(t("errors.remoteServerRequired"));
|
||||||
|
|||||||
@@ -80,17 +80,17 @@ export async function buildOriginWsUrl({
|
|||||||
localPort,
|
localPort,
|
||||||
localPath,
|
localPath,
|
||||||
remotePath,
|
remotePath,
|
||||||
includeLocalJwt = true,
|
includeJwt = true,
|
||||||
}: {
|
}: {
|
||||||
origin: ConnectionOrigin;
|
origin: ConnectionOrigin;
|
||||||
localPort: number;
|
localPort: number;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
remotePath: string;
|
remotePath: string;
|
||||||
includeLocalJwt?: boolean;
|
includeJwt?: boolean;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (origin === "local") {
|
if (origin === "local") {
|
||||||
let url = `ws://127.0.0.1:${localPort}${localPath}`;
|
let url = `ws://127.0.0.1:${localPort}${localPath}`;
|
||||||
if (includeLocalJwt) {
|
if (includeJwt) {
|
||||||
const token = localStorage.getItem("jwt");
|
const token = localStorage.getItem("jwt");
|
||||||
if (token) url += `?token=${encodeURIComponent(token)}`;
|
if (token) url += `?token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
@@ -107,6 +107,8 @@ export async function buildOriginWsUrl({
|
|||||||
.replace(/^https?:\/\//, "")
|
.replace(/^https?:\/\//, "")
|
||||||
.replace(/\/$/, "");
|
.replace(/\/$/, "");
|
||||||
let url = `${wsProtocol}${wsHost}${remotePath}`;
|
let url = `${wsProtocol}${wsHost}${remotePath}`;
|
||||||
if (remote.jwt) url += `?token=${encodeURIComponent(remote.jwt)}`;
|
if (includeJwt && remote.jwt) {
|
||||||
|
url += `?token=${encodeURIComponent(remote.jwt)}`;
|
||||||
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,12 +132,34 @@ describe("buildOriginWsUrl", () => {
|
|||||||
localPort: 30009,
|
localPort: 30009,
|
||||||
localPath: "/docker/console/",
|
localPath: "/docker/console/",
|
||||||
remotePath: "/docker/console/",
|
remotePath: "/docker/console/",
|
||||||
includeLocalJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe("ws://127.0.0.1:30009/docker/console/");
|
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 () => {
|
it("leaves the URL alone when there is no token stored", async () => {
|
||||||
delete store.jwt;
|
delete store.jwt;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user