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
+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);
}
}