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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,16 +215,16 @@ Trust authorization order:

### NAT Traversal

- **STUN**: RFC 5389 Binding Request to discover public endpoint + NAT type
- **Hole punching**: Rendezvous-mediated UDP probing for cone NATs (4 concurrent, 5s timeout)
- **Relay**: Public-IP mesh member forwards WG ciphertext for symmetric NATs
- **STUN**: RFC 5389 Binding Requests across multiple servers to discover public endpoint + NAT mapping type
- **Hole punching**: Rendezvous-mediated, identity/session-bound UDP probing for cone NATs (4 concurrent, 5s timeout)
- **Relay**: Public-IP relay forwards only opaque WireGuard/Noise ciphertext for symmetric NATs

### Wire Protocol

- Binary codec: type-tag-delimited, fixed-size fields, little-endian
- SWIM: Ping (`0x01`), Ack (`0x03`), PingReq (`0x02`)
- Handshake: Standard WireGuard Noise_IKpsk2 (Type 1, Type 2)
- NAT: HolepunchRequest (`0x33`), HolepunchResponse (`0x34`)
- NAT: RelayData (`0x31`), HolepunchRequest (`0x33`), HolepunchResponse (`0x34`)
- Org: OrgAliasAnnounce (`0x41`), OrgCertRevoke (`0x42`), OrgTrustVouch (`0x43`)

## Benchmarks
Expand Down
35 changes: 23 additions & 12 deletions docs/concepts/nat-traversal.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ Node STUN Server
│ (XOR-MAPPED-ADDRESS) │
```

The response contains the **XOR-MAPPED-ADDRESS** attribute — the node's public IP and port as seen by the STUN server. By comparing the local and external ports:
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:

| Local Port == External Port | NAT Type | Implication |
| --------------------------- | --------- | ------------------------------------------ |
| Yes | `public` | No NAT or full cone — direct connect works |
| No | `cone` | Port-mapped NAT — hole punch will work |
| _(STUN fails)_ | `unknown` | Likely symmetric NAT or firewall |
| Observation | NAT Type | Implication |
| -------------------------------------------- | ----------- | ------------------------------------------ |
| Stable mapping and local port matches | `public` | No NAT or directly reachable mapping |
| Stable mapping and local port differs | `cone` | Endpoint-independent mapping; punch first |
| Mapping changes between STUN servers | `symmetric` | Endpoint-dependent mapping; prefer relay |
| No STUN response | `unknown` | Firewall or undetermined |

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

Expand Down Expand Up @@ -69,10 +70,10 @@ Node A (NATed) Rendezvous (Public) Node B (NATed)
### Details

- **Rendezvous selection**: Any mutual public-IP peer in the membership table
- **Probe magic**: `MGHP` (`0x4D 0x47 0x48 0x50`) — 4-byte packet recognized by `Holepuncher.isProbe()`
- **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 prevents spoofing
- **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
Expand All @@ -84,22 +85,26 @@ Hole punching works for **endpoint-independent mapping** (cone NAT). It fails fo

## Tier 3: Relay Fallback

When hole punching fails, a **public-IP mesh member** serves as a relay:
When hole punching fails, a **public-IP relay** forwards an opaque relay frame:

```
Node A (NATed) ←─WG─→ Relay (public) ←─WG─→ Node B (NATed)
Node A (NATed) -- RelayData(sender=A,target=B,payload=WG bytes) --> Relay
Relay -- RelayData(sender=A,target=B,payload=WG bytes) --> Node B
```

Since WireGuard provides end-to-end encryption, the relay only handles ciphertext. No special relay protocol is needed — the relay is simply a WireGuard peer of both NATed nodes.
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:
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(
Expand All @@ -108,6 +113,12 @@ pub fn selectRelay(
) ?*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:
Expand Down
40 changes: 33 additions & 7 deletions docs/concepts/wire-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,26 @@ SWIM messages use a **1-byte type tag**:

## SWIM / Protocol Codec Tags

The 1-byte tags below are handled by `protocol/codec.zig` after packet
classification has ruled out WireGuard and STUN:
The 1-byte tags below are handled after packet classification has ruled out
WireGuard and STUN. SWIM and hole-punch messages use `protocol/codec.zig`;
`RelayData` uses the ciphertext-only helpers in `nat/relay.zig`.

| Tag | Name | Category | Direction |
| ------ | ----------------- | --------- | --------------- |
| `0x01` | Ping | SWIM | A → B |
| `0x02` | PingReq | SWIM | A → C (probe B) |
| `0x03` | Ack | SWIM | B → A |
| `0x31` | RelayData | NAT | A → Relay → B |
| `0x33` | HolepunchRequest | NAT | A → Rendezvous |
| `0x34` | HolepunchResponse | NAT | B → Rendezvous |
| `0x41` | OrgAliasAnnounce | Org Trust | Gossip |
| `0x42` | OrgCertRevoke | Org Trust | Gossip |
| `0x43` | OrgTrustVouch | Org Trust | Gossip |

`messages.zig` reserves additional enum values for future protocol messages,
but the codec currently decodes the tags listed above. WireGuard handshake,
cookie, and transport packets are classified by their 4-byte WireGuard type
(`1`-`4`), not by this 1-byte table. The FFI app-message path uses `0x50`
outside this codec.
`messages.zig` reserves additional enum values for future protocol messages.
WireGuard handshake, cookie, and transport packets are classified by their
4-byte WireGuard type (`1`-`4`), not by this 1-byte table. The FFI app-message
path uses `0x50` outside this codec.

## Ping

Expand Down Expand Up @@ -119,6 +120,20 @@ Standard Noise_IKpsk2 response message.

Total: **92 bytes**.

## RelayData

Opaque relay frame for WireGuard/Noise packets when direct and punched paths are
unavailable. The relay routes by identity metadata and forwards the payload
unchanged; it is not a WireGuard peer for the relayed tunnel.

```
[0x31][32B sender_pubkey][32B target_pubkey][2B payload_len (BE)][N WireGuard packet bytes]
```

The payload must be shaped like WireGuard message type `1`, `2`, `3`, or `4`
using the standard little-endian WireGuard type field. Non-WireGuard payloads
are rejected by the relay frame decoder.

## HolepunchRequest

```
Expand All @@ -135,6 +150,17 @@ Total: **101 bytes**.

Total: **69 bytes**.

## HolepunchProbe

Raw probe packet, not decoded by `protocol/codec.zig`:

```
["MGHP"][16B punch token]
```

The token is the same identity/session-bound nonce from the corresponding
HolepunchRequest/HolepunchResponse exchange.

## OrgAliasAnnounce

Propagated via gossip to claim a human-readable `*.name.mesh` domain for an org.
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ Reference map of all source modules and their responsibilities.

| File | Purpose |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `stun.zig` | STUN client (RFC 5389): Binding Request/Response encoding, XOR-MAPPED-ADDRESS parsing, NAT type detection |
| `holepunch.zig` | UDP hole punching: `Holepuncher` state machine, probe magic (`MGHP`), rendezvous-mediated exchange, 4 concurrent slots |
| `relay.zig` | Relay selection: best public-IP peer by RTT, capacity checking, `RelayInfo` struct |
| `stun.zig` | STUN client (RFC 5389): Binding Request/Response encoding, XOR-MAPPED-ADDRESS parsing, multi-server NAT classification |
| `holepunch.zig` | UDP hole punching: `Holepuncher` state machine, token-bound `MGHP` probes, rendezvous-mediated exchange |
| `relay.zig` | Ciphertext-only relay/rendezvous core: signed endpoint registration, relay frame codec, per-identity rate limiting |
| `upnp.zig` | UPnP-IGD port forwarding: SSDP discovery, SOAP AddPortMapping, lease renewal |
| `coordinated_punch.zig` | Token-based coordinated punch: `meshguard connect` token exchange for direct peer setup |

Expand Down
Loading
Loading