fix(selfbot): smooth VNC capture via keepalive + stop ffmpeg leak on stream end
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>
This commit is contained in:
203
bot/src/stream/vnc-keepalive.ts
Normal file
203
bot/src/stream/vnc-keepalive.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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 */
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user