Skip to content

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

dart_libp2p reaches peers behind NAT in the go-libp2p way:

  1. A public relay runs Circuit Relay v2 (/libp2p/circuit/relay/0.2.0/hop).
  2. A NAT’d peer uses AutoRelay to reserve a slot and advertises <relay addr>/p2p/<relay>/p2p-circuit/p2p/<self>.
  3. Another peer dials that address; the relay splices the two streams, and the relayed connection gets its own Noise + Yamux inside.
  4. DCUtR (/libp2p/dcutr) then hole-punches a direct UDX connection; new streams move to it.
  5. 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.

dependencies:
dart_libp2p: ^4.12.0
import 'dart:async'; // Timer
import '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'; // EvtAutoRelayAddrsUpdated
import 'package:dart_libp2p/p2p/host/basic/basic_host.dart'; // BasicHost extras
import 'package:dart_libp2p/p2p/discovery/mdns/mdns.dart'; // MdnsDiscovery, MdnsNotifee

The pattern below is the one the internet_chat demo runs in production (dart-libp2p-demos/internet_chat/lib/src/host.dart).

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
],
]);
}
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.

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);
}
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.

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.

host.eventBus.subscribe(EvtLocalReachabilityChanged).stream.listen((dynamic e) {
if (e is EvtLocalReachabilityChanged) print('reachability ${e.reachability}');
});
(host as BasicHost).autoNATService?.status; // Reachability.unknown/public/private

Tune 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.

import 'package:dart_libp2p/p2p/protocol/circuitv2/client/reservation.dart'; // reserve() extension
final client = (host as BasicHost).circuitV2Client!; // needs relay or autoRelay on
final rsvp = await client.reserve(relayId);
print('expires ${rsvp.expire}, limit ${rsvp.limitDuration} / ${rsvp.limitData} bytes');

A manual reservation is not refreshed; AutoRelay’s are.

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.addrs
final 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:.

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.

  • Libp2p.relay(true): RelayManager starts 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 + RelayFinder reserve on connected peers that speak hop; emits EvtAutoRelayAddrsUpdated(advertisableAddrs).
  • Libp2p.relayServers(List<String>): full /p2p/<id> multiaddr strings dialled in start() (10 s each, failures logged).
  • CircuitV2Client: the /p2p-circuit transport 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; logger p2p-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).
  • A relay with relay(true) alone does nothing until it is public. Use forceReachability(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 Libp2p option 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 on 127.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-circuit address is not dialable and is not advertised.
  • AutoRelay starts while reachability is unknown (no need to emit a fake private event, 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 (relayServers does).
  • The default AutoRelay timing in BasicHost is a 5 s boot delay and 10 s re-check; tests should wait for EvtAutoRelayAddrsUpdated, 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. relayServers and 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.addrs at start(): 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 and mdns_dart cannot bind it, so Mac instances do not find each other; test on Linux or in Docker. reusePort is off by default on Android. mDNS does not connect for you; connect in handlePeerFound.
  • 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.
  • In-process relay: three hosts on loopback. The relay uses Libp2p.relay(true), forceReachability(Reachability.public) and Libp2p.addrsFactory((a) => a) (keep loopback). Peers use autoRelay(true) and relayServers(['<relay loopback addr>/p2p/<relay id>']). Wait for EvtAutoRelayAddrsUpdated with a timeout, dial <relay>/p2p-circuit/p2p/<B> from A, then assert the connection hasProtocol('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/, demo docker compose -f docker/compose.yml up, NAT_TYPE=symmetric for the failing case).
  • go-libp2p interop: test/interop/go_interop_relay_test.dart runs 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.
  • 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 limited and 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 with PeerIdMismatchException, by design.
  • Never rotate a relay’s key casually: every peer’s configured relay address contains its peer ID.

Built by Stephan Februarygithub.com/stephanfebLinkedIn