feat(nat): make NAT prediction deployment-configurable (#372)

* Make NAT prediction deployment-configurable

* Ignore optional predicted candidate rejection
This commit is contained in:
AAA authored and GitHub committed 2026-09-02 01:37:04 +08:00
1 parent 1ed7ffc84b
commit 3583e92938
34 files changed
+579 -304

No files matched your search

+5 -4
View File
@@ -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
View File
@@ -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/)
+86
View File
@@ -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/)
+5 -12
View File
@@ -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.
+8 -7
View File
@@ -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.
+6 -6
View File
@@ -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
+12 -16
View File
@@ -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
+32 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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") })
}
+2 -2
View File
@@ -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>{`
+20
View File
@@ -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> {
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "自动选择",
+22 -13
View File
@@ -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">
+5 -2
View File
@@ -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);
}
}
+38 -32
View File
@@ -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);
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+9 -2
View File
@@ -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>;
+4
View File
@@ -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);
});
+12 -5
View File
@@ -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
View File
@@ -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",
]);
});
});
+46 -17
View File
@@ -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
View File
@@ -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",
);
});
+28 -1
View File
@@ -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
View File
@@ -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",
],
});
});
+4 -2
View File
@@ -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 = [