From f72693cb7b2ba9a20e9565047ecf1a727eb5c919 Mon Sep 17 00:00:00 2001
From: zortos293 <65777760+zortos293@users.noreply.github.com>
Date: Mon, 27 Jul 2026 21:59:16 +0000
Subject: [PATCH] refactor(gfn): modularize NVST RTSP probe
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---
.../main/platforms/gfn/nvstRtsp/probe.test.ts | 66 +
.../src/main/platforms/gfn/nvstRtsp/probe.ts | 329 +++++
.../platforms/gfn/nvstRtsp/rtspClient.test.ts | 29 +
.../main/platforms/gfn/nvstRtsp/rtspClient.ts | 237 ++++
.../main/platforms/gfn/nvstRtsp/sdp.test.ts | 46 +
.../src/main/platforms/gfn/nvstRtsp/sdp.ts | 195 +++
.../gfn/nvstRtsp/websocketTransport.test.ts | 67 +
.../gfn/nvstRtsp/websocketTransport.ts | 332 +++++
.../main/platforms/gfn/nvstRtspProbe.test.ts | 169 +--
.../src/main/platforms/gfn/nvstRtspProbe.ts | 1093 +----------------
10 files changed, 1341 insertions(+), 1222 deletions(-)
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.test.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.test.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.test.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.test.ts
create mode 100644 opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.ts
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.test.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.test.ts
new file mode 100644
index 00000000..ccd6e135
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.test.ts
@@ -0,0 +1,66 @@
+///
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ collectRtspsEndpoints,
+ rtspsUrlToWssUrl,
+ selectPrimaryRtspsEndpoint,
+} from "./probe";
+
+test("selectPrimaryRtspsEndpoint prefers :322", () => {
+ const selected = selectPrimaryRtspsEndpoint([
+ "rtsps://host.example:48322",
+ "rtsps://host.example:322",
+ ]);
+ assert.equal(selected, "rtsps://host.example:322");
+});
+
+test("rtspsUrlToWssUrl is host:port with no path (empty upgrade path is manual)", () => {
+ assert.equal(
+ rtspsUrlToWssUrl("rtsps://80-250-97-37.cloudmatchbeta.nvidiagrid.net:322"),
+ "wss://80-250-97-37.cloudmatchbeta.nvidiagrid.net:322",
+ );
+});
+
+test("collectRtspsEndpoints keeps both usage=14 paths", () => {
+ const endpoints = collectRtspsEndpoints(
+ [
+ {
+ usage: 14,
+ port: 322,
+ resourcePath: "rtsps://host.example:322",
+ },
+ {
+ usage: 14,
+ port: 48322,
+ resourcePath: "rtsps://host.example:48322",
+ },
+ {
+ usage: 2,
+ port: 49006,
+ resourcePath: null,
+ },
+ ],
+ "host.example",
+ );
+ assert.deepEqual(endpoints, [
+ "rtsps://host.example:322",
+ "rtsps://host.example:48322",
+ ]);
+});
+
+test("collectRtspsEndpoints synthesizes from port when resourcePath missing", () => {
+ const endpoints = collectRtspsEndpoints(
+ [
+ { usage: 14, port: 322, resourcePath: null },
+ { usage: 14, port: 48322, resourcePath: null },
+ ],
+ "host.example",
+ );
+ assert.deepEqual(endpoints, [
+ "rtsps://host.example:322",
+ "rtsps://host.example:48322",
+ ]);
+});
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.ts
new file mode 100644
index 00000000..da5da826
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/probe.ts
@@ -0,0 +1,329 @@
+/**
+ * Classic NVST RTSPS-over-WSS handshake (GO-with-Moonlight-hypothesis).
+ *
+ * Runs OPTIONS → DESCRIBE → SETUP video/0/0 → ANNOUNCE → PLAY against `:322`.
+ * Extracts or generates runtime.encryptionKey for SRTP, then returns nvstVideo
+ * handoff fields for the native UDP receive scaffold. Does not keep the UDP
+ * socket open across the process boundary — native rebinds clientUdpPort.
+ *
+ * Evidence: docs/research/nvst-wire-format.md, nvst-srtp-key-derivation.md,
+ * nvst-announce-allowlist-1080p60.json.
+ */
+
+import { createSocket, type Socket } from "node:dgram";
+
+import type { NvstVideoSession } from "@shared/gfn";
+
+import {
+ extractVideoPeer,
+ header,
+ RtspOverWssClient,
+} from "./rtspClient";
+import {
+ buildAnnounceSdp,
+ extractHmacSeed,
+ extractRuntimeEncryptionKey,
+ generateClientEncryptionKey,
+ packSrtpMasterKeySalt,
+ redactKey,
+} from "./sdp";
+
+const DEFAULT_PROBE_TIMEOUT_MS = 20_000;
+
+export interface NvstRtspProbeInput {
+ sessionId: string;
+ rtspsEndpoints: string[];
+ resolution?: string;
+ fps?: number;
+ codec?: string;
+ timeoutMs?: number;
+ onLog?: (message: string) => void;
+}
+
+export interface NvstSrtpMaterial {
+ /** 64-char hex AES-256 key (runtime.encryptionKey). */
+ aesKeyHex: string;
+ /** Unsigned key id used for salt packing (runtime.encryptionKeyId as u32). */
+ keyId: number;
+ /** 88-char hex libsrtp master key||salt (AES-256 || 12-byte salt). */
+ masterKeySaltHex: string;
+ /** True when OpenNOW generated the key for ANNOUNCE (DESCRIBE lacked it). */
+ clientGenerated: boolean;
+}
+
+export interface NvstRtspProbeResult {
+ ok: boolean;
+ endpoint: string;
+ session?: string;
+ hmacSeedPresent: boolean;
+ videoPeer?: { ip: string; port: number };
+ clientUdpPort?: number;
+ srtp?: NvstSrtpMaterial;
+ pingPayload?: string;
+ pingVersion?: number;
+ /** Handoff for native UDP video (shared NvstVideoSession shape). */
+ videoSession?: NvstVideoSession;
+ steps: string[];
+ error?: string;
+}
+
+function log(onLog: NvstRtspProbeInput["onLog"], message: string): void {
+ console.log(`[NvstRtspProbe] ${message}`);
+ onLog?.(message);
+}
+
+export function selectPrimaryRtspsEndpoint(endpoints: string[]): string | null {
+ const normalized = endpoints
+ .map((value) => value.trim())
+ .filter((value) => /^rtsps?:\/\//i.test(value));
+ if (normalized.length === 0) {
+ return null;
+ }
+ const port322 = normalized.find((url) => /:322(?:\/|$)/.test(url));
+ return port322 ?? normalized[0] ?? null;
+}
+
+/**
+ * Logging identity only (`wss://host:port`, no path). Real `:322` connect uses
+ * {@link connectNvstWss} with absolute-form upgrade cascade (rtsps/wss/https).
+ * See docs/research/nvst-rtsps-wss-connect.md and `_tmp-bifrost2-ws-400-followup.txt`.
+ */
+export function rtspsUrlToWssUrl(rtspsUrl: string): string {
+ const parsed = new URL(rtspsUrl.replace(/^rtsps:/i, "https:").replace(/^rtsp:/i, "http:"));
+ const port = parsed.port || "322";
+ return `wss://${parsed.hostname}:${port}`;
+}
+
+export function collectRtspsEndpoints(
+ connections: Array<{ usage?: number; port?: number; resourcePath?: string | null }>,
+ fallbackHost?: string | null,
+): string[] {
+ const endpoints: string[] = [];
+ const seen = new Set();
+
+ for (const conn of connections) {
+ if (conn.usage !== 14) {
+ continue;
+ }
+ const resourcePath = typeof conn.resourcePath === "string" ? conn.resourcePath.trim() : "";
+ if (/^rtsps?:\/\//i.test(resourcePath)) {
+ if (!seen.has(resourcePath)) {
+ seen.add(resourcePath);
+ endpoints.push(resourcePath);
+ }
+ continue;
+ }
+ if (!fallbackHost || !conn.port) {
+ continue;
+ }
+ const synthesized = `rtsps://${fallbackHost}:${conn.port}`;
+ if (!seen.has(synthesized)) {
+ seen.add(synthesized);
+ endpoints.push(synthesized);
+ }
+ }
+
+ return endpoints;
+}
+
+async function bindEphemeralUdp(): Promise<{ socket: Socket; port: number }> {
+ const socket = createSocket("udp4");
+ await new Promise((resolve, reject) => {
+ socket.once("error", reject);
+ socket.bind(0, "0.0.0.0", () => {
+ socket.off("error", reject);
+ resolve();
+ });
+ });
+ const address = socket.address();
+ if (typeof address === "string") {
+ socket.close();
+ throw new Error("Unexpected UDP socket address shape");
+ }
+ return { socket, port: address.port };
+}
+
+export async function runNvstRtspHandshakeProbe(input: NvstRtspProbeInput): Promise {
+ const steps: string[] = [];
+ const endpoint = selectPrimaryRtspsEndpoint(input.rtspsEndpoints);
+ if (!endpoint) {
+ return {
+ ok: false,
+ endpoint: "",
+ hmacSeedPresent: false,
+ steps,
+ error: "No rtsps:// endpoints available on the session",
+ };
+ }
+
+ const timeoutMs = input.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
+ const wssUrl = rtspsUrlToWssUrl(endpoint);
+ const parsedEndpoint = new URL(endpoint.replace(/^rtsps:/i, "https:").replace(/^rtsp:/i, "http:"));
+ const host = parsedEndpoint.hostname;
+ const port = Number(parsedEndpoint.port || "322");
+ const client = new RtspOverWssClient(
+ host,
+ port,
+ timeoutMs,
+ (message) => log(input.onLog, message),
+ );
+ let udp: { socket: Socket; port: number } | null = null;
+
+ try {
+ log(
+ input.onLog,
+ `Connecting RTSPS WSS ${wssUrl} via raw-TLS Bifrost-shaped upgrade (GET / then /v2/session/) (session ${input.sessionId})`,
+ );
+ await client.connect(input.sessionId);
+ steps.push("wss-open");
+
+ const options = await client.request("OPTIONS", endpoint);
+ if (options.statusCode !== 200) {
+ throw new Error(`OPTIONS failed: ${options.statusCode} ${options.statusText}`);
+ }
+ steps.push("options");
+ log(input.onLog, `OPTIONS ok (X-GS-Version=${header(options.headers, "x-gs-version") ?? "n/a"})`);
+
+ const describe = await client.request("DESCRIBE", endpoint, {
+ Accept: "application/sdp",
+ });
+ if (describe.statusCode !== 200) {
+ throw new Error(`DESCRIBE failed: ${describe.statusCode} ${describe.statusText}`);
+ }
+ steps.push("describe");
+ const session = header(describe.headers, "session")?.split(";")[0]?.trim();
+ if (!session) {
+ throw new Error("DESCRIBE response missing Session header");
+ }
+ const hmacSeed = extractHmacSeed(describe.body);
+ const describedKey = extractRuntimeEncryptionKey(describe.body);
+ let encryptionKeyHex: string;
+ let encryptionKeyId: number;
+ let clientGenerated = false;
+ if (describedKey) {
+ encryptionKeyHex = describedKey.aesKeyHex;
+ encryptionKeyId = describedKey.keyId;
+ log(
+ input.onLog,
+ `DESCRIBE ok (Session=${session}, HMAC ${hmacSeed ? "present" : "missing"}, encryptionKey ${redactKey(encryptionKeyHex)} from server)`,
+ );
+ } else {
+ const generated = generateClientEncryptionKey();
+ encryptionKeyHex = generated.aesKeyHex;
+ encryptionKeyId = generated.keyId;
+ clientGenerated = true;
+ log(
+ input.onLog,
+ `DESCRIBE ok (Session=${session}, HMAC ${hmacSeed ? "present" : "missing"}, encryptionKey absent — client-generated ${redactKey(encryptionKeyHex)} for ANNOUNCE)`,
+ );
+ }
+
+ udp = await bindEphemeralUdp();
+ const clientPort = udp.port;
+ // Transport uses X-GS-ClientPort (GameStream/Moonlight family). Official logs omit the
+ // client Transport summary string; server still returns X-GS-ServerPort + source.
+ const setup = await client.request("SETUP", `${endpoint}/streamid=video/0/0`, {
+ Session: session,
+ Transport: `unicast;X-GS-ClientPort=${clientPort}-${clientPort + 1}`,
+ });
+ if (setup.statusCode !== 200) {
+ throw new Error(`SETUP video failed: ${setup.statusCode} ${setup.statusText}`);
+ }
+ steps.push("setup-video");
+ const videoPeer = extractVideoPeer(header(setup.headers, "transport"));
+ const pingPayload = header(setup.headers, "x-nv-ping-payload");
+ const pingVersionRaw = header(setup.headers, "x-nv-ping");
+ const pingVersion = pingVersionRaw ? Number(pingVersionRaw) : undefined;
+ log(
+ input.onLog,
+ `SETUP video/0/0 ok (clientPort=${clientPort}, peer=${videoPeer ? `${videoPeer.ip}:${videoPeer.port}` : "unknown"})`,
+ );
+
+ const srtp: NvstSrtpMaterial = {
+ aesKeyHex: encryptionKeyHex,
+ keyId: encryptionKeyId,
+ masterKeySaltHex: packSrtpMasterKeySalt(encryptionKeyHex, encryptionKeyId),
+ clientGenerated,
+ };
+
+ const announceBody = buildAnnounceSdp({
+ resolution: input.resolution,
+ fps: input.fps,
+ encryptionKeyHex,
+ encryptionKeyId,
+ });
+ const announce = await client.request(
+ "ANNOUNCE",
+ endpoint,
+ {
+ Session: session,
+ "Content-Type": "application/sdp",
+ },
+ announceBody,
+ );
+ if (announce.statusCode !== 200) {
+ throw new Error(`ANNOUNCE failed: ${announce.statusCode} ${announce.statusText}`);
+ }
+ steps.push("announce");
+ log(input.onLog, "ANNOUNCE ok (allowlist + encryptionKey; ICE/DTLS omitted)");
+
+ const play = await client.request("PLAY", endpoint, {
+ Session: session,
+ Range: "npt=0.000-",
+ });
+ if (play.statusCode !== 200) {
+ throw new Error(`PLAY failed: ${play.statusCode} ${play.statusText}`);
+ }
+ steps.push("play");
+
+ // Close the probe UDP socket so the native streamer can rebind the same port.
+ udp.socket.close();
+ udp = null;
+
+ if (!videoPeer) {
+ throw new Error("SETUP did not return video peer (X-GS-ServerPort/source)");
+ }
+
+ const videoSession: NvstVideoSession = {
+ clientUdpPort: clientPort,
+ videoPeerIp: videoPeer.ip,
+ videoPeerPort: videoPeer.port,
+ srtpAesKeyHex: srtp.aesKeyHex,
+ srtpKeyId: srtp.keyId,
+ pingPayload,
+ codec: input.codec,
+ };
+
+ log(
+ input.onLog,
+ `PLAY ok — NVST video handoff ready (peer ${videoPeer.ip}:${videoPeer.port}, clientUdp ${clientPort}); WebRTC remains for SCTP input`,
+ );
+
+ return {
+ ok: true,
+ endpoint,
+ session,
+ hmacSeedPresent: Boolean(hmacSeed),
+ videoPeer,
+ clientUdpPort: clientPort,
+ srtp,
+ pingPayload,
+ pingVersion: Number.isFinite(pingVersion) ? pingVersion : undefined,
+ videoSession,
+ steps,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ log(input.onLog, `Probe failed: ${message}`);
+ return {
+ ok: false,
+ endpoint,
+ hmacSeedPresent: false,
+ steps,
+ error: message,
+ };
+ } finally {
+ client.close();
+ udp?.socket.close();
+ }
+}
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.test.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.test.ts
new file mode 100644
index 00000000..a021bb64
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.test.ts
@@ -0,0 +1,29 @@
+///
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { extractVideoPeer, parseRtspResponse } from "./rtspClient";
+
+test("extractVideoPeer prefers SETUP X-GS-ServerPort", () => {
+ assert.deepEqual(
+ extractVideoPeer("unicast;X-GS-ServerPort=5004-5005;source=80.250.97.37"),
+ { ip: "80.250.97.37", port: 5004 },
+ );
+});
+
+test("parseRtspResponse preserves status, normalized headers, and SDP body", () => {
+ const response = parseRtspResponse(
+ "RTSP/1.0 200 OK\r\nCSeq: 2\r\nSession: session-id;timeout=60\r\nContent-Length: 5\r\n\r\nv=0\r\n",
+ );
+ assert.deepEqual(response, {
+ statusCode: 200,
+ statusText: "OK",
+ headers: {
+ cseq: "2",
+ session: "session-id;timeout=60",
+ "content-length": "5",
+ },
+ body: "v=0\n",
+ });
+});
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.ts
new file mode 100644
index 00000000..2136dfbc
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/rtspClient.ts
@@ -0,0 +1,237 @@
+import type { Duplex } from "node:stream";
+
+import { connectNvstWss, encodeWsTextFrame, WsFrameReader } from "./websocketTransport";
+
+const GS_VERSION = "14.2";
+
+export interface ParsedRtspResponse {
+ statusCode: number;
+ statusText: string;
+ headers: Record;
+ body: string;
+}
+
+export function parseRtspResponse(raw: string): ParsedRtspResponse {
+ const normalized = raw.replace(/\r\n/g, "\n");
+ const splitAt = normalized.indexOf("\n\n");
+ const headerBlock = splitAt >= 0 ? normalized.slice(0, splitAt) : normalized;
+ const body = splitAt >= 0 ? normalized.slice(splitAt + 2) : "";
+ const headerLines = headerBlock.split("\n").filter((line) => line.length > 0);
+ const statusLine = headerLines[0] ?? "";
+ const statusMatch = /^(?:RTSP|HTTP)\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i.exec(statusLine);
+ if (!statusMatch) {
+ throw new Error(`Invalid RTSP status line: ${statusLine.slice(0, 120)}`);
+ }
+ const headers: Record = {};
+ for (const line of headerLines.slice(1)) {
+ const idx = line.indexOf(":");
+ if (idx <= 0) {
+ continue;
+ }
+ headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
+ }
+ return {
+ statusCode: Number(statusMatch[1]),
+ statusText: statusMatch[2]?.trim() ?? "",
+ headers,
+ body,
+ };
+}
+
+export function header(headers: Record, name: string): string | undefined {
+ return headers[name.toLowerCase()];
+}
+
+export function extractVideoPeer(
+ transport: string | undefined,
+): { ip: string; port: number } | undefined {
+ if (!transport) {
+ return undefined;
+ }
+ const portMatch = /X-GS-ServerPort=(\d+)/i.exec(transport);
+ const sourceMatch = /source=([^;,\s]+)/i.exec(transport);
+ if (!portMatch || !sourceMatch) {
+ return undefined;
+ }
+ return { ip: sourceMatch[1]!, port: Number(portMatch[1]) };
+}
+
+export class RtspOverWssClient {
+ private socket: Duplex | null = null;
+ private frameReader = new WsFrameReader();
+ private buffer = Buffer.alloc(0);
+ private cseq = 0;
+ private pending: {
+ resolve: (response: ParsedRtspResponse) => void;
+ reject: (error: Error) => void;
+ } | null = null;
+
+ constructor(
+ private readonly host: string,
+ private readonly port: number,
+ private readonly timeoutMs: number,
+ private readonly onLog?: (message: string) => void,
+ ) {}
+
+ async connect(sessionId?: string): Promise {
+ const socket = await connectNvstWss(
+ this.host,
+ this.port,
+ this.timeoutMs,
+ sessionId,
+ this.onLog,
+ );
+ this.socket = socket;
+ socket.on("data", (chunk: Buffer) => this.onSocketData(chunk));
+ socket.on("error", (error) => {
+ if (this.pending) {
+ this.pending.reject(error instanceof Error ? error : new Error(String(error)));
+ this.pending = null;
+ }
+ });
+ socket.on("close", () => {
+ if (this.pending) {
+ this.pending.reject(new Error("RTSPS WebSocket closed"));
+ this.pending = null;
+ }
+ });
+ }
+
+ close(): void {
+ try {
+ this.socket?.destroy();
+ } catch {
+ // ignore
+ }
+ this.socket = null;
+ if (this.pending) {
+ this.pending.reject(new Error("RTSPS probe closed"));
+ this.pending = null;
+ }
+ }
+
+ async request(
+ method: string,
+ uri: string,
+ extraHeaders: Record = {},
+ body = "",
+ ): Promise {
+ if (!this.socket || this.socket.destroyed) {
+ throw new Error("RTSPS WebSocket is not open");
+ }
+ if (this.pending) {
+ throw new Error("Overlapping RTSP requests are not supported");
+ }
+
+ this.cseq += 1;
+ const headers: Record = {
+ CSeq: String(this.cseq),
+ "Request-Id": String(this.cseq),
+ "X-GS-Version": GS_VERSION,
+ ...extraHeaders,
+ };
+ if (body.length > 0) {
+ headers["Content-Length"] = String(Buffer.byteLength(body, "utf8"));
+ }
+
+ let message = `${method} ${uri} RTSP/1.0\r\n`;
+ for (const [key, value] of Object.entries(headers)) {
+ message += `${key}: ${value}\r\n`;
+ }
+ message += "\r\n";
+ if (body.length > 0) {
+ message += body;
+ }
+
+ return await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ if (this.pending) {
+ this.pending = null;
+ reject(new Error(`RTSP ${method} timed out after ${this.timeoutMs}ms`));
+ }
+ }, this.timeoutMs);
+
+ this.pending = {
+ resolve: (response) => {
+ clearTimeout(timer);
+ resolve(response);
+ },
+ reject: (error) => {
+ clearTimeout(timer);
+ reject(error);
+ },
+ };
+
+ try {
+ this.socket?.write(encodeWsTextFrame(Buffer.from(message, "utf8")));
+ } catch (error) {
+ clearTimeout(timer);
+ this.pending = null;
+ reject(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+ }
+
+ private onSocketData(chunk: Buffer): void {
+ try {
+ for (const payload of this.frameReader.push(chunk)) {
+ this.buffer = Buffer.concat([this.buffer, payload]);
+ this.tryCompleteResponse();
+ }
+ } catch (error) {
+ if (this.pending) {
+ const pending = this.pending;
+ this.pending = null;
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
+ }
+ }
+ }
+
+ private tryCompleteResponse(): void {
+ if (!this.pending) {
+ return;
+ }
+
+ const text = this.buffer.toString("utf8");
+ const crlfSplit = text.indexOf("\r\n\r\n");
+ const lfSplit = text.indexOf("\n\n");
+ let headerEnd = -1;
+ let sepLen = 0;
+ if (crlfSplit >= 0 && (lfSplit < 0 || crlfSplit <= lfSplit)) {
+ headerEnd = crlfSplit;
+ sepLen = 4;
+ } else if (lfSplit >= 0) {
+ headerEnd = lfSplit;
+ sepLen = 2;
+ }
+ if (headerEnd < 0) {
+ return;
+ }
+
+ const headerText = text.slice(0, headerEnd);
+ const contentLengthMatch = /^Content-Length:\s*(\d+)\s*$/im.exec(headerText);
+ const contentLength = contentLengthMatch ? Number(contentLengthMatch[1]) : 0;
+ const bodyStart = headerEnd + sepLen;
+ // Use byte length of remaining buffer after header separator.
+ const headerByteLength = Buffer.byteLength(text.slice(0, bodyStart), "utf8");
+ const availableBodyBytes = this.buffer.length - headerByteLength;
+ if (availableBodyBytes < contentLength) {
+ return;
+ }
+
+ const totalBytes = headerByteLength + contentLength;
+ const raw = this.buffer.subarray(0, totalBytes).toString("utf8");
+ this.buffer = this.buffer.subarray(totalBytes);
+
+ try {
+ const parsed = parseRtspResponse(raw);
+ const pending = this.pending;
+ this.pending = null;
+ pending?.resolve(parsed);
+ } catch (error) {
+ const pending = this.pending;
+ this.pending = null;
+ pending?.reject(error instanceof Error ? error : new Error(String(error)));
+ }
+ }
+}
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.test.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.test.ts
new file mode 100644
index 00000000..d6900a87
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.test.ts
@@ -0,0 +1,46 @@
+///
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ buildAnnounceSdp,
+ extractHmacSeed,
+ extractRuntimeEncryptionKey,
+ packSrtpMasterKeySalt,
+} from "./sdp";
+
+test("extractHmacSeed reads DESCRIBE k= line", () => {
+ const seed = extractHmacSeed(
+ "v=0\r\nk=HMAC:76A28E94D8C07CB67C04C29CFAAAAF64BE4BA0899456217CB73D070E5060965F\r\na=x-nv-general.rtspWebSocketPerConnection:1\r\n",
+ );
+ assert.equal(seed, "76A28E94D8C07CB67C04C29CFAAAAF64BE4BA0899456217CB73D070E5060965F");
+});
+
+test("buildAnnounceSdp uses allowlist shape and omits ICE/DTLS", () => {
+ const sdp = buildAnnounceSdp({ resolution: "1920x1080", fps: 60 });
+ assert.match(sdp, /a=x-nv-video\[0\]\.clientViewportWd:1920/);
+ assert.match(sdp, /a=x-nv-video\[0\]\.maxFPS:60/);
+ assert.match(sdp, /a=x-nv-general\.controlProtocol:udp_ag/);
+ assert.doesNotMatch(sdp, /iceUsernameFragment|dtlsFingerprint/);
+});
+
+test("packSrtpMasterKeySalt matches geronimo keyId packing", () => {
+ const aes = `${"1C98".padEnd(60, "0")}07D2`;
+ const packed = packSrtpMasterKeySalt(aes, 2664076126);
+ assert.equal(packed.length, 88);
+ assert.equal(packed.slice(0, 64), aes.toUpperCase());
+ assert.equal(packed.slice(64), "00000000000000009ECA935E");
+});
+
+test("extractRuntimeEncryptionKey reads DESCRIBE attrs", () => {
+ const sdp = [
+ "v=0",
+ "a=x-nv-runtime.encryptionKey:AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899",
+ "a=x-nv-runtime.encryptionKeyId:-1630891170",
+ "",
+ ].join("\r\n");
+ const parsed = extractRuntimeEncryptionKey(sdp);
+ assert.ok(parsed);
+ assert.equal(parsed?.keyId, 2664076126);
+});
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.ts
new file mode 100644
index 00000000..6f009571
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/sdp.ts
@@ -0,0 +1,195 @@
+import { randomBytes } from "node:crypto";
+
+/** Minimal ANNOUNCE attrs from docs/research/nvst-announce-allowlist-1080p60.json */
+const ANNOUNCE_ALLOWLIST = {
+ video: {
+ clientViewportWd: "1920",
+ clientViewportHt: "1080",
+ maxFPS: "60",
+ videoSplitEncodeStripsPerFrame: "3",
+ updateSplitEncodeStateDynamically: "1",
+ packetSize: "1408",
+ enableRtpNack: "1",
+ rtpNackQueueLength: "2048",
+ rtpNackQueueMaxPackets: "1024",
+ rtpNackMaxPacketCount: "64",
+ "framePacing.mode": "2",
+ "framePacing.pid.minTargetFrameTimeUs": "16666",
+ "adaptiveQuantization.spatialAQSetting": "7",
+ "adaptiveQuantization.temporalAQSetting": "0",
+ "adaptiveQuantization.spatialAQStrength": "12",
+ "adaptiveQuantization.qpThresholdAdjPercent": "2",
+ "adaptiveQuantization.saqAdaptMinQpThresholdPercent": "40",
+ "adaptiveQuantization.saqAdaptMaxQpThresholdPercent": "100",
+ "adaptiveQuantization.saqAdaptDecayStrengthX100": "250",
+ "adaptiveQuantization.perfAdjEnablement": "1",
+ enableAv1RcPrecisionFactor: "1",
+ },
+ vqos: {
+ "fec.enable": "1",
+ "fec.repairPercent": "20",
+ "fec.repairMinPercent": "5",
+ "fec.repairMaxPercent": "40",
+ "bllFec.enable": "1",
+ "grc.enable": "0",
+ "drc.enable": "0",
+ "dfc.adjustResAndFps": "0",
+ calculateAvgVideoStreamingBitrate: "1",
+ "bw.maximumBitrateKbps": "100000",
+ "bw.minimumBitrateKbps": "1000",
+ },
+ packetPacing: {
+ version: "3",
+ mode: "1",
+ numGroups: "5",
+ maxDelayUs: "1000",
+ minNumPacketsFrame: "10",
+ minNumPacketsPerGroup: "0",
+ enableAccurateSleep: "1",
+ enableSmoothTransition: "1",
+ allowFpsBasedToggle: "1",
+ },
+ ri: {
+ partialReliableThresholdMs: "300",
+ timestampsEnabled: "1",
+ useMultipleGamepads: "1",
+ usePartiallyReliableUdpChannel: "0",
+ enablePartiallyReliableTransferGamepad: "255",
+ enablePartiallyReliableTransferHid: "-1",
+ },
+ aqos: {
+ enableRedundancy: "1",
+ redundancyLevel: "2",
+ },
+ general: {
+ rtspWebSocketPerConnection: "1",
+ "enetControlChannel.mtuSize": "1191",
+ pingIntervalBeforeConnectionMs: "20",
+ pingIntervalAfterConnectionMs: "100",
+ },
+ runtime: {
+ audioSrtp: "0",
+ micSrtp: "0",
+ },
+} as const;
+
+function parseResolution(resolution: string | undefined): { width: number; height: number } {
+ const match = /^(\d+)\s*[xX]\s*(\d+)$/.exec(resolution?.trim() ?? "");
+ if (!match) {
+ return { width: 1920, height: 1080 };
+ }
+ return { width: Number(match[1]), height: Number(match[2]) };
+}
+
+export function buildAnnounceSdp(
+ options: {
+ resolution?: string;
+ fps?: number;
+ encryptionKeyHex?: string;
+ encryptionKeyId?: number;
+ } = {},
+): string {
+ const { width, height } = parseResolution(options.resolution);
+ const fps = options.fps && options.fps > 0 ? Math.round(options.fps) : 60;
+ const frameTimeUs = String(Math.round(1_000_000 / fps));
+
+ const lines: string[] = [
+ "v=0",
+ "o=- 0 0 IN IP4 127.0.0.1",
+ "s=OpenNOW NVST Handshake",
+ "t=0 0",
+ ];
+
+ const pushGroup = (
+ prefix: string,
+ indexed: boolean,
+ values: Record,
+ ): void => {
+ for (const [key, value] of Object.entries(values)) {
+ let nextValue = value;
+ if (prefix === "video" && key === "clientViewportWd") {
+ nextValue = String(width);
+ } else if (prefix === "video" && key === "clientViewportHt") {
+ nextValue = String(height);
+ } else if (prefix === "video" && key === "maxFPS") {
+ nextValue = String(fps);
+ } else if (prefix === "video" && key === "framePacing.pid.minTargetFrameTimeUs") {
+ nextValue = frameTimeUs;
+ }
+ const name = indexed ? `x-nv-${prefix}[0].${key}` : `x-nv-${prefix}.${key}`;
+ lines.push(`a=${name}:${nextValue}`);
+ }
+ };
+
+ pushGroup("video", true, ANNOUNCE_ALLOWLIST.video);
+ pushGroup("vqos", true, ANNOUNCE_ALLOWLIST.vqos);
+ pushGroup("packetPacing", false, ANNOUNCE_ALLOWLIST.packetPacing);
+ pushGroup("ri", false, ANNOUNCE_ALLOWLIST.ri);
+ pushGroup("aqos", false, ANNOUNCE_ALLOWLIST.aqos);
+ pushGroup("general", false, ANNOUNCE_ALLOWLIST.general);
+ pushGroup("runtime", false, ANNOUNCE_ALLOWLIST.runtime);
+ lines.push("a=x-nv-runtime.videoSrtp:1");
+ if (options.encryptionKeyHex && options.encryptionKeyId !== undefined) {
+ lines.push(`a=x-nv-runtime.encryptionKey:${options.encryptionKeyHex.toUpperCase()}`);
+ // Signed i32 form matches geronimo runtime.encryptionKeyId dumps.
+ const signedId = options.encryptionKeyId > 0x7fffffff
+ ? options.encryptionKeyId - 0x1_0000_0000
+ : options.encryptionKeyId;
+ lines.push(`a=x-nv-runtime.encryptionKeyId:${signedId}`);
+ }
+ // Control protocol preference evidenced in geronimo: udp_ag (not encrypted).
+ lines.push("a=x-nv-general.controlProtocol:udp_ag");
+ lines.push("");
+ return lines.join("\r\n");
+}
+
+export function extractHmacSeed(sdp: string): string | null {
+ const match = /^k=HMAC:([0-9A-Fa-f]{64})\s*$/m.exec(sdp);
+ return match?.[1] ?? null;
+}
+
+/**
+ * Pack AES-256 key + keyId into libsrtp master key||salt (88 hex).
+ * Salt = keyId as `%024x` (12 bytes BE). See docs/research/nvst-srtp-key-derivation.md.
+ */
+export function packSrtpMasterKeySalt(aesKeyHex: string, keyId: number): string {
+ const key = aesKeyHex.trim().toUpperCase();
+ if (!/^[0-9A-F]{64}$/.test(key)) {
+ throw new Error(`encryptionKey must be 64 hex chars, got length ${key.length}`);
+ }
+ const id = keyId >>> 0;
+ const salt = id.toString(16).toUpperCase().padStart(24, "0");
+ return `${key}${salt}`;
+}
+
+export function extractRuntimeEncryptionKey(
+ sdp: string,
+): { aesKeyHex: string; keyId: number } | null {
+ const keyMatch = /^a=x-nv-runtime\.encryptionKey:([0-9A-Fa-f]{64})\s*$/m.exec(sdp);
+ const idMatch = /^a=x-nv-runtime\.encryptionKeyId:(-?\d+)\s*$/m.exec(sdp);
+ if (!keyMatch || !idMatch) {
+ return null;
+ }
+ let keyId = Number(idMatch[1]);
+ if (!Number.isFinite(keyId)) {
+ return null;
+ }
+ // Signed i32 in SDP → unsigned u32 for salt packing.
+ if (keyId < 0) {
+ keyId = keyId + 0x1_0000_0000;
+ }
+ return { aesKeyHex: keyMatch[1]!.toUpperCase(), keyId: keyId >>> 0 };
+}
+
+export function generateClientEncryptionKey(): { aesKeyHex: string; keyId: number } {
+ const aesKeyHex = randomBytes(32).toString("hex").toUpperCase();
+ const keyId = randomBytes(4).readUInt32BE(0);
+ return { aesKeyHex, keyId };
+}
+
+export function redactKey(aesKeyHex: string): string {
+ if (aesKeyHex.length < 8) {
+ return "****";
+ }
+ return `${aesKeyHex.slice(0, 4)}…${aesKeyHex.slice(-4)}`;
+}
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.test.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.test.ts
new file mode 100644
index 00000000..f6cf8004
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.test.ts
@@ -0,0 +1,67 @@
+///
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ buildEmptyPathUpgradeRequest,
+ buildNvstWssUpgradeRequest,
+ buildNvstWssUpgradeRequestTarget,
+ encodeWsTextFrame,
+ WsFrameReader,
+} from "./websocketTransport";
+
+test("buildNvstWssUpgradeRequest uses Bifrost-shaped GET / by default", () => {
+ const request = buildNvstWssUpgradeRequest(
+ "80-250-97-40.cloudmatchbeta.nvidiagrid.net",
+ 322,
+ "dGVzdGtleTEyMzQ1Njc4OQ==",
+ );
+ const requestLine = request.split("\r\n")[0] ?? "";
+ assert.equal(requestLine, "GET / HTTP/1.1");
+ assert.equal(Buffer.from(requestLine, "utf8").toString("hex"), "474554202f20485454502f312e31");
+ assert.equal(buildNvstWssUpgradeRequestTarget("host.example", 322, "slash"), "/");
+ assert.equal(
+ buildNvstWssUpgradeRequestTarget("host.example", 322, "sessionPath", "abc-uuid"),
+ "/v2/session/abc-uuid",
+ );
+ assert.match(request, /^GET \/ HTTP\/1\.1\r\nHost: 80-250-97-40\.cloudmatchbeta\.nvidiagrid\.net:322\r\n/);
+ assert.match(request, /\r\nConnection: Upgrade\r\n/);
+ assert.match(request, /\r\nUpgrade: websocket\r\n/);
+ assert.match(request, /\r\nSec-WebSocket-Version: 13\r\n/);
+ assert.match(request, /\r\nSec-WebSocket-Key: dGVzdGtleTEyMzQ1Njc4OQ==\r\n/);
+ assert.match(request, /\r\nContent-Length: 0\r\n\r\n$/);
+ assert.doesNotMatch(request, /Sec-WebSocket-Protocol/i);
+ assert.doesNotMatch(request, /User-Agent/i);
+ assert.doesNotMatch(request, /x-nv-sessionid/i);
+});
+
+test("buildEmptyPathUpgradeRequest keeps empty URI for research (live → 400)", () => {
+ const request = buildEmptyPathUpgradeRequest(
+ "80-250-97-40.cloudmatchbeta.nvidiagrid.net",
+ 322,
+ "dGVzdGtleTEyMzQ1Njc4OQ==",
+ );
+ const requestLine = request.split("\r\n")[0] ?? "";
+ assert.equal(requestLine, "GET HTTP/1.1");
+ assert.equal(Buffer.from(requestLine, "utf8").toString("hex"), "4745542020485454502f312e31");
+});
+
+test("buildNvstWssUpgradeRequest can attach x-nv-sessionid for 403 retry", () => {
+ const request = buildNvstWssUpgradeRequest("host.example", 322, "abc", {
+ form: "slash",
+ sessionId: "sess-uuid",
+ });
+ assert.match(request, /\r\nx-nv-sessionid: sess-uuid\r\n/);
+});
+
+test("WebSocket framing preserves masked client payload bytes", () => {
+ const payload = Buffer.from("OPTIONS rtsps://host.example:322 RTSP/1.0\r\n\r\n");
+ const frame = encodeWsTextFrame(payload);
+ assert.equal(frame[0], 0x81);
+ assert.equal(frame[1]! & 0x80, 0x80);
+
+ const reader = new WsFrameReader();
+ assert.deepEqual(reader.push(frame.subarray(0, 3)), []);
+ assert.deepEqual(reader.push(frame.subarray(3)), [payload]);
+});
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.ts b/opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.ts
new file mode 100644
index 00000000..3d0f334f
--- /dev/null
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtsp/websocketTransport.ts
@@ -0,0 +1,332 @@
+import { createHash, randomBytes } from "node:crypto";
+import type { Duplex } from "node:stream";
+import { connect as tlsConnect, type TLSSocket } from "node:tls";
+
+/**
+ * Official `:322` upgrade request-target is still under research.
+ *
+ * Live matrix (raw TLS unless noted):
+ * - empty (`GET HTTP/1.1`) → HTTP 400
+ * - absolute `rtsps://` / `wss://` / `https://` → HTTP 404
+ * - `/` via Node `ws` → HTTP 404 (extra Client headers; not Bifrost-shaped)
+ * - `/` via raw TLS Bifrost-shaped headers → **pending** (never tried)
+ *
+ * Poco 1.14.1 WebSocket::connect does not set method/URI; it only adds Upgrade
+ * headers. Default HTTPRequest is GET `/`. See docs/research/_tmp-bifrost2-ws-uri-resolved.txt.
+ */
+export type NvstWssUpgradeTargetForm =
+ | "slash"
+ | "sessionPath"
+ | "rtsps"
+ | "wss"
+ | "https"
+ | "empty";
+
+export function buildNvstWssUpgradeRequestTarget(
+ host: string,
+ port: number,
+ form: NvstWssUpgradeTargetForm,
+ sessionId?: string,
+): string {
+ switch (form) {
+ case "slash":
+ return "/";
+ case "sessionPath":
+ return sessionId && sessionId.trim().length > 0
+ ? `/v2/session/${sessionId.trim()}`
+ : "/";
+ case "rtsps":
+ return `rtsps://${host}:${port}`;
+ case "wss":
+ return `wss://${host}:${port}`;
+ case "https":
+ return `https://${host}:${port}`;
+ case "empty":
+ return "";
+ }
+}
+
+/**
+ * Raw TLS WebSocket upgrade for NVST `:322`.
+ * Header order matches Poco WebSocket::connect + Bifrost Content-Length: 0.
+ * Optional `x-nv-sessionid` is only for a 403 retry.
+ */
+export function buildNvstWssUpgradeRequest(
+ host: string,
+ port: number,
+ secWebSocketKey: string,
+ options: {
+ form?: NvstWssUpgradeTargetForm;
+ sessionId?: string;
+ } = {},
+): string {
+ const form = options.form ?? "slash";
+ const target = buildNvstWssUpgradeRequestTarget(host, port, form, options.sessionId);
+ const requestLine = `GET ${target} HTTP/1.1`;
+ // Poco sets Connection/Upgrade/Version/Key; Bifrost presets Content-Length: 0.
+ // Host is auto-set by HTTPClientSession (host:port for non-443).
+ let request =
+ `${requestLine}\r\n` +
+ `Host: ${host}:${port}\r\n` +
+ `Connection: Upgrade\r\n` +
+ `Upgrade: websocket\r\n` +
+ `Sec-WebSocket-Version: 13\r\n` +
+ `Sec-WebSocket-Key: ${secWebSocketKey}\r\n` +
+ `Content-Length: 0\r\n`;
+ const sessionId = options.sessionId?.trim();
+ if (sessionId && form !== "sessionPath") {
+ // Only attach as header on 403 retry paths; sessionPath already uses UUID in URI.
+ request += `x-nv-sessionid: ${sessionId}\r\n`;
+ }
+ return `${request}\r\n`;
+}
+
+/** @deprecated Empty path is live-falsified (HTTP 400). */
+export function buildEmptyPathUpgradeRequest(
+ host: string,
+ port: number,
+ secWebSocketKey: string,
+ sessionId?: string,
+): string {
+ return buildNvstWssUpgradeRequest(host, port, secWebSocketKey, {
+ form: "empty",
+ sessionId,
+ });
+}
+
+function connectNvstWssOnce(
+ host: string,
+ port: number,
+ timeoutMs: number,
+ form: NvstWssUpgradeTargetForm,
+ sessionId?: string,
+ attachSessionHeader = false,
+): Promise {
+ const key = randomBytes(16).toString("base64");
+ const expectedAccept = createHash("sha1")
+ .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
+ .digest("base64");
+ const request = buildNvstWssUpgradeRequest(host, port, key, {
+ form,
+ // For slash/rtsps/… first attempt: no session header.
+ // For 403 retry or sessionPath: pass sessionId.
+ sessionId: attachSessionHeader || form === "sessionPath" ? sessionId : undefined,
+ });
+ const requestLine = request.split("\r\n")[0] ?? "";
+ const requestLineHex = Buffer.from(requestLine, "utf8").toString("hex");
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let buffer = Buffer.alloc(0);
+ let socket: TLSSocket | null = null;
+
+ const fail = (error: Error): void => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ try {
+ socket?.destroy();
+ } catch {
+ // ignore
+ }
+ reject(error);
+ };
+
+ const succeed = (tlsSocket: TLSSocket, leftover: Buffer): void => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ tlsSocket.removeAllListeners("data");
+ tlsSocket.removeAllListeners("error");
+ tlsSocket.removeAllListeners("timeout");
+ tlsSocket.setTimeout(0);
+ if (leftover.length > 0) {
+ tlsSocket.unshift(leftover);
+ }
+ resolve(tlsSocket);
+ };
+
+ socket = tlsConnect(
+ {
+ host,
+ port,
+ servername: host,
+ rejectUnauthorized: true,
+ },
+ () => {
+ socket?.write(request);
+ },
+ );
+
+ socket.setTimeout(timeoutMs);
+ socket.on("timeout", () => fail(new Error(`WSS upgrade timed out after ${timeoutMs}ms`)));
+ socket.on("error", (error) => fail(error instanceof Error ? error : new Error(String(error))));
+ socket.on("data", (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk]);
+ const headerEnd = buffer.indexOf("\r\n\r\n");
+ if (headerEnd < 0) {
+ if (buffer.length > 16_384) {
+ fail(new Error("WSS upgrade failed: response headers too large"));
+ }
+ return;
+ }
+
+ const headerText = buffer.subarray(0, headerEnd).toString("utf8");
+ const leftover = buffer.subarray(headerEnd + 4);
+ const statusLine = headerText.split("\r\n")[0] ?? "";
+ const statusMatch = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i.exec(statusLine);
+ const statusCode = statusMatch ? Number(statusMatch[1]) : 0;
+ const headers: Record = {};
+ for (const line of headerText.split("\r\n").slice(1)) {
+ const idx = line.indexOf(":");
+ if (idx <= 0) {
+ continue;
+ }
+ headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
+ }
+
+ if (statusCode !== 101) {
+ fail(
+ new Error(
+ `WSS upgrade failed: HTTP ${statusCode || "unknown"} (${statusLine || "no status"}); ` +
+ `form=${form} request-line=${requestLine} hex=${requestLineHex}` +
+ (attachSessionHeader ? " with x-nv-sessionid" : ""),
+ ),
+ );
+ return;
+ }
+ if (headers["sec-websocket-accept"] !== expectedAccept) {
+ fail(new Error("WSS upgrade failed: invalid Sec-WebSocket-Accept"));
+ return;
+ }
+ succeed(socket!, leftover);
+ });
+ });
+}
+
+/** Primary: Bifrost-shaped GET /. Fallback: CloudMatch-style /v2/session/. */
+const UPGRADE_TARGET_FORMS: NvstWssUpgradeTargetForm[] = ["slash", "sessionPath"];
+
+export async function connectNvstWss(
+ host: string,
+ port: number,
+ timeoutMs: number,
+ sessionId?: string,
+ onLog?: (message: string) => void,
+): Promise {
+ let lastError: Error | null = null;
+ for (const form of UPGRADE_TARGET_FORMS) {
+ try {
+ const target = buildNvstWssUpgradeRequestTarget(host, port, form, sessionId);
+ onLog?.(
+ `Trying WSS upgrade form=${form} (GET ${target} HTTP/1.1) raw-TLS Bifrost headers`,
+ );
+ return await connectNvstWssOnce(host, port, timeoutMs, form, sessionId, false);
+ } catch (error) {
+ const err = error instanceof Error ? error : new Error(String(error));
+ lastError = err;
+ const message = err.message;
+ if (sessionId && /\bHTTP 403\b/.test(message)) {
+ onLog?.(`Upgrade HTTP 403 on form=${form}; retrying with x-nv-sessionid`);
+ try {
+ return await connectNvstWssOnce(host, port, timeoutMs, form, sessionId, true);
+ } catch (retryError) {
+ lastError = retryError instanceof Error ? retryError : new Error(String(retryError));
+ }
+ }
+ if (!/\bHTTP (400|404)\b/.test(message)) {
+ throw lastError;
+ }
+ onLog?.(message);
+ }
+ }
+ throw lastError ?? new Error("WSS upgrade failed for all request-target forms");
+}
+
+export function encodeWsTextFrame(payload: Buffer): Buffer {
+ const len = payload.length;
+ const mask = randomBytes(4);
+ let header: Buffer;
+ if (len < 126) {
+ header = Buffer.alloc(2);
+ header[0] = 0x81; // FIN + text
+ header[1] = 0x80 | len; // MASK bit set
+ } else if (len < 65536) {
+ header = Buffer.alloc(4);
+ header[0] = 0x81;
+ header[1] = 0x80 | 126;
+ header.writeUInt16BE(len, 2);
+ } else {
+ header = Buffer.alloc(10);
+ header[0] = 0x81;
+ header[1] = 0x80 | 127;
+ header.writeUInt32BE(0, 2);
+ header.writeUInt32BE(len, 6);
+ }
+ const masked = Buffer.alloc(len);
+ for (let i = 0; i < len; i++) {
+ masked[i] = payload[i]! ^ mask[i % 4]!;
+ }
+ return Buffer.concat([header, mask, masked]);
+}
+
+/** Minimal client-side WS frame reader (text + close; ignores ping/pong/control). */
+export class WsFrameReader {
+ private buffer = Buffer.alloc(0);
+
+ push(chunk: Buffer): Buffer[] {
+ this.buffer = Buffer.concat([this.buffer, chunk]);
+ const messages: Buffer[] = [];
+ while (true) {
+ if (this.buffer.length < 2) {
+ break;
+ }
+ const b1 = this.buffer[1]!;
+ const masked = (b1 & 0x80) !== 0;
+ let payloadLen = b1 & 0x7f;
+ let offset = 2;
+ if (payloadLen === 126) {
+ if (this.buffer.length < 4) {
+ break;
+ }
+ payloadLen = this.buffer.readUInt16BE(2);
+ offset = 4;
+ } else if (payloadLen === 127) {
+ if (this.buffer.length < 10) {
+ break;
+ }
+ const high = this.buffer.readUInt32BE(2);
+ const low = this.buffer.readUInt32BE(6);
+ if (high !== 0 || low > 0x7fffffff) {
+ throw new Error("WS frame too large");
+ }
+ payloadLen = low;
+ offset = 10;
+ }
+ const maskLen = masked ? 4 : 0;
+ if (this.buffer.length < offset + maskLen + payloadLen) {
+ break;
+ }
+ const opcode = this.buffer[0]! & 0x0f;
+ let payload = this.buffer.subarray(offset + maskLen, offset + maskLen + payloadLen);
+ if (masked) {
+ const mask = this.buffer.subarray(offset, offset + 4);
+ const unmasked = Buffer.alloc(payloadLen);
+ for (let i = 0; i < payloadLen; i++) {
+ unmasked[i] = payload[i]! ^ mask[i % 4]!;
+ }
+ payload = unmasked;
+ }
+ this.buffer = this.buffer.subarray(offset + maskLen + payloadLen);
+ if (opcode === 0x1 || opcode === 0x2) {
+ messages.push(payload);
+ } else if (opcode === 0x8) {
+ throw new Error("RTSPS WebSocket closed");
+ }
+ // 0x9/0xa ping/pong ignored for probe
+ }
+ return messages;
+ }
+}
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.test.ts b/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.test.ts
index cbd9b983..72806e68 100644
--- a/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.test.ts
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.test.ts
@@ -3,158 +3,21 @@
import test from "node:test";
import assert from "node:assert/strict";
-import {
- buildAnnounceSdp,
- buildEmptyPathUpgradeRequest,
- buildNvstWssUpgradeRequest,
- buildNvstWssUpgradeRequestTarget,
- collectRtspsEndpoints,
- extractHmacSeed,
- extractRuntimeEncryptionKey,
- extractVideoPeer,
- packSrtpMasterKeySalt,
- rtspsUrlToWssUrl,
- selectPrimaryRtspsEndpoint,
-} from "./nvstRtspProbe";
-
-test("selectPrimaryRtspsEndpoint prefers :322", () => {
- const selected = selectPrimaryRtspsEndpoint([
- "rtsps://host.example:48322",
- "rtsps://host.example:322",
+import * as nvstRtspProbe from "./nvstRtspProbe";
+
+test("nvstRtspProbe preserves its public runtime exports", () => {
+ assert.deepEqual(Object.keys(nvstRtspProbe).sort(), [
+ "buildAnnounceSdp",
+ "buildEmptyPathUpgradeRequest",
+ "buildNvstWssUpgradeRequest",
+ "buildNvstWssUpgradeRequestTarget",
+ "collectRtspsEndpoints",
+ "extractHmacSeed",
+ "extractRuntimeEncryptionKey",
+ "extractVideoPeer",
+ "packSrtpMasterKeySalt",
+ "rtspsUrlToWssUrl",
+ "runNvstRtspHandshakeProbe",
+ "selectPrimaryRtspsEndpoint",
]);
- assert.equal(selected, "rtsps://host.example:322");
-});
-
-test("rtspsUrlToWssUrl is host:port with no path (empty upgrade path is manual)", () => {
- assert.equal(
- rtspsUrlToWssUrl("rtsps://80-250-97-37.cloudmatchbeta.nvidiagrid.net:322"),
- "wss://80-250-97-37.cloudmatchbeta.nvidiagrid.net:322",
- );
-});
-
-test("buildNvstWssUpgradeRequest uses Bifrost-shaped GET / by default", () => {
- const request = buildNvstWssUpgradeRequest(
- "80-250-97-40.cloudmatchbeta.nvidiagrid.net",
- 322,
- "dGVzdGtleTEyMzQ1Njc4OQ==",
- );
- const requestLine = request.split("\r\n")[0] ?? "";
- assert.equal(requestLine, "GET / HTTP/1.1");
- assert.equal(Buffer.from(requestLine, "utf8").toString("hex"), "474554202f20485454502f312e31");
- assert.equal(buildNvstWssUpgradeRequestTarget("host.example", 322, "slash"), "/");
- assert.equal(
- buildNvstWssUpgradeRequestTarget("host.example", 322, "sessionPath", "abc-uuid"),
- "/v2/session/abc-uuid",
- );
- assert.match(request, /^GET \/ HTTP\/1\.1\r\nHost: 80-250-97-40\.cloudmatchbeta\.nvidiagrid\.net:322\r\n/);
- assert.match(request, /\r\nConnection: Upgrade\r\n/);
- assert.match(request, /\r\nUpgrade: websocket\r\n/);
- assert.match(request, /\r\nSec-WebSocket-Version: 13\r\n/);
- assert.match(request, /\r\nSec-WebSocket-Key: dGVzdGtleTEyMzQ1Njc4OQ==\r\n/);
- assert.match(request, /\r\nContent-Length: 0\r\n\r\n$/);
- assert.doesNotMatch(request, /Sec-WebSocket-Protocol/i);
- assert.doesNotMatch(request, /User-Agent/i);
- assert.doesNotMatch(request, /x-nv-sessionid/i);
-});
-
-test("buildEmptyPathUpgradeRequest keeps empty URI for research (live → 400)", () => {
- const request = buildEmptyPathUpgradeRequest(
- "80-250-97-40.cloudmatchbeta.nvidiagrid.net",
- 322,
- "dGVzdGtleTEyMzQ1Njc4OQ==",
- );
- const requestLine = request.split("\r\n")[0] ?? "";
- assert.equal(requestLine, "GET HTTP/1.1");
- assert.equal(Buffer.from(requestLine, "utf8").toString("hex"), "4745542020485454502f312e31");
-});
-
-test("buildNvstWssUpgradeRequest can attach x-nv-sessionid for 403 retry", () => {
- const request = buildNvstWssUpgradeRequest("host.example", 322, "abc", {
- form: "slash",
- sessionId: "sess-uuid",
- });
- assert.match(request, /\r\nx-nv-sessionid: sess-uuid\r\n/);
-});
-
-test("collectRtspsEndpoints keeps both usage=14 paths", () => {
- const endpoints = collectRtspsEndpoints(
- [
- {
- usage: 14,
- port: 322,
- resourcePath: "rtsps://host.example:322",
- },
- {
- usage: 14,
- port: 48322,
- resourcePath: "rtsps://host.example:48322",
- },
- {
- usage: 2,
- port: 49006,
- resourcePath: null,
- },
- ],
- "host.example",
- );
- assert.deepEqual(endpoints, [
- "rtsps://host.example:322",
- "rtsps://host.example:48322",
- ]);
-});
-
-test("collectRtspsEndpoints synthesizes from port when resourcePath missing", () => {
- const endpoints = collectRtspsEndpoints(
- [
- { usage: 14, port: 322, resourcePath: null },
- { usage: 14, port: 48322, resourcePath: null },
- ],
- "host.example",
- );
- assert.deepEqual(endpoints, [
- "rtsps://host.example:322",
- "rtsps://host.example:48322",
- ]);
-});
-
-test("extractHmacSeed reads DESCRIBE k= line", () => {
- const seed = extractHmacSeed(
- "v=0\r\nk=HMAC:76A28E94D8C07CB67C04C29CFAAAAF64BE4BA0899456217CB73D070E5060965F\r\na=x-nv-general.rtspWebSocketPerConnection:1\r\n",
- );
- assert.equal(seed, "76A28E94D8C07CB67C04C29CFAAAAF64BE4BA0899456217CB73D070E5060965F");
-});
-
-test("extractVideoPeer prefers SETUP X-GS-ServerPort", () => {
- assert.deepEqual(
- extractVideoPeer("unicast;X-GS-ServerPort=5004-5005;source=80.250.97.37"),
- { ip: "80.250.97.37", port: 5004 },
- );
-});
-
-test("buildAnnounceSdp uses allowlist shape and omits ICE/DTLS", () => {
- const sdp = buildAnnounceSdp({ resolution: "1920x1080", fps: 60 });
- assert.match(sdp, /a=x-nv-video\[0\]\.clientViewportWd:1920/);
- assert.match(sdp, /a=x-nv-video\[0\]\.maxFPS:60/);
- assert.match(sdp, /a=x-nv-general\.controlProtocol:udp_ag/);
- assert.doesNotMatch(sdp, /iceUsernameFragment|dtlsFingerprint/);
-});
-
-test("packSrtpMasterKeySalt matches geronimo keyId packing", () => {
- const aes = `${"1C98".padEnd(60, "0")}07D2`;
- const packed = packSrtpMasterKeySalt(aes, 2664076126);
- assert.equal(packed.length, 88);
- assert.equal(packed.slice(0, 64), aes.toUpperCase());
- assert.equal(packed.slice(64), "00000000000000009ECA935E");
-});
-
-test("extractRuntimeEncryptionKey reads DESCRIBE attrs", () => {
- const sdp = [
- "v=0",
- "a=x-nv-runtime.encryptionKey:AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899",
- "a=x-nv-runtime.encryptionKeyId:-1630891170",
- "",
- ].join("\r\n");
- const parsed = extractRuntimeEncryptionKey(sdp);
- assert.ok(parsed);
- assert.equal(parsed?.keyId, 2664076126);
});
diff --git a/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.ts b/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.ts
index f2070fea..5f36f318 100644
--- a/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.ts
+++ b/opennow-stable/src/main/platforms/gfn/nvstRtspProbe.ts
@@ -1,1069 +1,24 @@
-/**
- * Classic NVST RTSPS-over-WSS handshake (GO-with-Moonlight-hypothesis).
- *
- * Runs OPTIONS → DESCRIBE → SETUP video/0/0 → ANNOUNCE → PLAY against `:322`.
- * Extracts or generates runtime.encryptionKey for SRTP, then returns nvstVideo
- * handoff fields for the native UDP receive scaffold. Does not keep the UDP
- * socket open across the process boundary — native rebinds clientUdpPort.
- *
- * Evidence: docs/research/nvst-wire-format.md, nvst-srtp-key-derivation.md,
- * nvst-announce-allowlist-1080p60.json.
- */
-
-import { createHash, randomBytes } from "node:crypto";
-import { createSocket, type Socket } from "node:dgram";
-import { connect as tlsConnect, type TLSSocket } from "node:tls";
-import type { Duplex } from "node:stream";
-
-import type { NvstVideoSession } from "@shared/gfn";
-
-const GS_VERSION = "14.2";
-const DEFAULT_PROBE_TIMEOUT_MS = 20_000;
-
-/** Minimal ANNOUNCE attrs from docs/research/nvst-announce-allowlist-1080p60.json */
-const ANNOUNCE_ALLOWLIST = {
- video: {
- clientViewportWd: "1920",
- clientViewportHt: "1080",
- maxFPS: "60",
- videoSplitEncodeStripsPerFrame: "3",
- updateSplitEncodeStateDynamically: "1",
- packetSize: "1408",
- enableRtpNack: "1",
- rtpNackQueueLength: "2048",
- rtpNackQueueMaxPackets: "1024",
- rtpNackMaxPacketCount: "64",
- "framePacing.mode": "2",
- "framePacing.pid.minTargetFrameTimeUs": "16666",
- "adaptiveQuantization.spatialAQSetting": "7",
- "adaptiveQuantization.temporalAQSetting": "0",
- "adaptiveQuantization.spatialAQStrength": "12",
- "adaptiveQuantization.qpThresholdAdjPercent": "2",
- "adaptiveQuantization.saqAdaptMinQpThresholdPercent": "40",
- "adaptiveQuantization.saqAdaptMaxQpThresholdPercent": "100",
- "adaptiveQuantization.saqAdaptDecayStrengthX100": "250",
- "adaptiveQuantization.perfAdjEnablement": "1",
- enableAv1RcPrecisionFactor: "1",
- },
- vqos: {
- "fec.enable": "1",
- "fec.repairPercent": "20",
- "fec.repairMinPercent": "5",
- "fec.repairMaxPercent": "40",
- "bllFec.enable": "1",
- "grc.enable": "0",
- "drc.enable": "0",
- "dfc.adjustResAndFps": "0",
- calculateAvgVideoStreamingBitrate: "1",
- "bw.maximumBitrateKbps": "100000",
- "bw.minimumBitrateKbps": "1000",
- },
- packetPacing: {
- version: "3",
- mode: "1",
- numGroups: "5",
- maxDelayUs: "1000",
- minNumPacketsFrame: "10",
- minNumPacketsPerGroup: "0",
- enableAccurateSleep: "1",
- enableSmoothTransition: "1",
- allowFpsBasedToggle: "1",
- },
- ri: {
- partialReliableThresholdMs: "300",
- timestampsEnabled: "1",
- useMultipleGamepads: "1",
- usePartiallyReliableUdpChannel: "0",
- enablePartiallyReliableTransferGamepad: "255",
- enablePartiallyReliableTransferHid: "-1",
- },
- aqos: {
- enableRedundancy: "1",
- redundancyLevel: "2",
- },
- general: {
- rtspWebSocketPerConnection: "1",
- "enetControlChannel.mtuSize": "1191",
- pingIntervalBeforeConnectionMs: "20",
- pingIntervalAfterConnectionMs: "100",
- },
- runtime: {
- audioSrtp: "0",
- micSrtp: "0",
- },
-} as const;
-
-export interface NvstRtspProbeInput {
- sessionId: string;
- rtspsEndpoints: string[];
- resolution?: string;
- fps?: number;
- codec?: string;
- timeoutMs?: number;
- onLog?: (message: string) => void;
-}
-
-export interface NvstSrtpMaterial {
- /** 64-char hex AES-256 key (runtime.encryptionKey). */
- aesKeyHex: string;
- /** Unsigned key id used for salt packing (runtime.encryptionKeyId as u32). */
- keyId: number;
- /** 88-char hex libsrtp master key||salt (AES-256 || 12-byte salt). */
- masterKeySaltHex: string;
- /** True when OpenNOW generated the key for ANNOUNCE (DESCRIBE lacked it). */
- clientGenerated: boolean;
-}
-
-export interface NvstRtspProbeResult {
- ok: boolean;
- endpoint: string;
- session?: string;
- hmacSeedPresent: boolean;
- videoPeer?: { ip: string; port: number };
- clientUdpPort?: number;
- srtp?: NvstSrtpMaterial;
- pingPayload?: string;
- pingVersion?: number;
- /** Handoff for native UDP video (shared NvstVideoSession shape). */
- videoSession?: NvstVideoSession;
- steps: string[];
- error?: string;
-}
-
-interface ParsedRtspResponse {
- statusCode: number;
- statusText: string;
- headers: Record;
- body: string;
-}
-
-function log(onLog: NvstRtspProbeInput["onLog"], message: string): void {
- console.log(`[NvstRtspProbe] ${message}`);
- onLog?.(message);
-}
-
-export function selectPrimaryRtspsEndpoint(endpoints: string[]): string | null {
- const normalized = endpoints
- .map((value) => value.trim())
- .filter((value) => /^rtsps?:\/\//i.test(value));
- if (normalized.length === 0) {
- return null;
- }
- const port322 = normalized.find((url) => /:322(?:\/|$)/.test(url));
- return port322 ?? normalized[0] ?? null;
-}
-
-/**
- * Logging identity only (`wss://host:port`, no path). Real `:322` connect uses
- * {@link connectNvstWss} with absolute-form upgrade cascade (rtsps/wss/https).
- * See docs/research/nvst-rtsps-wss-connect.md and `_tmp-bifrost2-ws-400-followup.txt`.
- */
-export function rtspsUrlToWssUrl(rtspsUrl: string): string {
- const parsed = new URL(rtspsUrl.replace(/^rtsps:/i, "https:").replace(/^rtsp:/i, "http:"));
- const port = parsed.port || "322";
- return `wss://${parsed.hostname}:${port}`;
-}
-
-export function collectRtspsEndpoints(
- connections: Array<{ usage?: number; port?: number; resourcePath?: string | null }>,
- fallbackHost?: string | null,
-): string[] {
- const endpoints: string[] = [];
- const seen = new Set();
-
- for (const conn of connections) {
- if (conn.usage !== 14) {
- continue;
- }
- const resourcePath = typeof conn.resourcePath === "string" ? conn.resourcePath.trim() : "";
- if (/^rtsps?:\/\//i.test(resourcePath)) {
- if (!seen.has(resourcePath)) {
- seen.add(resourcePath);
- endpoints.push(resourcePath);
- }
- continue;
- }
- if (!fallbackHost || !conn.port) {
- continue;
- }
- const synthesized = `rtsps://${fallbackHost}:${conn.port}`;
- if (!seen.has(synthesized)) {
- seen.add(synthesized);
- endpoints.push(synthesized);
- }
- }
-
- return endpoints;
-}
-
-function parseResolution(resolution: string | undefined): { width: number; height: number } {
- const match = /^(\d+)\s*[xX]\s*(\d+)$/.exec(resolution?.trim() ?? "");
- if (!match) {
- return { width: 1920, height: 1080 };
- }
- return { width: Number(match[1]), height: Number(match[2]) };
-}
-
-export function buildAnnounceSdp(
- options: {
- resolution?: string;
- fps?: number;
- encryptionKeyHex?: string;
- encryptionKeyId?: number;
- } = {},
-): string {
- const { width, height } = parseResolution(options.resolution);
- const fps = options.fps && options.fps > 0 ? Math.round(options.fps) : 60;
- const frameTimeUs = String(Math.round(1_000_000 / fps));
-
- const lines: string[] = [
- "v=0",
- "o=- 0 0 IN IP4 127.0.0.1",
- "s=OpenNOW NVST Handshake",
- "t=0 0",
- ];
-
- const pushGroup = (
- prefix: string,
- indexed: boolean,
- values: Record,
- ): void => {
- for (const [key, value] of Object.entries(values)) {
- let nextValue = value;
- if (prefix === "video" && key === "clientViewportWd") {
- nextValue = String(width);
- } else if (prefix === "video" && key === "clientViewportHt") {
- nextValue = String(height);
- } else if (prefix === "video" && key === "maxFPS") {
- nextValue = String(fps);
- } else if (prefix === "video" && key === "framePacing.pid.minTargetFrameTimeUs") {
- nextValue = frameTimeUs;
- }
- const name = indexed ? `x-nv-${prefix}[0].${key}` : `x-nv-${prefix}.${key}`;
- lines.push(`a=${name}:${nextValue}`);
- }
- };
-
- pushGroup("video", true, ANNOUNCE_ALLOWLIST.video);
- pushGroup("vqos", true, ANNOUNCE_ALLOWLIST.vqos);
- pushGroup("packetPacing", false, ANNOUNCE_ALLOWLIST.packetPacing);
- pushGroup("ri", false, ANNOUNCE_ALLOWLIST.ri);
- pushGroup("aqos", false, ANNOUNCE_ALLOWLIST.aqos);
- pushGroup("general", false, ANNOUNCE_ALLOWLIST.general);
- pushGroup("runtime", false, ANNOUNCE_ALLOWLIST.runtime);
- lines.push("a=x-nv-runtime.videoSrtp:1");
- if (options.encryptionKeyHex && options.encryptionKeyId !== undefined) {
- lines.push(`a=x-nv-runtime.encryptionKey:${options.encryptionKeyHex.toUpperCase()}`);
- // Signed i32 form matches geronimo runtime.encryptionKeyId dumps.
- const signedId = options.encryptionKeyId > 0x7fffffff
- ? options.encryptionKeyId - 0x1_0000_0000
- : options.encryptionKeyId;
- lines.push(`a=x-nv-runtime.encryptionKeyId:${signedId}`);
- }
- // Control protocol preference evidenced in geronimo: udp_ag (not encrypted).
- lines.push("a=x-nv-general.controlProtocol:udp_ag");
- lines.push("");
- return lines.join("\r\n");
-}
-
-function parseRtspResponse(raw: string): ParsedRtspResponse {
- const normalized = raw.replace(/\r\n/g, "\n");
- const splitAt = normalized.indexOf("\n\n");
- const headerBlock = splitAt >= 0 ? normalized.slice(0, splitAt) : normalized;
- const body = splitAt >= 0 ? normalized.slice(splitAt + 2) : "";
- const headerLines = headerBlock.split("\n").filter((line) => line.length > 0);
- const statusLine = headerLines[0] ?? "";
- const statusMatch = /^(?:RTSP|HTTP)\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i.exec(statusLine);
- if (!statusMatch) {
- throw new Error(`Invalid RTSP status line: ${statusLine.slice(0, 120)}`);
- }
- const headers: Record = {};
- for (const line of headerLines.slice(1)) {
- const idx = line.indexOf(":");
- if (idx <= 0) {
- continue;
- }
- headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
- }
- return {
- statusCode: Number(statusMatch[1]),
- statusText: statusMatch[2]?.trim() ?? "",
- headers,
- body,
- };
-}
-
-function header(headers: Record, name: string): string | undefined {
- return headers[name.toLowerCase()];
-}
-
-export function extractHmacSeed(sdp: string): string | null {
- const match = /^k=HMAC:([0-9A-Fa-f]{64})\s*$/m.exec(sdp);
- return match?.[1] ?? null;
-}
-
-export function extractVideoPeer(
- transport: string | undefined,
-): { ip: string; port: number } | undefined {
- if (!transport) {
- return undefined;
- }
- const portMatch = /X-GS-ServerPort=(\d+)/i.exec(transport);
- const sourceMatch = /source=([^;,\s]+)/i.exec(transport);
- if (!portMatch || !sourceMatch) {
- return undefined;
- }
- return { ip: sourceMatch[1]!, port: Number(portMatch[1]) };
-}
-
-/**
- * Pack AES-256 key + keyId into libsrtp master key||salt (88 hex).
- * Salt = keyId as `%024x` (12 bytes BE). See docs/research/nvst-srtp-key-derivation.md.
- */
-export function packSrtpMasterKeySalt(aesKeyHex: string, keyId: number): string {
- const key = aesKeyHex.trim().toUpperCase();
- if (!/^[0-9A-F]{64}$/.test(key)) {
- throw new Error(`encryptionKey must be 64 hex chars, got length ${key.length}`);
- }
- const id = keyId >>> 0;
- const salt = id.toString(16).toUpperCase().padStart(24, "0");
- return `${key}${salt}`;
-}
-
-export function extractRuntimeEncryptionKey(
- sdp: string,
-): { aesKeyHex: string; keyId: number } | null {
- const keyMatch = /^a=x-nv-runtime\.encryptionKey:([0-9A-Fa-f]{64})\s*$/m.exec(sdp);
- const idMatch = /^a=x-nv-runtime\.encryptionKeyId:(-?\d+)\s*$/m.exec(sdp);
- if (!keyMatch || !idMatch) {
- return null;
- }
- let keyId = Number(idMatch[1]);
- if (!Number.isFinite(keyId)) {
- return null;
- }
- // Signed i32 in SDP → unsigned u32 for salt packing.
- if (keyId < 0) {
- keyId = keyId + 0x1_0000_0000;
- }
- return { aesKeyHex: keyMatch[1]!.toUpperCase(), keyId: keyId >>> 0 };
-}
-
-function generateClientEncryptionKey(): { aesKeyHex: string; keyId: number } {
- const aesKeyHex = randomBytes(32).toString("hex").toUpperCase();
- const keyId = randomBytes(4).readUInt32BE(0);
- return { aesKeyHex, keyId };
-}
-
-function redactKey(aesKeyHex: string): string {
- if (aesKeyHex.length < 8) {
- return "****";
- }
- return `${aesKeyHex.slice(0, 4)}…${aesKeyHex.slice(-4)}`;
-}
-
-async function bindEphemeralUdp(): Promise<{ socket: Socket; port: number }> {
- const socket = createSocket("udp4");
- await new Promise((resolve, reject) => {
- socket.once("error", reject);
- socket.bind(0, "0.0.0.0", () => {
- socket.off("error", reject);
- resolve();
- });
- });
- const address = socket.address();
- if (typeof address === "string") {
- socket.close();
- throw new Error("Unexpected UDP socket address shape");
- }
- return { socket, port: address.port };
-}
-
-/**
- * Official `:322` upgrade request-target is still under research.
- *
- * Live matrix (raw TLS unless noted):
- * - empty (`GET HTTP/1.1`) → HTTP 400
- * - absolute `rtsps://` / `wss://` / `https://` → HTTP 404
- * - `/` via Node `ws` → HTTP 404 (extra Client headers; not Bifrost-shaped)
- * - `/` via raw TLS Bifrost-shaped headers → **pending** (never tried)
- *
- * Poco 1.14.1 WebSocket::connect does not set method/URI; it only adds Upgrade
- * headers. Default HTTPRequest is GET `/`. See docs/research/_tmp-bifrost2-ws-uri-resolved.txt.
- */
-export type NvstWssUpgradeTargetForm =
- | "slash"
- | "sessionPath"
- | "rtsps"
- | "wss"
- | "https"
- | "empty";
-
-export function buildNvstWssUpgradeRequestTarget(
- host: string,
- port: number,
- form: NvstWssUpgradeTargetForm,
- sessionId?: string,
-): string {
- switch (form) {
- case "slash":
- return "/";
- case "sessionPath":
- return sessionId && sessionId.trim().length > 0
- ? `/v2/session/${sessionId.trim()}`
- : "/";
- case "rtsps":
- return `rtsps://${host}:${port}`;
- case "wss":
- return `wss://${host}:${port}`;
- case "https":
- return `https://${host}:${port}`;
- case "empty":
- return "";
- }
-}
-
-/**
- * Raw TLS WebSocket upgrade for NVST `:322`.
- * Header order matches Poco WebSocket::connect + Bifrost Content-Length: 0.
- * Optional `x-nv-sessionid` is only for a 403 retry.
- */
-export function buildNvstWssUpgradeRequest(
- host: string,
- port: number,
- secWebSocketKey: string,
- options: {
- form?: NvstWssUpgradeTargetForm;
- sessionId?: string;
- } = {},
-): string {
- const form = options.form ?? "slash";
- const target = buildNvstWssUpgradeRequestTarget(host, port, form, options.sessionId);
- const requestLine = `GET ${target} HTTP/1.1`;
- // Poco sets Connection/Upgrade/Version/Key; Bifrost presets Content-Length: 0.
- // Host is auto-set by HTTPClientSession (host:port for non-443).
- let request =
- `${requestLine}\r\n` +
- `Host: ${host}:${port}\r\n` +
- `Connection: Upgrade\r\n` +
- `Upgrade: websocket\r\n` +
- `Sec-WebSocket-Version: 13\r\n` +
- `Sec-WebSocket-Key: ${secWebSocketKey}\r\n` +
- `Content-Length: 0\r\n`;
- const sessionId = options.sessionId?.trim();
- if (sessionId && form !== "sessionPath") {
- // Only attach as header on 403 retry paths; sessionPath already uses UUID in URI.
- request += `x-nv-sessionid: ${sessionId}\r\n`;
- }
- return `${request}\r\n`;
-}
-
-/** @deprecated Empty path is live-falsified (HTTP 400). */
-export function buildEmptyPathUpgradeRequest(
- host: string,
- port: number,
- secWebSocketKey: string,
- sessionId?: string,
-): string {
- return buildNvstWssUpgradeRequest(host, port, secWebSocketKey, {
- form: "empty",
- sessionId,
- });
-}
-
-function connectNvstWssOnce(
- host: string,
- port: number,
- timeoutMs: number,
- form: NvstWssUpgradeTargetForm,
- sessionId?: string,
- attachSessionHeader = false,
-): Promise {
- const key = randomBytes(16).toString("base64");
- const expectedAccept = createHash("sha1")
- .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
- .digest("base64");
- const request = buildNvstWssUpgradeRequest(host, port, key, {
- form,
- // For slash/rtsps/… first attempt: no session header.
- // For 403 retry or sessionPath: pass sessionId.
- sessionId: attachSessionHeader || form === "sessionPath" ? sessionId : undefined,
- });
- const requestLine = request.split("\r\n")[0] ?? "";
- const requestLineHex = Buffer.from(requestLine, "utf8").toString("hex");
-
- return new Promise((resolve, reject) => {
- let settled = false;
- let buffer = Buffer.alloc(0);
- let socket: TLSSocket | null = null;
-
- const fail = (error: Error): void => {
- if (settled) {
- return;
- }
- settled = true;
- try {
- socket?.destroy();
- } catch {
- // ignore
- }
- reject(error);
- };
-
- const succeed = (tlsSocket: TLSSocket, leftover: Buffer): void => {
- if (settled) {
- return;
- }
- settled = true;
- tlsSocket.removeAllListeners("data");
- tlsSocket.removeAllListeners("error");
- tlsSocket.removeAllListeners("timeout");
- tlsSocket.setTimeout(0);
- if (leftover.length > 0) {
- tlsSocket.unshift(leftover);
- }
- resolve(tlsSocket);
- };
-
- socket = tlsConnect(
- {
- host,
- port,
- servername: host,
- rejectUnauthorized: true,
- },
- () => {
- socket?.write(request);
- },
- );
-
- socket.setTimeout(timeoutMs);
- socket.on("timeout", () => fail(new Error(`WSS upgrade timed out after ${timeoutMs}ms`)));
- socket.on("error", (error) => fail(error instanceof Error ? error : new Error(String(error))));
- socket.on("data", (chunk: Buffer) => {
- buffer = Buffer.concat([buffer, chunk]);
- const headerEnd = buffer.indexOf("\r\n\r\n");
- if (headerEnd < 0) {
- if (buffer.length > 16_384) {
- fail(new Error("WSS upgrade failed: response headers too large"));
- }
- return;
- }
-
- const headerText = buffer.subarray(0, headerEnd).toString("utf8");
- const leftover = buffer.subarray(headerEnd + 4);
- const statusLine = headerText.split("\r\n")[0] ?? "";
- const statusMatch = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/i.exec(statusLine);
- const statusCode = statusMatch ? Number(statusMatch[1]) : 0;
- const headers: Record = {};
- for (const line of headerText.split("\r\n").slice(1)) {
- const idx = line.indexOf(":");
- if (idx <= 0) {
- continue;
- }
- headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
- }
-
- if (statusCode !== 101) {
- fail(
- new Error(
- `WSS upgrade failed: HTTP ${statusCode || "unknown"} (${statusLine || "no status"}); ` +
- `form=${form} request-line=${requestLine} hex=${requestLineHex}` +
- (attachSessionHeader ? " with x-nv-sessionid" : ""),
- ),
- );
- return;
- }
- if (headers["sec-websocket-accept"] !== expectedAccept) {
- fail(new Error("WSS upgrade failed: invalid Sec-WebSocket-Accept"));
- return;
- }
- succeed(socket!, leftover);
- });
- });
-}
-
-/** Primary: Bifrost-shaped GET /. Fallback: CloudMatch-style /v2/session/. */
-const UPGRADE_TARGET_FORMS: NvstWssUpgradeTargetForm[] = ["slash", "sessionPath"];
-
-async function connectNvstWss(
- host: string,
- port: number,
- timeoutMs: number,
- sessionId?: string,
- onLog?: (message: string) => void,
-): Promise {
- let lastError: Error | null = null;
- for (const form of UPGRADE_TARGET_FORMS) {
- try {
- const target = buildNvstWssUpgradeRequestTarget(host, port, form, sessionId);
- onLog?.(
- `Trying WSS upgrade form=${form} (GET ${target} HTTP/1.1) raw-TLS Bifrost headers`,
- );
- return await connectNvstWssOnce(host, port, timeoutMs, form, sessionId, false);
- } catch (error) {
- const err = error instanceof Error ? error : new Error(String(error));
- lastError = err;
- const message = err.message;
- if (sessionId && /\bHTTP 403\b/.test(message)) {
- onLog?.(`Upgrade HTTP 403 on form=${form}; retrying with x-nv-sessionid`);
- try {
- return await connectNvstWssOnce(host, port, timeoutMs, form, sessionId, true);
- } catch (retryError) {
- lastError = retryError instanceof Error ? retryError : new Error(String(retryError));
- }
- }
- if (!/\bHTTP (400|404)\b/.test(message)) {
- throw lastError;
- }
- onLog?.(message);
- }
- }
- throw lastError ?? new Error("WSS upgrade failed for all request-target forms");
-}
-
-function encodeWsTextFrame(payload: Buffer): Buffer {
- const len = payload.length;
- const mask = randomBytes(4);
- let header: Buffer;
- if (len < 126) {
- header = Buffer.alloc(2);
- header[0] = 0x81; // FIN + text
- header[1] = 0x80 | len; // MASK bit set
- } else if (len < 65536) {
- header = Buffer.alloc(4);
- header[0] = 0x81;
- header[1] = 0x80 | 126;
- header.writeUInt16BE(len, 2);
- } else {
- header = Buffer.alloc(10);
- header[0] = 0x81;
- header[1] = 0x80 | 127;
- header.writeUInt32BE(0, 2);
- header.writeUInt32BE(len, 6);
- }
- const masked = Buffer.alloc(len);
- for (let i = 0; i < len; i++) {
- masked[i] = payload[i]! ^ mask[i % 4]!;
- }
- return Buffer.concat([header, mask, masked]);
-}
-
-/** Minimal client-side WS frame reader (text + close; ignores ping/pong/control). */
-class WsFrameReader {
- private buffer = Buffer.alloc(0);
-
- push(chunk: Buffer): Buffer[] {
- this.buffer = Buffer.concat([this.buffer, chunk]);
- const messages: Buffer[] = [];
- while (true) {
- if (this.buffer.length < 2) {
- break;
- }
- const b1 = this.buffer[1]!;
- const masked = (b1 & 0x80) !== 0;
- let payloadLen = b1 & 0x7f;
- let offset = 2;
- if (payloadLen === 126) {
- if (this.buffer.length < 4) {
- break;
- }
- payloadLen = this.buffer.readUInt16BE(2);
- offset = 4;
- } else if (payloadLen === 127) {
- if (this.buffer.length < 10) {
- break;
- }
- const high = this.buffer.readUInt32BE(2);
- const low = this.buffer.readUInt32BE(6);
- if (high !== 0 || low > 0x7fffffff) {
- throw new Error("WS frame too large");
- }
- payloadLen = low;
- offset = 10;
- }
- const maskLen = masked ? 4 : 0;
- if (this.buffer.length < offset + maskLen + payloadLen) {
- break;
- }
- const opcode = this.buffer[0]! & 0x0f;
- let payload = this.buffer.subarray(offset + maskLen, offset + maskLen + payloadLen);
- if (masked) {
- const mask = this.buffer.subarray(offset, offset + 4);
- const unmasked = Buffer.alloc(payloadLen);
- for (let i = 0; i < payloadLen; i++) {
- unmasked[i] = payload[i]! ^ mask[i % 4]!;
- }
- payload = unmasked;
- }
- this.buffer = this.buffer.subarray(offset + maskLen + payloadLen);
- if (opcode === 0x1 || opcode === 0x2) {
- messages.push(payload);
- } else if (opcode === 0x8) {
- throw new Error("RTSPS WebSocket closed");
- }
- // 0x9/0xa ping/pong ignored for probe
- }
- return messages;
- }
-}
-
-class RtspOverWssClient {
- private socket: Duplex | null = null;
- private frameReader = new WsFrameReader();
- private buffer = Buffer.alloc(0);
- private cseq = 0;
- private pending: {
- resolve: (response: ParsedRtspResponse) => void;
- reject: (error: Error) => void;
- } | null = null;
-
- constructor(
- private readonly host: string,
- private readonly port: number,
- private readonly timeoutMs: number,
- private readonly onLog?: (message: string) => void,
- ) {}
-
- async connect(sessionId?: string): Promise {
- const socket = await connectNvstWss(
- this.host,
- this.port,
- this.timeoutMs,
- sessionId,
- (message) => log(this.onLog, message),
- );
- this.socket = socket;
- socket.on("data", (chunk: Buffer) => this.onSocketData(chunk));
- socket.on("error", (error) => {
- if (this.pending) {
- this.pending.reject(error instanceof Error ? error : new Error(String(error)));
- this.pending = null;
- }
- });
- socket.on("close", () => {
- if (this.pending) {
- this.pending.reject(new Error("RTSPS WebSocket closed"));
- this.pending = null;
- }
- });
- }
-
- close(): void {
- try {
- this.socket?.destroy();
- } catch {
- // ignore
- }
- this.socket = null;
- if (this.pending) {
- this.pending.reject(new Error("RTSPS probe closed"));
- this.pending = null;
- }
- }
-
- async request(
- method: string,
- uri: string,
- extraHeaders: Record = {},
- body = "",
- ): Promise {
- if (!this.socket || this.socket.destroyed) {
- throw new Error("RTSPS WebSocket is not open");
- }
- if (this.pending) {
- throw new Error("Overlapping RTSP requests are not supported");
- }
-
- this.cseq += 1;
- const headers: Record = {
- CSeq: String(this.cseq),
- "Request-Id": String(this.cseq),
- "X-GS-Version": GS_VERSION,
- ...extraHeaders,
- };
- if (body.length > 0) {
- headers["Content-Length"] = String(Buffer.byteLength(body, "utf8"));
- }
-
- let message = `${method} ${uri} RTSP/1.0\r\n`;
- for (const [key, value] of Object.entries(headers)) {
- message += `${key}: ${value}\r\n`;
- }
- message += "\r\n";
- if (body.length > 0) {
- message += body;
- }
-
- return await new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- if (this.pending) {
- this.pending = null;
- reject(new Error(`RTSP ${method} timed out after ${this.timeoutMs}ms`));
- }
- }, this.timeoutMs);
-
- this.pending = {
- resolve: (response) => {
- clearTimeout(timer);
- resolve(response);
- },
- reject: (error) => {
- clearTimeout(timer);
- reject(error);
- },
- };
-
- try {
- this.socket?.write(encodeWsTextFrame(Buffer.from(message, "utf8")));
- } catch (error) {
- clearTimeout(timer);
- this.pending = null;
- reject(error instanceof Error ? error : new Error(String(error)));
- }
- });
- }
-
- private onSocketData(chunk: Buffer): void {
- try {
- for (const payload of this.frameReader.push(chunk)) {
- this.buffer = Buffer.concat([this.buffer, payload]);
- this.tryCompleteResponse();
- }
- } catch (error) {
- if (this.pending) {
- const pending = this.pending;
- this.pending = null;
- pending.reject(error instanceof Error ? error : new Error(String(error)));
- }
- }
- }
-
- private tryCompleteResponse(): void {
- if (!this.pending) {
- return;
- }
-
- const text = this.buffer.toString("utf8");
- const crlfSplit = text.indexOf("\r\n\r\n");
- const lfSplit = text.indexOf("\n\n");
- let headerEnd = -1;
- let sepLen = 0;
- if (crlfSplit >= 0 && (lfSplit < 0 || crlfSplit <= lfSplit)) {
- headerEnd = crlfSplit;
- sepLen = 4;
- } else if (lfSplit >= 0) {
- headerEnd = lfSplit;
- sepLen = 2;
- }
- if (headerEnd < 0) {
- return;
- }
-
- const headerText = text.slice(0, headerEnd);
- const contentLengthMatch = /^Content-Length:\s*(\d+)\s*$/im.exec(headerText);
- const contentLength = contentLengthMatch ? Number(contentLengthMatch[1]) : 0;
- const bodyStart = headerEnd + sepLen;
- // Use byte length of remaining buffer after header separator.
- const headerByteLength = Buffer.byteLength(text.slice(0, bodyStart), "utf8");
- const availableBodyBytes = this.buffer.length - headerByteLength;
- if (availableBodyBytes < contentLength) {
- return;
- }
-
- const totalBytes = headerByteLength + contentLength;
- const raw = this.buffer.subarray(0, totalBytes).toString("utf8");
- this.buffer = this.buffer.subarray(totalBytes);
-
- try {
- const parsed = parseRtspResponse(raw);
- const pending = this.pending;
- this.pending = null;
- pending?.resolve(parsed);
- } catch (error) {
- const pending = this.pending;
- this.pending = null;
- pending?.reject(error instanceof Error ? error : new Error(String(error)));
- }
- }
-}
-
-export async function runNvstRtspHandshakeProbe(input: NvstRtspProbeInput): Promise {
- const steps: string[] = [];
- const endpoint = selectPrimaryRtspsEndpoint(input.rtspsEndpoints);
- if (!endpoint) {
- return {
- ok: false,
- endpoint: "",
- hmacSeedPresent: false,
- steps,
- error: "No rtsps:// endpoints available on the session",
- };
- }
-
- const timeoutMs = input.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
- const wssUrl = rtspsUrlToWssUrl(endpoint);
- const parsedEndpoint = new URL(endpoint.replace(/^rtsps:/i, "https:").replace(/^rtsp:/i, "http:"));
- const host = parsedEndpoint.hostname;
- const port = Number(parsedEndpoint.port || "322");
- const client = new RtspOverWssClient(host, port, timeoutMs, input.onLog);
- let udp: { socket: Socket; port: number } | null = null;
-
- try {
- log(
- input.onLog,
- `Connecting RTSPS WSS ${wssUrl} via raw-TLS Bifrost-shaped upgrade (GET / then /v2/session/) (session ${input.sessionId})`,
- );
- await client.connect(input.sessionId);
- steps.push("wss-open");
-
- const options = await client.request("OPTIONS", endpoint);
- if (options.statusCode !== 200) {
- throw new Error(`OPTIONS failed: ${options.statusCode} ${options.statusText}`);
- }
- steps.push("options");
- log(input.onLog, `OPTIONS ok (X-GS-Version=${header(options.headers, "x-gs-version") ?? "n/a"})`);
-
- const describe = await client.request("DESCRIBE", endpoint, {
- Accept: "application/sdp",
- });
- if (describe.statusCode !== 200) {
- throw new Error(`DESCRIBE failed: ${describe.statusCode} ${describe.statusText}`);
- }
- steps.push("describe");
- const session = header(describe.headers, "session")?.split(";")[0]?.trim();
- if (!session) {
- throw new Error("DESCRIBE response missing Session header");
- }
- const hmacSeed = extractHmacSeed(describe.body);
- const describedKey = extractRuntimeEncryptionKey(describe.body);
- let encryptionKeyHex: string;
- let encryptionKeyId: number;
- let clientGenerated = false;
- if (describedKey) {
- encryptionKeyHex = describedKey.aesKeyHex;
- encryptionKeyId = describedKey.keyId;
- log(
- input.onLog,
- `DESCRIBE ok (Session=${session}, HMAC ${hmacSeed ? "present" : "missing"}, encryptionKey ${redactKey(encryptionKeyHex)} from server)`,
- );
- } else {
- const generated = generateClientEncryptionKey();
- encryptionKeyHex = generated.aesKeyHex;
- encryptionKeyId = generated.keyId;
- clientGenerated = true;
- log(
- input.onLog,
- `DESCRIBE ok (Session=${session}, HMAC ${hmacSeed ? "present" : "missing"}, encryptionKey absent — client-generated ${redactKey(encryptionKeyHex)} for ANNOUNCE)`,
- );
- }
-
- udp = await bindEphemeralUdp();
- const clientPort = udp.port;
- // Transport uses X-GS-ClientPort (GameStream/Moonlight family). Official logs omit the
- // client Transport summary string; server still returns X-GS-ServerPort + source.
- const setup = await client.request("SETUP", `${endpoint}/streamid=video/0/0`, {
- Session: session,
- Transport: `unicast;X-GS-ClientPort=${clientPort}-${clientPort + 1}`,
- });
- if (setup.statusCode !== 200) {
- throw new Error(`SETUP video failed: ${setup.statusCode} ${setup.statusText}`);
- }
- steps.push("setup-video");
- const videoPeer = extractVideoPeer(header(setup.headers, "transport"));
- const pingPayload = header(setup.headers, "x-nv-ping-payload");
- const pingVersionRaw = header(setup.headers, "x-nv-ping");
- const pingVersion = pingVersionRaw ? Number(pingVersionRaw) : undefined;
- log(
- input.onLog,
- `SETUP video/0/0 ok (clientPort=${clientPort}, peer=${videoPeer ? `${videoPeer.ip}:${videoPeer.port}` : "unknown"})`,
- );
-
- const srtp: NvstSrtpMaterial = {
- aesKeyHex: encryptionKeyHex,
- keyId: encryptionKeyId,
- masterKeySaltHex: packSrtpMasterKeySalt(encryptionKeyHex, encryptionKeyId),
- clientGenerated,
- };
-
- const announceBody = buildAnnounceSdp({
- resolution: input.resolution,
- fps: input.fps,
- encryptionKeyHex,
- encryptionKeyId,
- });
- const announce = await client.request(
- "ANNOUNCE",
- endpoint,
- {
- Session: session,
- "Content-Type": "application/sdp",
- },
- announceBody,
- );
- if (announce.statusCode !== 200) {
- throw new Error(`ANNOUNCE failed: ${announce.statusCode} ${announce.statusText}`);
- }
- steps.push("announce");
- log(input.onLog, "ANNOUNCE ok (allowlist + encryptionKey; ICE/DTLS omitted)");
-
- const play = await client.request("PLAY", endpoint, {
- Session: session,
- Range: "npt=0.000-",
- });
- if (play.statusCode !== 200) {
- throw new Error(`PLAY failed: ${play.statusCode} ${play.statusText}`);
- }
- steps.push("play");
-
- // Close the probe UDP socket so the native streamer can rebind the same port.
- udp.socket.close();
- udp = null;
-
- if (!videoPeer) {
- throw new Error("SETUP did not return video peer (X-GS-ServerPort/source)");
- }
-
- const videoSession: NvstVideoSession = {
- clientUdpPort: clientPort,
- videoPeerIp: videoPeer.ip,
- videoPeerPort: videoPeer.port,
- srtpAesKeyHex: srtp.aesKeyHex,
- srtpKeyId: srtp.keyId,
- pingPayload,
- codec: input.codec,
- };
-
- log(
- input.onLog,
- `PLAY ok — NVST video handoff ready (peer ${videoPeer.ip}:${videoPeer.port}, clientUdp ${clientPort}); WebRTC remains for SCTP input`,
- );
-
- return {
- ok: true,
- endpoint,
- session,
- hmacSeedPresent: Boolean(hmacSeed),
- videoPeer,
- clientUdpPort: clientPort,
- srtp,
- pingPayload,
- pingVersion: Number.isFinite(pingVersion) ? pingVersion : undefined,
- videoSession,
- steps,
- };
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- log(input.onLog, `Probe failed: ${message}`);
- return {
- ok: false,
- endpoint,
- hmacSeedPresent: false,
- steps,
- error: message,
- };
- } finally {
- client.close();
- udp?.socket.close();
- }
-}
+export {
+ buildAnnounceSdp,
+ extractHmacSeed,
+ extractRuntimeEncryptionKey,
+ packSrtpMasterKeySalt,
+} from "./nvstRtsp/sdp";
+export {
+ buildEmptyPathUpgradeRequest,
+ buildNvstWssUpgradeRequest,
+ buildNvstWssUpgradeRequestTarget,
+} from "./nvstRtsp/websocketTransport";
+export type { NvstWssUpgradeTargetForm } from "./nvstRtsp/websocketTransport";
+export { extractVideoPeer } from "./nvstRtsp/rtspClient";
+export {
+ collectRtspsEndpoints,
+ rtspsUrlToWssUrl,
+ runNvstRtspHandshakeProbe,
+ selectPrimaryRtspsEndpoint,
+} from "./nvstRtsp/probe";
+export type {
+ NvstRtspProbeInput,
+ NvstRtspProbeResult,
+ NvstSrtpMaterial,
+} from "./nvstRtsp/probe";