From 9c6493332f336ac99ee7cfcb6e815cc65291f546 Mon Sep 17 00:00:00 2001 From: zefie Date: Wed, 22 Apr 2026 00:01:34 -0400 Subject: [PATCH] WTVPNM: implement UDP retransmit --- .../ServiceVault/wtv-music/ragen/catchall.js | 8 - zefie_wtvp_minisrv/includes/classes/WTVPNM.js | 739 ++++++++++++++++-- zefie_wtvp_minisrv/includes/config.json | 2 +- zefie_wtvp_minisrv/realaudio3.pcap | Bin 33650 -> 0 bytes 4 files changed, 670 insertions(+), 79 deletions(-) delete mode 100644 zefie_wtvp_minisrv/realaudio3.pcap diff --git a/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ragen/catchall.js b/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ragen/catchall.js index 5be42660..5cf16b2f 100644 --- a/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ragen/catchall.js +++ b/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ragen/catchall.js @@ -34,7 +34,6 @@ if (serviceVaultIdx !== -1) { subDirPath = '/' + subdirs.join('/'); } } -console.log("DEBUG: Detected subDirPath =", subDirPath); const url_path = request_headers.request_url.split('?')[0]; const pathParts = url_path.split('/').filter(p => p); @@ -42,8 +41,6 @@ const serviceName = pathParts.length > 0 ? pathParts[0] : ''; let remainingPath = '/' + pathParts.slice(1).join('/'); const hadTrailingSlash = request_headers.request_url.endsWith('/'); -console.log("DEBUG: Before stripping - subDirPath =", subDirPath, "remainingPath =", remainingPath); - let strippedSubDir = ''; // Store what was stripped for link rebuilding // Strip the subdirectory structure from the request path if (subDirPath) { @@ -58,7 +55,6 @@ if (subDirPath) { } } -console.log("DEBUG: After stripping - remainingPath =", remainingPath, "strippedSubDir =", strippedSubDir); // Restore trailing slash if original URL had one if (hadTrailingSlash && !remainingPath.endsWith('/')) { @@ -67,7 +63,6 @@ if (hadTrailingSlash && !remainingPath.endsWith('/')) { const filename = remainingPath.endsWith('/') ? '' : remainingPath.split('/').pop().replace('.ram', ''); const directory = remainingPath.endsWith('/') ? remainingPath.replace(/\/$/, '') : remainingPath.substring(0, remainingPath.lastIndexOf('/')); -console.log("DEBUG: Request for service", serviceName, "with filename", filename, "and directory", directory, "remainingPath", remainingPath); let fileFound = false; const extensions = ['.ra', '.rm']; @@ -80,7 +75,6 @@ if (!filename || (request_headers.request_url.endsWith('/') && minisrv_config.se for (const pnmVault of pnmVaults) { const targetDir = path.join(pnmVault, listingDir); - console.log("DEBUG: Listing files in", targetDir); if (fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory()) { const files = fs.readdirSync(targetDir); files.forEach(file => { @@ -118,7 +112,6 @@ Content-type: text/html`; for (const pnmVault of pnmVaults) { for (const ext of extensions) { const filePath = path.join(pnmVault, directory, filename + ext); - console.log("DEBUG: Checking for file", filePath); if (fs.existsSync(filePath)) { fileFound = true; resolvedPath = filePath; @@ -134,7 +127,6 @@ Content-type: text/html`; } else { const filePath = path.join(directory || '/', filename + path.extname(resolvedPath)); const pnmURL = `pnm://${minisrv_config.config.service_ip}:${minisrv_config.services['pnm'].port}${filePath.replace(/\\/g, '/')}`; - console.log("DEBUG: File found at", resolvedPath, "serving as", pnmURL); headers = `200 OK Content-type: audio/x-pn-realaudio` data = pnmURL; diff --git a/zefie_wtvp_minisrv/includes/classes/WTVPNM.js b/zefie_wtvp_minisrv/includes/classes/WTVPNM.js index 76cab7f5..6e00ec9b 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVPNM.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVPNM.js @@ -37,13 +37,9 @@ class WTVPNM { this.wtvshared = new WTVShared(minisrv_config, true); this.server = net.createServer((socket) => this.handleConnection(socket)); - // Stable 32-bit "server id" byte embedded in the first 0x4F chunk of every - // descriptor. Observed RealServer values all share the same high bytes - // (0x00071a??), so keep the upper 24 bits fixed and randomize the low byte - // per process lifetime. The low byte (= descriptor[5]) is NOT a checksum; - // cross-referenced against multi_auth.pcap it is constant within a single - // server process. - this.serverId = 0x00071a00 | (crypto.randomInt(0, 0x100) & 0xff); + // Auth-sensitive descriptor server id shape observed in captures. + // Keep fixed upper 24 bits 0x00071a and vary only the low byte. + this.serverIdBase = 0x00071a00; // Per-session counter embedded in bytes 9..12 of the first 0x4F chunk. // multi_auth.pcap increments this 1,2,3,... across successive sessions. @@ -77,6 +73,7 @@ class WTVPNM { remoteIp: (socket.remoteAddress || '').replace('::ffff:', ''), helloSent: false, descriptorSent: false, + descriptorInFlight: false, descriptorTimer: null, capabilitiesLogged: false, capabilities: [], @@ -92,6 +89,8 @@ class WTVPNM { // receive events or coalesced with other bytes; we accumulate and // decode them here in handleControlCommands(). ctrlBuf: Buffer.alloc(0), + // 35-byte auth hash can be fragmented/coalesced on TCP. + authBuf: Buffer.alloc(0), // RDT b5 nibble state. High nibble = 'seek generation' (starts // at 1, bumped on every 0x53 seek). Low nibble = (seq - // seekBaseSeq) & 0xf where seekBaseSeq is the wall-seq of the @@ -107,7 +106,21 @@ class WTVPNM { // (avg bitrate, etc.). Falls back to global defaults when unset. rdtDataTypeLo: null, rdtSyncType: null, - audioChannels: null + audioChannels: null, + mediaUdpPort: null, + serverId: null, + serverUdpPort: null, + _udpSocketHandlersAttached: false, + udpSocket: null, + udpPacketCache: new Map(), + udpPacketOrder: [], + udpFeedbackWindowStart: 0, + udpFeedbackResentInWindow: 0, + udpFeedbackDropped: 0, + udpFeedbackPeerPort: null, + udpPriorityUntil: 0, + udpInboundCount: 0, + udpFeedbackProbeTimer: null }; this.sessions.set(socket, session); @@ -170,10 +183,13 @@ class WTVPNM { const parsedChallenge = this.getClientChallenge(session.pnaFields); if (parsedChallenge) session.clientChallenge = parsedChallenge; - // Extract UDP port from PNA field ID 1 (2 bytes, big-endian) + // Extract client UDP port from PNA field ID 1 (big-endian u16). const udpPortField = session.pnaFields.find(f => f.id === 1 && f.len === 2); - if (udpPortField) { - session.clientUdpPort = udpPortField.value.readUInt16BE(0); + session.clientUdpPort = (udpPortField && Buffer.isBuffer(udpPortField.value)) + ? this.sanitizeUdpPort(udpPortField.value.readUInt16BE(0)) + : null; + session.mediaUdpPort = session.clientUdpPort; + if (session.clientUdpPort) { this.debugLog('client udp port', session.id, session.clientUdpPort); } @@ -182,7 +198,6 @@ class WTVPNM { session.requestedMedia = parsedMedia; session.mediaPath = this.resolveMediaPath(session.requestedMedia); } - if (!session.capabilitiesLogged) { session.capabilitiesLogged = true; const cap = this.extractCapabilities(data); @@ -205,14 +220,20 @@ class WTVPNM { this.debugLog('requested media', session.id, session.requestedMedia); } } - + console.log('*', `[${session.id}]`, `PNM RealServer Request for media ${session.mediaPath}`); + const pnmHeaders = { + 'clientChallenge': session.clientChallenge, + 'timestamp': session.pnaFields?.timestamp, + 'requestedMedia': session.requestedMedia, + 'User-Agent': session.pnaFields?.useragent, + 'clientUDPPort': session.clientUdpPort, + }; + console.log('*', 'PNM Request Data:', pnmHeaders); if (session.requestedMedia && !session.mediaPath) { - console.log(' * PNM RealServer Warning: requested media not found', session.requestedMedia); + console.log('*', 'PNM Error:', session.requestedMedia, 'not found in service vault(s)'); this.sendNotFound(socket, session.requestedMedia); session.notFoundSent = true; return; - } else { - console.log(' * PNM RealServer Request from', session.id, 'for media', session.mediaPath); } } @@ -229,28 +250,113 @@ class WTVPNM { } if (session.helloSent && session.descriptorSent) { - // Client sends hash response: 0x23 0x00 0x20 + 32 hex chars (35 bytes total) - if (data.length === 35 && data[0] === 0x23) { - const hashHex = data.toString('ascii', 3, 35); - this.debugLog('client hash response', session.id, hashHex); - // Verify client response: RNWK_MD5(serverHello_BE, 0x00000000, clientChallenge) - const expectedResp = this.computeClientResponse(session); - if (expectedResp && hashHex === expectedResp) { - this.debugLog('client hash VERIFIED', session.id); - session.hashVerified = true; - } else { - this.debugLog('client hash MISMATCH', session.id, 'expected', expectedResp); + if (!session.hashVerified) { + session.authBuf = session.authBuf && session.authBuf.length + ? Buffer.concat([session.authBuf, data]) + : Buffer.from(data); + + if (session.authBuf.length < 35) { + return; } + + const expectedResp = this.computeClientResponse(session); + let authOffset = -1; + let hashHex = null; + + // Find a complete auth frame anywhere in the buffered stream: + // [0x23, 0x00, 0x20, 32 ASCII hex chars] + for (let i = 0; i + 35 <= session.authBuf.length; i++) { + if (session.authBuf[i] !== 0x23 || session.authBuf[i + 1] !== 0x00 || session.authBuf[i + 2] !== 0x20) { + continue; + } + + const candidate = session.authBuf.toString('ascii', i + 3, i + 35).toLowerCase(); + if (!/^[a-f0-9]{32}$/.test(candidate)) continue; + + // Prefer a candidate that matches the expected digest. + if (expectedResp && candidate === expectedResp) { + authOffset = i; + hashHex = candidate; + break; + } + + // Keep first syntactically valid candidate as fallback. + if (authOffset < 0) { + authOffset = i; + hashHex = candidate; + } + } + + if (authOffset < 0) { + // No complete auth frame yet; spill older bytes to control parser + // while keeping a tail window for fragmented auth frames. + if (session.authBuf.length > 512) { + const keepTail = 96; + const spill = session.authBuf.slice(0, session.authBuf.length - keepTail); + session.authBuf = session.authBuf.slice(session.authBuf.length - keepTail); + if (spill.length > 0) { + this.handleControlCommands(socket, session, spill); + } + } + return; + } + + if (expectedResp && hashHex !== expectedResp) { + // Found a syntactically valid auth frame, but not ours yet. + // Keep buffering in case the matching frame is still pending. + if (session.authBuf.length > 768) { + session.authBuf = session.authBuf.slice(-128); + } + return; + } + + const preAuth = session.authBuf.slice(0, authOffset); + const remaining = session.authBuf.slice(authOffset + 35); + session.authBuf = Buffer.alloc(0); + + if (preAuth.length > 0) { + this.handleControlCommands(socket, session, preAuth); + } + + this.debugLog('client hash response', session.id, hashHex); + + if (expectedResp && hashHex === expectedResp) { + session.hashVerified = true; + const burstPrestartMs = typeof this.service_config.burst_prestart_ms === 'number' + ? this.service_config.burst_prestart_ms + : 3000; + const mediaHeaders = { + 'challengeResponse': expectedResp, + 'avgBitrate': session.avgBitRate, + 'audioChannels': session.audioChannels, + 'burstMaxRate': session.avgBitRate * 2, + 'burstDurationMs': burstPrestartMs + }; + console.log('*', 'PNM Result Data:', mediaHeaders); + } else { + console.log('*', 'PNM Error: client hash response did not match expected value', session.requestedMedia); + socket.close(); + return; + } + if (session.clientUdpPort) { this.startUdpStream(socket, session); + } else { + this.debugLog('hash verified, waiting for UDP peer port', session.id); + this.attachUdpSocketHandlers(socket, session); } - } else { - // Post-descriptor control byte stream. See handleControlCommands - // for opcode list. Accumulate and decode — RP8 uses seek/ - // pause/resume commands here that can arrive coalesced or - // fragmented across TCP segments. - this.handleControlCommands(socket, session, data); + + if (remaining.length > 0) { + this.handleControlCommands(socket, session, remaining); + } + return; } + + // Post-descriptor control byte stream. See handleControlCommands + // for opcode list. Accumulate and decode — RP8 uses seek/ + // pause/resume commands here that can arrive coalesced or + // fragmented across TCP segments. + this.handleControlCommands(socket, session, data); return; } } @@ -262,6 +368,7 @@ class WTVPNM { session.helloSent = false; session.descriptorSent = false; + session.descriptorInFlight = false; session.notFoundSent = false; session.capabilitiesLogged = false; session.capabilities = []; @@ -270,10 +377,24 @@ class WTVPNM { session.mediaPath = null; session.pnaFields = null; session.ctrlBuf = Buffer.alloc(0); + session.authBuf = Buffer.alloc(0); session.paused = false; session.eosSent = false; session.hashVerified = false; session.sessionNumber = undefined; + session.mediaUdpPort = null; + session.udpPacketCache = new Map(); + session.udpPacketOrder = []; + session.udpFeedbackWindowStart = 0; + session.udpFeedbackResentInWindow = 0; + session.udpFeedbackDropped = 0; + session.udpFeedbackPeerPort = null; + session.udpPriorityUntil = 0; + session.udpInboundCount = 0; + if (session.udpFeedbackProbeTimer) { + clearTimeout(session.udpFeedbackProbeTimer); + session.udpFeedbackProbeTimer = null; + } } // Parse the post-descriptor TCP control stream sent by RealPlayer during @@ -605,14 +726,30 @@ class WTVPNM { ? this.service_config.descriptor_after_hello_ms : 100; - session.descriptorTimer = setTimeout(() => { + session.descriptorTimer = setTimeout(async () => { session.descriptorTimer = null; - if (socket.destroyed || session.descriptorSent) return; - const descriptor = this.buildDescriptorPacket(session); - session.descriptorSent = true; - this.send(socket, descriptor); - this.debugLog('descriptor sent', session.id, `len=${descriptor.length}`, `delay=${descriptorDelay}ms`); - this.prepareMediaData(session); + if (socket.destroyed || session.descriptorSent || session.descriptorInFlight) return; + + session.descriptorInFlight = true; + + try { + const udpReady = await this.ensureSessionUdpSocket(session); + if (!udpReady) { + this.debugLog('descriptor aborted: failed to reserve UDP socket', session.id); + socket.destroy(); + return; + } + + if (socket.destroyed || session.descriptorSent) return; + + const descriptor = this.buildDescriptorPacket(session); + session.descriptorSent = true; + this.send(socket, descriptor); + this.debugLog('descriptor sent', session.id, `len=${descriptor.length}`, `delay=${descriptorDelay}ms`); + this.prepareMediaData(session); + } finally { + session.descriptorInFlight = false; + } }, descriptorDelay); } @@ -624,19 +761,34 @@ class WTVPNM { } } - sendDescriptorAndStartStream(socket, session, reason) { - if (!socket || !session || session.descriptorSent) return; + async sendDescriptorAndStartStream(socket, session, reason) { + if (!socket || !session || session.descriptorSent || session.descriptorInFlight) return; this.clearDescriptorTimer(session); if (socket.destroyed) return; - this.send(socket, this.buildDescriptorPacket(session)); - session.descriptorSent = true; - this.debugLog('descriptor sent', session.id, reason); + session.descriptorInFlight = true; - this.prepareMediaData(session); + try { + const udpReady = await this.ensureSessionUdpSocket(session); + if (!udpReady) { + this.debugLog('descriptor aborted: failed to reserve UDP socket', session.id); + socket.destroy(); + return; + } - // Wait for UDP port response from client before starting stream - this.debugLog('descriptor sent, waiting for client UDP port response on TCP connection', session.id); + if (socket.destroyed || session.descriptorSent) return; + + this.send(socket, this.buildDescriptorPacket(session)); + session.descriptorSent = true; + this.debugLog('descriptor sent', session.id, reason); + + this.prepareMediaData(session); + + // Wait for UDP port response from client before starting stream + this.debugLog('descriptor sent, waiting for client UDP port response on TCP connection', session.id); + } finally { + session.descriptorInFlight = false; + } } stopUdpStream(session) { @@ -645,6 +797,10 @@ class WTVPNM { clearTimeout(session.udpStartTimer); session.udpStartTimer = null; } + if (session.udpFeedbackProbeTimer) { + clearTimeout(session.udpFeedbackProbeTimer); + session.udpFeedbackProbeTimer = null; + } if (session.udpTimer) { clearInterval(session.udpTimer); session.udpTimer = null; @@ -653,6 +809,333 @@ class WTVPNM { try { session.udpSocket.close(); } catch(e) {} session.udpSocket = null; } + session.serverId = null; + session.serverUdpPort = null; + session._udpSocketHandlersAttached = false; + session.udpPacketCache = new Map(); + session.udpPacketOrder = []; + session.udpFeedbackWindowStart = 0; + session.udpFeedbackResentInWindow = 0; + session.udpFeedbackDropped = 0; + session.udpFeedbackPeerPort = null; + session.udpInboundCount = 0; + } + + sanitizeUdpPort(port) { + const parsed = Number(port); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null; + } + + async ensureSessionUdpSocket(session) { + if (!session) return false; + + const existingPort = this.sanitizeUdpPort(session.serverUdpPort); + if (session.udpSocket && existingPort) return true; + + const bindIp = this.minisrv_config?.config?.bind_ip || '0.0.0.0'; + // Keep source ports in 0x1a00-0x1aff so descriptor serverId can stay + // in the auth-compatible 0x00071a?? shape while still supporting + // per-session unique ports. + const basePort = Number.isInteger(this.service_config.udp_bind_port_base) + ? this.service_config.udp_bind_port_base + : 0x1a00; + const span = Number.isInteger(this.service_config.udp_bind_port_span) + ? Math.max(1, this.service_config.udp_bind_port_span) + : 0x100; + + const startOffset = crypto.randomInt(0, span); + let udpSocket = null; + let boundPort = null; + + for (let i = 0; i < span; i++) { + const port = basePort + ((startOffset + i) % span); + if (port <= 0 || port > 65535) continue; + + const candidate = dgram.createSocket('udp4'); + const didBind = await new Promise((resolve) => { + const onError = () => resolve(false); + candidate.once('error', onError); + candidate.bind(port, bindIp, () => { + candidate.removeListener('error', onError); + resolve(true); + }); + }); + + if (didBind) { + udpSocket = candidate; + boundPort = port; + break; + } + + try { candidate.close(); } catch (_) {} + } + + if (!udpSocket || !boundPort) { + this.debugLog('udp reserve bind failed', session.id, + `base=${basePort}`, `span=${span}`, 'no free ports'); + return false; + } + + session.udpSocket = udpSocket; + session._udpSocketHandlersAttached = false; + + try { + const addr = udpSocket.address(); + session.serverUdpPort = this.sanitizeUdpPort(addr.port); + session.serverId = (this.serverIdBase | (addr.port & 0xff)) >>> 0; + this.debugLog('udp socket reserved', session.id, `${addr.address}:${addr.port}`); + } catch (_) { + session.serverId = null; + session.serverUdpPort = null; + } + + return !!session.serverUdpPort; + } + + normalizeIpAddress(ip) { + return String(ip || '').replace(/^::ffff:/i, ''); + } + + attachUdpSocketHandlers(socket, session) { + if (!socket || !session || !session.udpSocket || session._udpSocketHandlersAttached) return; + + session.udpSocket.on('error', (err) => { + this.debugLog('udp socket error', session.id, err.message); + this.stopUdpStream(session); + }); + + // Some clients send UDP resend/feedback before playback is fully + // underway. Keep this listener active as soon as socket is reserved. + session.udpSocket.on('message', (msg, rinfo) => { + session.udpInboundCount = (session.udpInboundCount || 0) + 1; + this.debugLog('udp rx', session.id, `from=${rinfo.address}:${rinfo.port}`, + `len=${msg.length}`, 'hex', msg.slice(0, 32).toString('hex')); + this.handleUdpFeedback(socket, session, msg, rinfo); + }); + + session._udpSocketHandlersAttached = true; + } + + cacheUdpPacketForRetransmit(session, seq16, payload) { + if (!session || !Buffer.isBuffer(payload)) return; + const enabled = this.service_config.udp_retransmit_enabled !== false; + if (!enabled) return; + + if (!session.udpPacketCache) session.udpPacketCache = new Map(); + if (!Array.isArray(session.udpPacketOrder)) session.udpPacketOrder = []; + + const maxCache = Number.isInteger(this.service_config.udp_retransmit_cache_size) + ? Math.max(64, this.service_config.udp_retransmit_cache_size) + : 4096; + const now = Date.now(); + const key = seq16 & 0xffff; + const existing = session.udpPacketCache.get(key); + if (!existing) { + session.udpPacketOrder.push(key); + } + session.udpPacketCache.set(key, { payload: Buffer.from(payload), ts: now }); + + while (session.udpPacketOrder.length > maxCache) { + const dropKey = session.udpPacketOrder.shift(); + session.udpPacketCache.delete(dropKey); + } + + const maxAgeMs = Number.isInteger(this.service_config.udp_retransmit_cache_max_age_ms) + ? Math.max(250, this.service_config.udp_retransmit_cache_max_age_ms) + : 30000; + while (session.udpPacketOrder.length > 0) { + const oldestKey = session.udpPacketOrder[0]; + const oldestEntry = session.udpPacketCache.get(oldestKey); + if (!oldestEntry || now - oldestEntry.ts > maxAgeMs) { + session.udpPacketOrder.shift(); + session.udpPacketCache.delete(oldestKey); + continue; + } + break; + } + } + + extractUdpRetransmitSeqs(session, msg) { + if (!session || !session.udpPacketCache || !Buffer.isBuffer(msg) || msg.length === 0) { + return []; + } + + const out = new Set(); + const maxSeqs = Number.isInteger(this.service_config.udp_retransmit_max_seqs_per_feedback) + ? Math.max(1, this.service_config.udp_retransmit_max_seqs_per_feedback) + : 32; + + const pushIfCached = (seq) => { + const key = seq & 0xffff; + if (session.udpPacketCache.has(key)) { + out.add(key); + } + }; + + // ASCII feedback support (test clients/tools): + // NAK 12,13,0x0014 + // RETRANS 12 13 + const ascii = msg.toString('latin1').replace(/[^\x20-\x7E]/g, ' ').trim(); + if (/^(NAK|NACK|RETRANS|RESEND)\b/i.test(ascii)) { + const matches = ascii.match(/0x[0-9a-fA-F]+|\d+/g) || []; + for (const token of matches) { + const parsed = token.toLowerCase().startsWith('0x') + ? parseInt(token, 16) + : parseInt(token, 10); + if (Number.isInteger(parsed)) pushIfCached(parsed); + if (out.size >= maxSeqs) break; + } + return Array.from(out); + } + + // Binary fallback heuristics. + // Some clients encode seq requests as BE u16, others as LE u16, and + // some prepend an opcode byte. + if (msg.length === 2) { + pushIfCached(msg.readUInt16BE(0)); + pushIfCached(msg.readUInt16LE(0)); + return Array.from(out); + } + + const collectWords = (startOffset, littleEndian = false) => { + for (let i = startOffset; i + 1 < msg.length; i += 2) { + const seq = littleEndian ? msg.readUInt16LE(i) : msg.readUInt16BE(i); + pushIfCached(seq); + if (out.size >= maxSeqs) break; + } + }; + + const collectDwordsLow16 = (startOffset, littleEndian = false) => { + for (let i = startOffset; i + 3 < msg.length; i += 4) { + const val = littleEndian ? msg.readUInt32LE(i) : msg.readUInt32BE(i); + pushIfCached(val & 0xffff); + if (out.size >= maxSeqs) break; + } + }; + + if (msg.length % 2 === 0) { + collectWords(0, false); + if (out.size < maxSeqs) collectWords(0, true); + } else { + collectWords(1, false); + if (out.size < maxSeqs) collectWords(1, true); + } + + // Try opposite alignment once. + if (out.size === 0 && msg.length >= 4) { + const alt = msg.length % 2 === 0 ? 1 : 0; + collectWords(alt, false); + if (out.size < maxSeqs) collectWords(alt, true); + } + + // Some feedback payloads carry 32-bit request entries. + if (out.size === 0 && msg.length >= 8) { + const preferred = msg.length % 2 === 0 ? 0 : 1; + collectDwordsLow16(preferred, false); + if (out.size < maxSeqs) collectDwordsLow16(preferred, true); + if (out.size === 0) { + const alt = preferred === 0 ? 1 : 0; + collectDwordsLow16(alt, false); + if (out.size < maxSeqs) collectDwordsLow16(alt, true); + } + } + + return Array.from(out).slice(0, maxSeqs); + } + + handleUdpFeedback(socket, session, msg, rinfo) { + if (!socket || !session || !session.udpSocket) return; + if (this.service_config.udp_retransmit_enabled === false) return; + + const expectedIp = this.normalizeIpAddress(socket.remoteAddress); + const rxIp = this.normalizeIpAddress(rinfo?.address); + const rxPort = Number.isInteger(rinfo?.port) ? rinfo.port : -1; + const strictPeerPort = this.service_config.udp_retransmit_strict_peer_port === true; + + if (rxIp !== expectedIp) { + this.debugLog('udp feedback ignored (endpoint mismatch)', session.id, + `from=${rxIp}:${rxPort}`, + `expected=${expectedIp}:${session.clientUdpPort}`); + return; + } + + if (strictPeerPort && session.clientUdpPort && rxPort !== session.clientUdpPort) { + this.debugLog('udp feedback ignored (port mismatch, strict mode)', session.id, + `from=${rxIp}:${rxPort}`, + `expected=${expectedIp}:${session.clientUdpPort}`); + return; + } + + if (!strictPeerPort && !session.udpFeedbackPeerPort) { + session.udpFeedbackPeerPort = rxPort; + this.debugLog('udp feedback peer learned', session.id, + `peer=${rxIp}:${rxPort}`, + `mediaTarget=${expectedIp}:${session.mediaUdpPort || session.clientUdpPort}`, + `retransmitTarget=${expectedIp}:${rxPort}`); + + // If auth already passed and stream hasn't started yet, begin now. + if (session.hashVerified && !session.udpTimer && !session.udpStartTimer) { + this.debugLog('starting UDP stream after peer learn', session.id, + `target=${expectedIp}:${session.mediaUdpPort || session.clientUdpPort}`); + this.startUdpStream(socket, session); + } + } + + if (!session.clientUdpPort) return; + + const requestedSeqs = this.extractUdpRetransmitSeqs(session, msg); + if (!requestedSeqs.length) return; + + const priorityHoldMs = Number.isInteger(this.service_config.udp_retransmit_priority_hold_ms) + ? Math.max(0, this.service_config.udp_retransmit_priority_hold_ms) + : 18; + if (priorityHoldMs > 0) { + session.udpPriorityUntil = Math.max(session.udpPriorityUntil || 0, Date.now() + priorityHoldMs); + } + + const now = Date.now(); + const windowMs = Number.isInteger(this.service_config.udp_retransmit_window_ms) + ? Math.max(250, this.service_config.udp_retransmit_window_ms) + : 1000; + const maxPerWindow = Number.isInteger(this.service_config.udp_retransmit_max_per_window) + ? Math.max(1, this.service_config.udp_retransmit_max_per_window) + : 24; + + if (!session.udpFeedbackWindowStart || now - session.udpFeedbackWindowStart >= windowMs) { + session.udpFeedbackWindowStart = now; + session.udpFeedbackResentInWindow = 0; + } + + let resent = 0; + for (const seq16 of requestedSeqs) { + if (session.udpFeedbackResentInWindow >= maxPerWindow) { + session.udpFeedbackDropped = (session.udpFeedbackDropped || 0) + 1; + this.debugLog('udp retransmit rate-limited', session.id, + `windowMs=${windowMs}`, + `max=${maxPerWindow}`, + `dropped=${session.udpFeedbackDropped}`); + break; + } + + const cached = session.udpPacketCache.get(seq16 & 0xffff); + if (!cached || !Buffer.isBuffer(cached.payload)) continue; + + const txPort = this.sanitizeUdpPort(session.udpFeedbackPeerPort) + || session.mediaUdpPort + || session.clientUdpPort; + session.udpSocket.send(cached.payload, 0, cached.payload.length, + txPort, expectedIp, (err) => { + if (err) this.debugLog('udp retransmit send err', session.id, `seq=${seq16}`, err.message); + }); + resent++; + session.udpFeedbackResentInWindow++; + } + + if (resent > 0) { + this.debugLog('udp retransmit', session.id, + `count=${resent}`, + `seqs=${requestedSeqs.slice(0, resent).join(',')}`); + } } prepareMediaData(session) { @@ -674,7 +1157,7 @@ class WTVPNM { session.rdtPacketMode = 'classic-len'; session.syncEvery = Number.isInteger(this.service_config.rdt_sync_every_classic) ? Math.max(1, this.service_config.rdt_sync_every_classic) - : 8; + : 5; const cfgDataTypeLo = Number.isInteger(this.service_config.rdt_data_type_lo) ? (this.service_config.rdt_data_type_lo & 0xff) @@ -689,7 +1172,7 @@ class WTVPNM { } else { const useLegacyProfile = classicRa.channels === 1 || classicRa.channels === null; session.rdtDataTypeLo = useLegacyProfile ? 0x64 : 0x50; - session.rdtSyncType = 0x0455; + session.rdtSyncType = useLegacyProfile ? 0x0477 : 0x04ba; } const payload = media.subarray(classicRa.dataOffset); @@ -993,25 +1476,36 @@ class WTVPNM { const burstFrameCount = burstPrestartMs > 0 ? Math.ceil(burstPrestartMs / intervalMs) : 0; session.burstFramesSent = 0; + const targetIp = this.normalizeIpAddress(socket.remoteAddress); + const mediaTargetPort = this.sanitizeUdpPort(session.mediaUdpPort) || session.clientUdpPort; this.debugLog('udp stream start', session.id, `frames=${session.mediaFrames?.length || 0}`, `avgBitRate=${session.avgBitRate || 'unknown'}bps`, `bodyLen=${bodyLen}`, `interval=${intervalMs.toFixed(2)}ms`, `burstFrames=${burstFrameCount}`, - `target=${socket.remoteAddress}:${session.clientUdpPort}`); + `target=${targetIp}:${mediaTargetPort}`, + `sourcePort=${session.serverUdpPort || 'unknown'}`); - session.udpSocket = dgram.createSocket('udp4'); - session.udpSocket.on('error', (err) => { - this.debugLog('udp socket error', session.id, err.message); - this.stopUdpStream(session); - }); - // Some RDT clients also send ACK/resend requests back on the same UDP - // flow. Bind so we can receive (even if we ignore the content). - session.udpSocket.on('message', (msg, rinfo) => { - this.debugLog('udp rx', session.id, `from=${rinfo.address}:${rinfo.port}`, - `len=${msg.length}`, 'hex', msg.slice(0, 32).toString('hex')); - }); + if (!session.udpSocket || !this.sanitizeUdpPort(session.serverUdpPort)) { + this.debugLog('udp stream start failed: socket not reserved', session.id); + return; + } + + this.attachUdpSocketHandlers(socket, session); + + if (session.udpFeedbackProbeTimer) { + clearTimeout(session.udpFeedbackProbeTimer); + } + session.udpFeedbackProbeTimer = setTimeout(() => { + session.udpFeedbackProbeTimer = null; + if (!session.udpSocket || socket.destroyed) return; + if ((session.udpInboundCount || 0) === 0) { + this.debugLog('udp feedback not seen yet', session.id, + `streamTarget=${this.normalizeIpAddress(socket.remoteAddress)}:${mediaTargetPort}`, + 'If packet capture shows ICMP port unreachable, client is not listening on requested UDP port.'); + } + }, 2500); // sendPacket wraps buildMediaPayload with the every-5th-sync-frame // prefix and writes to the UDP socket. Wall-seq and frame are passed @@ -1024,9 +1518,11 @@ class WTVPNM { const out = withSync ? Buffer.concat([this.buildSyncFrame(session, seq), dataFrame]) : dataFrame; + this.cacheUdpPacketForRetransmit(session, seq, out); + const txPort = mediaTargetPort; session.udpSocket.send(out, 0, out.length, - session.clientUdpPort, socket.remoteAddress, (err) => { - if (err) this.debugLog('udp send err', session.id, err.message); + txPort, targetIp, (err) => { + if (err) this.debugLog('udp send err', session.id, `${targetIp}:${txPort}`, err.message); }); }; @@ -1048,6 +1544,14 @@ class WTVPNM { } // Don't re-arm while paused; resumeUdpStream calls _startDataInterval. if (session.paused) return; + + const priorityUntil = session.udpPriorityUntil || 0; + if (priorityUntil > Date.now()) { + const waitMs = Math.max(1, Math.min(priorityUntil - Date.now(), 10)); + session.udpTimer = setTimeout(tick, waitMs); + return; + } + const frames = session.mediaFrames; if (!frames || session.mediaFrameIdx >= frames.length) { // End of media: stop sending once all RA frames are out. @@ -1520,11 +2024,12 @@ class WTVPNM { const out = Buffer.concat(outChunks); // The first 0x4F/0x08 chunk carries [serverId_u32_BE][sessionCounter_u32_BE]. - // These are NOT a checksum of serverChallenge — verified against - // multi_auth.pcap (6 sessions, constant serverId, incrementing counter). - // Descriptor layout: [0x4F, 0x08, serverId(4), sessionCounter(4), ...] - // so serverId occupies out[2..6] and sessionCounter occupies out[6..10]. - out.writeUInt32BE(this.serverId >>> 0, 2); + // Keep auth-compatible 0x00071a?? serverId shape and encode per-session + // UDP port in the low byte (port range 0x1a00-0x1aff). + const serverId = Number.isInteger(session?.serverId) + ? session.serverId + : ((this.serverIdBase | 0x27) >>> 0); + out.writeUInt32BE(serverId, 2); const sessionNumber = (session && typeof session.sessionNumber === 'number') ? session.sessionNumber : ++this.sessionCounter; @@ -1896,6 +2401,100 @@ class WTVPNM { return Array.from(out).slice(0, 20); } + getPnaFieldAliases(field) { + if (!field) return []; + + switch (field.id) { + case 0: + return ['maskedclienttime', 'maskedtime']; + case 1: + return ['udpport', 'clientudpport']; + case 4: + return ['challenge', 'clientchallenge']; + case 23: + return ['timestamp', 'clienttimestamp']; + case 0x42: + return ['bitrate']; + case 0x52: + return ['requestedmedia', 'resourcepath', 'filename']; + case 0x63: + return ['useragent']; + default: + return []; + } + } + + decodePnaFieldValue(field, alias = null) { + if (!field || !Buffer.isBuffer(field.value)) return null; + + if (alias === 'udpport' || alias === 'clientudpport') { + return field.len >= 2 ? field.value.readUInt16BE(0) : null; + } + + if (alias === 'maskedclienttime' || alias === 'maskedtime') { + return field.len >= 4 ? field.value.readUInt32BE(0) : null; + } + + if (alias === 'bitrate') { + if (field.len >= 4) { + return field.value.readUInt32BE(0); + } + + const bitrateText = field.value.toString('latin1').replace(/\x00+$/g, '').trim(); + const bitrateMatch = bitrateText.match(/(?:bitrate|avg[_ -]?bitrate|max[_ -]?bitrate)\D+(\d{3,})/i); + if (bitrateMatch) { + return parseInt(bitrateMatch[1], 10); + } + return bitrateText || null; + } + + const textValue = field.value.toString('latin1').replace(/\x00+$/g, '').trim(); + if (textValue.length > 0) { + return textValue; + } + + return Buffer.from(field.value); + } + + attachPnaFieldAlias(fields, alias, field) { + if (!alias || !fields || !field) return; + + const decodedValue = this.decodePnaFieldValue(field, alias); + const fieldKey = `${alias}Field`; + + if (!(alias in fields)) { + fields[alias] = decodedValue; + fields[fieldKey] = field; + return; + } + + if (!Array.isArray(fields[alias])) { + fields[alias] = [fields[alias]]; + } + fields[alias].push(decodedValue); + + if (!Array.isArray(fields[fieldKey])) { + fields[fieldKey] = [fields[fieldKey]]; + } + fields[fieldKey].push(field); + } + + decoratePnaFields(fields) { + if (!Array.isArray(fields)) return fields; + + for (const field of fields) { + if (!field) continue; + + this.attachPnaFieldAlias(fields, `field_${field.id}`, field); + + for (const alias of this.getPnaFieldAliases(field)) { + this.attachPnaFieldAlias(fields, alias, field); + } + } + + return fields; + } + parsePnaMessage(data) { const pnaOffset = data.indexOf(Buffer.from('PNA\x00\x0a', 'latin1')); if (pnaOffset < 0) return null; @@ -1988,7 +2587,7 @@ class WTVPNM { if (dbg) this.debugLog('pna phase 2 complete', `start_offset=${phase2Start}`, `end_offset=${offset}`, `phase2_markers=${phase2Count}`, `total_fields=${fields.length}`); - return fields; + return this.decoratePnaFields(fields); } } diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index 9fb48db5..89447d9f 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -401,7 +401,7 @@ "protocol_handler": "pnm", "descriptor_after_hello_ms": 85, "burst_prestart_ms": 5000, - "debug": false, + "debug": true, "allow_indexing": true } }, diff --git a/zefie_wtvp_minisrv/realaudio3.pcap b/zefie_wtvp_minisrv/realaudio3.pcap deleted file mode 100644 index bdb05005ff5a49fdc22e642594f3c76ea2d5eedb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33650 zcma&u2V4_b_c;8accgcfjx?nuG(~z3Ae{h8hh!3ZQAEW?Q05B&Fw9teYe5L{fu zaOW}r`=Q(L1^~3w@4vq>114O665Rj*=ri8XV+8=Z&T=+p?q_htyaH{{O_9H&6;|B54B0ssysMo#u7;Akr-dlKT=0L2JMJ2c8EIaqT*n3G%`@#$imXp-`gr2>x{KD z^>YXT-GairtsSvWhE_H$Gv*x*2^|F1o882?~+hj5HHPB}OV@8ch?Bd)3H ztKzHTucD&jqoShbt?_$b|6t$nW1*2yARY0@@Tfl#yhFWxFgQ%)G35Z9cVL8$cz~+5 zwvS(cKlI=4UyL{w5$TUtj`k0SM*)Y|Qr+_K;UReC$YY^U7MvWEt&P-_{V)-rvI@#j zSqo*jWlx`wkVts%|HM*L(Lio_6yG3B=zsndp^8vdRny+O2Q(ZACIAztBd(!}R99Ei zg6qqp{Qps>L;veoe5CZJPR~LIH5^CQn&!Xt{_kUFkAX3K?7W)%$FWmFkA0+M$Mx%1 za_jFnos~4eXQ!@hysH?z_5(ytszsuP>$`2E=@2GNU zuyVM+H;($_e|Ew72M0z5K}Vz-E9$$6}tZ)6|p4s!w_S~#c=LS{g+O7VvL`}qwpFaft{0P6PdBZsD%ZVZl7 zQ2IaPKu3<`6!a=%V*s!MurUolE2;yDL9gHmUH}vICwf5K52=RGMh2+)`Dm(mYiX(Z z`e+kQ> z`+whNrryI$y@!cAT6NJSCVEqCxIKLqO5HKVpDl{xK1RD-U;-Uh; zpg<54gu?~~2KZt^apA#XAz@J=Vd3E+!Qqi$6g0sHVuC_2fDpiSgz5B3`)FC$jsi}x zWOoqiTLNEdhxr0ZsE}9;4(Gj3eXokRi@%Sf^FFn`2=P5eA^1>e((u9g>x!csEKJ1J z_NvI^0Kgt#iu8|&+#BwF%n+`xQ7D)H=^p-FUwiDT^r2kx`TnE680hg*SM9hqaO)5f z9OoM&6JP*6ZFu`1^N6ZlH8qag|BNHFfs4ueQxgEs92}7B&HUeiQ40l*41ZBuw>@;@ z05q^0@>`2c>MB&MoKUeIe699-V0WN6@ldhC+rI}!6KY3<2gWP>kAZPgpCB}_MJSoJ z%m9-}IQ;vzjMQY)+0p{+(8dXEJD`mp+PK02H9LD-=y(I5hXw#W_um_h0951927S?0 zul?sX{4+G9aDd0i*2WR~fd{_l_g6TOiZZsdhki)?eaHWZ1;>Qq(E$AaYYv2e%>qjD z5BRWx{;vTrISo^rWNp|I`ducc9Drt&_s9Wuev%y1K|^(spjqPt?CaQrE30KD%u)IKP{xTwzrBmLMs5F;fwTDM|i9I zX=;1>BenfheSHJG;k?R+uxy`K3N^3)m>l7}dO1UrEbv5owJR#)@W(Dqf;(16CTrS7e%q7Dx^5_%{b zC|B_IKT1eXECDJZZWbBnp=jZUBhHBG&?smz&`1IR8lDfJ`(xM!jO@%im6asc6r+A_ z#4WBB48`fKZ452y6>P+1ebLFJ7e9aNxzq9PHSTqEM%9K#^D4$B+++9EITu4r_BqjY zpUMfkORnjgP5XCnHdfmAJuGfORU9|A7f7bOlbW^%`7DX)z951aR6Wj#Cs}$FQu>9a zri$iupQtvP>maV#^@R`KRd5&0JSk|;h~Sv82=Fg9+)>wC_#|KGr9*yi_lt^w^jiiz z>r6KZnPm1GBd>CXy&6dr3=!<7py@jbO;g1@JS>={uQ?a%@asl5J8N<{@JNwOT&|&|B~fw08A(L7Zx2+dfNIs9ECOnx `&Zd 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 YOQyWvBm3U(W8nSTgL{B4KY~F1ACfs70{{R3