WebRTC transport
A browser cannot open a UDP socket, so it cannot send a Net packet the way every
other node does. The WebRTC transport is how a tab joins anyway: the node runs as
a WebAssembly leaf, reaches a native anchor over an RTCPeerConnection, and
speaks the same Noise session, streams, channels and nRPC over the resulting
DataChannel.
Nothing above the transport changes. A browser leaf has its own entity identity, its own session keys, and its own capability announcements, and an anchor cannot tell a browser session from a UDP one once the transport is up.
page (tab) native anchor (webrtc) mesh
┌───────────────┐ HTTPS ┌───────────────────────┐
│ net-mesh-leaf │ ───────▶ │ POST /rtc/offer │
│ (wasm) │ ◀─────── │ GET /rtc/trickle │
│ │ │ GET /rtc/anchor │
│ RTCPeerConn │ ═══════▶ │ DataChannel endpoint │ ──▶ UDP peers
│ + Noise NKpsk0│ DTLS │ + Noise NKpsk0 │
└───────────────┘ └───────────────────────┘Two security layers, and why both
DTLS already secures a DataChannel, and Net runs its own Noise NKpsk0 session on top of it rather than trusting the DTLS session to be the mesh session. That is what makes a browser node a node: its identity is the mesh's Ed25519 entity, its session is authenticated by the mesh's admission rules, and a relay above it cannot see or forge application traffic.
Dropping Noise in favour of the DTLS exporter was measured and left deferred. On
a headless Chromium 149 run with the scalar-wasm build, Net's AEAD on top of
DTLS costs +3.5 µs per 1 KiB packet hot — +0.21 ms of main-thread time per
second at 60 Hz. The measurement and its method are in
docs/internal/performance/WEBRTC_DOUBLE_AEAD.md in the repository; the
decision it supports is that the second AEAD stays.
The anchor
An anchor is an ordinary native node built with the webrtc feature. It does
three things a browser cannot do for itself:
- Bootstrap over HTTPS. A page's first contact is
POST /rtc/offer, not a Net packet. - Answer STUN. The anchor announces a STUN endpoint separately from its own RTC endpoint, and a leaf's connections gather against it by default.
- Forward. Until a pair's ICE connects, the anchor relays their traffic — and it keeps relaying for pairs ICE never connects.
NET runs a public anchor at https://anchor.ai2070.net. It admits any game id
from any page, keeping each site's games apart, so a browser game can use it
without running an anchor at all (CLI: open games).
The feature is off by default, and that is a build-cost decision rather than
a maturity one: str0m's pinned crypto stack resolves aws-lc-rs, whose build
script compiles C, so --features webrtc needs cmake and a C toolchain. The
default build's exported C-ABI symbol set and observable behaviour are unchanged
by it. See CONTRIBUTING.md in the repository for the build detail.
The two endpoints an anchor publishes are distinct and easy to confuse:
| Field | What it is |
|---|---|
rtc_addr | The anchor's own RTC endpoint — the ICE peer of a connection to it |
rtc_addrs | On a dual-stack anchor, every RTC endpoint it publishes, one per family (IPv4 and IPv6), primary first. Absent otherwise |
stun_addr | The STUN endpoint a connection gathers against |
Configuring a connection's ICE servers with any of its own peer's RTC endpoints is refused before any ICE work, because a peer cannot be its own STUN server.
Dual-stack anchors. A browser can only pair with the address families its
network routes, so an anchor with one IPv4 socket cannot reach a player on an
IPv6-only network. An anchor given an RTC socket in each family offers a host
candidate per family in every session, and ICE picks whichever works; sessions,
identity and relay stay shared, so an IPv4-only player and an IPv6-only player
still meet through the anchor. It also needs a STUN endpoint in each family
(stun_addrs), because a browser without a media permission learns its
candidate on a network only from a STUN server of that family. Firefox on an
IPv6-only network with no IPv4 route gathers nothing and is not supported. On
464XLAT networks, as mobile IPv6-only networks are, it connects. See
anchor serve for the flags.
Bootstrap: the credential
A page has no provisioning step, so the secret its Noise handshake needs arrives
in a browser bootstrap credential — a net-bootstrap: string minted by an
operator and handed to the page out of band.
net-mesh anchor credential mint --root … --psk-hex … \
--anchor-noise-pubkey … --url https://anchor.example
net-mesh anchor credential inspect --credential @credential.txtThe credential carries an invite token, the anchor's Noise static public key, the mesh PSK, a trust-domain id derived from that PSK, and the bootstrap URL. Two lifetimes ride in the format and are enforced separately: the invite nonce is single-use and short-lived, and the PSK is standing with its own longer deadline. An anchor refuses a credential minted for another trust domain before any handshake — the domain is a one-way function of the PSK, so the check is mechanical and publishing the id reveals nothing.
The encoded string contains the PSK, which is why mint stages it with the same
file-permission discipline the CLI uses for key material.
The bootstrap listener
| Request | Purpose |
|---|---|
POST /rtc/offer | Credential + claimed node id + SDP offer → SDP answer |
GET /rtc/trickle | Upgraded to a WebSocket; ICE candidates both ways |
GET /rtc/anchor | The announcement fields, so a page can compare the credential's pinned key against the live anchor |
An HTTP-originated offer is an offer: it enters the same path a 0x0D02
signalling offer takes, and the session that results is provisional under the
same admission contract as any other browser-facing session, until enrollment
promotes it. The claimed node id is a claim — it rides into the Noise prologue,
so a page that claims an id it cannot handshake as fails the handshake.
Two deployment constraints are deliberate and have no override:
- TLS is browser-trusted or nothing — an operator PEM chain and key, or ACME. There is no self-signed mode, because a page cannot fetch an endpoint whose certificate it does not accept, and a harness that ignored that would prove something no deployment can rely on.
- CORS is an explicit allow-list with no wildcard, because the endpoint takes
a credential. Origin is validated on the trickle WebSocket too. An anchor
serving open games (
--open-games, as the public one does) admits every page instead: it echoes the caller's own origin, never*, never with credentials, and keys each open game on that origin.
Credentials for games
A game cannot hand every visitor an operator-minted credential, so an anchor run
with --issuer-identity and one --game <id> per game issues them:
POST /credential with {"game": "<id>"} returns a short-lived anonymous
credential for that game (requestCredential() in the package). Each game has
its own enrollment root derived from the issuer key, and a credential's invite
binds to the first browser identity that enrolls with it: that identity may
reconnect with it, and any other is refused. Games on one anchor are kept
apart: the anchor never floods or replays one game's announcements to another
game's players, and refuses relayed traffic between them. Flags and limits are
in the CLI reference.
Direct and routed
Every browser ↔ browser and browser ↔ native pair starts routed: the two leaves have a session through the anchor, and the anchor forwards their packets. A peer attempt then tries to install a direct session over ICE, and once it lands the pair's traffic moves to the DataChannel with no anchor in the path.
A routed pair is not a degraded state to be fixed, and an ICE failure is not a
connection failure: if ICE does not connect inside the attempt's deadline, the
routed session simply stays, and the pair keeps working. What a page can observe
is a typed outcome — direct, iceTimeout, or udpBlocked — and the anchor's
own per-pair forwarded_app_packets counter, which goes flat when a pair stops
needing it.
That counter excludes 0x0D02 signalling, so "flat once direct" is a claim
about application traffic rather than an artefact of signalling having stopped.
Two DataChannels
Every connection opens two DataChannels. net is ordered and fully
retransmitted, and everything that assumes delivery rides it. net-u is
unordered with no retransmits, for a stream opened with lossy: true: each
packet arrives promptly or not at all, never delaying anything else, and a
packet that would queue behind a backed-up buffer is dropped rather than sent
stale. The packets carry a flag saying which channel they belong on. A peer or
anchor that opens no net-u still receives lossy packets, on net. An anchor
from an older release does not know the second channel, so run the anchor from
the same release as the package.
Native ↔ native pairs keep using UDP and the existing hole punch; WebRTC's value
here is browser reach, not a better path between two native nodes.
When ICE fails
An ICE timeout is not evidence that UDP is blocked. A down, misconfigured or
saturated anchor produces the same symptom, so a failure is typed ice-timeout
unless two observations hold together:
- the HTTPS bootstrap to that anchor succeeded — it is up and addressable;
- a STUN binding to every RTC endpoint that same anchor published went unanswered (one per family on a dual-stack anchor, probed together).
udp-blocked is the name for exactly that pair of facts, and it is the only path
to it. One answered family keeps a failure ice-timeout, and even when both
observations hold the error states what was seen — no UDP response from the
advertised endpoints — rather than a cause, because a stopped UDP listener or a
wrong advertised address looks the same. Networks that block UDP outright still work: the pair stays routed through
the anchor, and the page sees udpBlocked as an explanation rather than as a
dead end. The classification, its probes and the measured browser behaviour
behind the rules are documented with the browser SDK errors.
What this is not
- Not a replacement for NAT traversal between native nodes. UDP, reflex discovery, rendezvous and hole punching remain the native path; see NAT and traversal.
- Not a browser transport without an anchor. Signalling, the initial bootstrap and the routed fallback all live on a native node; a page needs one to reach.
- Not a second identity system. The origin is the browser trust boundary, and a deployment that needs the key held elsewhere injects it custodially rather than relying on browser storage.
Where to read next
- Browser SDK — the TypeScript surface over this transport
- Browser quickstart — build the leaf and connect
- NAT and traversal — the native path
- Production deployment — running an anchor