Files
javis_bot/bot/src/stream/vnc-keepalive.ts
javis-bot 4176a68873 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>
2026-06-10 15:21:44 +09:00

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 */
}
}
})();
}
}