Skip to content

feat(webrtc): map the browser listener's port through UPnP - #264

Merged
jacderida merged 6 commits into
mainfrom
mickvandijke/webrtc-upnp
Nov 3, 2026
Merged

jacderida merged 6 commits into
mainfrom
mickvandijke/webrtc-upnp

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Browsers reach a node only through its WebRTC Direct endpoint. A wildcard listener binds an OS-chosen port and advertises the native view's IP with it, but only the native QUIC endpoint asks the router for a UPnP port mapping. Behind a home router the browser port is never forwarded: native clients reach the node (directly or through a MASQUE relay), browsers cannot.

This is why the browser SDK fails to download chunks the native client fetches fine (ant-browser-sdk#10). A census of 802 mainnet nodes' WebRTC endpoints, dialled from Chromium with a 9 s ICE budget, twice each, found 51% never answer. In that issue all nine nearest holders of each failing chunk are among them.

What changes

  • A listener that chooses its own address (wildcard IPv4 bind, no --webrtc-direct-advertised-addr) starts saorsa-transport's UpnpMappingService for its port. This is the same best-effort service the native QUIC endpoint already uses. A development-mode node, whose native endpoint is loopback-only, keeps its loopback browser endpoint and never contacts the gateway. An IPv6 listener isn't mapped either, because the service maps IPv4 only.
  • While the router holds the mapping, it is advertised before the native view. The service asks for the listener's own port first, so the advertised port usually stays the same. Without a router, or when it refuses (UPnP off, non-public external IP, CGNAT), or after the lease is lost, publication is exactly as before. The existing 1 s refresh picks up each change.
  • An endpoint only the mapping reached (the native view yields no address, or a different one) is withdrawn when the mapping ends and nothing replaces it: from HELLO, the in-process catalog and the DHT record first, then the endpoint file. A file error cannot keep the endpoint advertised, and the node retries the file on each refresh until it is gone. An endpoint the native view still yields stays published, as before.
  • The published endpoint records where its address came from (operator, explicit bind, gateway mapping or native view), the way saorsa-transport tags each native candidate with its source. Only an endpoint whose source is the mapping is withdrawn; a withdrawal that is still waiting on the file is its own state.
  • Graceful shutdown removes the mapping; after a crash its one-hour lease expires.
  • An explicit bind IP or advertised address is never mapped, so devnets (which always set an advertised address) and operator-configured nodes are unaffected.

Companion PR in ant-client: ant node add --webrtc-port gives each node a fixed browser port operators can forward by hand when UPnP isn't available (WithAutonomi/ant-client#245).

Linear issue

Closes V2-1796

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

T2 proposed: nodes behind a UPnP router now open a second router port and advertise a different browser endpoint. There is no wire, format or economics change.

Compatibility

  • Wire: none. The signed address record format is unchanged; only the advertised WebRTC address differs when a mapping exists.
  • Storage: none.
  • API: none. No new configuration or CLI flags; --webrtc-direct-advertised-addr or an explicit --webrtc-direct-bind IP opts a node out.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • Unit: new only_a_listener_choosing_its_own_address_maps_its_port, a_development_node_does_not_map_its_port, an_ipv6_listener_does_not_map_its_port, a_gateway_mapping_is_advertised_before_the_native_view, a_mapping_the_native_view_also_yields_comes_from_the_native_view and only_a_mapped_publication_is_withdrawn. The existing advertised-address tests pass no mapping and also assert the address's source.
  • Refresh-level regression (review): an_endpoint_only_its_mapping_reached_is_withdrawn_when_the_mapping_ends publishes a mapping on a node with no native address, then removes the mapping and asserts HELLO state, the endpoint file, the catalog and the DHT supplemental record are all cleared. It fails on the first commit (the endpoint is kept) and passes on the fix.
  • Withdrawal retry (second review): a_withdrawal_the_endpoint_file_blocks_still_unpublishes_and_is_retried puts a directory where the endpoint file was, so removing the file fails. The refresh returns the error, yet HELLO state, the catalog and the DHT record are already cleared. After the directory is replaced by a removable file, the next refresh removes it and the publication returns to Unpublished. Run against the previous production code, it fails because the catalog still holds the endpoint.
  • cargo test --lib --features test-utils: 1197 passed. cargo test --bin ant-node: 5 passed. cargo test --test webrtc_direct_devnet: 3 passed. cargo fmt --all -- --check is clean.
  • Clippy: cargo +1.99.0 clippy --all-targets --all-features -- -D warnings, the toolchain CI uses, is clean.
  • Live, behind a consumer router with UPnP (Sagemcom F3896LG), against a local devnet plus one extra node on this branch with default WebRTC settings:
    • The listener bound 0.0.0.0:51812. The router granted UDP 51812 → host:51812, and the node advertised /ip4/<public IP>/udp/51812/webrtc-direct/….
    • SIGINT removed the mapping.
    • The same node on main got a mapping for its native QUIC port only, and advertised an unmapped browser port.
    • With the companion ant-client PR, ant node add --webrtc-port 24610-24611 → daemon-started nodes listened on 24610/24611, and the router mapped exactly those ports.
  • Not shown: inbound reachability from the Internet. This router doesn't loop UDP back to its own LAN, so a browser dial of the public address from the same LAN got no answer. A LAN dial completed ICE, DTLS and the PQ handshake against the mapped listener.
  • Not run: dev testnet. Testnet hosts have public interfaces, where router discovery finds nothing and publication is unchanged.
  • Follow-up after release: rerun the mainnet census. The share of WebRTC endpoints that never answer should fall.

New dependency

none (saorsa_transport::upnp and igd-next are already built through saorsa-transport/default under the webrtc-direct feature)

ADR

ADR-0027: Map the browser listener's port through UPnP (new; amends ADR-0015's "NAT and relays")

Mitigation / rollback

Revert the commit. Per node, --webrtc-direct-advertised-addr or an explicit bind IP skips the mapping, and a leftover mapping expires within an hour.

🤖 Generated with Claude Code

Browsers reach a node only through its WebRTC Direct endpoint. A wildcard
listener binds an OS-chosen port and advertises the native view's IP with
it, but only the native QUIC endpoint asks the gateway for a UPnP mapping.
Behind a home router the advertised port is dropped: native clients still
reach the node, browsers cannot. A mainnet census found 51% of 802 nodes'
WebRTC endpoints never answer ICE (ant-browser-sdk#10).

A listener that chooses its own address (wildcard bind, no advertised
address) now starts saorsa-transport's UpnpMappingService for its port,
advertises the gateway's mapping before the native view while it holds,
and removes it on shutdown. Without a gateway, or when it refuses,
publication is unchanged. An explicit bind IP or advertised address is
never mapped, so devnets and operator-configured nodes are unaffected.

ADR-0027 records the decision and the census.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES

At src/web_rtc.rs:752-756, a mapped-only endpoint is not withdrawn when the UPnP mapping is lost. advertised_addr(...) then returns None, and this early return leaves the previous endpoint in state.endpoint, the persisted endpoint file, the endpoint catalog, and the supplemental self-address set. This can occur when the native address view has no non-relay address of the listener's family. Browsers will keep dialling the expired or reassigned mapping, contrary to the documented promise to advertise it only while the gateway holds it.

Please clear or replace a mapping-derived publication when its mapping disappears and there is no valid native fallback. Add a refresh-level regression test for Mapped → Unavailable with no usable native address; assert the persisted/catalog/supplemental endpoint is withdrawn. Keep the existing last-known endpoint behaviour where that endpoint remains valid.

Panel: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: sol-1 identified this stale-publication path; glm-1 approved, but its claim that the early return is fail-safe is contradicted by the retained state and side effects above.

Checks: GitHub CI tests, builds, format, clippy, MSRV, ADR governance and devnet checks passed at this head. Security Audit failed on crypto-bigint 0.5.5 (RUSTSEC-2026-0387); the base lockfile has the same version and this PR changes no dependency files, so this is inherited, not introduced here. The focused local Rust test command exceeded its 420-second execution limit before returning a result; no local test pass is claimed.

mickvandijke and others added 2 commits November 2, 2026 17:09
An endpoint only the UPnP mapping reached stayed advertised after the
gateway dropped the mapping when the native view had no address to fall
back on: the early return kept it in HELLO state, the endpoint file, the
in-process catalog and the DHT supplemental record, so browsers kept
dialling an expired or reassigned port.

The listener now records whether its published endpoint is valid only
while mapped (the mapping, which the native view would not yield). When
the mapping ends and nothing replaces it, the endpoint is withdrawn from
all four. An endpoint the native view still yields is kept, as before.

The mapping source is a small trait so a refresh-level test can grant
and remove a mapping on a node with no native address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clippy 1.99 rejects `assert!(..is_empty())` (assert_is_empty); comparing
against an empty vec also shows any leftover record on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 52b7382

The previous blocker is fixed on the normal withdrawal path: when a mapping-only endpoint loses its mapping and has no native fallback, the new refresh-level test verifies removal from endpoint state, the file, catalog and DHT supplemental addresses. The native-address fallback is still selected when present.

New blocker — cleanup can stop halfway and will not retry (src/web_rtc.rs:800-815). refresh_browser_endpoint clears endpoint_needs_mapping before awaiting withdrawal. withdraw_browser_endpoint then takes state.endpoint before removing the persisted file with ?. If that file removal fails, the function exits before removing the catalog and DHT entries; later refreshes see the flag cleared (and endpoint already None) and do not retry. The node can therefore keep advertising the dead mapping through catalog/DHT after reporting the endpoint withdrawn. Please make withdrawal retryable and ensure cleanup of the in-memory/catalog/DHT publications is not skipped by a file error; add a test that injects a file-removal failure and verifies eventual cleanup after recovery.

Panel: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). Both confirmed the cleanup-error path.

Checks: Current GitHub test jobs passed. Build (macos-latest) is cancelled. Security Audit fails on the unchanged base-branch dependency crypto-bigint 0.5.5 (RUSTSEC-2026-0387). The focused local Rust test command timed out before returning; no local test pass is claimed.

mickvandijke and others added 3 commits November 3, 2026 09:34
Withdrawing a mapped-only endpoint cleared its retry flag first and removed
the endpoint file before the catalog and DHT record, so a file error left
the endpoint advertised with no later attempt to withdraw it.

Withdraw it from HELLO, the catalog and the DHT record first, once; they
cannot fail. Remove the file last and keep the flag set until that
succeeds, so each refresh retries only the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A development node binds its native endpoint to loopback, so before this
change its browser endpoint was the loopback view. Mapping its port asked
the developer's router to open it and advertised a public address that a
browser on the same machine cannot reach without hairpin NAT.

Map only when the native endpoint is not loopback-only, and only for an
IPv4 listener: the gateway mapping is IPv4, which an IPv6 listener would
never advertise. Bring the advertised_addr config and CLI docs, ADR-0027
and the testnet guide in line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser endpoint and a separate endpoint_needs_mapping flag had to be
kept in step at three sites, and the flag was derived by re-running address
selection without the mapping. Native QUIC instead tags each candidate with
its source.

advertised_addr now returns the address with its source (operator, bind,
mapping or native view), and the listener holds one Publication: nothing,
an endpoint with its source, or a mapped endpoint whose withdrawal still
waits on its file. Only a mapping-sourced endpoint is withdrawn. Behaviour
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@MaidSafe-Hermes MaidSafe-Hermes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — re-review of 2956894

Both earlier findings are fixed. The mapping-only endpoint is withdrawn from HELLO, catalog and DHT when its mapping ends; a file-removal failure leaves a retryable Withdrawing state. The new regression test covers failure and recovery. The source-tagged publication also limits withdrawal to mapping-derived endpoints, and the new development-mode and IPv4 guards match the documented scope.

Panel: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3); both approve.

Checks: Current unit, e2e, platform, devnet, build, format, Clippy, MSRV, ADR and third-party-notice checks pass. Security Audit remains failed on crypto-bigint 0.5.5 (RUSTSEC-2026-0387), which is also in the base lockfile; this PR changes no dependency files. This approval covers the PR changes only. It does not waive the separate audit finding or authorize merge.

@jacderida
jacderida merged commit d82c31d into main Nov 3, 2026
39 of 42 checks passed
@jacderida
jacderida deleted the mickvandijke/webrtc-upnp branch November 3, 2026 12:17
grumbach added a commit to grumbach/ant-node that referenced this pull request Nov 3, 2026
Brings in the browser listener's UPnP port mapping (WithAutonomi#264, ADR-0027), the CI
concurrency and job timeouts (WithAutonomi#268) and the removal of claude.yml (WithAutonomi#267).

Conflicts: the endpoint publication, where main's Publication replaces the
single endpoint and the swarm's STUN server is still set beside it, and the
ADR index, which lists both ADR-0026 and ADR-0027.

A mapped endpoint withdrawn when its mapping ends now also clears the swarm's
STUN server, so the node hosts no rooms while browsers cannot reach it, as
before any endpoint is published. ADR-0026 says so, and main's two withdrawal
tests check the STUN server is set on publication and gone after withdrawal.
grumbach added a commit to grumbach/ant-node that referenced this pull request Nov 5, 2026
Brings the branch up to main f37429a, which adds pressure-gated retention
(WithAutonomi#271), get-or-closer chunk GETs (WithAutonomi#249), the WebRTC UPnP mapping (WithAutonomi#264) and
listener summary (WithAutonomi#278), and the consolidated PR CI (WithAutonomi#269).

The only conflict was the ADR index: ADR-0024 now sits before main's
ADR-0027 and ADR-0028. None of main's changes touch the fetch scheduler.
grumbach added a commit to grumbach/ant-node that referenced this pull request Nov 5, 2026
Takes the per-source scheduler branch at f568f91, which merged upstream
main f37429a, so this branch carries main's pressure-gated retention
(WithAutonomi#271), get-or-closer chunk GETs (WithAutonomi#249), WebRTC changes (WithAutonomi#264, WithAutonomi#278) and
the consolidated PR CI (WithAutonomi#269) through it.

The only conflict was the ADR index: ADR-0025 now sits before main's
ADR-0027 and ADR-0028. The resulting tree equals a direct merge of this
branch with main apart from that index.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants