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:
@@ -44,7 +44,13 @@ WHISPER_MODEL=small
|
|||||||
# Docker desktop (VNC) — used only by the container image
|
# Docker desktop (VNC) — used only by the container image
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# VNC viewer password (max 8 chars effective). Watch the screen at localhost:5901.
|
# VNC viewer password (max 8 chars effective). Watch the screen at localhost:5901.
|
||||||
|
# Also used by the broadcast keepalive: TigerVNC only refreshes its framebuffer
|
||||||
|
# while a VNC client is attached, so the stream keeps a tiny client connected to
|
||||||
|
# avoid a choppy (~1.5 fps) capture. Must match the VNC server's password. If
|
||||||
|
# unset, the keepalive falls back to the obfuscated passwd file (VNC_PASSWD_FILE,
|
||||||
|
# default ~/.config/tigervnc/passwd).
|
||||||
VNC_PASSWORD=javis123
|
VNC_PASSWORD=javis123
|
||||||
|
# VNC_PASSWD_FILE=/home/claude/.config/tigervnc/passwd
|
||||||
# Auto-opened page in the in-container Chrome.
|
# Auto-opened page in the in-container Chrome.
|
||||||
CHROME_START_URL=about:blank
|
CHROME_START_URL=about:blank
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -211,6 +212,8 @@
|
|||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="],
|
"@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="],
|
||||||
|
|
||||||
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
|
||||||
@@ -243,6 +246,8 @@
|
|||||||
|
|
||||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"discord.js-selfbot-v13": "^3.7.1"
|
"discord.js-selfbot-v13": "^3.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.14",
|
||||||
"@types/node": "^22.7.0",
|
"@types/node": "^22.7.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ bun bot/scripts/stream-test/stream-hold.ts
|
|||||||
node bot/scripts/stream-test/scenario.mjs
|
node bot/scripts/stream-test/scenario.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Recommended Chrome flags on the streamed display (avoids the "restore pages?"
|
||||||
|
bubble after an unclean exit and keeps a single clean window):
|
||||||
|
```
|
||||||
|
google-chrome --remote-debugging-port=9222 --start-maximized \
|
||||||
|
--hide-crash-restore-bubble --disable-session-crashed-bubble \
|
||||||
|
--autoplay-policy=no-user-gesture-required <url>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smooth capture (VNC keepalive)
|
||||||
|
TigerVNC only refreshes its framebuffer while a VNC client is attached. The
|
||||||
|
Discord broadcast reads the framebuffer with `x11grab` (not as a VNC client), so
|
||||||
|
with no viewer attached the captured screen idles at ~1.5 fps and the stream
|
||||||
|
looks badly choppy while the cursor still moves smoothly (x11grab overlays the
|
||||||
|
live cursor each frame). `SelfbotStreamer` fixes this automatically: it keeps a
|
||||||
|
tiny headless RFB client (`vnc-keepalive.ts`) connected for the life of the
|
||||||
|
stream, requesting incremental updates at the stream framerate. Measured: 3/30
|
||||||
|
distinct frames without it, ~57/60 with it. The keepalive authenticates with
|
||||||
|
`VNC_PASSWORD` (or the `~/.config/tigervnc/passwd` file) and is fail-open.
|
||||||
|
|
||||||
## A/B framerate/resolution
|
## A/B framerate/resolution
|
||||||
Lower settings to compare what Discord actually delivers to viewers, e.g.:
|
Lower settings to compare what Discord actually delivers to viewers, e.g.:
|
||||||
```
|
```
|
||||||
|
|||||||
61
bot/src/stream/selfbot.test.ts
Normal file
61
bot/src/stream/selfbot.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Regression: when the Go-Live stream ends on its own (Discord closes it, the
|
||||||
|
// voice UDP drops, or ffmpeg exits) instead of via stop(), the capture ffmpeg
|
||||||
|
// MUST be killed and voice left. Otherwise the x11grab->nvenc encoder keeps
|
||||||
|
// running forever, feeding a pipe nobody reads, pinning a CPU core while no
|
||||||
|
// media is actually transmitted (observed live: 0 UDP sockets, 100% CPU).
|
||||||
|
import { test, expect, mock } from "bun:test";
|
||||||
|
|
||||||
|
test("a self-ended stream tears down the capture pipeline (no ffmpeg leak)", async () => {
|
||||||
|
const kill = mock(() => {});
|
||||||
|
const leaveVoice = mock(() => {});
|
||||||
|
const destroy = mock(() => {});
|
||||||
|
|
||||||
|
// Controllable Go-Live: resolve endStream() to simulate Discord closing it.
|
||||||
|
let endStream!: () => void;
|
||||||
|
const playPromise = new Promise<void>((r) => {
|
||||||
|
endStream = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("node:child_process", () => ({
|
||||||
|
spawn: () => ({ stdout: {}, stderr: { on() {} }, kill }),
|
||||||
|
}));
|
||||||
|
mock.module("discord.js-selfbot-v13", () => ({
|
||||||
|
Client: class {
|
||||||
|
destroy = destroy;
|
||||||
|
async login() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("@dank074/discord-video-stream", () => ({
|
||||||
|
Streamer: class {
|
||||||
|
client: any;
|
||||||
|
constructor(c: any) {
|
||||||
|
this.client = c;
|
||||||
|
}
|
||||||
|
async joinVoice() {}
|
||||||
|
leaveVoice = leaveVoice;
|
||||||
|
},
|
||||||
|
prepareStream: () => ({ command: { on() {} }, output: {} }),
|
||||||
|
playStream: () => playPromise,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { SelfbotStreamer } = await import("./selfbot.ts");
|
||||||
|
const s = new SelfbotStreamer({
|
||||||
|
selfbotToken: "token",
|
||||||
|
vncDisplay: ":1",
|
||||||
|
vncResolution: "1920x1080",
|
||||||
|
vncFramerate: 60,
|
||||||
|
vncBitrateKbps: 8000,
|
||||||
|
streamHw: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await s.start({ guildId: "g", voiceChannelId: "v" } as any);
|
||||||
|
expect(s.isActive()).toBe(true);
|
||||||
|
|
||||||
|
// Discord closes the Go-Live on its own (not a stop() call).
|
||||||
|
endStream();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(kill).toHaveBeenCalled(); // capture ffmpeg killed -> no CPU-burning orphan
|
||||||
|
expect(leaveVoice).toHaveBeenCalled(); // voice connection released
|
||||||
|
expect(s.isActive()).toBe(false);
|
||||||
|
}, 30000);
|
||||||
@@ -19,11 +19,13 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import type { AppConfig } from "../config.ts";
|
import type { AppConfig } from "../config.ts";
|
||||||
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
import type { ScreenStreamer, StreamContext } from "./index.ts";
|
||||||
|
import { VncKeepalive, resolveVncPassword, vncPortForDisplay } from "./vnc-keepalive.ts";
|
||||||
|
|
||||||
export class SelfbotStreamer implements ScreenStreamer {
|
export class SelfbotStreamer implements ScreenStreamer {
|
||||||
readonly kind = "selfbot" as const;
|
readonly kind = "selfbot" as const;
|
||||||
private streamer: any = null;
|
private streamer: any = null;
|
||||||
private capture: ChildProcess | null = null;
|
private capture: ChildProcess | null = null;
|
||||||
|
private keepalive: VncKeepalive | null = null;
|
||||||
private controller: AbortController | null = null;
|
private controller: AbortController | null = null;
|
||||||
private active = false;
|
private active = false;
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
const signal = controller.signal;
|
const signal = controller.signal;
|
||||||
let streamer: any = null;
|
let streamer: any = null;
|
||||||
let capture: ChildProcess | null = null;
|
let capture: ChildProcess | null = null;
|
||||||
|
let keepalive: VncKeepalive | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { selfbot, vs } = await this.loadLib();
|
const { selfbot, vs } = await this.loadLib();
|
||||||
@@ -157,6 +160,23 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
|
if (!signal.aborted) console.error("[selfbot x11grab]", d.toString().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep a VNC client attached for the life of the stream. TigerVNC only
|
||||||
|
// flushes its framebuffer at full rate while a client pulls updates; the
|
||||||
|
// Discord broadcast reads that framebuffer with x11grab (not as a VNC
|
||||||
|
// client), so without this the captured screen would idle at ~1.5 fps and
|
||||||
|
// the stream would look badly choppy. Fail-open: a missing password just
|
||||||
|
// skips it. Matched to the stream framerate so motion stays smooth.
|
||||||
|
const vncPw = resolveVncPassword();
|
||||||
|
if (vncPw) {
|
||||||
|
keepalive = this.keepalive = new VncKeepalive({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: vncPortForDisplay(this.config.vncDisplay),
|
||||||
|
password: vncPw,
|
||||||
|
fps: this.config.vncFramerate,
|
||||||
|
});
|
||||||
|
keepalive.start();
|
||||||
|
}
|
||||||
|
|
||||||
const { command, output } = prepareStream(
|
const { command, output } = prepareStream(
|
||||||
capture.stdout,
|
capture.stdout,
|
||||||
{
|
{
|
||||||
@@ -182,8 +202,35 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
if (!signal.aborted) console.error("[selfbot] playStream:", err);
|
if (!signal.aborted) console.error("[selfbot] playStream:", err);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// The stream ended on its own (not via stop()); release the lock.
|
// The stream ended on its own (Discord closed the Go-Live, the voice
|
||||||
if (this.controller === controller) this.active = false;
|
// UDP dropped, or ffmpeg exited) rather than via stop(). If we are
|
||||||
|
// still the current attempt, tear the pipeline DOWN: kill the capture
|
||||||
|
// ffmpeg and leave voice. Otherwise the x11grab->nvenc encoder keeps
|
||||||
|
// running forever feeding a pipe nobody reads, pinning a CPU core
|
||||||
|
// while no media is actually transmitted. Skip if a concurrent
|
||||||
|
// stop()/start() already replaced the controller (it owns teardown).
|
||||||
|
if (this.controller !== controller) return;
|
||||||
|
try {
|
||||||
|
capture?.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
keepalive?.stop();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
streamer?.leaveVoice?.();
|
||||||
|
streamer?.client?.destroy?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (this.capture === capture) this.capture = null;
|
||||||
|
if (this.keepalive === keepalive) this.keepalive = null;
|
||||||
|
if (this.streamer === streamer) this.streamer = null;
|
||||||
|
this.controller = null;
|
||||||
|
this.active = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
|
return "🔴 셀프봇으로 VNC 화면을 음성채널에 실시간 송출 중입니다 (Go Live).";
|
||||||
@@ -196,6 +243,11 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
keepalive?.stop();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
streamer?.leaveVoice?.();
|
streamer?.leaveVoice?.();
|
||||||
streamer?.client?.destroy?.();
|
streamer?.client?.destroy?.();
|
||||||
@@ -208,6 +260,7 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
// unlock it mid-startup and let a third start() race in.
|
// unlock it mid-startup and let a third start() race in.
|
||||||
if (this.controller === controller) {
|
if (this.controller === controller) {
|
||||||
if (this.capture === capture) this.capture = null;
|
if (this.capture === capture) this.capture = null;
|
||||||
|
if (this.keepalive === keepalive) this.keepalive = null;
|
||||||
if (this.streamer === streamer) this.streamer = null;
|
if (this.streamer === streamer) this.streamer = null;
|
||||||
this.controller = null;
|
this.controller = null;
|
||||||
this.active = false;
|
this.active = false;
|
||||||
@@ -226,6 +279,12 @@ export class SelfbotStreamer implements ScreenStreamer {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
this.capture = null;
|
this.capture = null;
|
||||||
|
try {
|
||||||
|
this.keepalive?.stop();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.keepalive = null;
|
||||||
try {
|
try {
|
||||||
this.streamer?.leaveVoice?.();
|
this.streamer?.leaveVoice?.();
|
||||||
this.streamer?.client?.destroy?.();
|
this.streamer?.client?.destroy?.();
|
||||||
|
|||||||
53
bot/src/stream/vnc-keepalive.test.ts
Normal file
53
bot/src/stream/vnc-keepalive.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import {
|
||||||
|
decodeVncPassword,
|
||||||
|
vncChallengeResponse,
|
||||||
|
vncPortForDisplay,
|
||||||
|
resolveVncPassword,
|
||||||
|
} from "./vnc-keepalive.ts";
|
||||||
|
|
||||||
|
// Independent reference for VNC's bit-reversed-key DES, to cross-check the module.
|
||||||
|
const rev = (b: number) => {
|
||||||
|
let r = 0;
|
||||||
|
for (let i = 0; i < 8; i++) r = (r << 1) | ((b >> i) & 1);
|
||||||
|
return r & 0xff;
|
||||||
|
};
|
||||||
|
const vncKey = (buf: Buffer) => Buffer.from([...buf.subarray(0, 8)].map(rev));
|
||||||
|
const desEnc = (key: Buffer, data: Buffer) => {
|
||||||
|
const c = crypto.createCipheriv("des-ecb", key, null);
|
||||||
|
c.setAutoPadding(false);
|
||||||
|
return Buffer.concat([c.update(data), c.final()]);
|
||||||
|
};
|
||||||
|
const FIXED_KEY = Buffer.from([23, 82, 107, 6, 35, 78, 88, 7]);
|
||||||
|
|
||||||
|
test("decodeVncPassword inverts the fixed-key obfuscation", () => {
|
||||||
|
const pw = Buffer.from("s3cr3t\0\0", "binary"); // 8 bytes, trailing nulls
|
||||||
|
const obf = desEnc(vncKey(FIXED_KEY), pw); // how vncpasswd stores it
|
||||||
|
expect(decodeVncPassword(obf).toString()).toBe("s3cr3t");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vncChallengeResponse encrypts both challenge blocks with the bit-reversed password key", () => {
|
||||||
|
const pw = Buffer.from("hunter12");
|
||||||
|
const challenge = crypto.randomBytes(16);
|
||||||
|
const expected = desEnc(vncKey(pw), challenge);
|
||||||
|
const got = vncChallengeResponse(pw, challenge);
|
||||||
|
expect(got.length).toBe(16);
|
||||||
|
expect(got.equals(expected)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vncPortForDisplay maps an X display to its RFB port", () => {
|
||||||
|
expect(vncPortForDisplay(":1")).toBe(5901);
|
||||||
|
expect(vncPortForDisplay(":0")).toBe(5900);
|
||||||
|
expect(vncPortForDisplay(":5")).toBe(5905);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveVncPassword prefers the VNC_PASSWORD env var", () => {
|
||||||
|
const pw = resolveVncPassword({ VNC_PASSWORD: "letmein9" } as NodeJS.ProcessEnv);
|
||||||
|
expect(pw?.toString()).toBe("letmein9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveVncPassword returns null when nothing is available", () => {
|
||||||
|
const pw = resolveVncPassword({ VNC_PASSWD_FILE: "/nonexistent/path/xyz" } as NodeJS.ProcessEnv);
|
||||||
|
expect(pw).toBeNull();
|
||||||
|
});
|
||||||
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"types": ["node"],
|
"types": ["node", "bun"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user