allow binding of custom UDP retransmit ports

This commit is contained in:
zefie
2026-04-22 11:27:50 -04:00
parent 9c6493332f
commit a1c8ec09e3
3 changed files with 75 additions and 25 deletions

View File

@@ -37,9 +37,9 @@ class WTVPNM {
this.wtvshared = new WTVShared(minisrv_config, true); this.wtvshared = new WTVShared(minisrv_config, true);
this.server = net.createServer((socket) => this.handleConnection(socket)); this.server = net.createServer((socket) => this.handleConnection(socket));
// Auth-sensitive descriptor server id shape observed in captures. // Descriptor server-id mapping uses full 16-bit source UDP port:
// Keep fixed upper 24 bits 0x00071a and vary only the low byte. // serverId = 0x0007pppp where pppp is the reserved UDP source port.
this.serverIdBase = 0x00071a00; this.serverIdPort16Base = 0x00070000;
// Per-session counter embedded in bytes 9..12 of the first 0x4F chunk. // Per-session counter embedded in bytes 9..12 of the first 0x4F chunk.
// multi_auth.pcap increments this 1,2,3,... across successive sessions. // multi_auth.pcap increments this 1,2,3,... across successive sessions.
@@ -826,16 +826,17 @@ class WTVPNM {
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null; return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null;
} }
async ensureSessionUdpSocket(session) { getUdpBindRange() {
if (!session) return false; const configuredMin = this.sanitizeUdpPort(this.service_config.udp_bind_port_min);
const configuredMax = this.sanitizeUdpPort(this.service_config.udp_bind_port_max);
const existingPort = this.sanitizeUdpPort(session.serverUdpPort); if (configuredMin && configuredMax) {
if (session.udpSocket && existingPort) return true; const min = Math.min(configuredMin, configuredMax);
const max = Math.max(configuredMin, configuredMax);
return { min, max, mode: 'minmax' };
}
const bindIp = this.minisrv_config?.config?.bind_ip || '0.0.0.0'; // Backward compatibility for older base/span config.
// 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) const basePort = Number.isInteger(this.service_config.udp_bind_port_base)
? this.service_config.udp_bind_port_base ? this.service_config.udp_bind_port_base
: 0x1a00; : 0x1a00;
@@ -843,12 +844,34 @@ class WTVPNM {
? Math.max(1, this.service_config.udp_bind_port_span) ? Math.max(1, this.service_config.udp_bind_port_span)
: 0x100; : 0x100;
const min = Math.max(1, basePort);
const max = Math.min(65535, basePort + span - 1);
return { min, max, mode: 'basespan' };
}
buildServerIdForPort(port) {
const parsedPort = this.sanitizeUdpPort(port);
if (!parsedPort) return null;
return (this.serverIdPort16Base | (parsedPort & 0xffff)) >>> 0;
}
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 by default. When a custom range
// is used, server-id mapping can follow the full source port.
const range = this.getUdpBindRange();
const span = Math.max(1, (range.max - range.min) + 1);
const startOffset = crypto.randomInt(0, span); const startOffset = crypto.randomInt(0, span);
let udpSocket = null; let udpSocket = null;
let boundPort = null; let boundPort = null;
for (let i = 0; i < span; i++) { for (let i = 0; i < span; i++) {
const port = basePort + ((startOffset + i) % span); const port = range.min + ((startOffset + i) % span);
if (port <= 0 || port > 65535) continue; if (port <= 0 || port > 65535) continue;
const candidate = dgram.createSocket('udp4'); const candidate = dgram.createSocket('udp4');
@@ -872,7 +895,7 @@ class WTVPNM {
if (!udpSocket || !boundPort) { if (!udpSocket || !boundPort) {
this.debugLog('udp reserve bind failed', session.id, this.debugLog('udp reserve bind failed', session.id,
`base=${basePort}`, `span=${span}`, 'no free ports'); `range=${range.min}-${range.max}`, `mode=${range.mode}`, 'no free ports');
return false; return false;
} }
@@ -882,8 +905,10 @@ class WTVPNM {
try { try {
const addr = udpSocket.address(); const addr = udpSocket.address();
session.serverUdpPort = this.sanitizeUdpPort(addr.port); session.serverUdpPort = this.sanitizeUdpPort(addr.port);
session.serverId = (this.serverIdBase | (addr.port & 0xff)) >>> 0; session.serverId = this.buildServerIdForPort(addr.port);
this.debugLog('udp socket reserved', session.id, `${addr.address}:${addr.port}`); this.debugLog('udp socket reserved', session.id,
`${addr.address}:${addr.port}`,
`serverId=0x${(session.serverId >>> 0).toString(16)}`);
} catch (_) { } catch (_) {
session.serverId = null; session.serverId = null;
session.serverUdpPort = null; session.serverUdpPort = null;
@@ -896,6 +921,13 @@ class WTVPNM {
return String(ip || '').replace(/^::ffff:/i, ''); return String(ip || '').replace(/^::ffff:/i, '');
} }
getMediaTargetPort(session) {
if (!session) return null;
return this.sanitizeUdpPort(session.udpFeedbackPeerPort)
|| this.sanitizeUdpPort(session.mediaUdpPort)
|| this.sanitizeUdpPort(session.clientUdpPort);
}
attachUdpSocketHandlers(socket, session) { attachUdpSocketHandlers(socket, session) {
if (!socket || !session || !session.udpSocket || session._udpSocketHandlersAttached) return; if (!socket || !session || !session.udpSocket || session._udpSocketHandlersAttached) return;
@@ -1068,15 +1100,19 @@ class WTVPNM {
if (!strictPeerPort && !session.udpFeedbackPeerPort) { if (!strictPeerPort && !session.udpFeedbackPeerPort) {
session.udpFeedbackPeerPort = rxPort; session.udpFeedbackPeerPort = rxPort;
const currentTargetPort = this.getMediaTargetPort(session);
if (this.sanitizeUdpPort(rxPort) && currentTargetPort !== rxPort) {
session.mediaUdpPort = rxPort;
}
this.debugLog('udp feedback peer learned', session.id, this.debugLog('udp feedback peer learned', session.id,
`peer=${rxIp}:${rxPort}`, `peer=${rxIp}:${rxPort}`,
`mediaTarget=${expectedIp}:${session.mediaUdpPort || session.clientUdpPort}`, `mediaTarget=${expectedIp}:${this.getMediaTargetPort(session) || 'unknown'}`,
`retransmitTarget=${expectedIp}:${rxPort}`); `retransmitTarget=${expectedIp}:${rxPort}`);
// If auth already passed and stream hasn't started yet, begin now. // If auth already passed and stream hasn't started yet, begin now.
if (session.hashVerified && !session.udpTimer && !session.udpStartTimer) { if (session.hashVerified && !session.udpTimer && !session.udpStartTimer) {
this.debugLog('starting UDP stream after peer learn', session.id, this.debugLog('starting UDP stream after peer learn', session.id,
`target=${expectedIp}:${session.mediaUdpPort || session.clientUdpPort}`); `target=${expectedIp}:${this.getMediaTargetPort(session) || 'unknown'}`);
this.startUdpStream(socket, session); this.startUdpStream(socket, session);
} }
} }
@@ -1120,9 +1156,8 @@ class WTVPNM {
const cached = session.udpPacketCache.get(seq16 & 0xffff); const cached = session.udpPacketCache.get(seq16 & 0xffff);
if (!cached || !Buffer.isBuffer(cached.payload)) continue; if (!cached || !Buffer.isBuffer(cached.payload)) continue;
const txPort = this.sanitizeUdpPort(session.udpFeedbackPeerPort) const txPort = this.getMediaTargetPort(session);
|| session.mediaUdpPort if (!txPort) continue;
|| session.clientUdpPort;
session.udpSocket.send(cached.payload, 0, cached.payload.length, session.udpSocket.send(cached.payload, 0, cached.payload.length,
txPort, expectedIp, (err) => { txPort, expectedIp, (err) => {
if (err) this.debugLog('udp retransmit send err', session.id, `seq=${seq16}`, err.message); if (err) this.debugLog('udp retransmit send err', session.id, `seq=${seq16}`, err.message);
@@ -1477,7 +1512,7 @@ class WTVPNM {
session.burstFramesSent = 0; session.burstFramesSent = 0;
const targetIp = this.normalizeIpAddress(socket.remoteAddress); const targetIp = this.normalizeIpAddress(socket.remoteAddress);
const mediaTargetPort = this.sanitizeUdpPort(session.mediaUdpPort) || session.clientUdpPort; const mediaTargetPort = this.getMediaTargetPort(session);
this.debugLog('udp stream start', session.id, this.debugLog('udp stream start', session.id,
`frames=${session.mediaFrames?.length || 0}`, `frames=${session.mediaFrames?.length || 0}`,
`avgBitRate=${session.avgBitRate || 'unknown'}bps`, `avgBitRate=${session.avgBitRate || 'unknown'}bps`,
@@ -1491,6 +1526,12 @@ class WTVPNM {
this.debugLog('udp stream start failed: socket not reserved', session.id); this.debugLog('udp stream start failed: socket not reserved', session.id);
return; return;
} }
if (!mediaTargetPort) {
this.debugLog('udp stream start failed: no target port', session.id,
`clientUdpPort=${session.clientUdpPort || 'none'}`,
`feedbackPeerPort=${session.udpFeedbackPeerPort || 'none'}`);
return;
}
this.attachUdpSocketHandlers(socket, session); this.attachUdpSocketHandlers(socket, session);
@@ -1519,7 +1560,8 @@ class WTVPNM {
? Buffer.concat([this.buildSyncFrame(session, seq), dataFrame]) ? Buffer.concat([this.buildSyncFrame(session, seq), dataFrame])
: dataFrame; : dataFrame;
this.cacheUdpPacketForRetransmit(session, seq, out); this.cacheUdpPacketForRetransmit(session, seq, out);
const txPort = mediaTargetPort; const txPort = this.getMediaTargetPort(session);
if (!txPort) return;
session.udpSocket.send(out, 0, out.length, session.udpSocket.send(out, 0, out.length,
txPort, targetIp, (err) => { txPort, targetIp, (err) => {
if (err) this.debugLog('udp send err', session.id, `${targetIp}:${txPort}`, err.message); if (err) this.debugLog('udp send err', session.id, `${targetIp}:${txPort}`, err.message);
@@ -2024,11 +2066,12 @@ class WTVPNM {
const out = Buffer.concat(outChunks); const out = Buffer.concat(outChunks);
// The first 0x4F/0x08 chunk carries [serverId_u32_BE][sessionCounter_u32_BE]. // The first 0x4F/0x08 chunk carries [serverId_u32_BE][sessionCounter_u32_BE].
// Keep auth-compatible 0x00071a?? serverId shape and encode per-session // serverId is mapped from the reserved UDP source port as 0x0007pppp
// UDP port in the low byte (port range 0x1a00-0x1aff). // so the client can route UDP feedback to the same socket used for
// media transmission.
const serverId = Number.isInteger(session?.serverId) const serverId = Number.isInteger(session?.serverId)
? session.serverId ? session.serverId
: ((this.serverIdBase | 0x27) >>> 0); : ((this.serverIdPort16Base | 0x1a27) >>> 0);
out.writeUInt32BE(serverId, 2); out.writeUInt32BE(serverId, 2);
const sessionNumber = (session && typeof session.sessionNumber === 'number') const sessionNumber = (session && typeof session.sessionNumber === 'number')
? session.sessionNumber ? session.sessionNumber

View File

@@ -399,6 +399,8 @@
"flags": "0x00000001", "flags": "0x00000001",
"allow_double_slash": true, "allow_double_slash": true,
"protocol_handler": "pnm", "protocol_handler": "pnm",
"udp_bind_port_min": 57361,
"udp_bind_port_max": 57391,
"descriptor_after_hello_ms": 85, "descriptor_after_hello_ms": 85,
"burst_prestart_ms": 5000, "burst_prestart_ms": 5000,
"debug": true, "debug": true,

View File

@@ -129,6 +129,11 @@
"password": "mylocalpass" "password": "mylocalpass"
} }
}, },
"pnm": {
// optional UDP source-port range used by WTVPNM
"udp_bind_port_min": 6656,
"udp_bind_port_max": 6911
},
// the following uses zefie's public proxy with webone for web surfing and image scaling // the following uses zefie's public proxy with webone for web surfing and image scaling
"services": { "services": {
"http": { "http": {