Skip to content

feat(webrtc): STUN-dispatch WebRTC-Direct listener (v1) + v1 dialer, /sdp harness opt-in - #1449

Merged
acul71 merged 18 commits into
libp2p:mainfrom
yashksaini-coder:feat/webrtc-direct-stun-listener-v1
Aug 28, 2026
Merged

acul71 merged 18 commits into
libp2p:mainfrom
yashksaini-coder:feat/webrtc-direct-stun-listener-v1

Conversation

@yashksaini-coder

Copy link
Copy Markdown
Contributor

What

Third step for #1437 — the spec-aligned listener.

Listener (default path) — one shared UDP socket (UdpMux):

  • First inbound STUN BINDING REQUEST → USERNAME = server_ufrag:client_ufrag; libp2p+webrtc+v1/ prefix selects the flow. Unknown/missing prefix → rejected (spec: never assume v1). Both halves validated (ice-chars, 4–256).
  • Registers a mux-backed ICE connection for the ufrag, replays the packet, attaches an aiortc PC (attach_muxed_connection, feat(webrtc): run aiortc RTCPeerConnection over UdpMux (attach_muxed_connection) #1448).
  • Infers the dialer's offer: ufrag == pwd == server credential, c=/candidate at the STUN source, a=setup:active (so aiortc takes the DTLS server role — with actpass it would answer as client), placeholder fingerprint with DTLS peer verification disabled for inbound per spec step 6.2/7 (Noise authenticates).
  • Then the fix(webrtc): wire the WebRTC-Direct inbound path; spec Noise roles; real loopback test #1447 completion path (Noise initiator → handler). In-flight cap on unauthenticated inbounds (max_in_flight_connections).

Dialer (v1) — sets libp2p+webrtc+v1/<random> as ufrag and pwd on the aioice Connection (aiortc regenerates ICE creds from it in setLocalDescription; SDP-text munging is ignored) and on a synthetic ICE-Lite a=setup:passive answer built from the multiaddr; the certhash fingerprint in that answer makes aiortc pin the server's DTLS cert.

HTTP POST /sdp harness → opt-in WebRTCTransportConfig(enable_sdp_http_harness=True) (experimental, py↔py). Off by default; nothing else used it.

sdp.py: parse_direct_username, build_inferred_offer, build_synthetic_answer, make_v1_credential; credentials generated with ice-chars only (token_urlsafe produced -/_, which aioice rejects). UdpMux unknown-STUN handler now receives the full USERNAME.

Not in this PR (tracked on #1437)

  • v2 flow (libp2p+webrtc+v2/, no munging; specs#715 still open) — listener currently drops v2 first contacts with a debug log.
  • go/js interop vectors.
  • Known ceilings, commented in code: listener is a full aioice agent acting controlled (aioice has no ICE-Lite mode; interoperates because the dialer nominates); rate limiting is the in-flight cap only.

Tests

  • Loopback echo parametrized over STUN (default) and harness; two concurrent dials on one port (asserts two ufrags in the mux); unknown-prefix rejection (no PC created); in-flight cap; sdp helper table tests.
  • tests/core/transport/webrtc: 204 passed; loopback file 6× no flake; mypy/pyrefly clean.

Stacked on #1448 → #1447 → #1446.

Refs #1437

@yashksaini-coder

Copy link
Copy Markdown
Contributor Author

@seetadev @acul71 Rebased on latest main. This PR adds the spec-aligned WebRTC-Direct listener: one shared UDP port, STUN USERNAME dispatch with libp2p+webrtc+v1/ validation, offer inferred from the first packet, and a v1 dialer so py↔py loopback exercises the real STUN path; the HTTP /sdp harness is now opt-in. Stacked on #1448; closes the listener scope of #1437 — v2 (libp2p/specs#715) and go/js interop are planned follow-ups.

@yashksaini-coder
yashksaini-coder force-pushed the feat/webrtc-direct-stun-listener-v1 branch 4 times, most recently from 41968d8 to 0d24ab7 Compare August 27, 2026 06:14

@acul71 acul71 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for pushing this through — this is the right shape for the v1 STUN listener slice of #1437: shared UdpMux port, libp2p+webrtc+v1/ first-contact dispatch, inferred offer, Noise initiator path, and /sdp harness opt-in off by default. Tests covering STUN vs harness, concurrent dials, unknown-prefix rejection, and the in-flight cap are in good shape. CI looks green.

Please do not Fixes/Closes #1437 on merge — v2, go/js interop, and true ICE-Lite are still open on that tracking issue. Refs #1437 is correct.

Requested changes

1. Spec-path dial must not use default Google STUN (blocker)

dial() always passes self._config.ice_servers into create_peer_connection. The default is stun:stun.l.google.com:19302. That undoes the offline-dial fix from the inbound/Noise PR in this stack: gathering the public STUN server adds seconds on firewalled/offline networks, phones Google on every /webrtc-direct dial, and does not help the STUN listener path (the offer is inferred from the first BINDING REQUEST to the advertised multiaddr). Loopback tests hide this with ice_servers=[] in _transport().

For enable_sdp_http_harness=False, create the PC with ice_servers=[] (same as the listener). Keep config.ice_servers for the harness / explicit NAT-TURN opt-in. Please add a regression test that the default spec dial does not configure RTCIceServers.

2. Unknown-STUN rate limiting (ship here or park on #1437)

Spec step 6 / #1437 ask for rate-limiting first-contact STUN. Today only max_in_flight_connections bounds allocated PCs, and it runs after parse + add_ice_connection. Malformed / wrong-prefix floods still hit parse_direct_username on every datagram. Prefer a small per-source-IP token bucket before allocating ICE/PC state (go-libp2p also bounds the mux accept queue). If you want to defer, tick it explicitly on #1437 and leave the ponytail comment — but this socket is now a public STUN endpoint, so shipping the bucket here is better.

Please also

  • Infer offer ICE creds from the client USERNAME half (_client_ufrag), matching go-libp2p’s v1 recovery (client_pwd == client_ufrag). py↔py works today only because both halves are the same make_v1_credential() string.
  • After detecting v1, also validate the credential as an ice-pwd (RFC 8839 min 22). libp2p+webrtc+v1/ is 17 chars, so a 4-char suffix yields a 21-char string that passes ufrag checks but is not a valid pwd. Add a reject table case for that.
  • Drop unused WebRTCDirectTransport._sdp_builder (SDPBuilder stays for private transport).

Questions

  • Was re-passing config.ice_servers into spec-path dial() intentional, or leftover from the harness era?
  • Is inferred a=setup:active (vs spec/go actpass) the long-term aiortc constraint, or can we force the DTLS server role another way before go interop?
  • Should 0.0.0.0 listen still advertise 127.0.0.1, or should get_addrs() prefer a non-loopback iface the way the Windows tests do?

Happy to re-review once the STUN-server dial regression is fixed.

@acul71 acul71 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Inline notes for the changes requested above.

Comment thread libp2p/transport/webrtc/transport.py Outdated
# 1. Create RTCPeerConnection + Noise channel
pc = await bridge.run_coro(create_peer_connection(rtc_cert))
pc = await bridge.run_coro(
create_peer_connection(rtc_cert, ice_servers=self._config.ice_servers)

@acul71 acul71 Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker: on the default (spec) path this reintroduces the Google STUN gather that the inbound/Noise work removed for offline dials. Srflx candidates never reach the STUN listener anyway — offer is inferred from the first BINDING REQUEST. Please use ice_servers=[] when enable_sdp_http_harness is false (same as the listener), and keep config.ice_servers for the harness / explicit TURN. Add a regression test so loopback helpers with ice_servers=[] cannot hide this again.

return
# ponytail: in-flight cap only; add a per-source-IP token bucket if
# STUN floods from one address become a problem (spec: SHOULD rate-limit).
if self._in_flight >= self._config.max_in_flight_connections:

@acul71 acul71 Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In-flight cap alone is not the rate-limit the spec / #1437 ask for — it only bounds allocated PCs after a successful parse + add_ice_connection. Malformed / wrong-prefix floods still pay parse_direct_username on every datagram. Prefer a per-source-IP token bucket before allocating ICE state (or explicitly park on #1437).

Comment thread libp2p/transport/webrtc/listener.py Outdated

offer_sdp = build_inferred_offer(
client_ufrag=server_ufrag,
client_pwd=server_ufrag,

@acul71 acul71 Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please use the client half of the USERNAME for the inferred offer (client_ufrag=_client_ufrag, client_pwd=_client_ufrag for v1), matching go-libp2p. Today py↔py works because both halves are the same credential; unequal halves (or a crafted v1_cred:other) would mismatch.

server_ufrag, sep, client_ufrag = username.partition(":")
if not sep:
raise WebRTCConnectionError("STUN USERNAME is not 'server:client'")
if not (is_ice_ufrag(server_ufrag) and is_ice_ufrag(client_ufrag)):

@acul71 acul71 Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

For v1 the same string is also ice-pwd (RFC 8839 min 22). libp2p+webrtc+v1/ is 17 chars, so a 4-char suffix (21 total) passes ufrag checks but is not a valid pwd. After detecting v1, also require is_ice_pwd(...) and add a reject table case.

tox commands_pre installed no extras, so aiortc was absent in CI and every
test under tests/core/transport/webrtc was skipped on GitHub. aiortc 1.15 is
pure Python and its native deps (av, pylibsrtp) ship manylinux/win wheels, so
no system packages are needed.

Refs libp2p#1437
…oles

The listener's inbound completion was a stub: after ICE it logged and
returned, so no inbound connection ever reached the handler. dial() was
broken too - get_remote_fingerprint read attributes aiortc does not have.

- listener: own a trio nursery (system task, like TCP), hop asyncio->trio
  without blocking the loop, run Noise XX as *initiator* (spec: server
  initiates, dialer responds), then hand the authenticated connection to
  the handler; bound in-flight unauthenticated inbounds.
- transport.dial(): Noise responder; verify authenticated peer ID against
  /p2p/ after the handshake.
- noise: role-ordered prologue (dialer fingerprint, then server);
  PatternXX.handshake_outbound(remote_peer=None) skips only the ID
  equality check; DataChannelReadWriter.read(n) honours n (the Noise
  packet reader asks for the 2-byte prefix alone).
- helpers: get_remote_fingerprint reads pc.sctp.transport._ssl peer cert;
  noise send waits for data channel 0 to open.
- tests: transport-level dial->listen->stream echo loopback, wrong /p2p/
  rejection, e2e Noise role test, buffered read test, fingerprint test.

Refs libp2p#1437
- Frame Noise handshake bytes as uvarint-prefixed webrtc.pb.Message
  stream frames on channel 0 (spec 'Multiplexing'; what go/js do) instead
  of raw bytes - required for interop; py<->py was symmetric so tests did
  not notice.
- Listener: guard the handler call - the nursery lives in a trio system
  task, so an escaping exception aborted the whole trio run.
- dial(): bound the Noise phase (responder's first step is a read) with
  handshake_timeout; close the PC on every failure path, not just the two
  explicit mismatches; do not pass config.ice_servers (previously no STUN
  servers were used; the default Google STUN added ~5s to offline dials).
- Tests: framing round-trip/chunking/FIN/malformed, handler-exception
  regression.

Refs libp2p#1437
…string

Review follow-up: the module docstring still described the prologue as
local_fp || remote_fp; it is dialer_fp || server_fp (spec role order),
matching build_noise_prologue. Also bound the dialer's wait_for_connected
with config.handshake_timeout like the listener does.

Refs libp2p#1437
…connection)

aiortc has no injection point for an external ICE connection, so a
listener that demuxes on one UDP port could not use RTCPeerConnection on
top of UdpMux. Add attach_muxed_connection(pc, mux, conn): swaps the
gatherer/transport aioice.Connection for the mux-backed one, rebinds the
DTLS _recv/_send that RTCIceTransport captured at construction, and keeps
the mux tables in sync on ICE state changes.

UdpMux fixes found while validating that path with a real peer:
- mark _local_candidates_start so aiortc's gather() from
  setLocalDescription is a no-op (it bound extra sockets and appended
  their candidates to the answer);
- STUN responses/indications carry no USERNAME - route them by address,
  and learn peer addresses from inbound checks and outbound sends;
- unregister(ufrag) also drops the addresses learned for that protocol;
- STUN-shaped-but-malformed datagrams go straight to the connection's data
  path (StunProtocol only catches ValueError, so struct.error escaped).

Test: mux-backed server PC vs plain aiortc client PC on loopback -
answer advertises only the shared port, ICE/DTLS/SCTP connect, data flows
both ways, tables empty after close. webrtc extra now aiortc>=1.15.

Refs libp2p#1437
… mux test

- _learn_addr: latest connection wins for a reused (ip, port) (pion
  behaviour) so a redial from the same socket reaches its new connection;
  cap learned addresses per protocol (unauthenticated STUN with a live
  ufrag from many source ports must not grow the table without bound).
- test_pc_over_mux: bind the mux on 0.0.0.0 and advertise a real host
  address - a 127.0.0.1-bound socket cannot reply to a LAN-bound peer
  socket on Windows (WinError 1231), which Linux's weak-host model hid.

Refs libp2p#1437
A hung close() after a primary failure would outlive asyncio.wait_for and
be killed by pytest-timeout, reported as an xdist 'worker crashed' that
hides the real error (seen on Windows CI).

Refs libp2p#1437
…ection)

pc.close() deadlocks on Windows CPython 3.12/3.13: DTLS queues its
close_notify datagram, transport.close() runs with that write in flight
and defers connection_lost to the write callback, but
_ProactorDatagramTransport._loop_writing early-returns on _conn_lost -
the deferred connection_lost is never delivered and aioice's
StunProtocol.close() awaits its closed-future forever (confirmed via
task/transport dumps on CI: closing=True, write_fut finished,
closed.done=False).

close_peer_connection(pc) bounds close() and, on timeout, abort()s the
lingering aioice transports - _force_close delivers connection_lost
unconditionally - then lets close() finish. Used in the PC-over-mux test;
production call sites switch in the listener PR.

Refs libp2p#1437
Writing a wrong - or upgraded-away - private/mangled attribute name is a
silent no-op: setattr creates a new attribute the library never reads and
the failure only surfaces far downstream (the SDP-fingerprint invariant
test, an ICE that binds extra sockets). Assert hasattr at the write so it
fails at the line that caused it, and so an aiortc/aioice upgrade that
renames or drops a slot breaks loudly at construction.

Applied to the DTLS cert pin (_RTCPeerConnection__certificates, ca8331f),
the attach_muxed_connection injection points, and add_ice_connection's
aioice fields.

Refs libp2p#1437
…Windows

Windows surfaces an ICMP port-unreachable for a datagram we sent (a
keepalive to a peer that just closed) as WSAECONNRESET on the shared
socket's next recv, and the proactor loop does not re-arm reading after
error_received - the mux went deaf for every other peer (seen in CI as
'[WinError 10054]' followed by the next dial's ICE timing out). Disable
that behaviour with SIO_UDP_CONNRESET on the mux socket; no-op elsewhere.

Refs libp2p#1437
Listener (spec path, default): one shared UDP socket via UdpMux. The
first inbound STUN BINDING REQUEST is parsed for
USERNAME = server_ufrag:client_ufrag; the libp2p+webrtc+v1/ prefix
selects the flow (unknown/missing prefix -> rejected, never assumed v1;
both halves validated as ice-chars, 4..256). A mux-backed ICE connection
is registered for the ufrag, the packet replayed into it, and an aiortc
PC attached (attach_muxed_connection). The dialer's offer is inferred
from the packet: ufrag == pwd == server credential, c=/candidate at the
STUN source, a=setup:active so aiortc takes the DTLS server role,
placeholder fingerprint with DTLS peer verification disabled for inbound
per spec (Noise authenticates). Then the existing PR1 completion path
(Noise initiator -> handler). In-flight cap on unauthenticated inbounds.

Dialer (v1): the same libp2p+webrtc+v1/<random> string is set as ufrag
and pwd on the aioice Connection (aiortc regenerates ICE creds from it
in setLocalDescription; SDP text munging is ignored) and on a synthetic
ICE-Lite, setup:passive answer built from the multiaddr - aiortc pins the
server's DTLS cert via the certhash fingerprint.

The HTTP POST /sdp harness is now opt-in
(WebRTCTransportConfig.enable_sdp_http_harness, off by default): when on,
the listener also serves it on TCP and our dialer uses it.

sdp.py: parse_direct_username, build_inferred_offer,
build_synthetic_answer, make_v1_credential; _generate_ice_credential
emits ice-chars only (token_urlsafe produced '-'/'_' which aioice
rejects). UdpMux passes the full USERNAME to the unknown-STUN handler.

Tests: loopback echo over STUN and over the harness, two concurrent
dials on one port, unknown-prefix rejection, in-flight cap, sdp helpers.

Refs libp2p#1437
- listen(): bind the UDP socket before spawning the trio nursery and map
  OSError to WebRTCConnectionError, so a bind failure leaves no orphaned
  system task.
- close(): cancel in-flight inbound setup tasks so their peer connections
  close immediately instead of after handshake_timeout.
- ice-char validation uses fullmatch ($ accepted a trailing newline).
- tests: bind listeners on 0.0.0.0 (advertised as 127.0.0.1) - on Windows
  a 127.0.0.1-bound socket cannot answer a LAN-bound peer socket; add
  bind-failure and cancel-on-close assertions, newline rejection cases.

Refs libp2p#1437
aiortc dialers gather LAN host candidates only (aioice skips loopback) and
on Windows a LAN-bound UDP socket cannot send to 127.0.0.1 (WinError
1214), so a listener advertising 127.0.0.1 is unreachable from a Windows
aiortc dialer. Bind the test listeners on the first non-loopback IPv4
interface (fallback 127.0.0.1) and target the advertised host from the
STUN poke tests (which bind 0.0.0.0 for the same reason).

Refs libp2p#1437
… site

The Windows proactor close hang (see the close_peer_connection commit)
would otherwise stall the listener's inbound failure paths, the dialer's
failure path, and WebRTCConnection.close() via _close_pc_cb - each a
leaked asyncio task on the bridge loop.

Refs libp2p#1437
…_attr

Dialer v1 ICE-credential fields on the aioice Connection and the
listener's inbound _validate_peer_identity replacement now assert the
slot exists before writing, matching the pattern introduced in libp2p#1448.

Refs libp2p#1437
- dial(): no STUN/TURN on the spec path (config.ice_servers had been
  re-passed when the STUN path was added, undoing the offline-dial fix);
  ice_servers now only applies to the HTTP harness. Regression test uses
  default configs so the loopback helper's ice_servers=[] cannot hide it.
- Per-source-IP token bucket on first-contact STUN, applied before any
  parsing or ICE/PC allocation (spec step 6).
- Inferred offer takes the client half of USERNAME (client_pwd ==
  client_ufrag for v1), matching go-libp2p.
- parse_direct_username: v1 halves must also be valid ice-pwds (>= 22) -
  the 17-char prefix plus a 4-char suffix passed the ufrag check only.
- Drop unused WebRTCDirectTransport._sdp_builder; note on ice_servers.

Refs libp2p#1437
@yashksaini-coder
yashksaini-coder force-pushed the feat/webrtc-direct-stun-listener-v1 branch from 898d1da to 6b7d1b3 Compare August 27, 2026 11:48
@yashksaini-coder

Copy link
Copy Markdown
Contributor Author

@acul71 All requested changes are in (rebased on main):

  • STUN-server dial (blocker): spec path now creates the PC with ice_servers=[]; config.ice_servers only applies to the HTTP harness. Regression test test_spec_dial_configures_no_ice_servers uses default configs and asserts every create_peer_connection call saw []. Answer to your question: it was a leftover from the harness era — my earlier "revert" edit hadn't actually applied, and CI couldn't tell because the helper passed ice_servers=[].
  • Rate limiting: per-source-IP token bucket (_FirstContactLimiter, burst 8, 4/s, bounded to 4096 sources) runs in _on_unknown_stun before any parse or ICE/PC allocation; the in-flight cap stays as the second bound. Test floods 32 first contacts from one IP and asserts ≤ burst get through.
  • Client half for the inferred offer: client_ufrag = client_pwd = <client half of USERNAME> (go-libp2p v1 recovery).
  • v1 pwd length: parse_direct_username now requires both halves to be valid ice-pwds (≥ 22) for v1; reject cases added for the 21-char credential and a short client half.
  • Dropped _sdp_builder; Refs #1437 only (no Fixes/Closes).

On the other questions: a=setup:active is only in the inferred offer, which never goes on the wire — it exists to steer aiortc, which answers actpass as DTLS client. Equivalent alternative is actpass + _set_role("server") on the DTLS transport; happy to switch if you prefer the spec-literal text. For 0.0.0.0, advertising 127.0.0.1 predates this stack; I'd follow the TCP listener (advertise the bound wildcard / let the host expand interfaces) in a small follow-up rather than here.

Use actpass in inferred offers and force the listener DTLS server role,
advertise concrete interface IPs when listening on 0.0.0.0, and document
the default STUN path vs the opt-in HTTP /sdp harness.

Co-authored-by: Cursor <cursoragent@cursor.com>

@acul71 acul71 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 3 addresses the deferred follow-ups:

  • DTLS setup: build_inferred_offer now uses a=setup:actpass; force_listener_dtls_server_role() sets the listener as DTLS server before createAnswer.
  • 0.0.0.0 advertising: wildcard listens publish concrete interface IPs via get_available_interfaces; _resolve_candidate_host picks a non-loopback ICE host when available.
  • Docs: README + module docstring note the default STUN path vs opt-in enable_sdp_http_harness.

All prior review blockers were already fixed in round 2. LGTM to merge once CI is green.

@acul71

acul71 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Pushed round 3 (b1c548bb) with the three deferred follow-ups:

  1. actpass + DTLS server role — libp2p/transport/webrtc/sdp.py, _aiortc_helpers.force_listener_dtls_server_role, listener._accept_v1
  2. 0.0.0.0 → concrete addrs — listener._advertised_hosts_for_listen / _resolve_candidate_host + test_listener_on_wildcard_advertises_concrete_interfaces
  3. README / module note — STUN path vs WebRTCTransportConfig(enable_sdp_http_harness=True)

Local: lint, typecheck, docs, 219/219 webrtc tests, full suite green (Kad DHT flakes passed on retry).

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.

2 participants