MESH ONLINE│CODENAME: —
v0.46
concept

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.

◇ A browser leaf, its anchor, and the mesh
  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:

FieldWhat it is
rtc_addrThe anchor's own RTC endpoint — the ICE peer of a connection to it
rtc_addrsOn a dual-stack anchor, every RTC endpoint it publishes, one per family (IPv4 and IPv6), primary first. Absent otherwise
stun_addrThe 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.

▸ shell
net-mesh anchor credential mint --root … --psk-hex … \
  --anchor-noise-pubkey … --url https://anchor.example
net-mesh anchor credential inspect --credential @credential.txt

The 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

RequestPurpose
POST /rtc/offerCredential + claimed node id + SDP offer → SDP answer
GET /rtc/trickleUpgraded to a WebSocket; ICE candidates both ways
GET /rtc/anchorThe 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:

  1. the HTTPS bootstrap to that anchor succeeded — it is up and addressable;
  2. 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.