dart-libp2p-nat-relay
Skill
dart-libp2p-nat-relay. Use when making dart_libp2p peers reachable across NATs in Dart: running a Circuit Relay v2 relay, AutoRelay reservations, dialing via /p2p-circuit, AutoNAT v2 reachability, DCUtR hole punching over UDX, mDNS local discovery (MdnsDiscovery), Happy Eyeballs dialing, go-libp2p relay interop.SKILL.md (Claude Code, Codex, Cursor) · dart-libp2p-nat-relay.md (Boubou) · install:
curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- dart-libp2p-nat-relay
What it is
Section titled “What it is”dart_libp2p reaches peers behind NAT in the go-libp2p way:
- A public relay runs Circuit Relay v2 (
/libp2p/circuit/relay/0.2.0/hop). - A NAT’d peer uses AutoRelay to reserve a slot and advertises
<relay addr>/p2p/<relay>/p2p-circuit/p2p/<self>. - Another peer dials that address; the relay splices the two streams, and the relayed connection gets its own Noise + Yamux inside.
- DCUtR (
/libp2p/dcutr) then hole-punches a direct UDX connection; new streams move to it. - AutoNAT v2 tells a host whether it is public or private.
mDNS (MdnsDiscovery) finds peers on the same LAN only. For internet
discovery use the DHT (dart-libp2p-kad-dht). Host basics:
dart-libp2p-host. Browser-style WebRTC over a relay: dart-libp2p-webrtc.
Install
Section titled “Install”dependencies: dart_libp2p: ^4.12.0import 'dart:async'; // Timerimport 'package:dart_libp2p/dart_libp2p.dart' hide Option;import 'package:dart_libp2p/config/config.dart';import 'package:dart_libp2p/p2p/transport/udx_transport.dart';import 'package:dart_libp2p/p2p/transport/tcp_transport.dart';import 'package:dart_libp2p/p2p/transport/connection_manager.dart';import 'package:dart_libp2p/p2p/host/autorelay/autorelay.dart'; // EvtAutoRelayAddrsUpdatedimport 'package:dart_libp2p/p2p/host/basic/basic_host.dart'; // BasicHost extrasimport 'package:dart_libp2p/p2p/discovery/mdns/mdns.dart'; // MdnsDiscovery, MdnsNotifeeCore workflow
Section titled “Core workflow”The pattern below is the one the internet_chat demo runs in production
(dart-libp2p-demos/internet_chat/lib/src/host.dart).
Shared host builder
Section titled “Shared host builder”Future<Host> buildHost({ required KeyPair identity, required bool relay, int port = 0, List<MultiAddr> announce = const [], // public addrs a cloud VM cannot see List<String> relayServers = const [], // '/ip4/x/udp/4001/udx/p2p/12D3...'}) async { final cm = ConnectionManager(); return Libp2p.new_([ Libp2p.identity(identity), // relays: a persisted key Libp2p.connManager(cm), Libp2p.transport(UDXTransport(connManager: cm)), // UDP: needed for punching Libp2p.transport(TCPTransport(connManager: cm)), // fallback where UDP is blocked Libp2p.listenAddrs([ MultiAddr('/ip4/0.0.0.0/udp/$port/udx'), MultiAddr('/ip4/0.0.0.0/tcp/$port'), ]), Libp2p.addrsFactory((addrs) => [ ...addrs.where((a) => !a.isLoopback() && a.valueForProtocol('ip4') != '0.0.0.0' && a.valueForProtocol('ip6') != '::'), ...announce, ]), Libp2p.holePunching(true), if (relay) ...[ Libp2p.relay(true), Libp2p.forceReachability(Reachability.public), // relay service starts only when public ] else ...[ Libp2p.autoRelay(true), Libp2p.relayServers(relayServers), // dialled during start() Libp2p.observedAddrActivationThreshold(1), // trust the relay's observation ], ]);}Run a relay
Section titled “Run a relay”final relay = await buildHost(identity: await loadKey('relay.key'), relay: true, port: 4001, announce: [MultiAddr('/ip4/203.0.113.7/udp/4001/udx'), MultiAddr('/ip4/203.0.113.7/tcp/4001')]);await relay.start();for (final a in relay.addrs.where((a) => !a.hasProtocol('p2p-circuit'))) { print('$a/p2p/${relay.id.toBase58()}'); // hand these to peers}Open UDP and TCP on the port. The relay also answers AutoNAT v2 checks (server on by default) and Identify, so peers learn their public address from it.
NAT’d peer: reserve and get advertised
Section titled “NAT’d peer: reserve and get advertised”final peer = await buildHost(identity: kp, relay: false, relayServers: relayAddrs);await peer.start(); // connects to relayServers; AutoRelay starts at once // (reachability unknown/private) and reserves
peer.eventBus.subscribe(EvtAutoRelayAddrsUpdated).stream.listen((dynamic e) { if (e is! EvtAutoRelayAddrsUpdated) return; for (final a in e.advertisableAddrs.where((a) => a.hasProtocol('p2p-circuit'))) { print('reachable at $a'); // .../p2p/<relay>/p2p-circuit/p2p/<self> }});The circuit addresses also appear in peer.addrs, in Identify and in the
signed peer record. AutoRelay refreshes the reservation and books again when
the relay connection returns (4.8.1). If the relay goes away, reconnect it
yourself, as the demo does:
for (final r in relayAddrs) { final info = AddrInfo.fromMultiaddr(MultiAddr(r)); if (peer.network.connsToPeer(info.id).isEmpty) await peer.connect(info);}Dial a peer through the relay
Section titled “Dial a peer through the relay”final viaRelay = [ for (final r in relayAddrs) MultiAddr('$r/p2p-circuit/p2p/${target.toBase58()}'),];await host.connect(AddrInfo(target, viaRelay));final relayed = host.network.connsToPeer(target) .any((c) => c.remoteMultiaddr.hasProtocol('p2p-circuit'));The dialling host needs Libp2p.relay(true) or Libp2p.autoRelay(true):
either registers the circuit transport (CircuitV2Client). The target must
hold a reservation on that relay, or the dial fails with NO_RESERVATION.
Hole punching (DCUtR)
Section titled “Hole punching (DCUtR)”Automatic: when a host accepts a relayed connection, it starts DCUtR with the dialer. Both sides exchange their public addresses over the relay and dial each other at the same time over UDX. On success the direct connection is added and new streams prefer it; the relayed one stays as a fallback.
// Watch for the switch:Timer.periodic(const Duration(seconds: 2), (_) { final direct = host.network.connsToPeer(target) .where((c) => !c.remoteMultiaddr.hasProtocol('p2p-circuit')); if (direct.isNotEmpty) print('direct: ${direct.first.remoteMultiaddr}');});
// Or trigger it explicitly over an existing relayed connection:await host.holePunchService?.directConnect(target);If both peers might dial each other at the same moment, let only one dial
(the demo dials only when host.id.toBase58().compareTo(peer.toBase58()) < 0),
or the two punches collide.
Reachability (AutoNAT v2)
Section titled “Reachability (AutoNAT v2)”host.eventBus.subscribe(EvtLocalReachabilityChanged).stream.listen((dynamic e) { if (e is EvtLocalReachabilityChanged) print('reachability ${e.reachability}');});(host as BasicHost).autoNATService?.status; // Reachability.unknown/public/privateTune the client with Libp2p.ambientAutoNATv2Config(AmbientAutoNATv2Config( bootDelay: ..., retryInterval: ..., refreshInterval: ..., ipv4Only: ...))
(p2p/host/autonat/ambient_config.dart; defaults 15 s / 1 min / 15 min).
Turn off answering others’ checks on phones: Libp2p.autoNATv2Server(false).
For local test networks a server needs Libp2p.autoNATv2AllowPrivateAddrs().
Optional STUN NAT-type probe: Libp2p.stunNatDetection(true, servers: [...])
emits EvtNATDeviceTypeChanged; BasicHost.udpNatDeviceType reads it.
Manual reservation (tests, custom relays)
Section titled “Manual reservation (tests, custom relays)”import 'package:dart_libp2p/p2p/protocol/circuitv2/client/reservation.dart'; // reserve() extension
final client = (host as BasicHost).circuitV2Client!; // needs relay or autoRelay onfinal rsvp = await client.reserve(relayId);print('expires ${rsvp.expire}, limit ${rsvp.limitDuration} / ${rsvp.limitData} bytes');A manual reservation is not refreshed; AutoRelay’s are.
mDNS on the LAN
Section titled “mDNS on the LAN”class Found implements MdnsNotifee { Found(this.host); final Host host; @override void handlePeerFound(AddrInfo peer) { if (host.network.connectedness(peer.id) != Connectedness.connected) { host.connect(peer).catchError((Object e) => print('dial ${peer.id}: $e')); } }}
await host.start(); // first: mDNS advertises host.addrsfinal mdns = MdnsDiscovery(host, notifee: Found(host));await mdns.start();// ...await mdns.stop();Service _p2p._udp.local, TXT records dnsaddr=<addr>/p2p/<id>.
MdnsQueryOptions (timeout, interface, IPv4/IPv6 only, reusePort,
reuseAddress, multicast TTL) goes in queryOptions:.
Happy Eyeballs
Section titled “Happy Eyeballs”Swarm.dialPeer ranks a peer’s addresses (public IPv6, public IPv4, private
IPv4, then relay addresses) and starts them 250 ms apart; the first connection
wins and the losers are cancelled (UDX dials stop sending, 4.5.0). Direct
attempts time out after Libp2p.dialTimeout (15 s), relayed ones after
Libp2p.relayDialTimeout (30 s). Circuit addresses are deduplicated to one
attempt per relay, so a slow relay handshake does not open several relayed
connections to the same peer. Nothing to configure; give the peerstore all
addresses and let it race.
Key concepts / API map
Section titled “Key concepts / API map”Libp2p.relay(true):RelayManagerstarts a Circuit Relay v2 service when reachability becomes public; stops it when private.Libp2p.forceReachability(Reachability.public): emits public at start and skips ambient AutoNAT probing.Libp2p.autoRelay(true):AutoRelay+RelayFinderreserve on connected peers that speak hop; emitsEvtAutoRelayAddrsUpdated(advertisableAddrs).Libp2p.relayServers(List<String>): full/p2p/<id>multiaddr strings dialled instart()(10 s each, failures logged).CircuitV2Client: the/p2p-circuittransport and STOP handler;BasicHost.circuitV2Client;reserve(relayId)→Reservation(expire,addrs,voucher,limitDuration,limitData).CircuitV2Protocol.protoIDv2Hop/protoIDv2Stop(p2p/protocol/circuitv2/proto.dart).HolePunchService.directConnect(peer);host.holePunchService; protocol/libp2p/dcutr; loggerp2p-holepunch.AmbientAutoNATv2(BasicHost.autoNATService),AmbientAutoNATv2Config; AutoNAT v2 protocols/libp2p/autonat/2/dial-request,/libp2p/autonat/2/dial-back.EvtLocalReachabilityChanged(reachability),EvtNATDeviceTypeChanged.Conn.stat.stats.limited: true when the relay announced limits;Swarm.isRelayedRoute(addr).MdnsDiscovery(host, {serviceName, notifee, queryOptions}),start(),stop(),findPeers(ns);MdnsNotifee.handlePeerFound(AddrInfo).
Gotchas
Section titled “Gotchas”- A relay with
relay(true)alone does nothing until it is public. UseforceReachability(Reachability.public)on a dedicated relay; turning AutoNAT off does not make it public. - Relayed connections are limited. A dart_libp2p relay allows 1 h and
1 MiB per relayed connection (fixed defaults; no
Libp2poption changes them) and 10 HOP requests per minute per peer; a go-libp2p relay allows 2 min and 128 KiB. Use the relay to coordinate and for small messages; move bulk data to the direct connection, or design the app to survive the relayed connection ending (the demo routes chat through the relay as a GossipSub member). - go-libp2p peers will not open streams on a limited connection unless
their code allows it (
network.WithAllowLimitedConn). Expect Go services to answer only after DCUtR made a direct connection. - Hole punching needs UDX and public addresses. TCP ignores the simultaneous-connect hint. From 4.1.8 a punch uses only public addresses: two peers whose only addresses are private do not punch (on one LAN they connect directly). Symmetric NATs (mobile carriers) defeat punching; with STUN detection on, the puncher tries once instead of three times.
- A NAT’d peer must learn its public address. It comes from Identify
observations, which by default need 4 peers to agree. A peer that knows
only its relay must set
Libp2p.observedAddrActivationThreshold(1), or DCUtR has nothing to send. UDX dials leave from the listen socket (4.1.0) so the observed port is the real mapping; do not listen only on127.0.0.1. - Advertised circuit addresses already end in
/p2p/<self>. Older docs say to append the destination ID; check before appending twice. When you build one yourself, the form is<relay addr>/p2p/<relay id>/p2p-circuit/p2p/<target>. A bare/p2p-circuitaddress is not dialable and is not advertised. - AutoRelay starts while reachability is unknown (no need to emit a fake
privateevent, which old docs show) and stops when it turns public. Its relay candidates are connected peers whose Identify listed the hop protocol, so connect to the relay first (relayServersdoes). - The default AutoRelay timing in
BasicHostis a 5 s boot delay and 10 s re-check; tests should wait forEvtAutoRelayAddrsUpdated, not a fixed sleep. - Before 4.8.1 a node behind NAT stayed undialable up to an hour after its relay connection dropped (no connectedness event, a 1-hour backoff). Use a current version.
- Two peers dialling each other through one relay at once closed each other’s connection before 4.6.3; now kept. Still prefer one dialer.
- Relay addresses need IPs.
relayServersand dial addresses with/dns4/...are not dialable by TCP or UDX; resolve first. - AutoNAT v2 ignores relay addresses (4.1.7): a host whose only public
address is a circuit address stays
unknown. That is correct. - mDNS is LAN-only and silent on failure. It advertises
host.addrsatstart(): start the host first, and do not listen only on loopback (filtered out), or nothing is advertised. On macOS the system responder holds UDP 5353 andmdns_dartcannot bind it, so Mac instances do not find each other; test on Linux or in Docker.reusePortis off by default on Android. mDNS does not connect for you; connect inhandlePeerFound. - AutoNAT v2 server dial-backs come from a separate host with its own key (4.0.0, as go-libp2p); do not treat that peer ID as the server.
Testing
Section titled “Testing”- In-process relay: three hosts on loopback. The relay uses
Libp2p.relay(true),forceReachability(Reachability.public)andLibp2p.addrsFactory((a) => a)(keep loopback). Peers useautoRelay(true)andrelayServers(['<relay loopback addr>/p2p/<relay id>']). Wait forEvtAutoRelayAddrsUpdatedwith a timeout, dial<relay>/p2p-circuit/p2p/<B>from A, then assert the connectionhasProtocol('p2p-circuit'). To force the relay path, keep only circuit addresses in the peerstore (addrBook.clearAddrs(B)first). - Hole punching cannot be tested on loopback: no NAT, and private addresses
are not punched. The repo uses Docker NAT harnesses
(
test/integration/holepunch_network/, demodocker compose -f docker/compose.yml up,NAT_TYPE=symmetricfor the failing case). - go-libp2p interop:
test/interop/go_interop_relay_test.dartruns a Go relay and Go peers over TCP (Dart dials a Go peer through a Go relay; Go dials Dart through a reservation). DCUtR with Go needs a Go peer with UDX. - Close every host in
tearDown; relay and AutoRelay timers otherwise keep the test process alive.
Red lines
Section titled “Red lines”- Never run a public relay with
FixedLimiter/NullResourceManager; keep resource limits on a node strangers connect to. - Never treat a relay as trusted for content: it sees who talks to whom and can drop traffic, but cannot read it (end-to-end Noise). Sign application data that matters.
- Never send large or long transfers over a limited relayed connection and call
it done; detect
limitedand wait for (or require) a direct connection. - Never hard-code a relay without its
/p2p/<id>; dialling an address that a different peer answers fails withPeerIdMismatchException, by design. - Never rotate a relay’s key casually: every peer’s configured relay address contains its peer ID.
Reference
Section titled “Reference”- Relay setup: https://cryptopeer.net/libp2p/core/relay-setup/
- Transports: https://cryptopeer.net/libp2p/core/transports/
- Configuration: https://cryptopeer.net/libp2p/core/configuration/
- Event bus: https://cryptopeer.net/libp2p/core/event-bus/
- Internet chat demo (relay + DHT + GossipSub + DCUtR): https://cryptopeer.net/demos/internet-chat/
- Source and CHANGELOG: https://github.com/stephanfeb/dart_libp2p
- Demo source: https://github.com/stephanfeb/dart-libp2p-demos