Skip to content

NAT Traversal

meshguard uses a three-tier strategy to establish direct connections between peers behind NAT:

  1. STUN — discover your public IP and port
  2. UDP Hole Punching — create direct paths through cone NATs
  3. Relay — fallback for symmetric NATs

Tier 1: STUN Discovery

On startup, meshguard sends STUN Binding Requests (RFC 5389) to discover the node's public endpoint:

Node                          STUN Server
  │  Binding Request ────────►  │
  │  ◄──── Binding Response ──  │
  │    (XOR-MAPPED-ADDRESS)     │

The response contains the XOR-MAPPED-ADDRESS attribute — the node's public IP and port as seen by the STUN server. meshguard compares observations from up to two STUN servers:

ObservationNAT TypeImplication
Stable mapping and local port matchespublicNo NAT or directly reachable mapping
Stable mapping and local port differsconeEndpoint-independent mapping; punch first
Mapping changes between STUN serverssymmetricEndpoint-dependent mapping; prefer relay
No STUN responseunknownFirewall or undetermined

The discovered public endpoint is then shared via gossip, so other peers know how to reach this node.

STUN Servers

The runtime STUN client resolves the configured host:port entries from Config.stun_servers:

ServerPort
stun.l.google.com19302
stun.cloudflare.com3478

If no configured server resolves, meshguard falls back to deterministic IPv4 endpoints for the same services so environments without working DNS still have a stable startup path.

Tier 2: UDP Hole Punching

When both peers are behind cone NATs, meshguard performs rendezvous-mediated hole punching from SWIM membership:

Protocol

Node A (NATed)          Rendezvous (Public)         Node B (NATed)
    │                         │                          │
    │── HolepunchRequest ────►│                          │
    │   (A's pub endpoint,    │── HolepunchRequest ─────►│
    │    target=B, token)     │   (forwarded)            │
    │                         │                          │
    │                         │◄── HolepunchResponse ────│
    │◄── HolepunchResponse ──-│   (B's pub endpoint,     │
    │   (forwarded)           │    token_echo)            │
    │                         │                          │
    │────── UDP Probe ───────────────────────────────────►│
    │◄───── UDP Probe ──────────────────────────────────-─│
    │          (MGHP magic: 0x4D474850)                  │
    │                                                    │
    │◄═══════ WireGuard tunnel established ══════════════►│

Details

  • Rendezvous selection: Any mutual public-IP peer in the membership table
  • Probe magic: MGHP (0x4D 0x47 0x48 0x50) followed by the 16-byte punch token
  • Probe timing: Every 200ms, up to 25 probes (5-second timeout)
  • Concurrency: Up to 4 concurrent hole punch attempts
  • Token verification: Random 16-byte nonce is bound to the initiator/target identities and echoed in probes

The meshguard connect token-exchange command uses a separate coordinated punch path in coordinated_punch.zig. Its raw probe magic is MGCP and the probe includes an 8-byte nonce from the signed token.

When Hole Punching Fails

Hole punching works for endpoint-independent mapping (cone NAT). It fails for symmetric NAT where each destination gets a different external port. In that case, meshguard falls back to relay.

Tier 3: Relay Fallback

When hole punching fails, a public-IP relay forwards an opaque relay frame:

Node A (NATed) -- RelayData(sender=A,target=B,payload=WG bytes) --> Relay
Relay          -- RelayData(sender=A,target=B,payload=WG bytes) --> Node B

The relay frame carries routing metadata plus a WireGuard/Noise packet payload (message types 1-4). The relay validates that the payload is shaped like a WireGuard packet, rate-limits by identity, and forwards the bytes unchanged. It is never a WireGuard peer for the relayed tunnel and never receives plaintext or authorization authority.

Hosted rendezvous registration is identity-authenticated: the relay issues a nonce, and the node signs (identity pubkey, endpoint, nonce) with its Ed25519 identity key before the relay stores the identity -> observed endpoint mapping.

Relay Selection

The relay.zig module selects the best relay candidate and exposes the hosted relay/rendezvous core:

  1. Must be alive in the membership table
  2. Must be a public NAT type
  3. Must be relay-capable (not at capacity — default max: 10 relay connections)
  4. Prefer lowest RTT (measured by SWIM ping round-trips)
  5. Prefer direct path, then hole punch, then relay
zig
pub fn selectRelay(
    peers: *std.AutoHashMap([32]u8, Membership.Peer),
    exclude: ?[32]u8,
) ?*const Membership.Peer

Relay frames use wire type 0x31:

[0x31][32B sender pubkey][32B target pubkey][2B payload length][WireGuard packet bytes]

NAT Type Classification

Each node's NAT type is broadcast via gossip, so the mesh knows which peers need relaying:

NatTypeValueMeaning
public0No NAT — can receive unsolicited UDP
cone1Endpoint-independent mapping — hole punch works
symmetric2Endpoint-dependent mapping — relay required
unknown3STUN failed or undetermined

Released under the MIT License.