Repository navigation
feat(webrtc): map the browser listener's port through UPnP - #264
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
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.
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.
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.
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
--webrtc-direct-advertised-addr) starts saorsa-transport'sUpnpMappingServicefor 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.Companion PR in ant-client:
ant node add --webrtc-portgives 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
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
--webrtc-direct-advertised-addror an explicit--webrtc-direct-bindIP opts a node out.Semver impact
Test evidence
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_viewandonly_a_mapped_publication_is_withdrawn. The existing advertised-address tests pass no mapping and also assert the address's source.an_endpoint_only_its_mapping_reached_is_withdrawn_when_the_mapping_endspublishes 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.a_withdrawal_the_endpoint_file_blocks_still_unpublishes_and_is_retriedputs 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 toUnpublished. 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 -- --checkis clean.cargo +1.99.0 clippy --all-targets --all-features -- -D warnings, the toolchain CI uses, is clean.0.0.0.0:51812. The router granted UDP 51812 → host:51812, and the node advertised/ip4/<public IP>/udp/51812/webrtc-direct/….maingot a mapping for its native QUIC port only, and advertised an unmapped browser port.ant node add --webrtc-port 24610-24611→ daemon-started nodes listened on 24610/24611, and the router mapped exactly those ports.New dependency
none (
saorsa_transport::upnpandigd-nextare already built throughsaorsa-transport/defaultunder thewebrtc-directfeature)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-addror an explicit bind IP skips the mapping, and a leftover mapping expires within an hour.🤖 Generated with Claude Code