/** * Headless RFB (VNC) keepalive client. * * TigerVNC's Xvnc only flushes pending rendering into the readable framebuffer * while a VNC client is actively pulling updates. The Discord broadcast reads * that framebuffer with x11grab (it is NOT a VNC client), so with no viewer * attached Xvnc idles and the captured screen updates at ~1.5 fps - the stream * looks badly choppy even though Chrome renders at 60 fps. (Measured: 3/30 * distinct frames without a client, 30/30 with one.) * * This client stays connected to the VNC server and continuously requests * incremental framebuffer updates, keeping the framebuffer fresh for the whole * duration of a broadcast. It is intentionally fail-open: any connection/auth * problem is logged and retried, never thrown, so it can never break the stream. */ import net from "node:net"; import crypto from "node:crypto"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; // VNC's DES variant uses each key byte with its bits mirrored. function revByte(b: number): number { let r = 0; for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1); return r & 0xff; } function vncKey(buf: Buffer): Buffer { return Buffer.from([...buf.subarray(0, 8)].map(revByte)); } function desEcb(key: Buffer, data: Buffer, decrypt = false): Buffer { const c = decrypt ? crypto.createDecipheriv("des-ecb", key, null) : crypto.createCipheriv("des-ecb", key, null); c.setAutoPadding(false); return Buffer.concat([c.update(data), c.final()]); } // The fixed key TigerVNC/RealVNC use to obfuscate the stored password file. const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]); /** Decode an 8-byte obfuscated VNC password file payload to plaintext. */ export function decodeVncPassword(obf: Buffer): Buffer { const pt = desEcb(vncKey(FIXED_KEY), obf.subarray(0, 8), true); const z = pt.indexOf(0); return pt.subarray(0, z < 0 ? 8 : z); } /** Compute the 16-byte VncAuth response for a server challenge. */ export function vncChallengeResponse(password: Buffer, challenge: Buffer): Buffer { const key = Buffer.alloc(8); password.subarray(0, 8).copy(key); return desEcb(vncKey(key), challenge.subarray(0, 16)); } /** Map an X display like ":1" to its TigerVNC RFB port (5900 + n). */ export function vncPortForDisplay(display: string): number { const n = parseInt(String(display).replace(/^:/, ""), 10); return 5900 + (Number.isFinite(n) ? n : 0); } /** * Resolve the VNC password: VNC_PASSWORD (plaintext) wins, otherwise decode the * obfuscated passwd file (VNC_PASSWD_FILE, default ~/.config/tigervnc/passwd). * Returns null when nothing is available (caller then skips the keepalive). */ export function resolveVncPassword(env: NodeJS.ProcessEnv = process.env): Buffer | null { if (env.VNC_PASSWORD) return Buffer.from(env.VNC_PASSWORD, "utf8").subarray(0, 8); const file = env.VNC_PASSWD_FILE || `${homedir()}/.config/tigervnc/passwd`; try { const obf = readFileSync(file); if (obf.length >= 8) return decodeVncPassword(obf); } catch { /* no file - fall through */ } return null; } export class VncKeepalive { private sock: net.Socket | null = null; private timer: ReturnType | null = null; private retry: ReturnType | null = null; private stopped = false; constructor( private opts: { host: string; port: number; password: Buffer; fps?: number }, ) {} start(): void { this.stopped = false; this.connect(); } stop(): void { this.stopped = true; if (this.timer) clearInterval(this.timer); if (this.retry) clearTimeout(this.retry); this.timer = this.retry = null; try { this.sock?.destroy(); } catch { /* ignore */ } this.sock = null; } private scheduleReconnect(): void { if (this.stopped || this.retry) return; this.retry = setTimeout(() => { this.retry = null; if (!this.stopped) this.connect(); }, 2000); } private connect(): void { const sock = net.connect(this.opts.port, this.opts.host); this.sock = sock; sock.setNoDelay(true); let buf = Buffer.alloc(0); const waiters: { n: number; res: (b: Buffer) => void }[] = []; const pump = () => { while (waiters.length && buf.length >= waiters[0].n) { const w = waiters.shift()!; const d = buf.subarray(0, w.n); buf = buf.subarray(w.n); w.res(d); } }; const onData = (d: Buffer) => { buf = Buffer.concat([buf, d]); pump(); }; sock.on("data", onData); sock.on("error", () => { /* handled by close */ }); sock.on("close", () => { if (this.sock === sock) { this.sock = null; if (this.timer) { clearInterval(this.timer); this.timer = null; } this.scheduleReconnect(); } }); const read = (n: number) => new Promise((res) => { waiters.push({ n, res }); pump(); }); (async () => { try { await read(12); // ProtocolVersion sock.write("RFB 003.008\n"); const nTypes = (await read(1))[0]; const types = await read(nTypes); if (types.includes(2)) { sock.write(Buffer.from([2])); // VNC Auth const challenge = await read(16); sock.write(vncChallengeResponse(this.opts.password, challenge)); if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy(); } else if (types.includes(1)) { sock.write(Buffer.from([1])); // None if ((await read(4)).readUInt32BE(0) !== 0) return sock.destroy(); } else { return sock.destroy(); } sock.write(Buffer.from([1])); // ClientInit (shared) const si = await read(24); const w = si.readUInt16BE(0); const h = si.readUInt16BE(2); await read(si.readUInt32BE(20)); // desktop name sock.write(Buffer.from([2, 0, 0, 1, 0, 0, 0, 0])); // SetEncodings: Raw // Past the handshake: stop buffering and just drain whatever the server // sends so its send buffer never blocks (we never decode the pixels). sock.removeListener("data", onData); sock.on("data", () => {}); buf = Buffer.alloc(0); const req = Buffer.from([3, 1, 0, 0, 0, 0, 0, 0, 0, 0]); req.writeUInt16BE(w, 6); req.writeUInt16BE(h, 8); const interval = Math.max(1, Math.round(1000 / (this.opts.fps ?? 60))); this.timer = setInterval(() => { try { if (this.sock === sock && sock.writable) sock.write(req); } catch { /* ignore */ } }, interval); } catch { try { sock.destroy(); } catch { /* ignore */ } } })(); } }