/** * Local HTTP relay for LAN casting. * * Re-serves decrypted fMP4 segments from the renderer over HTTP so that a * Chromecast, Smart TV or any player on the same Wi-Fi can stream the video. * * The renderer sends raw byte chunks from the WebRTC DataChannel — these are * arbitrary-sized slices of the fMP4 stream, NOT aligned to MP4 box boundaries. * MSE handles this internally, but external players (VLC, Chromecast) need * properly framed fMP4 fragments. The BoxAccumulator reassembles the byte * stream and emits complete moof+mdat pairs. * * Mitigations: * · Bind to the LAN interface, never 0.0.0.0 * · Fixed port range (19550-19553), opened only during active cast * · Unguessable token in the URL (32 hex chars) * · Cache-Control: no-store on every response * · Server destroyed when playback stops — zero residual surface */ 'use strict'; const crypto = require('node:crypto'); const http = require('node:http'); const os = require('node:os'); const RING_CAP = 64; const BACKPRESSURE_HIGH = 8 * 1024 * 1024; const MOOF = 0x6d6f6f66; const PORT_BASE = 19550; const PORT_COUNT = 4; function lanAddress() { const ifaces = os.networkInterfaces(); for (const name of Object.keys(ifaces)) { for (const iface of ifaces[name]) { if (!iface.internal && iface.family === 'IPv4') { return iface.address; } } } return '127.0.0.1'; } class BoxAccumulator { constructor() { this._buf = Buffer.alloc(0); this._synced = false; } push(data) { this._buf = Buffer.concat([this._buf, data]); const fragments = []; if (!this._synced) { const idx = this._findMoof(); if (idx === -1) return fragments; console.log(`[cast-relay] box sync: found first moof at byte offset ${idx}, discarded ${idx} bytes`); this._buf = this._buf.subarray(idx); this._synced = true; } while (this._buf.length >= 8) { const size = this._buf.readUInt32BE(0); const type = this._buf.readUInt32BE(4); if (size < 8) { console.log(`[cast-relay] box sync lost: invalid size ${size}, rescanning`); this._synced = false; const idx = this._findMoof(); if (idx === -1) return fragments; this._buf = this._buf.subarray(idx); this._synced = true; continue; } if (type === MOOF) { if (this._buf.length < size + 8) break; const mdatSize = this._buf.readUInt32BE(size); const pairSize = size + mdatSize; if (this._buf.length < pairSize) break; fragments.push(Buffer.from(this._buf.subarray(0, pairSize))); this._buf = this._buf.subarray(pairSize); } else { if (this._buf.length < size) break; this._buf = this._buf.subarray(size); } } return fragments; } reset() { this._buf = Buffer.alloc(0); this._synced = false; } _findMoof() { for (let i = 0; i <= this._buf.length - 8; i++) { if (this._buf.readUInt32BE(i + 4) === MOOF) { const size = this._buf.readUInt32BE(i); if (size >= 8 && size < 1_000_000) { return i; } } } return -1; } } class CastRelay { constructor() { this._server = null; this._port = null; this._token = null; this._lanIP = null; this._initSegment = null; this._ring = []; this._clients = new Set(); this._accum = new BoxAccumulator(); this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; } get active() { return this._server !== null; } get url() { if (!this._server) return null; return `http://${this._lanIP}:${this._port}/stream.mp4?t=${this._token}`; } async start({ codec, initSegment }) { if (this._server) await this.stop(); this._token = crypto.randomBytes(16).toString('hex'); this._lanIP = lanAddress(); this._initSegment = initSegment ? Buffer.from(initSegment) : null; this._ring = []; this._clients = new Set(); this._accum = new BoxAccumulator(); this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; const server = http.createServer((req, res) => this._handle(req, res)); let bound = false; for (let i = 0; i < PORT_COUNT && !bound; i++) { const port = PORT_BASE + i; try { await new Promise((resolve, reject) => { const onError = (err) => { server.removeListener('error', onError); reject(err); }; server.on('error', onError); server.listen(port, this._lanIP, () => { server.removeListener('error', onError); this._port = port; resolve(); }); }); bound = true; } catch (err) { if (err.code !== 'EADDRINUSE') throw err; console.log(`[cast-relay] port ${port} busy, trying next`); } } if (!bound) throw new Error('All cast relay ports are in use'); this._server = server; console.log(`[cast-relay] started on ${this.url}`); console.log(`[cast-relay] init segment: ${this._initSegment ? this._initSegment.length + ' bytes' : 'none'}`); return { url: this.url, port: this._port, token: this._token }; } pushSegment(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); this._chunkCount++; const fragments = this._accum.push(buf); for (const frag of fragments) { this._fragCount++; if (this._fragCount <= 3 || this._fragCount % 50 === 0) { console.log(`[cast-relay] fragment #${this._fragCount}: ${frag.length} bytes (from ${this._chunkCount} chunks), ${this._clients.size} client(s)`); } if (this._ring.length >= RING_CAP) { this._ring.shift(); } this._ring.push(frag); for (const res of this._clients) { if (res.writableLength > BACKPRESSURE_HIGH) { console.log(`[cast-relay] backpressure: dropping fragment for slow client`); continue; } res.write(frag); this._bytesSent += frag.length; } } } finish() { console.log('[cast-relay] finishing stream'); for (const res of this._clients) { try { res.end(); } catch { /* already closed */ } } } async stop() { console.log(`[cast-relay] stopping — ${this._chunkCount} chunks, ${this._fragCount} fragments, ${(this._bytesSent / 1048576).toFixed(1)} MB sent`); for (const res of this._clients) { try { res.end(); } catch { /* already closed */ } } this._clients.clear(); if (this._server) { const srv = this._server; this._server = null; await new Promise((resolve) => srv.close(resolve)); } this._port = null; this._token = null; this._initSegment = null; this._ring = []; this._accum.reset(); this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; } _handle(req, res) { if (req.method !== 'GET') { res.writeHead(405); res.end(); return; } let url; try { url = new URL(req.url, `http://${req.headers.host}`); } catch { res.writeHead(400); res.end(); return; } if (url.searchParams.get('t') !== this._token) { res.writeHead(403); res.end(); return; } if (url.pathname !== '/stream.mp4') { res.writeHead(404); res.end(); return; } let sent = 0; res.writeHead(200, { 'Content-Type': 'video/mp4', 'Cache-Control': 'no-store', 'Accept-Ranges': 'none', 'Connection': 'keep-alive', }); if (this._initSegment) { res.write(this._initSegment); sent += this._initSegment.length; } for (const frag of this._ring) { res.write(frag); sent += frag.length; } console.log(`[cast-relay] client connected from ${req.socket.remoteAddress} — sent init + ${this._ring.length} fragments (${(sent / 1024).toFixed(0)} KB)`); this._clients.add(res); req.on('close', () => { this._clients.delete(res); console.log(`[cast-relay] client disconnected, ${this._clients.size} remaining`); }); } } module.exports = CastRelay;