feat(nat): make NAT prediction deployment-configurable (#372)
* Make NAT prediction deployment-configurable * Ignore optional predicted candidate rejection
This commit is contained in:
1 parent
1ed7ffc84b
commit
3583e92938
34 files changed
+579
-304
No files matched your search
+5
-4
@@ -40,14 +40,15 @@ 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=
|
||||
# Optional bounded NAT traversal support. When enabled, Hosts see a per-share
|
||||
# switch that defaults on, and Browser P2P derives self-hosted UDP 3479/3480
|
||||
# from the first STUN_URLS endpoint on UDP 3478.
|
||||
NAT_PREDICTION_ENABLED=false
|
||||
|
||||
# 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
|
||||
# NAT_PREDICTION_ENABLED=true
|
||||
# Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not
|
||||
# replace STUN above):
|
||||
# LIVEKIT_URL=wss://share.example.com
|
||||
|
||||
+2
-2
@@ -45,8 +45,8 @@ ordinary implementation and UI detail.
|
||||
owned media adaptation.
|
||||
- [ADR-0008](./adr/0008-window-scoped-audio-capture.md): Browser/native window
|
||||
audio boundary.
|
||||
- [ADR-0009](./adr/0009-connection-local-nat-prediction-experiment.md): opt-in
|
||||
connection-local NAT candidate experiment.
|
||||
- [ADR-0009](./adr/0009-optional-nat-prediction.md): optional connection-local
|
||||
NAT prediction.
|
||||
|
||||
## Evidence
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# ADR-0009: Connection-Local NAT Candidate Experiment
|
||||
|
||||
- Status: accepted as an opt-in Browser experiment; default off
|
||||
- Date: 2026-09-01
|
||||
|
||||
## Context
|
||||
|
||||
The self-hosted deployment has two auxiliary STUN-only listeners for controlled
|
||||
NAT measurements. A disposable namespace lab showed that a sequential,
|
||||
endpoint-dependent mapping can sometimes be reached when the remote peer
|
||||
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. 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
|
||||
|
||||
1. Host Advanced settings exposes `NAT traversal experiment`, disabled by
|
||||
default and locked while sharing. It is part of the current share's room
|
||||
policy and is broadcast to every authenticated Viewer; it is not persisted
|
||||
beyond that share generation.
|
||||
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. 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 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 outward from both sequence endpoints: four above the high endpoint
|
||||
and four below the low 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. 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
|
||||
|
||||
Positive:
|
||||
|
||||
- operators can test the measured hole-punching mechanism without changing the
|
||||
default media path;
|
||||
- normal ICE remains immediately available, so an unavailable auxiliary STUN
|
||||
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; 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;
|
||||
- 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.
|
||||
|
||||
The controlled lab result (14/15 sequential-to-restricted runs with a bounded
|
||||
prediction set) is mechanism evidence, not a production success guarantee.
|
||||
Target-network prevalence, candidate direction, and long-run resource impact
|
||||
remain acceptance work.
|
||||
|
||||
## Primary Sources
|
||||
|
||||
- [RFC 8445: Interactive Connectivity Establishment](https://www.rfc-editor.org/rfc/rfc8445)
|
||||
- [RFC 4787: UDP NAT behavioral requirements](https://www.rfc-editor.org/rfc/rfc4787)
|
||||
- [RFC 5780: NAT Behavior Discovery Using STUN](https://www.rfc-editor.org/rfc/rfc5780)
|
||||
- [W3C WebRTC candidate model](https://www.w3.org/TR/webrtc/)
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-0009: Optional Connection-Local NAT Prediction
|
||||
|
||||
- Status: accepted as an optional deployment capability
|
||||
- Date: 2026-09-02
|
||||
|
||||
## Context
|
||||
|
||||
The self-hosted deployment has two auxiliary STUN-only listeners for controlled
|
||||
NAT measurements. A disposable namespace lab showed that a sequential,
|
||||
endpoint-dependent mapping can sometimes be reached when the remote peer
|
||||
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 deployment configuration, route policy, and ICE candidate owners
|
||||
are sufficient. The mechanism needs no NAT label, route-controller state, new
|
||||
media path, or third-party STUN dependency.
|
||||
|
||||
## Decision
|
||||
|
||||
1. `NAT_PREDICTION_ENABLED` defaults to `false`. A disabled deployment does not
|
||||
expose the Host control and the server rejects NAT prediction in the room
|
||||
policy. An enabled deployment exposes `NAT traversal`; the pre-share switch
|
||||
defaults on, remains Host-controllable, locks while sharing, and is not
|
||||
persisted beyond that share generation.
|
||||
2. An enabled deployment derives ports 3479 and 3480 from the first ordinary
|
||||
STUN authority on UDP 3478. When the share switch is on, every Browser P2P
|
||||
connection adds those same-host endpoints. This covers Host direct, Viewer
|
||||
upstream, and Viewer relay connections. SFU PeerConnections remain
|
||||
unchanged. No independent or third-party STUN endpoint is added.
|
||||
3. For one ICE gathering generation, the adapter observes only UDP `srflx`
|
||||
candidates whose Browser-reported STUN URL belongs to the self-hosted
|
||||
3478/3479/3480 survey set. 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 outward from both sequence
|
||||
endpoints: four above the high endpoint and four below the low 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 without prediction. Rejection of a
|
||||
predicted remote candidate discards only that optional candidate; ordinary
|
||||
candidate errors retain their normal connection error semantics.
|
||||
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. 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
|
||||
|
||||
Positive:
|
||||
|
||||
- deployments that operate the required STUN listeners can offer the bounded
|
||||
mechanism while smaller deployments retain stock ICE;
|
||||
- normal ICE remains immediately available, so an unavailable auxiliary STUN
|
||||
listener or unsuccessful prediction cannot delay direct or SFU fallback;
|
||||
- prediction is bounded to one connection generation and one small 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;
|
||||
- enabled P2P connections perform two extra self-hosted STUN transactions and
|
||||
may add connectivity checks for up to eight adjacent UDP ports; and
|
||||
- two endpoint-dependent NATs remain sensitive to candidate scheduling and
|
||||
intervening mappings, so room-wide coverage is not a success guarantee.
|
||||
|
||||
Predicted connectivity checks originate from participant Browsers and target
|
||||
the other participant's bounded candidate window. The Screener server only
|
||||
receives Binding requests on its fixed 3478/3479/3480 listeners; enabling this
|
||||
capability does not make the cloud host scan external ports.
|
||||
|
||||
The controlled lab result (14/15 sequential-to-restricted runs with a bounded
|
||||
prediction set) is mechanism evidence, not a production success guarantee.
|
||||
Target-network prevalence, candidate direction, and long-run resource impact
|
||||
remain acceptance work.
|
||||
|
||||
## Primary Sources
|
||||
|
||||
- [RFC 8445: Interactive Connectivity Establishment](https://www.rfc-editor.org/rfc/rfc8445)
|
||||
- [RFC 4787: UDP NAT behavioral requirements](https://www.rfc-editor.org/rfc/rfc4787)
|
||||
- [RFC 5780: NAT Behavior Discovery Using STUN](https://www.rfc-editor.org/rfc/rfc5780)
|
||||
- [W3C WebRTC candidate model](https://www.w3.org/TR/webrtc/)
|
||||
@@ -73,10 +73,10 @@ Coturn runs `stun-only`, `no-tcp`, and `no-tls`. It must answer STUN binding and
|
||||
reject TURN allocation. Avoid verbose STUN/ICE logging because infrastructure
|
||||
necessarily observes client IP addresses.
|
||||
|
||||
## Optional NAT Prediction Experiment
|
||||
## Optional NAT Prediction
|
||||
|
||||
Rooms can use the default-off NAT prediction experiment only while both
|
||||
auxiliary STUN-only listeners are reachable. Add the following to the
|
||||
Set `NAT_PREDICTION_ENABLED=true` only while both auxiliary STUN-only listeners
|
||||
are reachable. Add the following to the
|
||||
service-owned coturn configuration, using the local address already bound by
|
||||
the main listener:
|
||||
|
||||
@@ -91,21 +91,14 @@ the complete ruleset with `nft -c -f /etc/nftables.conf`, then restart only
|
||||
`coturn.service`. Verify `ss -lunp` shows all three UDP listeners and that an
|
||||
ordinary STUN Binding succeeds on each. Do not add these URLs to production
|
||||
`STUN_URLS`; enabled Browser connections derive them from the ordinary STUN
|
||||
authority. Keep both listeners and firewall rules for as long as the room
|
||||
experiment is offered. Disable the experiment before removing both
|
||||
authority. Keep both listeners and firewall rules for as long as the capability
|
||||
is enabled. Set `NAT_PREDICTION_ENABLED=false` before removing both
|
||||
`aux-server` lines and both firewall rules, validate again, and restart coturn.
|
||||
|
||||
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.
|
||||
|
||||
@@ -32,13 +32,14 @@ share generation:
|
||||
- native-edge topology convergence is enabled by default. The Host may disable
|
||||
it before sharing; when disabled, quality evidence stays diagnostic and
|
||||
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. 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.
|
||||
- NAT traversal is absent unless the deployment enables its self-hosted
|
||||
auxiliary STUN capability. When available, its Host switch defaults on and
|
||||
augments every Browser P2P edge in that share; the Host may disable it before
|
||||
sharing. It is not a participant capability, route score, or SFU preference.
|
||||
Candidate generation and selected-pair diagnostics retain only
|
||||
`ordinary | predicted | unknown` provenance.
|
||||
[ADR-0009](../adr/0009-optional-nat-prediction.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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project Memory
|
||||
|
||||
Last updated: 2026-09-01
|
||||
Last updated: 2026-09-02
|
||||
|
||||
Screener is private, low-latency game screen sharing for one Host and up to 20
|
||||
authenticated friends. The current product surface is Web Host, Web Viewer, and
|
||||
@@ -30,11 +30,11 @@ implementation and routine UI detail.
|
||||
lease. Production enables optional SQLite stable room authority; live
|
||||
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; 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
|
||||
direct/peer UDP. A deployment may enable same-host auxiliary STUN for bounded,
|
||||
room-wide, connection-local NAT prediction; its Host switch defaults on when
|
||||
available, and anonymous provenance distinguishes ordinary from predicted
|
||||
selected paths. The configuration default 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
|
||||
|
||||
@@ -20,7 +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. |
|
||||
| `NAT_PREDICTION_ENABLED` | Optional bounded NAT prediction capability, default `false`; requires an ordinary `STUN_URLS` endpoint on UDP 3478 plus reachable same-host UDP 3479/3480 listeners. |
|
||||
|
||||
Automatic SFU fallback is enabled only when `PEER_ASSISTED_MEDIA=true` and all
|
||||
four values below are present:
|
||||
@@ -51,7 +51,7 @@ Peer IDs, SDP, ICE candidates, tokens or media credentials.
|
||||
| ---: | --- | --- |
|
||||
| TCP 80/443 | public | HTTP redirect and HTTPS/WSS reverse proxy |
|
||||
| UDP 3478 | public | STUN-only coturn |
|
||||
| UDP 3479/3480 | public while the optional NAT experiment is offered | auxiliary STUN-only coturn listeners |
|
||||
| UDP 3479/3480 | public when NAT prediction is enabled | auxiliary STUN-only coturn listeners |
|
||||
| UDP 7882 | public when SFU enabled | LiveKit WebRTC media |
|
||||
| TCP 8787 | private | Screener application |
|
||||
| TCP 7880 | private when SFU enabled | LiveKit signaling/control |
|
||||
@@ -60,20 +60,16 @@ TCP 3478, TCP/TLS 5349, TURN relay ranges, LiveKit media TCP, and other media
|
||||
ports remain closed. HTTPS/WSS transport is independent of the UDP-only media
|
||||
contract.
|
||||
|
||||
The default-off NAT experiment derives `stun:<same-hostname>:3479` and `:3480`
|
||||
from the ordinary STUN authority for enabled Browser P2P connections. It does
|
||||
not change `STUN_URLS` or add a media route, and it needs both cloud
|
||||
security-group rules and the host's `/etc/nftables.conf` rule. Coturn must bind
|
||||
the auxiliary listeners to the host's local address with `aux-server`; opening
|
||||
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.
|
||||
When `NAT_PREDICTION_ENABLED=true`, the server derives
|
||||
`stun:<same-hostname>:3479` and `:3480` from the first ordinary STUN authority
|
||||
on UDP 3478. The Host sees a pre-share switch that defaults on and may disable
|
||||
it. The capability adds no media route or third-party service. It needs both
|
||||
cloud security-group rules and the host's `/etc/nftables.conf` rule; coturn must
|
||||
bind both auxiliary listeners with `aux-server`. Opening a cloud port without a
|
||||
listener has no effect, and no new DNS record is required. Disable the
|
||||
capability before removing either listener or firewall rule. Same-IP ports
|
||||
expose destination-port allocation behavior; a full RFC 5780 alternate-address
|
||||
test requires a second public IPv4.
|
||||
|
||||
## Bounds
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Browser NAT Traversal
|
||||
|
||||
Last reviewed: 2026-09-01
|
||||
Last reviewed: 2026-09-02
|
||||
|
||||
This document owns evidence for improving direct Browser ICE without adding a
|
||||
new relay or a custom transport. The current product contract remains standard
|
||||
@@ -56,20 +56,20 @@ supports an opt-in mechanism, not a default or a participant-wide classifier.
|
||||
|
||||
## Current Decision
|
||||
|
||||
The flagship STUN configuration remains unchanged. The self-hosted endpoint is
|
||||
still the ordinary discovery service; ports 3479 and 3480 are optional
|
||||
STUN-only listeners for an explicitly enabled room experiment.
|
||||
The ordinary self-hosted endpoint remains the discovery service. NAT prediction
|
||||
is absent by default; a deployment may enable same-host STUN-only listeners on
|
||||
3479 and 3480. Enabled deployments expose a per-share Host switch that defaults
|
||||
on and remains locked while sharing.
|
||||
|
||||
The accepted experiment is deliberately connection-local and additive. It
|
||||
The accepted adapter is deliberately connection-local and additive. It
|
||||
derives the two auxiliary ports from the existing STUN authority, waits for a
|
||||
clear three-point arithmetic `srflx` shape in one ICE generation, and appends a
|
||||
small two-sided candidate window from the observed port-sequence endpoint.
|
||||
Ordinary candidates trickle immediately, so unavailable auxiliary listeners
|
||||
cannot hold back stock ICE. There is no NAT label, hard candidate skip,
|
||||
route-controller input, or SFU preference. The Host switch is default off,
|
||||
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).
|
||||
route-controller input, or SFU preference. The switch applies to Host, Viewer
|
||||
upstream, and Viewer relay P2P connections. Its exact scope is recorded in
|
||||
[ADR-0009](../adr/0009-optional-nat-prediction.md).
|
||||
|
||||
### Independent Observation And Attribution Preflight
|
||||
|
||||
@@ -77,9 +77,9 @@ 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.
|
||||
STUN `url`, but the destinations supplied no selected-path benefit. They remain
|
||||
research evidence only; the product has no independent or third-party STUN
|
||||
configuration.
|
||||
|
||||
A second real-Chrome loopback rewrote only one remote candidate foundation to
|
||||
`sp1`, established the data channel, and read `sp1` from the selected remote
|
||||
@@ -88,6 +88,21 @@ 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 first flagship field runs emitted six predicted candidates in one 8545
|
||||
generation and selected none. Later 8545 and 5643 generations did not form the
|
||||
required arithmetic survey shape, so no prediction was emitted. This is
|
||||
negative prevalence evidence, not a regression: ordinary ICE and SFU fallback
|
||||
remained independent. The flagship deployment enables the bounded capability
|
||||
to continue attributable field observation without claiming a demonstrated
|
||||
reachability gain.
|
||||
|
||||
After an SFU commit, the current route controller gives each deferred Peer
|
||||
parent one full direct-convergence attempt and then consumes that continuation.
|
||||
Further generations come from a new participant session, publication, or share,
|
||||
not a periodic retry loop. This bounds current field exposure. A fixed repeat
|
||||
budget remains unjustified until attributable runs show that fresh generations
|
||||
repeatedly recover a usable arithmetic shape.
|
||||
|
||||
The Browser-only alternatives do not yet have an accepted implementation:
|
||||
|
||||
- WebRTC ICE already exchanges observed candidates and performs coordinated
|
||||
@@ -120,11 +135,11 @@ but cannot produce the proposed `eim | edm-sequential | edm-random` participant
|
||||
fact. They cannot safely remove a Peer candidate or alter route ordering and
|
||||
deadlines.
|
||||
|
||||
Port prediction remains an experiment rather than a default path. The
|
||||
controlled namespace matrix supports the bounded mechanism, but field
|
||||
prevalence, direction, and resource impact are not established. The experiment
|
||||
therefore stays opt-in and additive until target-network evidence justifies
|
||||
enabling it by default.
|
||||
Port prediction remains additive rather than an assumed path. The controlled
|
||||
namespace matrix supports the bounded mechanism, but field prevalence,
|
||||
direction, and resource impact are not established. Configuration therefore
|
||||
stays off by default even though the flagship deployment explicitly enables its
|
||||
field rollout.
|
||||
|
||||
## Rejected Product Inference
|
||||
|
||||
|
||||
+14
-12
@@ -1,6 +1,6 @@
|
||||
# Current Status
|
||||
|
||||
Last updated: 2026-09-01
|
||||
Last updated: 2026-09-02
|
||||
|
||||
This is the current execution index. Git history owns completed timelines;
|
||||
[verification status](./verification-status.md) owns evidence boundaries.
|
||||
@@ -23,11 +23,12 @@ 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. 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.
|
||||
- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882.
|
||||
Production enables optional NAT prediction with self-hosted 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
|
||||
fallback, plus the locked pre-share `VP8 | Auto | H264` Host selector and
|
||||
resolved codec display. Video retains `contentHint = "motion"`; audio uses
|
||||
@@ -75,12 +76,13 @@ This is the current execution index. Git history owns completed timelines;
|
||||
sender windows retain the bounded Peer operation; failed exact candidates stay
|
||||
consumed until the sender or candidate opportunity changes. The pre-share gate
|
||||
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. 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.
|
||||
- Source keeps NAT prediction deployment-gated and disabled by default. When a
|
||||
deployment enables it, Host Advanced settings shows a per-share switch that
|
||||
defaults on. Every Browser P2P role uses the same bounded, connection-local
|
||||
adapter with only the deployment's self-hosted 3478/3479/3480 STUN endpoints;
|
||||
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
|
||||
|
||||
+12
-9
@@ -1,6 +1,6 @@
|
||||
# Current TODO Ledger
|
||||
|
||||
Last reviewed: 2026-09-01
|
||||
Last reviewed: 2026-09-02
|
||||
|
||||
Only **Now** is executable. Observations, old branches, experiments, and parked
|
||||
topics are not implementation authority.
|
||||
@@ -81,14 +81,17 @@ until their stated boundary is met.
|
||||
`MediaStream` output contract. Reopen only for a registered custom receiver
|
||||
acting as an ordinary Viewer after a named Browser and physical receiver
|
||||
pass the [platform-output gate](./research/platform-output.md).
|
||||
14. **NAT inference and predictive candidates.** The default-off room-wide,
|
||||
connection-local experiment is accepted under
|
||||
[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. 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.
|
||||
14. **NAT inference and predictive candidates.** The deployment-gated,
|
||||
connection-local capability is accepted under
|
||||
[ADR-0009](./adr/0009-optional-nat-prediction.md). Production enables its
|
||||
self-hosted field rollout; the general configuration default stays off.
|
||||
Keep injected candidates additive and diagnostic; do not make them a
|
||||
participant-wide NAT label, hard candidate skip, route score, or SFU
|
||||
preference. Validate selected predicted paths and bounded resource impact
|
||||
before recommending it as a default for other deployments. Direct
|
||||
convergence currently consumes each deferred Peer parent once; add no
|
||||
cross-generation retry budget until field evidence proves that fresh ICE
|
||||
generations expose a repeatable prediction opportunity.
|
||||
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
|
||||
|
||||
@@ -99,7 +99,7 @@ async function main(): Promise<void> {
|
||||
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
|
||||
siteAccessPassword: sitePassword, roomLeaseMs: 86_400_000,
|
||||
maxViewersPerRoom: 2, endpointMediaCopyCapacity: 2,
|
||||
peerAssistedMedia: false, stunUrls: [], natPredictionStunUrls: [],
|
||||
peerAssistedMedia: false, stunUrls: [], natPredictionEnabled: false,
|
||||
};
|
||||
server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") });
|
||||
server.httpServer.prependListener("request", (request, response) => {
|
||||
|
||||
+24
-3
@@ -8,9 +8,11 @@ import {
|
||||
import {
|
||||
ApiError,
|
||||
authenticateSiteAccess,
|
||||
getRuntimeCapabilities,
|
||||
getSiteAccess,
|
||||
type SiteAccessStatus,
|
||||
} from "./lib/api";
|
||||
import type { RuntimeCapabilities } from "../shared/protocol";
|
||||
import { parseAppRoute, readViewerRoute } from "./lib/session";
|
||||
import { AppHeader } from "./components/living/Header";
|
||||
import { BrandLoader } from "./components/living/BrandMark";
|
||||
@@ -157,13 +159,24 @@ function SiteAccessGate({
|
||||
}) {
|
||||
const { t, vis } = useCopy();
|
||||
const [access, setAccess] = useState<AccessState>({ kind: "checking" });
|
||||
const [capabilities, setCapabilities] =
|
||||
useState<RuntimeCapabilities | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getSiteAccess().then(
|
||||
(status) => active && setAccess(stateFromStatus(status)),
|
||||
void Promise.all([
|
||||
getSiteAccess(),
|
||||
surface === "host"
|
||||
? getRuntimeCapabilities()
|
||||
: Promise.resolve<RuntimeCapabilities>({ natPrediction: false }),
|
||||
]).then(
|
||||
([status, nextCapabilities]) => {
|
||||
if (!active) return;
|
||||
setCapabilities(nextCapabilities);
|
||||
setAccess(stateFromStatus(status));
|
||||
},
|
||||
(error: unknown) =>
|
||||
active &&
|
||||
setAccess({ kind: "unavailable", message: readableError(error, t) }),
|
||||
@@ -206,7 +219,14 @@ function SiteAccessGate({
|
||||
async function retry(): Promise<void> {
|
||||
setAccess({ kind: "checking" });
|
||||
try {
|
||||
setAccess(stateFromStatus(await getSiteAccess()));
|
||||
const [status, nextCapabilities] = await Promise.all([
|
||||
getSiteAccess(),
|
||||
surface === "host"
|
||||
? getRuntimeCapabilities()
|
||||
: Promise.resolve<RuntimeCapabilities>({ natPrediction: false }),
|
||||
]);
|
||||
setCapabilities(nextCapabilities);
|
||||
setAccess(stateFromStatus(status));
|
||||
} catch (error) {
|
||||
setAccess({ kind: "unavailable", message: readableError(error, t) });
|
||||
}
|
||||
@@ -248,6 +268,7 @@ function SiteAccessGate({
|
||||
}
|
||||
return (
|
||||
<HostPage
|
||||
natPredictionAvailable={capabilities?.natPrediction === true}
|
||||
onAuthorizationRequired={() =>
|
||||
setAccess({ kind: "required", error: t("gate.expired") })
|
||||
}
|
||||
|
||||
@@ -493,8 +493,8 @@ ${rmBlock(
|
||||
);
|
||||
|
||||
/* hint-nat-prediction: [one direct path] -> [a small bounded fan of
|
||||
alternate direct paths]. The dots stay lightweight so the scene describes
|
||||
extra ICE chances without suggesting a media relay. */
|
||||
alternate direct paths]. The scene describes extra direct chances without
|
||||
suggesting a media relay. */
|
||||
const HintNatPrediction: HintScene = ({ theme }) => (
|
||||
<>
|
||||
<style>{`
|
||||
|
||||
@@ -2,10 +2,12 @@ import { say } from "../ui/copy";
|
||||
import {
|
||||
createRoomResponseSchema,
|
||||
roomAccessUpdateResponseSchema,
|
||||
runtimeCapabilitiesSchema,
|
||||
type CreateRoomResponse,
|
||||
type CodeEntryPolicy,
|
||||
type RoomAccessUpdateRequest,
|
||||
type RoomAccessUpdateResponse,
|
||||
type RuntimeCapabilities,
|
||||
} from "../../shared/protocol";
|
||||
|
||||
export interface SiteAccessStatus {
|
||||
@@ -66,6 +68,24 @@ export async function getSiteAccess(): Promise<SiteAccessStatus> {
|
||||
return parseSiteAccessStatus(body);
|
||||
}
|
||||
|
||||
export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
|
||||
const response = await fetch("/api/capabilities", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
say("gate.serviceUnavailable", { status: String(response.status) }),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const parsed = runtimeCapabilitiesSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(say("host.err.serverError"), 502);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function authenticateSiteAccess(
|
||||
password: string,
|
||||
): Promise<SiteAccessStatus> {
|
||||
|
||||
@@ -157,9 +157,9 @@ export const en: Record<CopyKey, string> = {
|
||||
"host.advanced.route.peerOnly": "Privacy mode",
|
||||
"host.advanced.route.peerOnlyHint":
|
||||
"Use P2P routes only; viewers without a P2P path cannot watch",
|
||||
"host.advanced.route.natPrediction": "NAT traversal experiment",
|
||||
"host.advanced.route.natPrediction": "NAT traversal",
|
||||
"host.advanced.route.natPredictionHint":
|
||||
"Try extra self-hosted STUN candidates for direct links; ordinary ICE remains available",
|
||||
"Use self-hosted STUN for a bounded set of extra direct attempts; ordinary ICE is unchanged",
|
||||
"host.advanced.codec": "Video codec",
|
||||
"host.advanced.codec.auto": "Auto",
|
||||
"host.advanced.codec.autoHint": "Pick automatically",
|
||||
|
||||
@@ -155,9 +155,9 @@ export const zh = {
|
||||
"host.advanced.route.peerOnly": "隐私模式",
|
||||
"host.advanced.route.peerOnlyHint":
|
||||
"仅使用 P2P 路线,不经过媒体服务器;无可用 P2P 路线时无法观看",
|
||||
"host.advanced.route.natPrediction": "NAT 打洞实验",
|
||||
"host.advanced.route.natPrediction": "NAT 穿透",
|
||||
"host.advanced.route.natPredictionHint":
|
||||
"为直连尝试额外的自托管 STUN 候选;失败时仍使用普通连接",
|
||||
"使用自建 STUN 增加有限的直连尝试;普通连接不受影响",
|
||||
"host.advanced.codec": "视频编码",
|
||||
"host.advanced.codec.auto": "自动",
|
||||
"host.advanced.codec.autoHint": "自动选择",
|
||||
|
||||
@@ -370,10 +370,14 @@ function hostTerminationKey(reason: SignalingTerminationReason): CopyKey {
|
||||
}
|
||||
|
||||
interface HostPageProps {
|
||||
natPredictionAvailable?: boolean;
|
||||
onAuthorizationRequired?: () => void;
|
||||
}
|
||||
|
||||
export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
export function HostPage({
|
||||
natPredictionAvailable = false,
|
||||
onAuthorizationRequired,
|
||||
}: HostPageProps = {}) {
|
||||
const { lang, vis, t, titleFrames } = useCopy();
|
||||
const [qualitySettings, setQualitySettings] = useState<QualitySettings>(
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
@@ -382,7 +386,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
DEFAULT_QUALITY_SETTINGS,
|
||||
);
|
||||
const [routePolicy, setRoutePolicy] = useState<RoutePolicy>(
|
||||
DEFAULT_ROUTE_POLICY,
|
||||
() => ({
|
||||
...DEFAULT_ROUTE_POLICY,
|
||||
natPrediction: natPredictionAvailable,
|
||||
}),
|
||||
);
|
||||
const [videoCodecMode, setVideoCodecMode] =
|
||||
useState<BrowserVideoCodecMode>("auto");
|
||||
@@ -513,7 +520,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
const qualityChangeRef = useRef<object | null>(null);
|
||||
const pendingQualityChangeRef = useRef<QualitySettings | null>(null);
|
||||
const qualitySettingsRef = useRef<QualitySettings>(DEFAULT_QUALITY_SETTINGS);
|
||||
const routePolicyRef = useRef<RoutePolicy>(DEFAULT_ROUTE_POLICY);
|
||||
const routePolicyRef = useRef<RoutePolicy>(routePolicy);
|
||||
const advancedQualityRef = useRef<QualitySettings>(advancedQuality);
|
||||
const videoCodecModeRef = useRef<BrowserVideoCodecMode>(videoCodecMode);
|
||||
const roomMutationRef = useRef<object | null>(null);
|
||||
@@ -3813,6 +3820,18 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
note={t("host.advanced.route.topoHint")}
|
||||
hint="hint-topology"
|
||||
/>
|
||||
{natPredictionAvailable ? (
|
||||
<SwitchItem
|
||||
checked={routePolicy.natPrediction}
|
||||
disabled={phase === "starting" || phase === "live"}
|
||||
onChange={(checked) =>
|
||||
changeRoutePolicy({ natPrediction: checked })
|
||||
}
|
||||
label={t("host.advanced.route.natPrediction")}
|
||||
note={t("host.advanced.route.natPredictionHint")}
|
||||
hint="hint-nat-prediction"
|
||||
/>
|
||||
) : null}
|
||||
<SwitchItem
|
||||
checked={routePolicy.peerOnly}
|
||||
disabled={phase === "starting" || phase === "live"}
|
||||
@@ -3823,16 +3842,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
|
||||
note={t("host.advanced.route.peerOnlyHint")}
|
||||
hint="hint-route-p2p"
|
||||
/>
|
||||
<SwitchItem
|
||||
checked={routePolicy.natPrediction}
|
||||
disabled={phase === "starting" || phase === "live"}
|
||||
onChange={(checked) =>
|
||||
changeRoutePolicy({ natPrediction: checked })
|
||||
}
|
||||
label={t("host.advanced.route.natPrediction")}
|
||||
note={t("host.advanced.route.natPredictionHint")}
|
||||
hint="hint-nat-prediction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lr-door-group">
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
VP8_ONLY_VIDEO_CODEC,
|
||||
} from "./video-codec";
|
||||
import {
|
||||
addRemoteIceCandidate,
|
||||
iceServersWithNatPrediction,
|
||||
natPredictionSurveyUrls,
|
||||
NatPredictionCandidateEmitter,
|
||||
@@ -122,6 +123,7 @@ export class HostPeer {
|
||||
(candidate) => this.sendIceCandidate(candidate),
|
||||
natPredictionSurveyUrls(
|
||||
iceConfig.iceServers,
|
||||
iceConfig.natPredictionStunUrls,
|
||||
),
|
||||
);
|
||||
this.snapshot = {
|
||||
@@ -383,7 +385,7 @@ export class HostPeer {
|
||||
}
|
||||
await this.enqueueNegotiation(() => this.acceptAnswer(payload));
|
||||
} else if (this.connection.remoteDescription) {
|
||||
await this.connection.addIceCandidate(payload.candidate);
|
||||
await addRemoteIceCandidate(this.connection, payload.candidate);
|
||||
} else if (this.pendingCandidates.length < MAX_PENDING_CANDIDATES) {
|
||||
this.pendingCandidates.push(payload.candidate);
|
||||
}
|
||||
@@ -427,6 +429,7 @@ export class HostPeer {
|
||||
this.localIceCandidates.setSurveyUrls(
|
||||
natPredictionSurveyUrls(
|
||||
iceConfig.iceServers,
|
||||
iceConfig.natPredictionStunUrls,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -571,7 +574,7 @@ export class HostPeer {
|
||||
private async flushCandidates(): Promise<void> {
|
||||
const candidates = this.pendingCandidates.splice(0);
|
||||
for (const candidate of candidates) {
|
||||
await this.connection.addIceCandidate(candidate);
|
||||
await addRemoteIceCandidate(this.connection, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
MAX_NAT_PREDICTION_STUN_URLS,
|
||||
type SignalPayload,
|
||||
} from "../../shared/protocol";
|
||||
import type { SignalPayload } from "../../shared/protocol";
|
||||
import { candidateSignalOrigin } from "../../shared/nat-candidate";
|
||||
|
||||
export type SignalCandidate = Extract<
|
||||
SignalPayload,
|
||||
@@ -18,6 +16,21 @@ const MAX_PREDICTABLE_PORT = 65_535;
|
||||
export const NAT_PREDICTION_STEPS = 4;
|
||||
export const MAX_NAT_PREDICTION_CANDIDATES = NAT_PREDICTION_STEPS * 2;
|
||||
|
||||
export async function addRemoteIceCandidate(
|
||||
connection: RTCPeerConnection,
|
||||
candidate: SignalCandidate,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await connection.addIceCandidate(candidate);
|
||||
} catch (error) {
|
||||
if (
|
||||
candidateSignalOrigin(candidate?.candidate ?? null) !== "predicted"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SrflxObservation {
|
||||
candidate: SignalCandidate;
|
||||
fields: string[];
|
||||
@@ -104,19 +117,30 @@ function normalizedStunUrl(url: string): string | null {
|
||||
/** URLs whose same-socket observations may form the controlled port sequence. */
|
||||
export function natPredictionSurveyUrls(
|
||||
iceServers: readonly RTCIceServer[] | undefined,
|
||||
auxiliaryStunUrls: readonly string[] = [],
|
||||
): ReadonlySet<string> {
|
||||
const configuredAuxiliaryUrls = new Set(
|
||||
auxiliaryStunUrls.flatMap((url) => {
|
||||
const normalized = normalizedStunUrl(url);
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
);
|
||||
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] : [];
|
||||
}),
|
||||
]);
|
||||
const normalizedAuxiliaryUrls = auxiliaryUrls.flatMap((entry) => {
|
||||
const normalized = normalizedStunUrl(entry);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
if (
|
||||
baseUrl &&
|
||||
normalizedAuxiliaryUrls.length === AUXILIARY_STUN_PORTS.length &&
|
||||
normalizedAuxiliaryUrls.every((entry) =>
|
||||
configuredAuxiliaryUrls.has(entry),
|
||||
)
|
||||
) {
|
||||
return new Set([baseUrl, ...normalizedAuxiliaryUrls]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +154,7 @@ export function natPredictionSurveyUrls(
|
||||
export function iceServersWithNatPrediction(
|
||||
iceServers: readonly RTCIceServer[] | undefined,
|
||||
enabled: boolean,
|
||||
externalStunUrls: readonly string[] = [],
|
||||
auxiliaryStunUrls: readonly string[] = [],
|
||||
): RTCIceServer[] {
|
||||
const base = (iceServers ?? []).map(cloneIceServer);
|
||||
if (!enabled) {
|
||||
@@ -143,25 +167,7 @@ export function iceServersWithNatPrediction(
|
||||
.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)) {
|
||||
const normalized = normalizedStunUrl(auxiliaryUrl) ?? auxiliaryUrl;
|
||||
if (!existing.has(normalized)) {
|
||||
additions.add(auxiliaryUrl);
|
||||
existing.add(normalized);
|
||||
}
|
||||
}
|
||||
if (additions.size >= AUXILIARY_STUN_PORTS.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (additions.size >= AUXILIARY_STUN_PORTS.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of externalStunUrls.slice(0, MAX_NAT_PREDICTION_STUN_URLS)) {
|
||||
for (const url of auxiliaryStunUrls) {
|
||||
const normalized = normalizedStunUrl(url);
|
||||
if (normalized && !existing.has(normalized)) {
|
||||
additions.add(url);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { preferScreenAudioStereo } from "./screen-audio-sdp";
|
||||
import { observeDecodedFrameProof } from "../media/decoded-frame-proof";
|
||||
import {
|
||||
addRemoteIceCandidate,
|
||||
iceServersWithNatPrediction,
|
||||
natPredictionSurveyUrls,
|
||||
NatPredictionCandidateEmitter,
|
||||
@@ -133,6 +134,7 @@ export class ViewerPeer {
|
||||
this.localIceCandidates?.setSurveyUrls(
|
||||
natPredictionSurveyUrls(
|
||||
iceConfig.iceServers,
|
||||
iceConfig.natPredictionStunUrls,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -251,6 +253,7 @@ export class ViewerPeer {
|
||||
},
|
||||
natPredictionSurveyUrls(
|
||||
this.currentIceConfig.iceServers,
|
||||
this.currentIceConfig.natPredictionStunUrls,
|
||||
),
|
||||
);
|
||||
this.connection = connection;
|
||||
@@ -402,7 +405,7 @@ export class ViewerPeer {
|
||||
const connection = this.connection;
|
||||
const connectionId = payload.connectionId;
|
||||
try {
|
||||
await connection.addIceCandidate(payload.candidate);
|
||||
await addRemoteIceCandidate(connection, payload.candidate);
|
||||
} catch (error) {
|
||||
if (this.isCurrentConnection(connection, connectionId)) {
|
||||
this.setError(error, say("host.fail.connection"));
|
||||
@@ -569,7 +572,7 @@ export class ViewerPeer {
|
||||
const candidates = this.pendingByConnection.get(connectionId) ?? [];
|
||||
this.pendingByConnection.delete(connectionId);
|
||||
for (const candidate of candidates) {
|
||||
await connection.addIceCandidate(candidate);
|
||||
await addRemoteIceCandidate(connection, candidate);
|
||||
if (!this.isCurrentConnection(connection, connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
+15
-1
@@ -105,7 +105,7 @@ export async function createScreenerServer(
|
||||
});
|
||||
const iceOptions = {
|
||||
stunUrls: config.stunUrls,
|
||||
natPredictionStunUrls: config.natPredictionStunUrls,
|
||||
natPredictionEnabled: config.natPredictionEnabled,
|
||||
};
|
||||
|
||||
let frontendHandler: FrontendHandler | undefined;
|
||||
@@ -341,6 +341,20 @@ async function handleRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/capabilities") {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (request.method !== "GET") {
|
||||
response.setHeader("Allow", "GET");
|
||||
sendJson(response, 405, { error: "Method not allowed" });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
natPrediction: config.natPredictionEnabled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/site-access") {
|
||||
handleSiteAccessRequest(request, response, config, siteAccess);
|
||||
return;
|
||||
|
||||
+15
-10
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
DEFAULT_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
MAX_ENDPOINT_MEDIA_COPY_CAPACITY,
|
||||
} from "../shared/media-copy-accounting.js";
|
||||
import { natPredictionStunUrls } from "./ice.js";
|
||||
|
||||
export type RuntimeEnvironment = "development" | "test" | "production";
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface ServerConfig {
|
||||
endpointMediaCopyCapacity: number;
|
||||
livekitFallback?: LiveKitFallbackConfig;
|
||||
stunUrls: readonly string[];
|
||||
natPredictionStunUrls: readonly string[];
|
||||
natPredictionEnabled: boolean;
|
||||
}
|
||||
|
||||
function parseBoolean(
|
||||
@@ -323,17 +323,22 @@ export function loadConfig(
|
||||
environment.ROOM_DATABASE_PATH,
|
||||
);
|
||||
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,
|
||||
const natPredictionEnabled = parseBoolean(
|
||||
environment.NAT_PREDICTION_ENABLED,
|
||||
false,
|
||||
"NAT_PREDICTION_ENABLED",
|
||||
);
|
||||
if (
|
||||
stunUrls.length + natPredictionStunUrls.length >
|
||||
MAX_ICE_SERVER_URLS
|
||||
natPredictionEnabled &&
|
||||
natPredictionStunUrls(stunUrls).length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
`STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most ${MAX_ICE_SERVER_URLS} URLs together`,
|
||||
"NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478",
|
||||
);
|
||||
}
|
||||
if (natPredictionEnabled && stunUrls.length + 2 > MAX_ICE_SERVER_URLS) {
|
||||
throw new Error(
|
||||
`STUN_URLS must contain at most ${MAX_ICE_SERVER_URLS - 2} URLs when NAT_PREDICTION_ENABLED=true`,
|
||||
);
|
||||
}
|
||||
const maxViewersPerRoom = parseBoundedInteger(
|
||||
@@ -410,6 +415,6 @@ export function loadConfig(
|
||||
endpointMediaCopyCapacity,
|
||||
livekitFallback,
|
||||
stunUrls,
|
||||
natPredictionStunUrls,
|
||||
natPredictionEnabled,
|
||||
};
|
||||
}
|
||||
+36
-2
@@ -2,13 +2,47 @@ import type { WireIceConfig } from "../shared/protocol.js";
|
||||
|
||||
export interface IceConfigOptions {
|
||||
stunUrls: readonly string[];
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
natPredictionEnabled: boolean;
|
||||
}
|
||||
|
||||
const NAT_PREDICTION_BASE_PORT = 3478;
|
||||
const NAT_PREDICTION_AUXILIARY_PORTS = [3479, 3480] as const;
|
||||
|
||||
export function natPredictionStunUrls(
|
||||
stunUrls: readonly string[],
|
||||
): string[] {
|
||||
for (const stunUrl of stunUrls) {
|
||||
const authorityText = stunUrl.slice(stunUrl.indexOf(":") + 1);
|
||||
let authority: URL;
|
||||
try {
|
||||
authority = new URL(`http://${authorityText}`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const port = authority.port
|
||||
? Number(authority.port)
|
||||
: NAT_PREDICTION_BASE_PORT;
|
||||
if (port !== NAT_PREDICTION_BASE_PORT) {
|
||||
continue;
|
||||
}
|
||||
const hostname = authority.hostname.startsWith("[")
|
||||
? authority.hostname
|
||||
: authority.hostname.includes(":")
|
||||
? `[${authority.hostname}]`
|
||||
: authority.hostname;
|
||||
return NAT_PREDICTION_AUXILIARY_PORTS.map(
|
||||
(auxiliaryPort) => `stun:${hostname}:${auxiliaryPort}`,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function createIceConfig(options: IceConfigOptions): WireIceConfig {
|
||||
return {
|
||||
iceServers:
|
||||
options.stunUrls.length > 0 ? [{ urls: [...options.stunUrls] }] : [],
|
||||
natPredictionStunUrls: [...(options.natPredictionStunUrls ?? [])],
|
||||
natPredictionStunUrls: options.natPredictionEnabled
|
||||
? natPredictionStunUrls(options.stunUrls)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
+14
-3
@@ -83,6 +83,16 @@ interface SenderQualityRateWindow {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function configuredRoutePolicy(
|
||||
policy: RoutePolicy,
|
||||
natPredictionEnabled: boolean,
|
||||
): RoutePolicy {
|
||||
return {
|
||||
...policy,
|
||||
natPrediction: natPredictionEnabled && policy.natPrediction,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SignalingOptions {
|
||||
server: HttpServer;
|
||||
roomStore: RoomStore;
|
||||
@@ -583,9 +593,10 @@ export class SignalingServer {
|
||||
currentGeneration !== shareGeneration ||
|
||||
!this.routePolicyByRoom.has(participant.roomId)
|
||||
) {
|
||||
const routePolicy = {
|
||||
...(message.routePolicy ?? DEFAULT_ROUTE_POLICY),
|
||||
};
|
||||
const routePolicy = configuredRoutePolicy(
|
||||
message.routePolicy ?? DEFAULT_ROUTE_POLICY,
|
||||
this.options.ice.natPredictionEnabled,
|
||||
);
|
||||
this.routePolicyByRoom.set(participant.roomId, routePolicy);
|
||||
for (const viewer of this.options.roomStore.getConnectedViewers(
|
||||
participant.roomId,
|
||||
|
||||
@@ -19,7 +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_NAT_PREDICTION_AUXILIARY_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;
|
||||
@@ -230,6 +230,13 @@ export const DEFAULT_ROUTE_POLICY = {
|
||||
natPrediction: false,
|
||||
} as const satisfies RoutePolicy;
|
||||
|
||||
export const runtimeCapabilitiesSchema = z
|
||||
.object({
|
||||
natPrediction: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
export type RuntimeCapabilities = z.infer<typeof runtimeCapabilitiesSchema>;
|
||||
|
||||
export const relayDownstreamEdgesSchema = z
|
||||
.number()
|
||||
.int()
|
||||
@@ -292,7 +299,7 @@ export const iceConfigSchema = z
|
||||
iceServers: z.array(iceServerSchema).max(8),
|
||||
natPredictionStunUrls: z
|
||||
.array(stunUrlSchema)
|
||||
.max(MAX_NAT_PREDICTION_STUN_URLS),
|
||||
.max(MAX_NAT_PREDICTION_AUXILIARY_STUN_URLS),
|
||||
})
|
||||
.strict();
|
||||
export type WireIceConfig = z.infer<typeof iceConfigSchema>;
|
||||
|
||||
@@ -21,6 +21,10 @@ describe("copy catalog", () => {
|
||||
expect(t("en", "join.title")).toBe("Join a room");
|
||||
expect(t("zh", "host.advanced.route.peerOnly")).toBe("隐私模式");
|
||||
expect(t("en", "host.advanced.route.peerOnly")).toBe("Privacy mode");
|
||||
expect(t("zh", "host.advanced.route.natPrediction")).toBe("NAT 穿透");
|
||||
expect(t("en", "host.advanced.route.natPrediction")).toBe(
|
||||
"NAT traversal",
|
||||
);
|
||||
expect(isCopyKey("viewer.reconnect")).toBe(true);
|
||||
expect(isCopyKey("Reconnect media")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -728,7 +728,10 @@ describe("HostPeer source replacement", () => {
|
||||
"viewer-peer",
|
||||
{
|
||||
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
natPredictionStunUrls: [
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
],
|
||||
},
|
||||
createStream(createTrack("video", "video"), null),
|
||||
QUALITY_PROFILES["720p30"],
|
||||
@@ -748,7 +751,6 @@ 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 surveyUrls = [
|
||||
@@ -774,7 +776,6 @@ describe("HostPeer source replacement", () => {
|
||||
});
|
||||
connection.dispatchEvent(event);
|
||||
};
|
||||
emitCandidate(50_000, "stun:observer.example.test:3478");
|
||||
emitCandidate(40_000, surveyUrls[0]);
|
||||
emitCandidate(40_003, surveyUrls[1]);
|
||||
emitCandidate(40_006, surveyUrls[2]);
|
||||
@@ -799,7 +800,7 @@ describe("HostPeer source replacement", () => {
|
||||
candidateSignals.filter((signal) =>
|
||||
signal.candidate?.candidate.startsWith("candidate:base"),
|
||||
),
|
||||
).toHaveLength(4);
|
||||
).toHaveLength(3);
|
||||
expect(candidateSignals.at(-1)?.candidate).toBeNull();
|
||||
expect(candidateSignals.filter((signal) => signal.candidate === null)).toHaveLength(1);
|
||||
peer.dispose();
|
||||
@@ -1840,7 +1841,13 @@ describe("Host provisional child runtime ownership", () => {
|
||||
describe("ViewerRelay downstream ownership", () => {
|
||||
it("applies the room NAT policy to downstream Peer connections", async () => {
|
||||
const relay = new ViewerRelay(
|
||||
{ iceServers: [{ urls: "stun:share.example.test:3478" }] },
|
||||
{
|
||||
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
||||
natPredictionStunUrls: [
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
],
|
||||
},
|
||||
QUALITY_PROFILES["720p30"],
|
||||
{ sendSignal: () => true },
|
||||
2,
|
||||
|
||||
+30
-3
@@ -10,7 +10,7 @@ describe("ordinary ICE configuration", () => {
|
||||
"stun:stun-a.example.test:3478",
|
||||
"stun:stun-b.example.test:3478",
|
||||
],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
natPredictionEnabled: true,
|
||||
}),
|
||||
).toEqual({
|
||||
iceServers: [
|
||||
@@ -21,13 +21,40 @@ describe("ordinary ICE configuration", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
natPredictionStunUrls: [
|
||||
"stun:stun-a.example.test:3479",
|
||||
"stun:stun-a.example.test:3480",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local development explicit when STUN is absent", () => {
|
||||
expect(
|
||||
createIceConfig({ stunUrls: [], natPredictionStunUrls: [] }),
|
||||
createIceConfig({ stunUrls: [], natPredictionEnabled: false }),
|
||||
).toEqual({ iceServers: [], natPredictionStunUrls: [] });
|
||||
});
|
||||
|
||||
it("does not expose auxiliary listeners when the capability is disabled", () => {
|
||||
expect(
|
||||
createIceConfig({
|
||||
stunUrls: ["stun:share.example.test:3478"],
|
||||
natPredictionEnabled: false,
|
||||
}),
|
||||
).toEqual({
|
||||
iceServers: [{ urls: ["stun:share.example.test:3478"] }],
|
||||
natPredictionStunUrls: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("derives auxiliary listeners from an IPv6 STUN authority", () => {
|
||||
expect(
|
||||
createIceConfig({
|
||||
stunUrls: ["STUN:[2001:db8::1]:3478"],
|
||||
natPredictionEnabled: true,
|
||||
}).natPredictionStunUrls,
|
||||
).toEqual([
|
||||
"stun:[2001:db8::1]:3479",
|
||||
"stun:[2001:db8::1]:3480",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
addRemoteIceCandidate,
|
||||
iceServersWithNatPrediction,
|
||||
MAX_NAT_PREDICTION_CANDIDATES,
|
||||
NatPredictionCandidateBatch,
|
||||
@@ -42,6 +43,30 @@ function rtcCandidate(port: number): RTCIceCandidate {
|
||||
}
|
||||
|
||||
describe("NAT prediction ICE adapter", () => {
|
||||
it("ignores only rejected predicted candidates", async () => {
|
||||
const error = new Error("candidate rejected");
|
||||
const connection = {
|
||||
addIceCandidate: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
} as unknown as RTCPeerConnection;
|
||||
const predicted = candidate(40_000);
|
||||
if (predicted) {
|
||||
predicted.candidate = predicted.candidate.replace(
|
||||
"candidate:base",
|
||||
"candidate:sp1",
|
||||
);
|
||||
}
|
||||
|
||||
await expect(
|
||||
addRemoteIceCandidate(connection, predicted),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
addRemoteIceCandidate(connection, candidate(40_000)),
|
||||
).rejects.toBe(error);
|
||||
await expect(addRemoteIceCandidate(connection, null)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it("leaves the configured servers unchanged when disabled", () => {
|
||||
const servers = [
|
||||
{ urls: ["stun:share.example.test:3478"] },
|
||||
@@ -52,26 +77,30 @@ describe("NAT prediction ICE adapter", () => {
|
||||
iceServersWithNatPrediction(
|
||||
servers,
|
||||
false,
|
||||
["stun:observer.example.test:3478"],
|
||||
[
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
],
|
||||
),
|
||||
).toEqual(servers);
|
||||
});
|
||||
|
||||
it("adds independent observers only inside the enabled experiment", () => {
|
||||
it("adds only configured self-hosted auxiliary listeners", () => {
|
||||
const servers = [{ urls: "stun:share.example.test:3478" }];
|
||||
const auxiliary = [
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
];
|
||||
|
||||
expect(
|
||||
iceServersWithNatPrediction(servers, true, [
|
||||
"stun:observer.example.test:3478",
|
||||
]),
|
||||
iceServersWithNatPrediction(servers, true, auxiliary),
|
||||
).toEqual([
|
||||
...servers,
|
||||
{ urls: "stun:share.example.test:3479" },
|
||||
{ urls: "stun:share.example.test:3480" },
|
||||
{ urls: "stun:observer.example.test:3478" },
|
||||
]);
|
||||
expect(
|
||||
natPredictionSurveyUrls(servers),
|
||||
natPredictionSurveyUrls(servers, auxiliary),
|
||||
).toEqual(
|
||||
new Set([
|
||||
"stun:share.example.test:3478",
|
||||
@@ -82,21 +111,18 @@ describe("NAT prediction ICE adapter", () => {
|
||||
expect(
|
||||
natPredictionSurveyUrls([
|
||||
{ urls: "stun:share.example.test:5349" },
|
||||
]),
|
||||
], auxiliary),
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("derives only the two optional same-host survey listeners", () => {
|
||||
it("does not invent auxiliary listeners that the server did not enable", () => {
|
||||
const servers = [
|
||||
{ urls: ["stun:share.example.test:3478", "stun:share.example.test:3478"] },
|
||||
{ urls: "stun:other.example.test:3478" },
|
||||
];
|
||||
|
||||
expect(iceServersWithNatPrediction(servers, true)).toEqual([
|
||||
...servers,
|
||||
{ urls: "stun:share.example.test:3479" },
|
||||
{ urls: "stun:share.example.test:3480" },
|
||||
]);
|
||||
expect(iceServersWithNatPrediction(servers, true)).toEqual(servers);
|
||||
expect(natPredictionSurveyUrls(servers)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("does not duplicate already configured auxiliary listeners", () => {
|
||||
@@ -105,7 +131,10 @@ describe("NAT prediction ICE adapter", () => {
|
||||
{ urls: "stun:share.example.test:3479" },
|
||||
];
|
||||
|
||||
expect(iceServersWithNatPrediction(servers, true)).toEqual([
|
||||
expect(iceServersWithNatPrediction(servers, true, [
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
])).toEqual([
|
||||
...servers,
|
||||
{ urls: "stun:share.example.test:3480" },
|
||||
]);
|
||||
@@ -214,7 +243,7 @@ describe("NAT prediction ICE adapter", () => {
|
||||
expect(sent).toEqual([first]);
|
||||
});
|
||||
|
||||
it("retains independent observations without using them as sequence evidence", () => {
|
||||
it("forwards an ineligible srflx candidate without using it as sequence evidence", () => {
|
||||
const sent: Array<SignalCandidate | null> = [];
|
||||
const batch = new NatPredictionCandidateBatch((value) => sent.push(value));
|
||||
const external = candidate(50_000);
|
||||
|
||||
+21
-21
@@ -18,7 +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.natPredictionEnabled).toBe(false);
|
||||
expect(config.maxViewersPerRoom).toBe(8);
|
||||
expect(config.peerAssistedMedia).toBe(false);
|
||||
expect(config.endpointMediaCopyCapacity).toBe(2);
|
||||
@@ -327,28 +327,29 @@ describe("server configuration", () => {
|
||||
).toEqual(["STUN:[2001:db8::1]:3478"]);
|
||||
});
|
||||
|
||||
it("accepts a bounded independent NAT prediction STUN list", () => {
|
||||
it("enables bounded NAT prediction only with a base STUN listener", () => {
|
||||
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", () => {
|
||||
STUN_URLS: "stun:share.test:3478",
|
||||
NAT_PREDICTION_ENABLED: "true",
|
||||
}).natPredictionEnabled,
|
||||
).toBe(true);
|
||||
expect(() =>
|
||||
loadConfig({ NAT_PREDICTION_STUN_URLS: "turn:observer.test:3478" }),
|
||||
).toThrow("NAT_PREDICTION_STUN_URLS contains an invalid STUN URL");
|
||||
loadConfig({ NAT_PREDICTION_ENABLED: "true" }),
|
||||
).toThrow(
|
||||
"NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478",
|
||||
);
|
||||
expect(() =>
|
||||
loadConfig({
|
||||
NAT_PREDICTION_STUN_URLS:
|
||||
"stun:a.test:3478,stun:b.test:3478,stun:c.test:3478",
|
||||
STUN_URLS: "stun:share.test:5349",
|
||||
NAT_PREDICTION_ENABLED: "true",
|
||||
}),
|
||||
).toThrow("NAT_PREDICTION_STUN_URLS must contain at most 2 URLs");
|
||||
).toThrow(
|
||||
"NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478",
|
||||
);
|
||||
expect(() =>
|
||||
loadConfig({ NAT_PREDICTION_ENABLED: "sometimes" }),
|
||||
).toThrow("NAT_PREDICTION_ENABLED must be true or false");
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -402,18 +403,17 @@ describe("server configuration", () => {
|
||||
).toThrow("STUN_URLS must contain at most 8 URLs");
|
||||
});
|
||||
|
||||
it("bounds ordinary and NAT prediction STUN URLs together", () => {
|
||||
it("reserves two ICE URL slots for configured NAT prediction", () => {
|
||||
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",
|
||||
NAT_PREDICTION_ENABLED: "true",
|
||||
}),
|
||||
).toThrow(
|
||||
"STUN_URLS and NAT_PREDICTION_STUN_URLS must contain at most 8 URLs together",
|
||||
"STUN_URLS must contain at most 6 URLs when NAT_PREDICTION_ENABLED=true",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ function testConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
|
||||
peerAssistedMedia: false,
|
||||
endpointMediaCopyCapacity: 2,
|
||||
stunUrls: [],
|
||||
natPredictionStunUrls: [],
|
||||
natPredictionEnabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -167,6 +167,33 @@ async function replaceRoom(
|
||||
});
|
||||
}
|
||||
|
||||
describe("runtime capabilities", () => {
|
||||
it("keeps NAT prediction absent by default", async () => {
|
||||
const baseUrl = await start();
|
||||
const response = await fetch(`${baseUrl}/api/capabilities`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ natPrediction: false });
|
||||
});
|
||||
|
||||
it("reports optional NAT prediction without exposing configuration", async () => {
|
||||
const baseUrl = await start(
|
||||
testConfig({
|
||||
stunUrls: ["stun:share.example.test:3478"],
|
||||
natPredictionEnabled: true,
|
||||
}),
|
||||
);
|
||||
const response = await fetch(`${baseUrl}/api/capabilities`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ natPrediction: true });
|
||||
expect(
|
||||
(await fetch(`${baseUrl}/api/capabilities`, { method: "POST" })).status,
|
||||
).toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe("site access", () => {
|
||||
it("reports status and issues a stateless 24-hour cookie", async () => {
|
||||
const baseUrl = await start();
|
||||
|
||||
+42
-10
@@ -137,7 +137,7 @@ function testConfig(): ServerConfig {
|
||||
peerAssistedMedia: false,
|
||||
endpointMediaCopyCapacity: 2,
|
||||
stunUrls: [],
|
||||
natPredictionStunUrls: [],
|
||||
natPredictionEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ async function startHarness(
|
||||
peerAssistedMedia?: boolean;
|
||||
endpointMediaCopyCapacity?: number;
|
||||
stunUrls?: readonly string[];
|
||||
natPredictionStunUrls?: readonly string[];
|
||||
natPredictionEnabled?: boolean;
|
||||
now?: () => number;
|
||||
roomStore?: RoomStore;
|
||||
room?: CreatedRoom;
|
||||
@@ -164,7 +164,7 @@ async function startHarness(
|
||||
config.endpointMediaCopyCapacity =
|
||||
overrides.endpointMediaCopyCapacity ?? 2;
|
||||
config.stunUrls = overrides.stunUrls ?? [];
|
||||
config.natPredictionStunUrls = overrides.natPredictionStunUrls ?? [];
|
||||
config.natPredictionEnabled = overrides.natPredictionEnabled ?? false;
|
||||
const maxViewersPerRoom = overrides.maxViewersPerRoom ?? 8;
|
||||
config.maxViewersPerRoom = maxViewersPerRoom;
|
||||
const roomStore =
|
||||
@@ -466,7 +466,11 @@ async function closeClient(client: TestClient): Promise<void> {
|
||||
|
||||
describe("WebSocket signaling", () => {
|
||||
it("echoes the exact per-share route policy authority", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: true });
|
||||
const harness = await startHarness({
|
||||
peerAssistedMedia: true,
|
||||
stunUrls: ["stun:share.example.test:3478"],
|
||||
natPredictionEnabled: true,
|
||||
});
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const waiting = peerAssisted(
|
||||
await authenticate(
|
||||
@@ -504,8 +508,12 @@ describe("WebSocket signaling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("broadcasts the NAT experiment policy in lightweight rooms", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: false });
|
||||
it("broadcasts configured NAT prediction in lightweight rooms", async () => {
|
||||
const harness = await startHarness({
|
||||
peerAssistedMedia: false,
|
||||
stunUrls: ["stun:share.example.test:3478"],
|
||||
natPredictionEnabled: true,
|
||||
});
|
||||
const waitingViewer = await openClient(harness.webSocketUrl);
|
||||
const waiting = await authenticate(
|
||||
waitingViewer,
|
||||
@@ -546,23 +554,47 @@ describe("WebSocket signaling", () => {
|
||||
expect(joined.routePolicy).toEqual(routePolicy);
|
||||
});
|
||||
|
||||
it("delivers independent NAT experiment STUN without changing ordinary ICE", async () => {
|
||||
it("clamps NAT prediction off when the server capability is disabled", async () => {
|
||||
const harness = await startHarness({ peerAssistedMedia: false });
|
||||
const host = await openClient(harness.webSocketUrl);
|
||||
const authenticated = await authenticate(
|
||||
host,
|
||||
harness.room,
|
||||
"host",
|
||||
"disabled-nat-host",
|
||||
null,
|
||||
"disabled_nat_share_generation_12345678",
|
||||
{
|
||||
routePolicy: {
|
||||
...DEFAULT_ROUTE_POLICY,
|
||||
natPrediction: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(authenticated.routePolicy.natPrediction).toBe(false);
|
||||
});
|
||||
|
||||
it("delivers only self-hosted auxiliary NAT STUN endpoints", async () => {
|
||||
const harness = await startHarness({
|
||||
stunUrls: ["stun:ordinary.example.test:3478"],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
natPredictionEnabled: true,
|
||||
});
|
||||
const viewer = await openClient(harness.webSocketUrl);
|
||||
const authenticated = await authenticate(
|
||||
viewer,
|
||||
harness.room,
|
||||
"viewer",
|
||||
"nat-observer-viewer",
|
||||
"nat-auxiliary-viewer",
|
||||
null,
|
||||
);
|
||||
|
||||
expect(authenticated.iceConfig).toEqual({
|
||||
iceServers: [{ urls: ["stun:ordinary.example.test:3478"] }],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
natPredictionStunUrls: [
|
||||
"stun:ordinary.example.test:3479",
|
||||
"stun:ordinary.example.test:3480",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -344,7 +344,10 @@ describe("ViewerPeer connection generations", () => {
|
||||
[],
|
||||
{
|
||||
iceServers: [{ urls: "stun:share.example.test:3478" }],
|
||||
natPredictionStunUrls: ["stun:observer.example.test:3478"],
|
||||
natPredictionStunUrls: [
|
||||
"stun:share.example.test:3479",
|
||||
"stun:share.example.test:3480",
|
||||
],
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -354,7 +357,6 @@ 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 surveyUrls = [
|
||||
|
||||
Reference in new issue
Block a user