Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions libp2p/transport/webrtc/_udp_mux.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ def add_ice_connection(
local_password: str,
*,
host: str,
ice_lite: bool = False,
) -> _ice.Connection:
"""
Create an ``aioice.Connection`` backed by this mux (no own UDP socket).
Expand All @@ -353,6 +354,11 @@ def add_ice_connection(
caller must **not** call ``conn.gather_candidates()`` — doing so binds
additional UDP sockets and defeats the shared-port design.

Pass ``ice_lite=True`` for the WebRTC-Direct listener path so the
connection is a true ICE-Lite agent (respond-only, always controlled);
see :func:`make_connection_ice_lite`. Default ``False`` keeps a full
controlled agent for tests and non-listener callers.

The caller must:
1. For each of the dialer's candidates, ``await conn.add_remote_candidate(c)``,
then ``await conn.add_remote_candidate(None)`` to signal end-of-candidates.
Expand Down Expand Up @@ -415,6 +421,8 @@ def add_ice_connection(
conn._local_candidates_start = True
conn._local_candidates_end = True
self.register(local_username, protocol) # type: ignore[arg-type]
if ice_lite:
make_connection_ice_lite(conn)
return conn

# ------------------------------------------------------------------
Expand All @@ -436,3 +444,61 @@ async def close(self) -> None:
self._by_addr.clear()
self._addr_count.clear()
self._unknown_stun_handler = None


def make_connection_ice_lite(conn: _ice.Connection) -> None:
"""
Turn a muxed listener connection into a true ICE-Lite agent (respond-only).

Per the WebRTC-Direct spec the publicly-reachable server "acts as an ICE
Lite agent": it binds a port, answers the controlling dialer's STUN checks,
and never initiates its own (RFC 8445 §2.1 — a Lite agent performs no
connectivity checks and offers only host candidates).

aioice has no local lite mode (only ``remote_is_lite``), so its controlled
agent still sends connectivity checks. ``check_start`` is aioice's *sole*
sender of connectivity-check requests (``pair.protocol.request`` at
ice.py:889/927). Replacing it with a respond-only version makes the agent
genuinely Lite: it never puts a check on the wire, marks the pair valid, and
honours the dialer's nomination so the incoming ``USE-CANDIDATE`` still
completes ICE. This mirrors the success tail of aioice's own ``check_start``
minus the network round trip; ``check_incoming`` handles the other ordering
(nomination arriving after the pair is already ``SUCCEEDED``). Binding
*responses* (``request_received`` → ``send_stun``) and consent-freshness
liveness are left untouched — the latter matching pion's Lite, which keeps
consent.

A Lite agent is also always in the *controlled* role and never switches
(RFC 8445 §6.1.1). aioice's ``request_received`` performs role-conflict
repair (ice.py:1127-1132): a peer that sends an ``ICE-CONTROLLED`` attribute
would flip this agent to controlling, after which the respond-only override
can no longer self-nominate and ICE fails. Pinning ``switch_role`` to a
no-op keeps the agent controlled — a no-op for conformant dialers (which are
controlling and never send ``ICE-CONTROLLED``) and correct against one that
does.

Touches aioice 0.10.x internals; the asserts fail loudly on a bump that
renames a slot rather than silently reverting to a full checking agent.
"""
assert not conn.ice_controlling, (
"ICE-Lite requires a controlled agent (ice_controlling=False)"
)
assert hasattr(conn, "check_start"), "aioice Connection has no check_start"
assert hasattr(conn, "switch_role"), "aioice Connection has no switch_role"
_succeeded = _ice.CandidatePair.State.SUCCEEDED

async def _lite_check_start(pair: _ice.CandidatePair) -> None:
if pair.remote_nominated:
pair.nominated = True
conn.check_state(pair, _succeeded)
conn.check_complete(pair)

def _stay_controlled(ice_controlling: bool) -> None:
# RFC 8445 §6.1.1: a Lite agent is always controlled; never switch.
return None

# Instance attributes shadow the bound methods; aioice calls
# ``self.check_start(pair)`` / ``self.switch_role(...)`` throughout, so both
# route here.
conn.check_start = _lite_check_start # type: ignore[method-assign]
conn.switch_role = _stay_controlled # type: ignore[method-assign]
14 changes: 12 additions & 2 deletions libp2p/transport/webrtc/listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,12 @@ def _on_unknown_stun(
_, server_ufrag, client_ufrag, client_pwd = parse_direct_username(username)
# Both versions: the dialer's synthetic answer carries
# ufrag == pwd == server_ufrag, so that is our local credential.
# Spec: publicly-reachable server = ICE-Lite (#1512).
conn = mux.add_ice_connection(
server_ufrag, server_ufrag, host=self._candidate_host
server_ufrag,
server_ufrag,
host=self._candidate_host,
ice_lite=True,
)
except (WebRTCConnectionError, ValueError):
logger.debug("WebRTC Direct: rejected first contact from %s", addr)
Expand Down Expand Up @@ -483,7 +487,13 @@ def _make_offer_handler(
bridge: AsyncioBridge,
rtc_cert: Any,
) -> Callable[..., Any]:
"""Build the async handler called for each incoming SDP offer."""
"""
Build the async handler called for each incoming SDP offer.

Experimental ``POST /sdp`` harness only (py↔py). ICE-Lite applies on the
STUN/spec path via ``add_ice_connection(..., ice_lite=True)``; this
harness uses a normal aiortc ICE agent and is not interop-facing.
"""

async def _handle_offer(offer_sdp: str) -> str:
from aiortc import RTCSessionDescription
Expand Down
1 change: 1 addition & 0 deletions newsfragments/1512.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The WebRTC-Direct listener now acts as a true ICE-Lite agent: it answers the dialer's STUN connectivity checks but never initiates its own, matching the spec (the publicly-reachable server "acts as an ICE Lite agent") and go-libp2p / rust-libp2p, whose ICE stacks flip a native lite flag. aioice has no local lite mode, so the muxed listener connection's ``check_start`` is replaced with a respond-only version that completes ICE on the controlling dialer's nomination.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ classifiers = [
Homepage = "https://github.com/libp2p/py-libp2p"

[project.optional-dependencies]
webrtc = ["aiortc>=1.15,<2.0"]
webrtc = ["aiortc>=1.15,<2.0", "aioice>=0.10.0,<0.11"]
health-demo = ["rich>=13.0"]

[project.scripts]
Expand Down
28 changes: 28 additions & 0 deletions tests/core/transport/webrtc/test_udp_mux.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,31 @@ async def _pc_over_mux(self) -> None:
except Exception:
pass
await asyncio.wait_for(mux.close(), 10)


# ---------------------------------------------------------------------------
# make_connection_ice_lite
# ---------------------------------------------------------------------------


class TestIceLite:
def test_lite_agent_stays_controlled(self) -> None:
asyncio.run(self._stays_controlled())

async def _stays_controlled(self) -> None:
mux, _ = await UdpMux.create("127.0.0.1", 0)
try:
conn = mux.add_ice_connection(
"liteufr1",
"litepassword1234567890ab",
host="127.0.0.1",
ice_lite=True,
)
assert conn.ice_controlling is False
# RFC 8445 §6.1.1: a Lite agent is always controlled and never
# switches, even if a peer sends an ICE-CONTROLLED attribute (which
# aioice's request_received would otherwise honour).
conn.switch_role(ice_controlling=True)
assert conn.ice_controlling is False
finally:
await mux.close()
75 changes: 75 additions & 0 deletions tests/core/transport/webrtc/test_webrtc_direct_loopback.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,81 @@ async def echo_handler(conn) -> None: # type: ignore[no-untyped-def]
await server.close()


@pytest.mark.trio
async def test_listener_is_ice_lite_respond_only(monkeypatch):
"""
The listener is a true ICE-Lite agent (#1512): it answers the dialer's STUN
connectivity checks but never initiates its own. We tag the listener's muxed
connections (they alone go through ``make_connection_ice_lite``) and count
outbound STUN binding requests from them — during ICE establishment it must
be zero.

Consent freshness (``query_consent``) is intentionally kept in production but
also sends binding requests once ICE completes; we neutralise it here so the
count reflects *establishment* checks only and the assertion is deterministic
rather than resting on a wall-clock margin before the first consent probe.
"""
import aioice.ice as _ice
from multiaddr import Multiaddr

from libp2p.peer.id import ID
import libp2p.transport.webrtc._udp_mux as _udp_mux

async def _no_consent(self): # type: ignore[no-untyped-def]
return None

monkeypatch.setattr(_ice.Connection, "query_consent", _no_consent)

lite_conn_ids: set[int] = set()
real_make_lite = _udp_mux.make_connection_ice_lite

def _tracking_make_lite(conn): # type: ignore[no-untyped-def]
lite_conn_ids.add(id(conn))
real_make_lite(conn)

monkeypatch.setattr(_udp_mux, "make_connection_ice_lite", _tracking_make_lite)

outbound = {"listener_checks": 0}
real_request = _ice.StunProtocol.request

async def _counting_request(self, *a, **k): # type: ignore[no-untyped-def]
owner = getattr(self, "receiver", None)
if owner is not None and id(owner) in lite_conn_ids:
outbound["listener_checks"] += 1
return await real_request(self, *a, **k)

monkeypatch.setattr(_ice.StunProtocol, "request", _counting_request)

server, server_kp = _transport()
dialer, _ = _transport(webrtc_direct_dial_version=1)
server_id = ID.from_pubkey(server_kp.public_key)

handler_fired = trio.Event()

async def handler(conn) -> None: # type: ignore[no-untyped-def]
handler_fired.set()
await trio.sleep_forever()

listener = server.create_listener(handler)
await listener.listen(Multiaddr(LISTEN_ADDR))
(maddr,) = listener.get_addrs()
try:
with trio.fail_after(30):
conn = await dialer.dial(maddr)
assert conn.peer_id == server_id
await handler_fired.wait()
assert lite_conn_ids, "listener never built a muxed ICE-Lite connection"
assert outbound["listener_checks"] == 0, (
f"ICE-Lite listener sent {outbound['listener_checks']} outbound "
"connectivity checks; a Lite agent must be respond-only"
)
await conn.close()
finally:
await listener.close()
await dialer.close()
await server.close()


@pytest.mark.trio
async def test_concurrent_dials_share_one_udp_port():
"""Two dialers hit the same advertised port; the mux demuxes by ufrag."""
Expand Down
Loading