diff --git a/.env.example b/.env.example index f581711..88ce17e 100644 --- a/.env.example +++ b/.env.example @@ -40,9 +40,14 @@ LIVEKIT_API_SECRET= # Separate multiple URLs with commas. STUN_URLS= +# Optional independent STUN observations used only when a Host enables the +# default-off NAT traversal experiment. Maximum two comma-separated URLs. +NAT_PREDICTION_STUN_URLS= + # Minimal production example (use independent untracked secrets): # SITE_ACCESS_PASSWORD=replace-with-an-independent-access-key # STUN_URLS=stun:stun.example.com:3478 +# NAT_PREDICTION_STUN_URLS=stun:stun-observer.example.com:3478 # Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not # replace STUN above): # LIVEKIT_URL=wss://share.example.com diff --git a/docs/adr/0009-connection-local-nat-prediction-experiment.md b/docs/adr/0009-connection-local-nat-prediction-experiment.md index dfba56b..6e234b7 100644 --- a/docs/adr/0009-connection-local-nat-prediction-experiment.md +++ b/docs/adr/0009-connection-local-nat-prediction-experiment.md @@ -12,9 +12,10 @@ receives a small set of adjacent server-reflexive candidates. The result is network- and generation-specific; it is not a reliable NAT type or participant capability. -The existing route-policy and ICE candidate owners are sufficient. Making the -experiment room-wide requires one strict protocol revision for the added policy -field, but no new message type, route-controller state, or media path. +The existing route-policy and ICE candidate owners are sufficient. The current +strict protocol carries the room policy, bounded independent observation URLs, +and anonymous selected-path provenance, but adds no NAT label, route-controller +state, or media path. ## Decision @@ -25,17 +26,25 @@ field, but no new message type, route-controller state, or media path. 2. When enabled, every Browser P2P connection adds ports 3479 and 3480 for the same STUN authority already supplied by the server. This covers Host direct, Viewer upstream, and Viewer relay connections. SFU PeerConnections remain - unchanged. + unchanged. A deployment may also provide at most two independent `stun:` + observation URLs. They are added only inside the enabled experiment and are + never part of ordinary room ICE. 3. For one ICE gathering generation, the adapter observes only UDP `srflx` - candidates. If three or more distinct candidates for one media section and - public address form an arithmetic port sequence, it appends at most eight - bounded candidates around the sequence's high endpoint (four in each - direction). Every ordinary candidate keeps normal Trickle ICE timing; no - ordinary candidate is delayed, rejected, or replaced. A failed or - inconclusive experiment uses the existing ICE and SFU fallback unchanged. + candidates whose Browser-reported STUN URL belongs to the self-hosted + 3478/3479/3480 survey set. Independent observations remain ordinary ICE + candidates and cannot enter the arithmetic sequence. If three or more + distinct survey candidates for one media section and public address form an + arithmetic port sequence, the adapter appends at most eight bounded + candidates around the sequence's high endpoint. Every ordinary candidate + keeps normal Trickle ICE timing; no ordinary candidate is delayed, rejected, + or replaced. A Browser that does not expose candidate source URLs keeps + ordinary ICE but does not mix independent observations into prediction. 4. Candidate observations remain in memory for that connection only. No NAT label, raw address, port, score, hard candidate skip, periodic probe, or - route-controller branch is introduced. + route-controller branch is introduced. Sanitized diagnostics record only + whether a signaled candidate was ordinary, predicted, or end-of-candidates, + and whether the selected remote foundation was ordinary, predicted, or + unknown. ## Consequences @@ -47,12 +56,16 @@ Positive: listener or unsuccessful prediction cannot delay the existing direct or SFU fallback; and - the experiment is bounded to one connection generation and one small - candidate set. + candidate set; and +- selected-foundation attribution can validate actual field benefit without + uploading candidate endpoints. Negative: - the Browser may still reject synthetic candidates or the NAT may not have a - matching mapping; and + matching mapping; +- every independent STUN operator observes connection metadata and supplies no + availability promise unless its own service contract says otherwise; and - two endpoint-dependent NATs remain sensitive to candidate scheduling and intervening mappings, so room-wide coverage is not a success guarantee. diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md index 29fb3a8..30c9a14 100644 --- a/docs/operations/self-hosting.md +++ b/docs/operations/self-hosting.md @@ -99,6 +99,13 @@ These same-IP auxiliary ports measure destination-port mapping behavior. They are not a full RFC 5780 alternate-address deployment; that requires a second public IPv4. +An operator may configure at most two independent observation URLs through +`NAT_PREDICTION_STUN_URLS`. They are contacted only by Browser P2P connections +in an opted-in room, remain ordinary ICE candidates, and require no local +listener or firewall change. Verify them with a real Browser from the target +network before use. DNS resolution alone is not a STUN availability check, and +an unaffiliated public endpoint has no implied SLA. + ## Operational Verification - `/healthz` returns `{"status":"ok"}` locally and through the public origin. diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index d9303ed..bc5b888 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -34,8 +34,11 @@ share generation: availability routing is unchanged; - the default-off NAT traversal experiment augments direct ICE candidates on every Browser P2P edge in that share. It is not a participant capability, - route score, or SFU preference. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md) - owns its bounded behavior and evidence boundary. + route score, or SFU preference. A deployment may add a bounded independent + STUN observation list only inside that experiment. Candidate generation and + selected-pair diagnostics retain only `ordinary | predicted | unknown` + provenance. [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md) + owns the bounded behavior and evidence boundary. These are route-policy gates, not new routing algorithms. Changing any policy requires stopping the current share and starting another generation. diff --git a/docs/project-memory.md b/docs/project-memory.md index 4609d27..fd22dd2 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -31,8 +31,10 @@ implementation and routine UI detail. participants, routes, and media remain process-only. - Media is automatic and distributed. Ordinary peers are STUN-only and prefer direct/peer UDP. Host may opt the current share into a bounded, room-wide, - connection-local NAT candidate experiment; the default path remains stock - ICE. The only application fallback is one bounded LiveKit SFU/UDP + connection-local NAT candidate experiment; a deployment may add two + independent observation STUN URLs only inside that experiment, and anonymous + provenance distinguishes ordinary from predicted selected paths. The default + path remains stock ICE. The only application fallback is one bounded LiveKit SFU/UDP publication; Screener configures no TURN or TCP media route. A pre-share peer-only policy can exclude that SFU suffix for one share generation. - Every endpoint shares one steady outbound-copy cap, default `2` and limited to @@ -57,7 +59,7 @@ implementation and routine UI detail. ## Current Snapshot -Canonical source uses the strict `screener-v18` Browser/server contract with +Canonical source uses the strict `screener-v19` Browser/server contract with committed media readiness, bounded candidate-relative progress and one-shot Host-root convergence. It retains optional SQLite room authority and the H.264/VP8 sender gate. [Status](./status.md) owns the compact current execution diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2cac141..875fd4e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -20,6 +20,7 @@ service secret store or an untracked access-restricted environment file. | `PEER_ASSISTED_MEDIA` | Enables the all-room bounded Peer/SFU controller. | | `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. | | `STUN_URLS` | Comma-separated `stun:` URLs; at least one is required in production. | +| `NAT_PREDICTION_STUN_URLS` | Optional comma-separated independent `stun:` observations, maximum `2`; used only by rooms that enable the default-off NAT experiment. | Automatic SFU fallback is enabled only when `PEER_ASSISTED_MEDIA=true` and all four values below are present: @@ -68,6 +69,11 @@ a cloud port without a listener has no effect. No new DNS record is required. Keep both listeners available while the experiment is offered and remove them only after disabling it. Same-IP ports expose destination-port allocation behavior; a full RFC 5780 alternate-address test requires a second public IPv4. +Optional `NAT_PREDICTION_STUN_URLS` add independent ordinary candidates without +changing `STUN_URLS`. Their operators receive client address, port, time, and +protocol metadata when the Host enables the experiment. They need no inbound +server port or DNS change, but should be omitted when that dependency is not +accepted. ## Bounds diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md index 7b15c7b..8d6f202 100644 --- a/docs/research/nat-traversal.md +++ b/docs/research/nat-traversal.md @@ -71,6 +71,23 @@ locks while sharing, and applies to Host, Viewer upstream, and Viewer relay P2P connections. Its exact scope is recorded in [ADR-0009](../adr/0009-connection-local-nat-prediction-experiment.md). +### Independent Observation And Attribution Preflight + +On 2026-09-01, Chrome 151 on the flagship Windows network gathered one `srflx` +candidate independently from the self-hosted endpoint, Xiaomi, Bilibili, and +Cloudflare within a five-second bound. `stun.qq.com:3478` produced none in the +same ordered run. Candidate events and local candidate stats preserved the exact +STUN `url`, so independent destinations can remain ordinary candidates while +only self-hosted 3478/3479/3480 observations feed prediction. These are measured +reachability results, not third-party availability commitments. + +A second real-Chrome loopback rewrote only one remote candidate foundation to +`sp1`, established the data channel, and read `sp1` from the selected remote +candidate stats. The product can therefore report `predicted | ordinary | +unknown` selection without retaining or uploading addresses, ports, foundations, +or STUN URLs. Field acceptance still requires target-network outcomes; this +preflight proves the attribution mechanism, not prediction prevalence. + The Browser-only alternatives do not yet have an accepted implementation: - WebRTC ICE already exchanges observed candidates and performs coordinated @@ -148,3 +165,4 @@ a new Peer route. - [libp2p Browser and DCUtR hole-punching boundary](https://github.com/libp2p/specs/blob/master/connections/hole-punching.md) - [coturn listener and auxiliary endpoint reference](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf) - [Cloudflare Realtime STUN service](https://developers.cloudflare.com/realtime/turn/) +- [WebRTC selected candidate stats](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats-foundation) diff --git a/docs/status.md b/docs/status.md index 5d3dcb4..d45011b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -7,7 +7,7 @@ This is the current execution index. Git history owns completed timelines; ## Production -- `https://share.bonfire.icu` runs the strict `screener-v18` Browser/server +- `https://share.bonfire.icu` runs the strict `screener-v19` Browser/server contract. Exact revision, release, artifact, manifest, and asset identity are retained by the immutable release descriptor, runtime `REVISION`, and deployment record rather than copied into this source snapshot. @@ -23,9 +23,9 @@ This is the current execution index. Git history owns completed timelines; - Site access uses a stateless 24-hour rolling idle cookie. An active site-authorized page renews it hourly through the existing status request; Viewer-grant admission remains independent and cannot create or renew it. -- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. During - the approved NAT survey, coturn also exposes auxiliary STUN-only UDP 3479 - and 3480; ordinary `STUN_URLS` and media routes remain unchanged. Web ingress +- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. The + optional NAT experiment also uses auxiliary STUN-only UDP 3479 and 3480; + ordinary `STUN_URLS` and media routes remain unchanged. Web ingress is TCP 80/443; Node 8787 and LiveKit control/signaling 7880 are private. TURN, ICE/TCP, media TCP, TLS relay, port 5349, and relay ranges are disabled. - Browser video uses the content-independent H.264 sender gate with VP8 @@ -49,7 +49,7 @@ This is the current execution index. Git history owns completed timelines; ## Current Source -- Canonical source uses the strict `screener-v18` Browser/server contract. +- Canonical source uses the strict `screener-v19` Browser/server contract. Optional SQLite stable authority, non-expiring local preferred code, atomic room replacement and the Host codec selector remain implemented; production selects stable storage. Graceful restart, crash, timeout and network loss use @@ -77,7 +77,10 @@ This is the current execution index. Git history owns completed timelines; defaults on and locks while sharing. - Host Advanced settings include a default-off, per-share NAT traversal experiment. Every Browser P2P role uses the same bounded, connection-local - candidate adapter; ordinary candidates and SFU fallback remain unchanged. + candidate adapter. At most two deployment-supplied independent STUN + observations are contacted only inside opted-in rooms; candidate and selected + path logs retain anonymous `ordinary | predicted | unknown` provenance. + Ordinary candidates and SFU fallback remain unchanged. - Every direct, Browser-relay, and Host SFU video sender owns one clone of its source track; the original remains presentation and source authority only. Replacement, rollback, unpublish, physical LiveKit sender recreation, and diff --git a/docs/todo.md b/docs/todo.md index e0478c8..0ce3da1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -86,8 +86,9 @@ until their stated boundary is met. [ADR-0009](./adr/0009-connection-local-nat-prediction-experiment.md). Keep multi-STUN observations and injected candidates additive and diagnostic; do not make them a participant-wide NAT label, hard candidate skip, route - score, or SFU preference. Validate target-network success, candidate - direction, and bounded resource impact before enabling it by default. + score, or SFU preference. Candidate generation and selected-path provenance + are now observable without endpoint data. Validate target-network success, + candidate direction, and bounded resource impact before enabling it by default. 15. **Unresolved route-state ownership claims.** Reopen lower-revision reauthentication, active SFU failure during an unrelated prepare, and multi-child relay-evidence ownership only from an exact current-wire diff --git a/scripts/access-privacy-gate.ts b/scripts/access-privacy-gate.ts index fa12537..b124bb6 100644 --- a/scripts/access-privacy-gate.ts +++ b/scripts/access-privacy-gate.ts @@ -99,7 +99,7 @@ async function main(): Promise { publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]), siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000, maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2, - peerAssistedMedia: false, stunUrls: [], + peerAssistedMedia: false, stunUrls: [], natPredictionStunUrls: [], }; server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") }); server.httpServer.prependListener("request", (request, response) => { diff --git a/src/client/media/sender-quality-evidence.ts b/src/client/media/sender-quality-evidence.ts index c306e46..029ffd9 100644 --- a/src/client/media/sender-quality-evidence.ts +++ b/src/client/media/sender-quality-evidence.ts @@ -102,6 +102,7 @@ export function senderQualityEvidenceFromSnapshot( routeRevision, state, diagnostics: { + natTraversalPath: snapshot.metrics.natTraversalPath, reason: state === "unknown" ? null : snapshot.metrics.qualityLimitationReason, framesPerSecond: snapshot.metrics.framesPerSecond, bitrateKbps: snapshot.metrics.bitrateKbps, @@ -166,6 +167,7 @@ export function sfuPublisherQualityEvidenceFromMetrics( state, sampleTimestampMs, diagnostics: { + natTraversalPath: metrics.natTraversalPath, reason: state === "unknown" ? null : metrics.qualityLimitationReason, framesPerSecond: metrics.framesPerSecond, bitrateKbps: metrics.bitrateKbps, diff --git a/src/client/media/viewer-quality-evidence.ts b/src/client/media/viewer-quality-evidence.ts index 4959a8f..594eeee 100644 --- a/src/client/media/viewer-quality-evidence.ts +++ b/src/client/media/viewer-quality-evidence.ts @@ -97,6 +97,7 @@ export function qualityEvidenceWindowFromMetrics( ? { width, height } : { width: null, height: null }; const candidate: ViewerQualityEvidenceMetrics = { + natTraversalPath: metrics.natTraversalPath, ...dimensions, framesPerSecond: boundedNumber(metrics.framesPerSecond, 240), bitrateKbps: boundedNumber(metrics.bitrateKbps, 100_000), @@ -502,6 +503,7 @@ export function metricsFromQualityEvidence( const metrics = evidence.metrics; return { ...EMPTY_METRICS, + natTraversalPath: metrics.natTraversalPath, sampleWindowMs: evidence.windowMs, framesPerSecond: metrics.framesPerSecond, frameWidth: metrics.width, diff --git a/src/client/types.ts b/src/client/types.ts index b201069..25b731c 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,3 +1,5 @@ +import type { NatTraversalPath } from "../shared/nat-candidate"; + export type SignalConnectionState = | "connecting" | "connected" @@ -28,6 +30,7 @@ export interface ConnectionMetrics { captureFramesPerSecond: number | null; mediaSourceFramesPerSecond: number | null; path: MediaPath; + natTraversalPath: NatTraversalPath; iceProtocol: string | null; localCandidateType: string | null; remoteCandidateType: string | null; @@ -114,6 +117,7 @@ export const EMPTY_METRICS: ConnectionMetrics = { captureFramesPerSecond: null, mediaSourceFramesPerSecond: null, path: "unknown", + natTraversalPath: "unknown", iceProtocol: null, localCandidateType: null, remoteCandidateType: null, diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index db1aeee..6421c63 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -39,12 +39,15 @@ import { } from "./video-codec"; import { iceServersWithNatPrediction, + natPredictionSurveyUrls, NatPredictionCandidateEmitter, type SignalCandidate, } from "./nat-prediction"; const MAX_PENDING_CANDIDATES = 64; -type PeerIceConfig = Pick; +type PeerIceConfig = Pick & { + natPredictionStunUrls?: readonly string[]; +}; interface HostPeerEvents { sendSignal: (peerId: string, payload: SignalPayload) => boolean; @@ -111,11 +114,15 @@ export class HostPeer { iceServers: iceServersWithNatPrediction( iceConfig.iceServers, this.natPredictionEnabled, + iceConfig.natPredictionStunUrls, ), }); this.localIceCandidates = new NatPredictionCandidateEmitter( this.natPredictionEnabled, (candidate) => this.sendIceCandidate(candidate), + natPredictionSurveyUrls( + iceConfig.iceServers, + ), ); this.snapshot = { peerId, @@ -414,8 +421,14 @@ export class HostPeer { iceServers: iceServersWithNatPrediction( iceConfig.iceServers, this.natPredictionEnabled, + iceConfig.natPredictionStunUrls, ), }); + this.localIceCandidates.setSurveyUrls( + natPredictionSurveyUrls( + iceConfig.iceServers, + ), + ); } catch (error) { this.setError(error, say("host.err.createConnection")); } diff --git a/src/client/webrtc/nat-prediction.ts b/src/client/webrtc/nat-prediction.ts index 34111aa..fee1382 100644 --- a/src/client/webrtc/nat-prediction.ts +++ b/src/client/webrtc/nat-prediction.ts @@ -1,4 +1,7 @@ -import type { SignalPayload } from "../../shared/protocol"; +import { + MAX_NAT_PREDICTION_STUN_URLS, + type SignalPayload, +} from "../../shared/protocol"; export type SignalCandidate = Extract< SignalPayload, @@ -69,6 +72,57 @@ function auxiliaryUrlsFor(url: string): readonly string[] { ); } +function normalizedStunUrl(url: string): string | null { + if (!/^stun:/i.test(url)) { + return null; + } + const authority = url.slice(url.indexOf(":") + 1); + let parsed: URL; + try { + parsed = new URL(`http://${authority}`); + } catch { + return null; + } + if ( + !parsed.hostname || + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + return null; + } + const hostname = parsed.hostname.startsWith("[") + ? parsed.hostname.toLowerCase() + : parsed.hostname.includes(":") + ? `[${parsed.hostname.toLowerCase()}]` + : parsed.hostname.toLowerCase(); + return `stun:${hostname}:${parsed.port || BASE_STUN_PORT}`; +} + +/** URLs whose same-socket observations may form the controlled port sequence. */ +export function natPredictionSurveyUrls( + iceServers: readonly RTCIceServer[] | undefined, +): ReadonlySet { + for (const server of iceServers ?? []) { + for (const url of urlsOf(server)) { + const auxiliaryUrls = auxiliaryUrlsFor(url); + const baseUrl = normalizedStunUrl(url); + if (baseUrl && auxiliaryUrls.length > 0) { + return new Set([ + baseUrl, + ...auxiliaryUrls.flatMap((entry) => { + const normalized = normalizedStunUrl(entry); + return normalized ? [normalized] : []; + }), + ]); + } + } + } + return new Set(); +} + /** * Add the two optional same-host survey listeners without changing the * server-provided STUN list. The caller opts into this per connection. @@ -76,6 +130,7 @@ function auxiliaryUrlsFor(url: string): readonly string[] { export function iceServersWithNatPrediction( iceServers: readonly RTCIceServer[] | undefined, enabled: boolean, + externalStunUrls: readonly string[] = [], ): RTCIceServer[] { const base = (iceServers ?? []).map(cloneIceServer); if (!enabled) { @@ -84,13 +139,17 @@ export function iceServersWithNatPrediction( const additions = new Set(); const existing = new Set( - (iceServers ?? []).flatMap((server) => urlsOf(server)), + (iceServers ?? []) + .flatMap((server) => urlsOf(server)) + .map((url) => normalizedStunUrl(url) ?? url), ); for (const server of iceServers ?? []) { for (const url of urlsOf(server)) { for (const auxiliaryUrl of auxiliaryUrlsFor(url)) { - if (!existing.has(auxiliaryUrl)) { + const normalized = normalizedStunUrl(auxiliaryUrl) ?? auxiliaryUrl; + if (!existing.has(normalized)) { additions.add(auxiliaryUrl); + existing.add(normalized); } } if (additions.size >= AUXILIARY_STUN_PORTS.length) { @@ -102,6 +161,14 @@ export function iceServersWithNatPrediction( } } + for (const url of externalStunUrls.slice(0, MAX_NAT_PREDICTION_STUN_URLS)) { + const normalized = normalizedStunUrl(url); + if (normalized && !existing.has(normalized)) { + additions.add(url); + existing.add(normalized); + } + } + return [ ...base, ...Array.from(additions, (url) => ({ urls: url })), @@ -255,7 +322,7 @@ export class NatPredictionCandidateBatch { constructor(private readonly send: (candidate: SignalCandidate | null) => void) {} - add(candidate: SignalCandidate): void { + add(candidate: SignalCandidate, predictionEligible = true): void { if (this.completed) { return; } @@ -263,8 +330,10 @@ export class NatPredictionCandidateBatch { this.send(candidate); return; } - this.observedSrflx.push(candidate); - this.trySendPredictions(); + if (predictionEligible) { + this.observedSrflx.push(candidate); + this.trySendPredictions(); + } this.send(candidate); } @@ -311,11 +380,24 @@ export class NatPredictionCandidateEmitter { private batch: NatPredictionCandidateBatch | null = null; private usernameFragment: string | null = null; private endSent = false; + private surveyUrls = new Set(); constructor( private readonly enabled: boolean, private readonly send: (candidate: SignalCandidate | null) => void, - ) {} + surveyUrls: ReadonlySet = new Set(), + ) { + this.setSurveyUrls(surveyUrls); + } + + setSurveyUrls(urls: ReadonlySet): void { + this.surveyUrls = new Set( + [...urls].flatMap((url) => { + const normalized = normalizedStunUrl(url); + return normalized ? [normalized] : []; + }), + ); + } add(candidate: RTCIceCandidate | null): void { if (!this.enabled) { @@ -345,7 +427,16 @@ export class NatPredictionCandidateEmitter { this.endSent = false; this.batch = new NatPredictionCandidateBatch(this.send); } - this.batch.add(next); + const serverUrl = ( + candidate as RTCIceCandidate & { readonly url?: string | null } + ).url; + const candidateUrl = serverUrl + ? normalizedStunUrl(serverUrl) + : null; + this.batch.add( + next, + candidateUrl !== null && this.surveyUrls.has(candidateUrl), + ); } gatheringComplete(): void { diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts index 8ccdca0..389c5c0 100644 --- a/src/client/webrtc/stats.ts +++ b/src/client/webrtc/stats.ts @@ -4,6 +4,7 @@ import { } from "../types"; import { packetLossPercentFromDeltas } from "../../shared/packet-loss"; import { deriveVideoCodecEvidence } from "../../shared/video-codec-evidence"; +import { isPredictedCandidateFoundation } from "../../shared/nat-candidate"; type StatsRecord = Record & { id: string; @@ -675,6 +676,13 @@ export function collectConnectionMetricsFromReport( } const localType = stringValue(localCandidate, "candidateType"); const remoteType = stringValue(remoteCandidate, "candidateType"); + const remoteFoundation = stringValue(remoteCandidate, "foundation"); + const natTraversalPath = + remoteFoundation === null + ? "unknown" + : isPredictedCandidateFoundation(remoteFoundation) + ? "predicted" + : "ordinary"; const path = localType === "relay" || remoteType === "relay" ? "unknown" @@ -1068,6 +1076,7 @@ export function collectConnectionMetricsFromReport( trackIdentifier, selectedCandidatePairId: pair?.id ?? null, path, + natTraversalPath, iceProtocol, localCandidateType: localType, remoteCandidateType: remoteType, diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index ec66a54..b1679e7 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -14,6 +14,7 @@ import { preferScreenAudioStereo } from "./screen-audio-sdp"; import { observeDecodedFrameProof } from "../media/decoded-frame-proof"; import { iceServersWithNatPrediction, + natPredictionSurveyUrls, NatPredictionCandidateEmitter, } from "./nat-prediction"; @@ -21,7 +22,9 @@ const MAX_PENDING_CANDIDATES = 64; const MAX_AUTOMATIC_RECOVERY_REQUESTS = 2; const INITIAL_CONNECTION_TIMEOUT_MS = 15_000; const AUTOMATIC_RECOVERY_TIMEOUT_MS = 3_000; -type PeerIceConfig = Pick; +type PeerIceConfig = Pick & { + natPredictionStunUrls?: readonly string[]; +}; type SignalCandidate = Extract< SignalPayload, { kind: "candidate" } @@ -124,8 +127,14 @@ export class ViewerPeer { iceServers: iceServersWithNatPrediction( iceConfig.iceServers, this.natPredictionEnabled, + iceConfig.natPredictionStunUrls, ), }); + this.localIceCandidates?.setSurveyUrls( + natPredictionSurveyUrls( + iceConfig.iceServers, + ), + ); } catch (error) { this.setError(error, say("host.fail.connection")); } @@ -225,6 +234,7 @@ export class ViewerPeer { iceServers: iceServersWithNatPrediction( this.currentIceConfig.iceServers, this.natPredictionEnabled, + this.currentIceConfig.natPredictionStunUrls, ), }); const localIceCandidates = new NatPredictionCandidateEmitter( @@ -239,6 +249,9 @@ export class ViewerPeer { candidate, }); }, + natPredictionSurveyUrls( + this.currentIceConfig.iceServers, + ), ); this.connection = connection; this.localIceCandidates = localIceCandidates; diff --git a/src/server/app.ts b/src/server/app.ts index 40e06ea..6a541b0 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -105,6 +105,7 @@ export async function createScreenerServer( }); const iceOptions = { stunUrls: config.stunUrls, + natPredictionStunUrls: config.natPredictionStunUrls, }; let frontendHandler: FrontendHandler | undefined; diff --git a/src/server/config.ts b/src/server/config.ts index 400d31e..62dc200 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -2,6 +2,7 @@ import { isAbsolute } from "node:path"; import { MAX_ICE_SERVER_URLS, + MAX_NAT_PREDICTION_STUN_URLS, MAX_VIEWERS_PER_ROOM_LIMIT, stunUrlSchema, } from "../shared/protocol.js"; @@ -56,6 +57,7 @@ export interface ServerConfig { endpointMediaCopyCapacity: number; livekitFallback?: LiveKitFallbackConfig; stunUrls: readonly string[]; + natPredictionStunUrls: readonly string[]; } function parseBoolean( @@ -228,11 +230,14 @@ function parseUrlList(value: string | undefined, name: string): string[] { }); } -function parseStunUrlList(value: string | undefined): string[] { - const name = "STUN_URLS"; +function parseStunUrlList( + value: string | undefined, + name: string, + maximum = MAX_ICE_SERVER_URLS, +): string[] { const values = parseUrlList(value, name); - if (values.length > MAX_ICE_SERVER_URLS) { - throw new Error(`${name} must contain at most ${MAX_ICE_SERVER_URLS} URLs`); + if (values.length > maximum) { + throw new Error(`${name} must contain at most ${maximum} URLs`); } return values.map((value) => { if (!stunUrlSchema.safeParse(value).success) { @@ -317,7 +322,20 @@ export function loadConfig( const roomDatabasePath = parseRoomDatabasePath( environment.ROOM_DATABASE_PATH, ); - const stunUrls = parseStunUrlList(environment.STUN_URLS); + const stunUrls = parseStunUrlList(environment.STUN_URLS, "STUN_URLS"); + const natPredictionStunUrls = parseStunUrlList( + environment.NAT_PREDICTION_STUN_URLS, + "NAT_PREDICTION_STUN_URLS", + MAX_NAT_PREDICTION_STUN_URLS, + ); + if ( + stunUrls.length + natPredictionStunUrls.length > + MAX_ICE_SERVER_URLS + ) { + throw new Error( + `STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most ${MAX_ICE_SERVER_URLS} URLs together`, + ); + } const maxViewersPerRoom = parseBoundedInteger( environment.MAX_VIEWERS_PER_ROOM, DEFAULT_MAX_VIEWERS_PER_ROOM, @@ -392,5 +410,6 @@ export function loadConfig( endpointMediaCopyCapacity, livekitFallback, stunUrls, + natPredictionStunUrls, }; } diff --git a/src/server/hybrid-media-router.ts b/src/server/hybrid-media-router.ts index 0d2b363..bb4291c 100644 --- a/src/server/hybrid-media-router.ts +++ b/src/server/hybrid-media-router.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { debuglog } from "node:util"; import { packetLossPercentFromDeltas } from "../shared/packet-loss.js"; +import type { CandidateSignalOrigin } from "../shared/nat-candidate.js"; import type { ClientMessage, @@ -324,6 +325,7 @@ export class HybridMediaRouter { input.upstream.kind === "peer" ? `p2p:${this.debugPeer(roomId, input.upstream.peerId)}` : "sfu", + natTraversalPath: input.metrics.natTraversalPath, framesPerSecond: input.metrics.framesPerSecond, bitrateKbps: input.metrics.bitrateKbps, width: input.metrics.width, @@ -397,6 +399,7 @@ export class HybridMediaRouter { routeRevision: message.routeRevision, ...copyContext, state: message.state, + natTraversalPath: message.diagnostics.natTraversalPath, reason: message.diagnostics.reason, framesPerSecond: message.diagnostics.framesPerSecond, bitrateKbps: message.diagnostics.bitrateKbps, @@ -683,6 +686,7 @@ export class HybridMediaRouter { sourcePeerId: string; targetPeerId: string; signalKind: "candidate" | "description"; + candidateOrigin?: CandidateSignalOrigin; descriptionType?: "offer" | "answer"; authorization: boolean | "probe" | undefined; }): void { @@ -690,6 +694,7 @@ export class HybridMediaRouter { source: this.debugPeer(input.roomId, input.sourcePeerId), target: this.debugPeer(input.roomId, input.targetPeerId), signalKind: input.signalKind, + candidateOrigin: input.candidateOrigin ?? null, descriptionType: input.descriptionType ?? null, authorization: input.authorization === undefined ? "assignment" : input.authorization, diff --git a/src/server/ice.ts b/src/server/ice.ts index 5bc44aa..0531003 100644 --- a/src/server/ice.ts +++ b/src/server/ice.ts @@ -1,12 +1,14 @@ -import type { IceConfig } from "../shared/protocol.js"; +import type { WireIceConfig } from "../shared/protocol.js"; export interface IceConfigOptions { stunUrls: readonly string[]; + natPredictionStunUrls?: readonly string[]; } -export function createIceConfig(options: IceConfigOptions): IceConfig { +export function createIceConfig(options: IceConfigOptions): WireIceConfig { return { iceServers: options.stunUrls.length > 0 ? [{ urls: [...options.stunUrls] }] : [], + natPredictionStunUrls: [...(options.natPredictionStunUrls ?? [])], }; } diff --git a/src/server/signaling.ts b/src/server/signaling.ts index 832fc6f..4ef1c87 100644 --- a/src/server/signaling.ts +++ b/src/server/signaling.ts @@ -25,6 +25,7 @@ import { type ServerMessage, type ParticipantPresenceEntry, } from "../shared/protocol.js"; +import { candidateSignalOrigin } from "../shared/nat-candidate.js"; import { assertEndpointMediaCopyCapacity } from "../shared/media-copy-accounting.js"; import { RoomStore, @@ -1534,6 +1535,13 @@ export class SignalingServer { sourcePeerId: source.peerId, targetPeerId: target.peerId, signalKind: message.payload.kind, + ...(message.payload.kind === "candidate" + ? { + candidateOrigin: candidateSignalOrigin( + message.payload.candidate?.candidate ?? null, + ), + } + : {}), ...(description ? { descriptionType: description.type } : {}), authorization: candidateAuthorized, }); diff --git a/src/shared/nat-candidate.ts b/src/shared/nat-candidate.ts new file mode 100644 index 0000000..9b36f97 --- /dev/null +++ b/src/shared/nat-candidate.ts @@ -0,0 +1,25 @@ +export const NAT_TRAVERSAL_PATHS = [ + "unknown", + "ordinary", + "predicted", +] as const; + +export type NatTraversalPath = (typeof NAT_TRAVERSAL_PATHS)[number]; +export type CandidateSignalOrigin = "end" | "ordinary" | "predicted"; + +export function isPredictedCandidateFoundation( + foundation: string | null, +): boolean { + return foundation !== null && /^s[pm]\d+$/.test(foundation); +} + +export function candidateSignalOrigin( + candidate: string | null, +): CandidateSignalOrigin { + if (candidate === null || candidate.trim() === "") { + return "end"; + } + return /^candidate:s[pm]\d+\s/i.test(candidate) + ? "predicted" + : "ordinary"; +} diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index c13b8e3..373316f 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -2,11 +2,12 @@ import { z } from "zod"; import { MAX_ENDPOINT_MEDIA_COPY_CAPACITY } from "./media-copy-accounting.js"; import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js"; +import { NAT_TRAVERSAL_PATHS } from "./nat-candidate.js"; export const MAX_VIEWERS_PER_ROOM_LIMIT = 20; export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1; export const MAX_SIGNAL_BYTES = 64 * 1024; -export const SIGNALING_PROTOCOL = "screener-v18"; +export const SIGNALING_PROTOCOL = "screener-v19"; export const SIGNAL_CLOSE_CODES = { serviceRestart: 1012, sessionReplaced: 4001, @@ -18,6 +19,7 @@ export const ROOM_CODE_LENGTH = 4; export const MAX_MEDIA_ROUTE_REVISION = Number.MAX_SAFE_INTEGER; export const MAX_SFU_TOKEN_LENGTH = 8 * 1024; export const MAX_ICE_SERVER_URLS = 8; +export const MAX_NAT_PREDICTION_STUN_URLS = 2; export const MAX_VIEWER_QUALITY_EVIDENCE_BYTES = 2 * 1024; export const VIEWER_QUALITY_EVIDENCE_INTERVAL_MS = 2_000; export const VIEWER_QUALITY_EVIDENCE_EXPIRY_MS = 5_000; @@ -288,9 +290,16 @@ const iceServerSchema = z export const iceConfigSchema = z .object({ iceServers: z.array(iceServerSchema).max(8), + natPredictionStunUrls: z + .array(stunUrlSchema) + .max(MAX_NAT_PREDICTION_STUN_URLS), }) .strict(); -export type IceConfig = z.infer; +export type WireIceConfig = z.infer; +export type IceConfig = { + iceServers: Array>; + natPredictionStunUrls?: string[]; +}; const sessionDescriptionSchema = z .object({ @@ -565,6 +574,7 @@ const nullableSafeEvidenceInteger = z export const viewerQualityEvidenceMetricsSchema = z .object({ + natTraversalPath: z.enum(NAT_TRAVERSAL_PATHS), width: z.number().int().min(1).max(16_384).nullable(), height: z.number().int().min(1).max(16_384).nullable(), framesPerSecond: nullableEvidenceNumber(240), @@ -644,6 +654,7 @@ const viewerQualityEvidenceWindowShape = { const senderQualityDiagnosticsSchema = z .object({ + natTraversalPath: z.enum(NAT_TRAVERSAL_PATHS), reason: z.enum(["none", "bandwidth", "cpu"]).nullable(), framesPerSecond: nullableEvidenceNumber(240), bitrateKbps: nullableEvidenceNumber(100_000), diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts index 0aaf6f1..82f41c1 100644 --- a/tests/client-state.test.ts +++ b/tests/client-state.test.ts @@ -864,7 +864,7 @@ describe("client signaling recovery policy", () => { hostOnline: true, connectionId: null, viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open", viewerAuthorizationGeneration: "viewer_generation_12345678", }), @@ -1008,7 +1008,7 @@ describe("client signaling recovery policy", () => { hostPaused: true, connectionId: null, viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open", viewerPasswordEnabled: false, viewerAuthorizationGeneration: "viewer_generation_12345678", @@ -1044,7 +1044,7 @@ describe("client signaling recovery policy", () => { hostPaused: true, connectionId: null, viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open", viewerPasswordEnabled: false, viewerAuthorizationGeneration: "viewer_generation_12345678", @@ -1157,7 +1157,7 @@ describe("client signaling recovery policy", () => { hostPaused: false, connectionId: null, viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open", viewerPasswordEnabled: false, viewerAuthorizationGeneration: "viewer_generation_12345678", @@ -1285,7 +1285,7 @@ describe("client signaling recovery policy", () => { hostOnline: true, connectionId: null, viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open", viewerPasswordEnabled: false, viewerAuthorizationGeneration: "viewer_generation_12345678", @@ -1402,7 +1402,7 @@ describe("client signaling recovery policy", () => { hostOnline: true, connectionId: "connection_12345678", viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open", viewerAuthorizationGeneration: "viewer_generation_12345678", mediaMode: "peer-assisted", @@ -1483,7 +1483,7 @@ describe("client signaling recovery policy", () => { hostOnline: true, connectionId: null, viewerPeerIds: [], - iceConfig: { iceServers: [] }, + iceConfig: { iceServers: [], natPredictionStunUrls: [] }, }), ])( "terminates once when a server payload is incompatible with the current wire", @@ -1710,6 +1710,7 @@ describe("WebRTC stats parsing", () => { }), entry("remote-candidate-a", "remote-candidate", { candidateType: "srflx", + foundation: "sp1", protocol: "udp", address: "2001:db8::10", port: 50_001, @@ -1804,6 +1805,7 @@ describe("WebRTC stats parsing", () => { mediaSourceFramesPerSecond: 58.5, selectedCandidatePairId: "pair-a", path: "direct", + natTraversalPath: "predicted", iceProtocol: "udp", localCandidateAddress: "192.0.2.10", localCandidatePort: 50_000, @@ -1840,6 +1842,12 @@ describe("WebRTC stats parsing", () => { }); report.get("remote-candidate-a")!.address = "2001:db8::10"; report.get("remote-candidate-a")!.port = 50_001; + report.get("remote-candidate-a")!.foundation = "ordinary"; + expect( + await collectConnectionMetrics(connection, "send", accumulator, { + trackIdentifier: "capture-track-a", + }), + ).toMatchObject({ natTraversalPath: "ordinary" }); report.get("transport-a")!.selectedCandidatePairId = "pair-z"; const wrongTransportPair = await collectConnectionMetrics( diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index ce79fa9..e98cbf4 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -726,7 +726,10 @@ describe("HostPeer source replacement", () => { const signals: SignalPayload[] = []; const peer = new HostPeer( "viewer-peer", - { iceServers: [{ urls: "stun:share.example.test:3478" }] }, + { + iceServers: [{ urls: "stun:share.example.test:3478" }], + natPredictionStunUrls: ["stun:observer.example.test:3478"], + }, createStream(createTrack("video", "video"), null), QUALITY_PROFILES["720p30"], { @@ -745,9 +748,15 @@ describe("HostPeer source replacement", () => { { urls: "stun:share.example.test:3478" }, { urls: "stun:share.example.test:3479" }, { urls: "stun:share.example.test:3480" }, + { urls: "stun:observer.example.test:3478" }, ]); - const emitCandidate = (port: number | null): void => { + const surveyUrls = [ + "stun:share.example.test:3478", + "stun:share.example.test:3479", + "stun:share.example.test:3480", + ]; + const emitCandidate = (port: number | null, url?: string): void => { const event = new Event("icecandidate"); Object.defineProperty(event, "candidate", { value: @@ -760,13 +769,15 @@ describe("HostPeer source replacement", () => { sdpMid: "0", sdpMLineIndex: 0, usernameFragment: "test", + url, }, }); connection.dispatchEvent(event); }; - emitCandidate(40_000); - emitCandidate(40_003); - emitCandidate(40_006); + emitCandidate(50_000, "stun:observer.example.test:3478"); + emitCandidate(40_000, surveyUrls[0]); + emitCandidate(40_003, surveyUrls[1]); + emitCandidate(40_006, surveyUrls[2]); connection.iceGatheringState = "complete"; connection.dispatchEvent(new Event("icegatheringstatechange")); emitCandidate(null); @@ -788,7 +799,7 @@ describe("HostPeer source replacement", () => { candidateSignals.filter((signal) => signal.candidate?.candidate.startsWith("candidate:base"), ), - ).toHaveLength(3); + ).toHaveLength(4); expect(candidateSignals.at(-1)?.candidate).toBeNull(); expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1); peer.dispose(); diff --git a/tests/hybrid-media-router.test.ts b/tests/hybrid-media-router.test.ts index c90f8d9..335b28f 100644 --- a/tests/hybrid-media-router.test.ts +++ b/tests/hybrid-media-router.test.ts @@ -14,6 +14,7 @@ const SHARE_GENERATION = "share_generation_12345678"; function senderDiagnostics(state: "unknown" | "healthy" | "degraded") { return { + natTraversalPath: "unknown", reason: state === "unknown" ? null : state === "healthy" ? "none" : "bandwidth", framesPerSecond: null, @@ -250,6 +251,41 @@ async function establishSfuRoom( } describe("HybridMediaRouter v9 runtime", () => { + it("logs only aggregate candidate origin for route signals", async () => { + const { router } = harness(2); + const debug = vi.spyOn( + router as unknown as { + debug( + roomId: string, + event: string, + details: Record, + ): void; + }, + "debug", + ); + try { + router.debugPeerSignal({ + roomId: "1234", + sourcePeerId: "source_12345678", + targetPeerId: "target_12345678", + signalKind: "candidate", + candidateOrigin: "predicted", + authorization: "probe", + }); + expect(debug).toHaveBeenCalledWith( + "1234", + "peer-signal", + expect.objectContaining({ + candidateOrigin: "predicted", + signalKind: "candidate", + authorization: "probe", + }), + ); + } finally { + await router.close(); + } + }); + it("projects exact endpoint copy context from the current snapshot", async () => { const { router } = harness(2); const internal = router as unknown as { diff --git a/tests/ice.test.ts b/tests/ice.test.ts index 5634706..07c4a6c 100644 --- a/tests/ice.test.ts +++ b/tests/ice.test.ts @@ -10,6 +10,7 @@ describe("ordinary ICE configuration", () => { "stun:stun-a.example.test:3478", "stun:stun-b.example.test:3478", ], + natPredictionStunUrls: ["stun:observer.example.test:3478"], }), ).toEqual({ iceServers: [ @@ -20,10 +21,13 @@ describe("ordinary ICE configuration", () => { ], }, ], + natPredictionStunUrls: ["stun:observer.example.test:3478"], }); }); it("keeps local development explicit when STUN is absent", () => { - expect(createIceConfig({ stunUrls: [] })).toEqual({ iceServers: [] }); + expect( + createIceConfig({ stunUrls: [], natPredictionStunUrls: [] }), + ).toEqual({ iceServers: [], natPredictionStunUrls: [] }); }); }); diff --git a/tests/nat-candidate.test.ts b/tests/nat-candidate.test.ts new file mode 100644 index 0000000..9e61727 --- /dev/null +++ b/tests/nat-candidate.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + candidateSignalOrigin, + isPredictedCandidateFoundation, +} from "../src/shared/nat-candidate.ts"; + +describe("NAT candidate diagnostics", () => { + it("recognizes only the bounded synthetic foundation namespace", () => { + expect(isPredictedCandidateFoundation("sp1")).toBe(true); + expect(isPredictedCandidateFoundation("sm4")).toBe(true); + expect(isPredictedCandidateFoundation("base")).toBe(false); + expect(isPredictedCandidateFoundation(null)).toBe(false); + }); + + it("classifies candidate signals without retaining endpoint data", () => { + expect(candidateSignalOrigin(null)).toBe("end"); + expect(candidateSignalOrigin(" ")).toBe("end"); + expect( + candidateSignalOrigin( + "candidate:sp2 1 udp 1 203.0.113.1 50000 typ srflx", + ), + ).toBe("predicted"); + expect( + candidateSignalOrigin( + "candidate:base 1 udp 1 203.0.113.1 50000 typ srflx", + ), + ).toBe("ordinary"); + }); +}); diff --git a/tests/nat-prediction.test.ts b/tests/nat-prediction.test.ts index 05585be..b3c2782 100644 --- a/tests/nat-prediction.test.ts +++ b/tests/nat-prediction.test.ts @@ -4,6 +4,8 @@ import { iceServersWithNatPrediction, MAX_NAT_PREDICTION_CANDIDATES, NatPredictionCandidateBatch, + NatPredictionCandidateEmitter, + natPredictionSurveyUrls, predictSrflxCandidates, type SignalCandidate, } from "../src/client/webrtc/nat-prediction.ts"; @@ -33,6 +35,12 @@ function portOf(value: SignalCandidate): number { return Number(value?.candidate.trim().split(/\s+/)[5]); } +function rtcCandidate(port: number): RTCIceCandidate { + return { + ...candidate(port), + } as unknown as RTCIceCandidate; +} + describe("NAT prediction ICE adapter", () => { it("leaves the configured servers unchanged when disabled", () => { const servers = [ @@ -40,7 +48,42 @@ describe("NAT prediction ICE adapter", () => { { urls: "stun:other.example.test:5349" }, ]; - expect(iceServersWithNatPrediction(servers, false)).toEqual(servers); + expect( + iceServersWithNatPrediction( + servers, + false, + ["stun:observer.example.test:3478"], + ), + ).toEqual(servers); + }); + + it("adds independent observers only inside the enabled experiment", () => { + const servers = [{ urls: "stun:share.example.test:3478" }]; + + expect( + iceServersWithNatPrediction(servers, true, [ + "stun:observer.example.test:3478", + ]), + ).toEqual([ + ...servers, + { urls: "stun:share.example.test:3479" }, + { urls: "stun:share.example.test:3480" }, + { urls: "stun:observer.example.test:3478" }, + ]); + expect( + natPredictionSurveyUrls(servers), + ).toEqual( + new Set([ + "stun:share.example.test:3478", + "stun:share.example.test:3479", + "stun:share.example.test:3480", + ]), + ); + expect( + natPredictionSurveyUrls([ + { urls: "stun:share.example.test:5349" }, + ]), + ).toEqual(new Set()); }); it("derives only the two optional same-host survey listeners", () => { @@ -158,4 +201,43 @@ describe("NAT prediction ICE adapter", () => { expect(sent).toEqual([first]); }); + + it("retains independent observations without using them as sequence evidence", () => { + const sent: Array = []; + const batch = new NatPredictionCandidateBatch((value) => sent.push(value)); + const external = candidate(50_000); + + batch.add(external, false); + batch.add(candidate(40_000)); + batch.add(candidate(40_003)); + batch.add(candidate(40_006)); + + expect(sent[0]).toEqual(external); + expect( + sent.some((value) => value?.candidate.startsWith("candidate:sp")), + ).toBe(true); + }); + + it("does not predict when the Browser omits candidate source URLs", () => { + const sent: Array = []; + const emitter = new NatPredictionCandidateEmitter( + true, + (value) => sent.push(value), + new Set([ + "stun:share.example.test:3478", + "stun:share.example.test:3479", + "stun:share.example.test:3480", + ]), + ); + + emitter.add(rtcCandidate(40_000)); + emitter.add(rtcCandidate(40_003)); + emitter.add(rtcCandidate(40_006)); + emitter.gatheringComplete(); + + expect( + sent.some((value) => value?.candidate.startsWith("candidate:sp")), + ).toBe(false); + expect(sent.filter((value) => value !== null)).toHaveLength(3); + }); }); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 603c166..f08cb5a 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -56,6 +56,7 @@ const qualityEvidence = { sequence: 0, windowMs: 2_000, metrics: { + natTraversalPath: "ordinary", width: 1_920, height: 1_080, framesPerSecond: 59.8, @@ -97,7 +98,7 @@ describe("client signaling protocol", () => { "utf8", ); - expect(SIGNALING_PROTOCOL).toBe("screener-v18"); + expect(SIGNALING_PROTOCOL).toBe("screener-v19"); expect(nativeWire).toMatch(/signalingProtocol\s*=\s*"screener-v6"/); }); @@ -768,6 +769,14 @@ describe("client signaling protocol", () => { it("accepts only strict, bounded viewer quality evidence", () => { expect(clientMessageSchema.safeParse(qualityEvidence).success).toBe(true); + const { natTraversalPath: _path, ...legacyMetrics } = + qualityEvidence.metrics; + expect( + clientMessageSchema.safeParse({ + ...qualityEvidence, + metrics: legacyMetrics, + }).success, + ).toBe(false); expect( clientMessageSchema.safeParse({ ...qualityEvidence, @@ -883,12 +892,21 @@ describe("client signaling protocol", () => { routeRevision: 3, state: "degraded", diagnostics: { + natTraversalPath: "ordinary", reason: "bandwidth", framesPerSecond: 24, bitrateKbps: 1_500, }, } as const; expect(clientMessageSchema.safeParse(sender).success).toBe(true); + const { natTraversalPath: _senderPath, ...legacyDiagnostics } = + sender.diagnostics; + expect( + clientMessageSchema.safeParse({ + ...sender, + diagnostics: legacyDiagnostics, + }).success, + ).toBe(false); expect( clientMessageSchema.safeParse({ ...sender, @@ -911,6 +929,7 @@ describe("client signaling protocol", () => { state: "healthy", sampleTimestampMs: 2_000, diagnostics: { + natTraversalPath: "ordinary", reason: "none", framesPerSecond: 30, bitrateKbps: 2_000, @@ -1253,6 +1272,7 @@ describe("server signaling protocol", () => { viewerAuthorizationGeneration: "viewer_generation_12345678", iceConfig: { iceServers: [], + natPredictionStunUrls: [], }, }; } @@ -1275,6 +1295,18 @@ describe("server signaling protocol", () => { expect(serverMessageSchema.safeParse(viewer).success).toBe(false); }); + it("requires the v19 NAT observation configuration", () => { + const message = authenticatedMessage(8); + const { natPredictionStunUrls: _urls, ...legacyIceConfig } = + message.iceConfig; + expect( + serverMessageSchema.safeParse({ + ...message, + iceConfig: legacyIceConfig, + }).success, + ).toBe(false); + }); + it("accepts dynamic viewer limits within the protocol boundary", () => { expect(MAX_VIEWERS_PER_ROOM_LIMIT).toBe(20); expect(serverMessageSchema.safeParse(authenticatedMessage(1)).success).toBe( diff --git a/tests/sender-quality-evidence.test.ts b/tests/sender-quality-evidence.test.ts index 819aa19..da79ac0 100644 --- a/tests/sender-quality-evidence.test.ts +++ b/tests/sender-quality-evidence.test.ts @@ -182,6 +182,7 @@ describe("native sender quality evidence", () => { routeRevision: 7, state: "healthy", sampleTimestampMs: 2_000, + diagnostics: { natTraversalPath: "unknown" }, }); invalidateSenderQualityEvidence(); expect(senderQualityEvidenceFromSnapshot(nextSnapshot, 7)).toMatchObject({ diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index dd3e5e0..25f4ad5 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -18,6 +18,7 @@ describe("server configuration", () => { expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"])); expect(config.roomDatabasePath).toBeUndefined(); expect(config.stunUrls).toEqual([]); + expect(config.natPredictionStunUrls).toEqual([]); expect(config.maxViewersPerRoom).toBe(8); expect(config.peerAssistedMedia).toBe(false); expect(config.endpointMediaCopyCapacity).toBe(2); @@ -326,6 +327,30 @@ describe("server configuration", () => { ).toEqual(["STUN:[2001:db8::1]:3478"]); }); + it("accepts a bounded independent NAT prediction STUN list", () => { + expect( + loadConfig({ + NAT_PREDICTION_STUN_URLS: + "stun:observer-a.test:3478, stun:observer-b.test:3478", + }).natPredictionStunUrls, + ).toEqual([ + "stun:observer-a.test:3478", + "stun:observer-b.test:3478", + ]); + }); + + it("rejects invalid or excessive NAT prediction STUN URLs", () => { + expect(() => + loadConfig({ NAT_PREDICTION_STUN_URLS: "turn:observer.test:3478" }), + ).toThrow("NAT_PREDICTION_STUN_URLS contains an invalid STUN URL"); + expect(() => + loadConfig({ + NAT_PREDICTION_STUN_URLS: + "stun:a.test:3478,stun:b.test:3478,stun:c.test:3478", + }), + ).toThrow("NAT_PREDICTION_STUN_URLS must contain at most 2 URLs"); + }); + it.each([ "stun:stun.test/path", "stun:stun.test?transport=udp", @@ -377,6 +402,21 @@ describe("server configuration", () => { ).toThrow("STUN_URLS must contain at most 8 URLs"); }); + it("bounds ordinary and NAT prediction STUN URLs together", () => { + expect(() => + loadConfig({ + STUN_URLS: Array.from( + { length: 7 }, + (_, index) => `stun:ordinary-${index}.test:3478`, + ).join(","), + NAT_PREDICTION_STUN_URLS: + "stun:observer-a.test:3478,stun:observer-b.test:3478", + }), + ).toThrow( + "STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most 8 URLs together", + ); + }); + it("requires and accepts a bounded production site access password", () => { expect(() => loadConfig({ diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts index 132d7cc..26ece4b 100644 --- a/tests/server-http.test.ts +++ b/tests/server-http.test.ts @@ -57,6 +57,7 @@ function testConfig(overrides: Partial = {}): ServerConfig { peerAssistedMedia: false, endpointMediaCopyCapacity: 2, stunUrls: [], + natPredictionStunUrls: [], ...overrides, }; } diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts index 720b02c..be8bb4a 100644 --- a/tests/server-signal.test.ts +++ b/tests/server-signal.test.ts @@ -137,6 +137,7 @@ function testConfig(): ServerConfig { peerAssistedMedia: false, endpointMediaCopyCapacity: 2, stunUrls: [], + natPredictionStunUrls: [], }; } @@ -151,6 +152,7 @@ async function startHarness( peerAssistedMedia?: boolean; endpointMediaCopyCapacity?: number; stunUrls?: readonly string[]; + natPredictionStunUrls?: readonly string[]; now?: () => number; roomStore?: RoomStore; room?: CreatedRoom; @@ -162,6 +164,7 @@ async function startHarness( config.endpointMediaCopyCapacity = overrides.endpointMediaCopyCapacity ?? 2; config.stunUrls = overrides.stunUrls ?? []; + config.natPredictionStunUrls = overrides.natPredictionStunUrls ?? []; const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8; config.maxViewersPerRoom = maxViewersPerRoom; const roomStore = @@ -420,6 +423,7 @@ function viewerQualityEvidenceMessage( sequence, windowMs: 2_000, metrics: { + natTraversalPath: "ordinary", width: 1_920, height: 1_080, framesPerSecond: 60, @@ -542,6 +546,26 @@ describe("WebSocket signaling", () => { expect(joined.routePolicy).toEqual(routePolicy); }); + it("delivers independent NAT experiment STUN without changing ordinary ICE", async () => { + const harness = await startHarness({ + stunUrls: ["stun:ordinary.example.test:3478"], + natPredictionStunUrls: ["stun:observer.example.test:3478"], + }); + const viewer = await openClient(harness.webSocketUrl); + const authenticated = await authenticate( + viewer, + harness.room, + "viewer", + "nat-observer-viewer", + null, + ); + + expect(authenticated.iceConfig).toEqual({ + iceServers: [{ urls: ["stun:ordinary.example.test:3478"] }], + natPredictionStunUrls: ["stun:observer.example.test:3478"], + }); + }); + it("rebuilds a route when a Viewer reconnects before the Host after restart", async () => { const directory = mkdtempSync(join(tmpdir(), "screener-route-restart-")); const path = join(directory, "rooms.sqlite"); diff --git a/tests/viewer-peer-generation.test.ts b/tests/viewer-peer-generation.test.ts index 031ff02..71aed7a 100644 --- a/tests/viewer-peer-generation.test.ts +++ b/tests/viewer-peer-generation.test.ts @@ -342,7 +342,10 @@ describe("ViewerPeer connection generations", () => { signals, [], [], - { iceServers: [{ urls: "stun:share.example.test:3478" }] }, + { + iceServers: [{ urls: "stun:share.example.test:3478" }], + natPredictionStunUrls: ["stun:observer.example.test:3478"], + }, true, ); await peer.acceptSignal("host", offer("nat-room-policy")); @@ -351,9 +354,15 @@ describe("ViewerPeer connection generations", () => { { urls: "stun:share.example.test:3478" }, { urls: "stun:share.example.test:3479" }, { urls: "stun:share.example.test:3480" }, + { urls: "stun:observer.example.test:3478" }, ]); - const emitCandidate = (port: number): void => { + const surveyUrls = [ + "stun:share.example.test:3478", + "stun:share.example.test:3479", + "stun:share.example.test:3480", + ]; + const emitCandidate = (port: number, url: string): void => { const event = new Event("icecandidate"); Object.defineProperty(event, "candidate", { value: { @@ -363,13 +372,14 @@ describe("ViewerPeer connection generations", () => { sdpMid: "0", sdpMLineIndex: 0, usernameFragment: "test", + url, }, }); connection.dispatchEvent(event); }; - emitCandidate(40_000); - emitCandidate(40_003); - emitCandidate(40_006); + emitCandidate(40_000, surveyUrls[0]!); + emitCandidate(40_003, surveyUrls[1]!); + emitCandidate(40_006, surveyUrls[2]!); connection.iceGatheringState = "complete"; connection.dispatchEvent(new Event("icegatheringstatechange")); diff --git a/tests/viewer-quality-evidence.test.ts b/tests/viewer-quality-evidence.test.ts index 8e0c21a..d88b826 100644 --- a/tests/viewer-quality-evidence.test.ts +++ b/tests/viewer-quality-evidence.test.ts @@ -33,6 +33,7 @@ function receiveMetrics( ...EMPTY_METRICS, sampleTimestampMs: 10_000, sampleWindowMs: 2_000, + natTraversalPath: "predicted", framesPerSecond: 60, frameWidth: 1_920, frameHeight: 1_080, @@ -116,6 +117,7 @@ describe("viewer quality evidence", () => { expect(qualityEvidenceWindowFromMetrics(receiveMetrics())).toEqual({ windowMs: 2_000, metrics: { + natTraversalPath: "predicted", width: 1_920, height: 1_080, framesPerSecond: 60, @@ -175,6 +177,7 @@ describe("viewer quality evidence", () => { }), ); expect(bounded?.metrics).toMatchObject({ + natTraversalPath: "predicted", width: null, height: null, bitrateKbps: null,