Add Nocturne debug web UI, core process control, and web QR login

This commit is contained in:
Fisto
2026-09-13 15:47:47 +02:00
parent 4e1046fae7
commit 2bcd8c6d7a
16 changed files with 4806 additions and 198 deletions
+81
View File
@@ -0,0 +1,81 @@
/*
* Veskslop daemon — auth state for the web UI.
*
* Owns at most one QR login session. The terminal tool and the web UI share
* the session class. The token file stays the single credential store.
*/
import { existsSync } from "node:fs";
import { isTerminalQrState, QrLoginSession, type QrLoginState, type QrLoginUpdate } from "./qr-session.js";
export interface AuthSessionSnapshot {
state: QrLoginState;
url?: string;
user?: object;
error?: string;
captcha?: object;
}
export interface AuthStatus {
tokenFile: string;
hasToken: boolean;
session: AuthSessionSnapshot | null;
}
export class AuthService {
private session?: QrLoginSession;
private snapshot: AuthSessionSnapshot | null = null;
private freshLogin = false;
constructor(
private readonly tokenFile: string,
private readonly onUpdate?: (update: QrLoginUpdate) => void
) {}
status(): AuthStatus {
return {
tokenFile: this.tokenFile,
hasToken: existsSync(this.tokenFile),
session: this.snapshot ? structuredClone(this.snapshot) : null
};
}
startQr(): AuthStatus {
if (this.session && !isTerminalQrState(this.session.state)) return this.status();
this.snapshot = { state: "connecting" };
this.session = new QrLoginSession({
tokenFile: this.tokenFile,
debug: process.env.DEBUG === "1",
onUpdate: update => {
this.snapshot = { ...update };
if (update.state === "finished") this.freshLogin = true;
this.onUpdate?.(update);
}
});
this.session.start();
return this.status();
}
cancelQr(): AuthStatus {
this.session?.cancel();
return this.status();
}
submitCaptcha(key: string): boolean {
return this.session?.submitCaptcha(key) ?? false;
}
/** True once after a successful login. Used to force the new token on start. */
consumeFreshLogin(): boolean {
const value = this.freshLogin;
this.freshLogin = false;
return value;
}
clearFreshLogin(): void {
this.freshLogin = false;
}
}
+115 -22
View File
@@ -14,9 +14,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import * as z from "zod/v4";
import { AuthService } from "./auth-service.js";
import { tokenFile } from "./config.js";
import { CoreClient, coreSocketPath } from "./core-client.js";
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
import { CoreProcess } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend, sleep } from "./pacing.js";
import { Puppet } from "./puppet.js";
import { startWebUi } from "./webui.js";
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8788);
@@ -24,27 +28,46 @@ const PORT = Number(process.env.MCP_PORT ?? 8788);
let core: CoreClient | undefined;
let puppet: Puppet | undefined;
let coreConnected = false;
let connecting = false;
async function connectLoop() {
for (;;) {
try {
const client = await CoreClient.connect();
async function connectLoop(): Promise<void> {
if (connecting) return;
connecting = true;
core = client;
puppet = new Puppet(client);
try {
for (;;) {
try {
const client = await CoreClient.connect();
client.onEvent(event => {
void puppet!.enqueueEvent(event);
});
core = client;
puppet = new Puppet(client);
coreConnected = true;
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
client.onEvent(event => {
void puppet!.enqueueEvent(event);
webui.broadcast(event);
});
return;
} catch (error) {
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
await sleep(2000);
client.onClose(() => {
console.warn("[daemon] core bridge disconnected");
coreConnected = false;
core = undefined;
puppet = undefined;
webui.broadcast({ type: "CORE_STATUS", coreConnected: false });
void connectLoop();
});
coreConnected = true;
console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`);
webui.broadcast({ type: "CORE_STATUS", coreConnected: true });
return;
} catch (error) {
console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`);
await sleep(2000);
}
}
} finally {
connecting = false;
}
}
@@ -58,6 +81,32 @@ function requirePuppet(): Puppet {
return puppet;
}
const coreProcess = new CoreProcess({ tokenFile });
const auth = new AuthService(tokenFile, update => {
webui.broadcast({ type: "AUTH_QR", update });
if (update.state === "finished" && coreProcess.status().running) {
setTimeout(() => {
void coreProcess.restart(auth.consumeFreshLogin()).catch(error => {
console.error("[core] restart after login failed:", error);
});
}, 500);
}
});
const webui = startWebUi({
getCore: () => core,
getPuppet: () => puppet,
isCoreConnected: () => coreConnected,
coreProcess,
auth
});
coreProcess.onExit(() => {
webui.broadcast({ type: "CORE_STATUS", coreConnected, process: coreProcess.status() });
});
function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
@@ -66,6 +115,9 @@ function jsonText(value: unknown) {
const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown> = {
get_state: () => requireCore().getState(),
get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId),
get_voice_overview: args => requireCore().voiceOverview(args?.guildId),
search_users: args => requireCore().userSearch(args?.query, args?.guildId, args?.limit),
list_dms: args => requireCore().dmList(args?.limit),
get_context: args =>
requirePuppet().getContext(args?.channelId, {
maxMessages: args?.maxMessages,
@@ -100,7 +152,7 @@ function buildServer(): McpServer {
}
},
async ({ channelId, content }) => {
await paced(() => requireCore().sendMessage(channelId, content));
await pacedSend(() => requireCore().sendMessage(channelId, content));
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
}
);
@@ -115,7 +167,7 @@ function buildServer(): McpServer {
}
},
async ({ userId, content }) => {
const result = await paced(() => requireCore().sendDm(userId, content));
const result = await pacedSend(() => requireCore().sendDm(userId, content));
return jsonText({ ...result, userId, queriesUsed: getQueryCount() });
}
);
@@ -199,12 +251,47 @@ function buildServer(): McpServer {
description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.",
inputSchema: {
userId: z.string().optional().describe("User ID. Defaults to the logged-in user."),
guildId: z.string().optional().describe("Guild ID. Omit for a DM call.")
guildId: z.string().optional().describe("Guild ID. Omit to search all guilds.")
}
},
async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId))
);
server.registerTool(
"get_voice_overview",
{
description: "List the occupied voice channels in a guild and their members.",
inputSchema: {
guildId: z.string().describe("Guild ID")
}
},
async ({ guildId }) => jsonText(await requireCore().voiceOverview(guildId))
);
server.registerTool(
"search_users",
{
description: "Search users in the client cache by username, global name, guild nickname, or user ID.",
inputSchema: {
query: z.string().min(1).describe("Username, global name, nickname, or user ID"),
guildId: z.string().optional().describe("Limit the search to one guild"),
limit: z.number().int().min(1).max(25).default(10).describe("Max results")
}
},
async ({ query, guildId, limit }) => jsonText(await requireCore().userSearch(query, guildId, limit))
);
server.registerTool(
"list_dms",
{
description: "List the cached DM and group DM channels with their recipients.",
inputSchema: {
limit: z.number().int().min(1).max(50).default(25).describe("Max channels")
}
},
async ({ limit }) => jsonText(await requireCore().dmList(limit))
);
server.registerTool(
"get_action_log",
{
@@ -338,7 +425,13 @@ app.listen(PORT, HOST, (error?: Error) => {
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
});
process.on("SIGINT", () => {
function shutdown() {
core?.close();
process.exit(0);
});
void coreProcess
.dispose(4000)
.catch(() => undefined)
.finally(() => process.exit(0));
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+251
View File
@@ -0,0 +1,251 @@
/*
* Veskslop daemon — Vesktop core process control.
*
* The daemon can start, stop, and restart the Electron client that hosts the
* core bridge. Only a process started by this daemon instance is managed.
*
* On Linux without a DISPLAY, the daemon starts Xvfb and points the client at
* it. Set VESKSLOP_XVFB=0 to disable that.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
const MAX_OUTPUT_LINES = 20;
export interface CoreExit {
code: number | null;
signal: string | null;
at: string;
}
export interface CoreProcessStatus {
managed: boolean;
running: boolean;
pid: number | null;
startedAt: string | null;
command: string | null;
lastExit: CoreExit | null;
output: string[];
}
export interface CoreProcessOptions {
tokenFile: string;
}
function prefixLines(prefix: string, chunk: Buffer): string {
return chunk
.toString()
.split("\n")
.map(line => (line ? `${prefix} ${line}` : line))
.join("\n");
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function findFreeDisplay(): string {
for (let number = 99; number < 110; number++) {
if (!existsSync(`/tmp/.X11-unix/X${number}`)) return `:${number}`;
}
return ":99";
}
export class CoreProcess {
private child?: ChildProcess;
private startedAt?: number;
private command?: string;
private xvfb?: ChildProcess;
private display?: string;
private lastExit: CoreExit | null = null;
private readonly output: string[] = [];
private readonly exitListeners = new Set<() => void>();
constructor(private readonly options: CoreProcessOptions) {}
status(): CoreProcessStatus {
const running = !!this.child && this.child.exitCode === null && !this.child.killed;
return {
managed: !!this.child,
running,
pid: this.child?.pid ?? null,
startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : null,
command: this.command ?? null,
lastExit: this.lastExit,
output: [...this.output]
};
}
onExit(listener: () => void) {
this.exitListeners.add(listener);
return () => this.exitListeners.delete(listener);
}
async start(forceToken = false): Promise<CoreProcessStatus> {
if (this.child) return this.status();
const override = process.env.VESKSLOP_CORE_CMD;
const args = (process.env.VESKSLOP_CORE_ARGS ?? DEFAULT_ARGS).split(/\s+/).filter(Boolean);
const display = await this.ensureDisplay();
const env = {
...process.env,
...(display ? { DISPLAY: display } : {}),
VESKSLOP_HEADLESS: process.env.VESKSLOP_HEADLESS ?? "1",
VESKSLOP_TOKEN_FILE: this.options.tokenFile,
...(forceToken ? { VESKSLOP_FORCE_TOKEN: "1" } : {})
};
let command: string;
let child: ChildProcess;
if (override) {
command = override;
child = spawn(override, { cwd: ROOT, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
} else {
const electron = join(
ROOT,
"node_modules",
".bin",
process.platform === "win32" ? "electron.cmd" : "electron"
);
if (!existsSync(electron)) {
throw new Error("electron is not installed; run pnpm install or set VESKSLOP_CORE_CMD");
}
command = `${electron} . ${args.join(" ")}`;
child = spawn(electron, [".", ...args], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
}
this.child = child;
this.startedAt = Date.now();
this.command = command;
this.lastExit = null;
this.output.length = 0;
child.stdout?.on("data", chunk => {
this.appendOutput(chunk);
process.stdout.write(prefixLines("[core]", chunk));
});
child.stderr?.on("data", chunk => {
this.appendOutput(chunk);
process.stderr.write(prefixLines("[core]", chunk));
});
child.on("error", error => {
console.error("[core] failed to start:", error.message);
this.appendOutput(Buffer.from(error.message));
});
child.on("exit", (code, signal) => {
console.log(`[core] exited (code ${code ?? "null"}${signal ? `, signal ${signal}` : ""})`);
this.lastExit = { code, signal, at: new Date().toISOString() };
this.child = undefined;
this.startedAt = undefined;
this.command = undefined;
for (const listener of this.exitListeners) listener();
});
console.log(`[core] starting: ${command}${display ? ` (DISPLAY=${display})` : ""}`);
return this.status();
}
async stop(timeoutMs = 8000): Promise<CoreProcessStatus> {
const child = this.child;
if (!child) return this.status();
await new Promise<void>(resolve => {
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// already gone
}
}, timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve();
});
try {
child.kill("SIGTERM");
} catch {
clearTimeout(timer);
resolve();
}
});
return this.status();
}
async restart(forceToken = false): Promise<CoreProcessStatus> {
await this.stop();
return this.start(forceToken);
}
/** Stop the core and any Xvfb that this daemon started. */
async dispose(timeoutMs = 4000): Promise<void> {
await this.stop(timeoutMs);
this.stopXvfb();
}
private appendOutput(chunk: Buffer) {
for (const line of chunk.toString().split("\n")) {
if (!line.trim()) continue;
this.output.push(line);
if (this.output.length > MAX_OUTPUT_LINES) this.output.shift();
}
}
private async ensureDisplay(): Promise<string | undefined> {
if (process.env.DISPLAY) return process.env.DISPLAY;
if (process.platform !== "linux" || process.env.VESKSLOP_XVFB === "0") return undefined;
if (this.xvfb && this.xvfb.exitCode === null) return this.display;
const display = findFreeDisplay();
const child = spawn("Xvfb", [display, ...XVFB_ARGS.split(" ")], { stdio: ["ignore", "ignore", "pipe"] });
child.stderr?.on("data", chunk => process.stderr.write(prefixLines("[xvfb]", chunk)));
child.on("error", error => {
console.warn(`[core] Xvfb is not available: ${error.message}`);
this.xvfb = undefined;
this.display = undefined;
});
child.on("exit", () => {
this.xvfb = undefined;
this.display = undefined;
});
this.xvfb = child;
this.display = display;
// Wait for the X socket so Electron does not race the server startup.
const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
for (let attempt = 0; attempt < 30 && !existsSync(socket); attempt++) await sleep(100);
if (!existsSync(socket)) console.warn(`[core] Xvfb socket ${socket} did not appear; continuing anyway`);
console.log(`[core] Xvfb on ${display} (pid ${child.pid})`);
return display;
}
private stopXvfb() {
const child = this.xvfb;
this.xvfb = undefined;
this.display = undefined;
if (child && child.exitCode === null) {
try {
child.kill("SIGTERM");
} catch {
// already gone
}
}
}
}
+49 -167
View File
@@ -1,53 +1,20 @@
/*
* Veskslop Phase 0 — Discord remote-auth QR login.
*
* Connects to Discord's remote-auth gateway, renders an approval QR in the
* terminal, waits for the phone to approve, and prints/saves the user token.
* Veskslop Phase 0 — Discord remote-auth QR login (terminal front end).
*
* Usage:
* pnpm poc:qr
* TOKEN_FILE=/path/to/token DEBUG=1 pnpm poc:qr
* HCAPTCHA_KEY=... pnpm poc:qr # only if Discord challenges the ticket exchange
*
* The token file is written with mode 600 and is gitignored.
* The session logic lives in qr-session.ts and is shared with the web UI.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import readline from "node:readline";
import qrcode from "qrcode-terminal";
import WebSocket from "ws";
import { tokenFile } from "./config.js";
const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
const debug = process.env.DEBUG === "1";
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const encodedPublicKey = keyPair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
function log(...args: unknown[]) {
console.log("[qr]", ...args);
}
function debugLog(...args: unknown[]) {
if (debug) console.error("[qr:debug]", ...args);
}
function decryptPayload(payload: string): Buffer {
return crypto.privateDecrypt(
{
key: keyPair.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
},
Buffer.from(payload, "base64")
);
}
import { QrLoginSession } from "./qr-session.js";
function promptCaptchaKey(): Promise<string | undefined> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -59,143 +26,58 @@ function promptCaptchaKey(): Promise<string | undefined> {
});
}
async function exchangeTicket(ticket: string, captchaKey?: string, captchaRqToken?: string): Promise<string | undefined> {
const body: Record<string, unknown> = { ticket };
if (captchaKey) {
body.captcha_key = captchaKey;
body.captcha_rqtoken = captchaRqToken;
}
const response = await fetch(LOGIN_URL, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
Origin: "https://discord.com",
Referer: "https://discord.com/login",
"User-Agent": USER_AGENT
},
body: JSON.stringify(body)
});
const data: any = await response.json();
debugLog("ticket exchange", response.status, JSON.stringify(data));
if (data.encrypted_token) {
return decryptPayload(data.encrypted_token).toString();
}
if (data.captcha_sitekey || data.captcha_rqtoken) {
log("Discord wants a captcha to finish this login.");
log(` service: ${data.captcha_service}`);
log(` sitekey: ${data.captcha_sitekey}`);
log(` rqdata: ${data.captcha_rqdata}`);
const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
if (!key) {
log("No captcha key provided, giving up.");
return undefined;
}
return exchangeTicket(ticket, key, data.captcha_rqtoken);
}
log(`Ticket exchange failed (HTTP ${response.status}):`, JSON.stringify(data));
return undefined;
}
log("Connecting to Discord remote-auth gateway...");
const ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
let heartbeat: NodeJS.Timeout | undefined;
let finished = false;
ws.on("open", () => log("Connected. Requesting QR..."));
ws.on("message", async (raw: WebSocket.RawData) => {
const packet: any = JSON.parse(raw.toString());
debugLog("<-", packet.op ?? packet);
switch (packet.op) {
case "hello": {
heartbeat = setInterval(
() => ws.send(JSON.stringify({ op: "heartbeat" })),
packet.heartbeat_interval
);
ws.send(JSON.stringify({ op: "init", encoded_public_key: encodedPublicKey }));
break;
}
case "nonce_proof": {
const nonce = decryptPayload(packet.encrypted_nonce);
const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
ws.send(JSON.stringify({ op: "nonce_proof", proof }));
break;
}
case "pending_remote_init": {
const url = `https://discord.com/ra/${packet.fingerprint}`;
log("Scan this QR code with the Discord mobile app (you may need the camera/QR scanner):\n");
qrcode.generate(url, { small: true });
log(`Or open this link on the phone: ${url}\n`);
break;
}
case "pending_ticket": {
const payload = decryptPayload(packet.encrypted_user_payload).toString();
const [id, discriminator, avatar, username] = payload.split(":");
debugLog("user payload:", payload);
const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
log(`Phone approved: ${tag} (${id})${avatar ? " with avatar " + avatar : ""}`);
break;
}
case "pending_login": {
const token = await exchangeTicket(packet.ticket);
if (!token) {
finished = true;
ws.close();
process.exitCode = 1;
const session = new QrLoginSession({
tokenFile,
debug: process.env.DEBUG === "1",
onUpdate: update => {
switch (update.state) {
case "pending":
console.log("[qr] Scan this QR code with the Discord mobile app:\n");
if (update.url) qrcode.generate(update.url, { small: true });
console.log(`[qr] Or open this link on the phone: ${update.url}\n`);
break;
}
finished = true;
fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
log("\n=== token acquired ===");
console.log(token);
log(`Saved to ${tokenFile} (mode 600).`);
ws.close();
break;
case "scanned":
console.log(`[qr] Phone approved: ${update.user?.tag} (${update.user?.id})`);
break;
case "captcha":
console.log("[qr] Discord wants a captcha to finish this login.");
console.log(` service: ${update.captcha?.service}`);
console.log(` sitekey: ${update.captcha?.sitekey}`);
console.log(` rqdata: ${update.captcha?.rqdata}`);
void (async () => {
const key = process.env.HCAPTCHA_KEY ?? (await promptCaptchaKey());
if (!key) session.cancel();
else session.submitCaptcha(key);
})();
break;
case "finished":
console.log("\n=== token acquired ===");
console.log(session.token);
console.log(`[qr] Saved to ${tokenFile} (mode 600).`);
process.exit(0);
break;
case "failed":
console.error(`[qr] Login failed: ${update.error}`);
process.exit(1);
break;
case "cancelled":
console.log("[qr] Login was cancelled.");
process.exit(0);
break;
}
case "cancel":
log("Login was cancelled on the phone.");
finished = true;
ws.close();
break;
case "heartbeat_ack":
break;
default:
debugLog("unhandled op", packet);
}
});
ws.on("close", () => {
if (heartbeat) clearInterval(heartbeat);
done();
});
ws.on("error", error => {
console.error("[qr] gateway error:", error.message);
process.exitCode = 1;
});
function done() {
if (!finished) log("Connection closed before login completed.");
process.exit(process.exitCode ?? 0);
}
console.log("[qr] Connecting to Discord remote-auth gateway...");
session.start();
process.on("SIGINT", () => {
log("Interrupted.");
ws.close();
console.log("[qr] Interrupted.");
session.cancel();
setTimeout(() => process.exit(0), 100);
});
+248
View File
@@ -0,0 +1,248 @@
/*
* Veskslop daemon — Discord remote-auth QR login session.
*
* The terminal tool and the web UI share this class. It connects to Discord's
* remote-auth gateway, produces the approval URL, waits for phone approval,
* and writes the token file on success.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import WebSocket from "ws";
const GATEWAY_URL = "wss://remote-auth-gateway.discord.gg/?v=2";
const LOGIN_URL = "https://discord.com/api/v9/users/@me/remote-auth/login";
const USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
export type QrLoginState = "connecting" | "pending" | "scanned" | "captcha" | "finished" | "failed" | "cancelled";
export interface QrLoginUser {
id: string;
tag: string;
avatar?: string;
}
export interface QrLoginCaptcha {
service?: string;
sitekey?: string;
rqdata?: string;
}
export interface QrLoginUpdate {
state: QrLoginState;
url?: string;
user?: QrLoginUser;
error?: string;
captcha?: QrLoginCaptcha;
}
export interface QrLoginOptions {
tokenFile: string;
onUpdate?: (update: QrLoginUpdate) => void;
debug?: boolean;
}
export function isTerminalQrState(state: QrLoginState): boolean {
return state === "finished" || state === "failed" || state === "cancelled";
}
export class QrLoginSession {
state: QrLoginState = "connecting";
url?: string;
user?: QrLoginUser;
error?: string;
captcha?: QrLoginCaptcha;
token?: string;
private readonly keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
private readonly encodedPublicKey = this.keyPair.publicKey
.export({ type: "spki", format: "der" })
.toString("base64");
private ws?: WebSocket;
private heartbeat?: NodeJS.Timeout;
private ticket?: string;
private captchaRqToken?: string;
private closed = false;
constructor(private readonly options: QrLoginOptions) {}
start(): void {
this.ws = new WebSocket(GATEWAY_URL, { headers: { Origin: "https://discord.com" } });
this.ws.on("open", () => this.debug("connected, requesting QR"));
this.ws.on("message", raw => {
void this.handle(raw).catch(error => this.fail(String(error?.message ?? error)));
});
this.ws.on("close", () => {
if (this.heartbeat) clearInterval(this.heartbeat);
if (!this.closed) this.fail("gateway connection closed");
});
this.ws.on("error", error => {
this.debug(`gateway error: ${error.message}`);
this.fail(error.message);
});
}
cancel(): void {
if (this.closed) return;
this.closed = true;
this.ws?.close();
this.update({ state: "cancelled" });
}
submitCaptcha(key: string): boolean {
if (this.state !== "captcha" || !this.ticket) return false;
void this.finishWithTicket(key, this.captchaRqToken);
return true;
}
private update(update: QrLoginUpdate) {
this.state = update.state;
if (update.url) this.url = update.url;
if (update.user) this.user = update.user;
if (update.error) this.error = update.error;
if (update.captcha) this.captcha = update.captcha;
this.options.onUpdate?.(update);
}
private fail(error: string) {
if (this.closed) return;
this.closed = true;
this.ws?.close();
this.update({ state: "failed", error });
}
private async handle(raw: WebSocket.RawData) {
const packet: any = JSON.parse(raw.toString());
this.debug(`<- ${packet.op}`);
switch (packet.op) {
case "hello":
this.heartbeat = setInterval(
() => this.ws?.send(JSON.stringify({ op: "heartbeat" })),
packet.heartbeat_interval
);
this.ws?.send(JSON.stringify({ op: "init", encoded_public_key: this.encodedPublicKey }));
break;
case "nonce_proof": {
const nonce = this.decrypt(packet.encrypted_nonce);
const proof = crypto.createHash("sha256").update(nonce).digest("base64url");
this.ws?.send(JSON.stringify({ op: "nonce_proof", proof }));
break;
}
case "pending_remote_init":
this.update({ state: "pending", url: `https://discord.com/ra/${packet.fingerprint}` });
break;
case "pending_ticket": {
const payload = this.decrypt(packet.encrypted_user_payload).toString();
const [id, discriminator, avatar, username] = payload.split(":");
const tag = discriminator && discriminator !== "0" ? `${username}#${discriminator}` : username;
this.update({ state: "scanned", user: { id, tag, ...(avatar ? { avatar } : {}) } });
break;
}
case "pending_login":
this.ticket = packet.ticket;
await this.finishWithTicket();
break;
case "cancel":
this.closed = true;
this.ws?.close();
this.update({ state: "cancelled" });
break;
case "heartbeat_ack":
break;
default:
this.debug(`unhandled op ${packet.op}`);
}
}
private async finishWithTicket(captchaKey?: string, captchaRqToken?: string) {
const token = await this.exchangeTicket(this.ticket!, captchaKey, captchaRqToken);
if (!token) return;
try {
fs.writeFileSync(this.options.tokenFile, token + "\n", { mode: 0o600 });
} catch (error) {
this.fail(`could not write the token file: ${String(error)}`);
return;
}
this.token = token;
this.closed = true;
this.ws?.close();
this.update({ state: "finished", user: this.user });
}
private async exchangeTicket(
ticket: string,
captchaKey?: string,
captchaRqToken?: string
): Promise<string | undefined> {
const body: Record<string, unknown> = { ticket };
if (captchaKey) {
body.captcha_key = captchaKey;
body.captcha_rqtoken = captchaRqToken;
}
const response = await fetch(LOGIN_URL, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
Origin: "https://discord.com",
Referer: "https://discord.com/login",
"User-Agent": USER_AGENT
},
body: JSON.stringify(body)
});
const data: any = await response.json();
this.debug(`ticket exchange ${response.status}`);
if (data.encrypted_token) return this.decrypt(data.encrypted_token).toString();
if (data.captcha_sitekey || data.captcha_rqtoken) {
this.captchaRqToken = data.captcha_rqtoken;
this.update({
state: "captcha",
captcha: {
service: data.captcha_service,
sitekey: data.captcha_sitekey,
rqdata: data.captcha_rqdata
}
});
return undefined;
}
this.fail(`ticket exchange failed (HTTP ${response.status})`);
return undefined;
}
private decrypt(payload: string): Buffer {
return crypto.privateDecrypt(
{
key: this.keyPair.privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
},
Buffer.from(payload, "base64")
);
}
private debug(...args: unknown[]) {
if (this.options.debug) console.error("[qr:debug]", ...args);
}
}
+349
View File
@@ -0,0 +1,349 @@
/*
* Veskslop daemon — web control UI.
*
* A separate Express listener serves static HTML/JS and a small JSON API on
* WEBUI_HOST:WEBUI_PORT. Remote clients can use the API directly, so the page
* stays replaceable. There is no authentication: bind to LAN or Tailscale only.
* See docs/webui.md for the API contract.
*/
import express, { type Request, type Response } from "express";
import { fileURLToPath } from "node:url";
import QRCode from "qrcode";
import type { AuthService, AuthStatus } from "./auth-service.js";
import { CoreError, type CoreClient } from "./core-client.js";
import type { CoreProcess } from "./core-process.js";
import { dedupe, getQueryCount, paced, pacedSend } from "./pacing.js";
import type { Puppet } from "./puppet.js";
const HOST = process.env.WEBUI_HOST ?? "0.0.0.0";
const PORT = Number(process.env.WEBUI_PORT ?? 8789);
export interface WebUiDeps {
getCore: () => CoreClient | undefined;
getPuppet: () => Puppet | undefined;
isCoreConnected: () => boolean;
coreProcess: CoreProcess;
auth: AuthService;
}
export interface WebUiHandle {
broadcast(event: unknown): void;
}
class HttpError extends Error {
constructor(
public readonly status: number,
message: string
) {
super(message);
}
}
function clampLimit(value: unknown, fallback: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), 1), max);
}
/** Render the pending approval URL as a PNG data URL for the login page. */
async function qrDataUrl(status: AuthStatus): Promise<string | undefined> {
const session = status.session;
if (session?.state !== "pending" || !session.url) return undefined;
return QRCode.toDataURL(session.url, { margin: 1, width: 280 });
}
export function startWebUi(deps: WebUiDeps): WebUiHandle {
const app = express();
app.use(express.json({ limit: "64kb" }));
app.use(express.static(fileURLToPath(new URL("../public", import.meta.url))));
const clients = new Set<Response>();
let eventCount = 0;
const requireCore = (): CoreClient => {
const core = deps.getCore();
if (!deps.isCoreConnected() || !core) throw new HttpError(503, "not connected to Vesktop");
return core;
};
const requirePuppet = (): Puppet => {
const puppet = deps.getPuppet();
if (!puppet) throw new HttpError(503, "puppet not connected yet");
return puppet;
};
const wrap =
(handler: (req: Request, res: Response) => Promise<void>) => (req: Request, res: Response) => {
handler(req, res).catch(error => {
if (error instanceof CoreError) {
res.status(400).json({ ok: false, code: error.code, error: error.message });
return;
}
const status = error instanceof HttpError ? error.status : 500;
res.status(status).json({ ok: false, error: String(error?.message ?? error) });
});
};
const coreStatus = () => ({
bridgeConnected: deps.isCoreConnected(),
process: deps.coreProcess.status()
});
const authStatus = async () => {
const status = deps.auth.status();
return { ...status, qrDataUrl: await qrDataUrl(status) };
};
app.get("/api/health", (_req, res) => {
res.json({
ok: true,
result: {
coreConnected: deps.isCoreConnected(),
ownerId: deps.getPuppet()?.owner ?? null,
process: deps.coreProcess.status(),
uptimeSeconds: Math.floor(process.uptime()),
events: eventCount,
queries: getQueryCount()
}
});
});
app.get("/api/core/status", (_req, res) => {
res.json({ ok: true, result: coreStatus() });
});
app.post(
"/api/core/start",
wrap(async (req, res) => {
const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin();
await deps.coreProcess.start(forceToken);
res.json({ ok: true, result: coreStatus() });
})
);
app.post(
"/api/core/stop",
wrap(async (_req, res) => {
await deps.coreProcess.stop();
res.json({ ok: true, result: coreStatus() });
})
);
app.post(
"/api/core/restart",
wrap(async (req, res) => {
const forceToken = !!req.body?.forceToken || deps.auth.consumeFreshLogin();
await deps.coreProcess.restart(forceToken);
res.json({ ok: true, result: coreStatus() });
})
);
app.get(
"/api/auth",
wrap(async (_req, res) => {
res.json({ ok: true, result: await authStatus() });
})
);
app.post(
"/api/auth/qr",
wrap(async (_req, res) => {
deps.auth.startQr();
res.json({ ok: true, result: await authStatus() });
})
);
app.post(
"/api/auth/qr/cancel",
wrap(async (_req, res) => {
deps.auth.cancelQr();
res.json({ ok: true, result: await authStatus() });
})
);
app.post(
"/api/auth/qr/captcha",
wrap(async (req, res) => {
const key = String(req.body?.key ?? "").trim();
if (!key) throw new HttpError(400, "a captcha key is required");
res.json({ ok: true, result: { accepted: deps.auth.submitCaptcha(key) } });
})
);
app.get(
"/api/state",
wrap(async (_req, res) => {
res.json({ ok: true, result: await requireCore().getState() });
})
);
app.get(
"/api/messages",
wrap(async (req, res) => {
const channelId = String(req.query.channelId ?? "");
const limit = clampLimit(req.query.limit, 50, 100);
const refresh = req.query.refresh === "1" || req.query.refresh === "true";
// refresh fetches from Discord and fills the client cache. Attachments
// stay as CDN links; nothing is stored as a file.
const result = await dedupe(`messages:${channelId}:${limit}:${refresh}`, () =>
paced(() =>
refresh
? requireCore().fetchMessages(channelId, limit)
: requireCore().readMessages(channelId, limit)
)
);
res.json({ ok: true, result });
})
);
app.post(
"/api/messages",
wrap(async (req, res) => {
const { channelId, content } = req.body ?? {};
const result = await pacedSend(() => requireCore().sendMessage(String(channelId), String(content ?? "")));
deps.getPuppet()?.appendAction({
type: "manual",
mode: "channel",
channelId: String(channelId),
contentLength: String(content ?? "").length
});
res.json({ ok: true, result });
})
);
app.post(
"/api/dm",
wrap(async (req, res) => {
const { content } = req.body ?? {};
let userId = req.body?.userId ? String(req.body.userId) : undefined;
if (!userId) {
const username = String(req.body?.username ?? "").trim();
if (!username) throw new HttpError(400, "userId or username is required");
const search = (await requireCore().userSearch(username, undefined, 10)) as {
users?: Array<{ id: string; username: string }>;
};
const needle = username.replace(/^@/, "").toLowerCase();
const match =
search?.users?.find(user => user.id === username) ??
search?.users?.find(user => user.username?.toLowerCase() === needle) ??
search?.users?.[0];
if (!match) throw new HttpError(404, `no cached user matches ${username}`);
userId = match.id;
}
const result = await pacedSend(() => requireCore().sendDm(String(userId), String(content ?? "")));
deps.getPuppet()?.appendAction({
type: "manual",
mode: "dm",
userId: String(userId),
contentLength: String(content ?? "").length
});
res.json({ ok: true, result });
})
);
app.get(
"/api/users",
wrap(async (req, res) => {
const query = String(req.query.q ?? "");
const guildId = req.query.guildId ? String(req.query.guildId) : undefined;
const limit = clampLimit(req.query.limit, 10, 25);
res.json({ ok: true, result: await requireCore().userSearch(query, guildId, limit) });
})
);
app.get(
"/api/dms",
wrap(async (req, res) => {
const limit = clampLimit(req.query.limit, 25, 50);
res.json({ ok: true, result: await requireCore().dmList(limit) });
})
);
app.get(
"/api/voice",
wrap(async (req, res) => {
const userId = req.query.userId ? String(req.query.userId) : undefined;
const guildId = req.query.guildId ? String(req.query.guildId) : undefined;
res.json({ ok: true, result: await requireCore().voiceGet(userId, guildId) });
})
);
app.get(
"/api/voice/overview",
wrap(async (req, res) => {
const guildId = String(req.query.guildId ?? "");
res.json({ ok: true, result: await requireCore().voiceOverview(guildId) });
})
);
app.post("/api/voice/join", (_req, res) => {
res.status(501).json({ ok: false, error: "voice.join is not implemented yet" });
});
app.post("/api/voice/leave", (_req, res) => {
res.status(501).json({ ok: false, error: "voice.leave is not implemented yet" });
});
app.get("/api/logs", (req, res) => {
const puppet = requirePuppet();
const limit = clampLimit(req.query.limit, 100, 500);
res.json({ ok: true, result: puppet.readActionLog(limit) });
});
app.get("/api/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no"
});
res.write(
`data: ${JSON.stringify({
type: "hello",
coreConnected: deps.isCoreConnected(),
ownerId: deps.getPuppet()?.owner ?? null
})}\n\n`
);
clients.add(res);
const keepAlive = setInterval(() => res.write(": ping\n\n"), 15000);
req.on("close", () => {
clearInterval(keepAlive);
clients.delete(res);
});
});
const server = app.listen(PORT, HOST, () => {
console.log(`[webui] listening on http://${HOST}:${PORT} (no auth; LAN or Tailscale only)`);
});
server.on("error", error => {
console.error("[webui] failed to start:", error);
});
return {
broadcast(event: unknown) {
eventCount++;
const line = `data: ${JSON.stringify(event)}\n\n`;
for (const client of clients) {
try {
client.write(line);
} catch {
clients.delete(client);
}
}
}
};
}