The Go-Live broadcast looked badly choppy: video and scrolling stuttered while the cursor stayed smooth. Root cause is TigerVNC: it only refreshes its framebuffer while a VNC client is attached, but the broadcast reads that framebuffer with x11grab (not as a VNC client). With no viewer attached the captured screen idled at ~1.5 fps (measured 3/30 distinct frames); the cursor looked smooth only because x11grab overlays the live cursor on every frame. - Add a headless RFB keepalive (vnc-keepalive.ts) that stays connected for the life of the stream and requests incremental framebuffer updates at the stream framerate. SelfbotStreamer starts it on broadcast start and tears it down on stop/self-end. Measured 3/30 -> 57/60 distinct frames at 60 fps. Fail-open; authenticates with VNC_PASSWORD or the ~/.config/tigervnc/passwd file. - Fix a resource leak: when the Go-Live ended on its own, only the active flag was cleared, leaving the x11grab->nvenc ffmpeg running forever (pinning a CPU core while no media was transmitted, with only the gateway TCP left and no UDP media). The self-end path now tears down capture, keepalive and voice like stop() does. - Tests for both paths (self-end teardown; keepalive DES auth, port mapping, password resolution). Add @types/bun so bun:test typechecks; document the keepalive and recommended Chrome flags in README and .env.example. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
/**
|
|
* 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<typeof setInterval> | null = null;
|
|
private retry: ReturnType<typeof setTimeout> | 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<Buffer>((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 */
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
}
|