feat: add ciphertext-only relay rendezvous#134
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f0301dc98
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (data.len > 0 and data[0] == @intFromEnum(messages.MessageType.relay_data)) { | ||
| self.handleRelayData(data, sender_endpoint); | ||
| return; |
There was a problem hiding this comment.
Gate relay frames with the trust checks
When a node is running with enforce_trust, this early relay_data branch bypasses the isAuthorizedPeer gate below entirely; handleRelayData then forwards based only on the target being in membership, and the sender identity used for rate limiting is just unauthenticated frame bytes. An outside host that knows a target pubkey can therefore rotate fake sender pubkeys and use a trusted node as a relay for arbitrary WG-looking datagrams, so relay frames should verify the sender/target against the same trust or membership policy before forwarding.
Useful? React with 👍 / 👎.
| pub const MAX_RELAY_PAYLOAD: usize = 2048; | ||
| pub const RELAY_FRAME_HEADER_SIZE: usize = 1 + 32 + 32 + 2; |
There was a problem hiding this comment.
Keep relay frames within receive buffers
MAX_RELAY_PAYLOAD allows a valid relay datagram of 67 + 2048 = 2115 bytes, but the newly wired receive paths use 2048-byte UDP buffers in the main event loops and SwimProtocol.tick/tickReceiveOnly still use 1500-byte buffers. Those receivers will truncate any max-sized frame, and gossip-only relay nodes will also truncate ordinary 1500-byte WG transport frames once the relay header is added, causing decodeRelayData to reject them as invalid.
Useful? React with 👍 / 👎.
| if (h.onRelayWgPacket) |cb| { | ||
| cb(h.ctx, frame.payload, frame.sender_pubkey, sender_endpoint); | ||
| } else if (h.onWgPacket) |cb| { | ||
| cb(h.ctx, frame.payload, sender_endpoint.addr, sender_endpoint.port); |
There was a problem hiding this comment.
Preserve relay sender when delivering to callbacks
If a consumer only registers onWgPacket—as the current FFI handlers do—this fallback drops frame.sender_pubkey and passes the relay's UDP endpoint as if it were the peer. The callback then sends handshake responses as raw WireGuard packets back to the relay instead of wrapping them in RelayData for the original sender, so relayed handshakes in that path cannot complete; require onRelayWgPacket or implement a relay-aware fallback.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Adds a ciphertext-only relay/rendezvous fallback to MeshGuard’s NAT traversal so symmetric-NAT peers can still form/maintain tunnels without giving the relay plaintext visibility or authorization power.
Changes:
- Introduces
RelayData(0x31) frames: sender/target identity metadata + opaque WireGuard/Noise packet bytes (types 1–4 only). - Improves NAT detection by classifying endpoint-dependent (symmetric) mappings using up to two STUN observations.
- Hardens hole punching by binding responses and probes to both peer identity and a shared session token; updates SWIM routing to forward relay frames.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/protocol/messages.zig | Adds RelayData message type/struct and reserves tag 0x31. |
| src/nat/stun.zig | Adds multi-server NAT mapping classification (symmetric vs cone/public). |
| src/nat/relay.zig | Adds relay frame codec, rendezvous registration primitives, and per-identity rate limiting. |
| src/nat/holepunch.zig | Makes probes token-carrying and binds response/probe handling to identity + token. |
| src/discovery/swim.zig | Adds relay frame forwarding/delivery path and relay rate limiting in SWIM. |
| src/main.zig | Adds relay-aware send/receive handling for WG ciphertext (direct → punch → relay). |
| README.md | Updates NAT traversal + protocol documentation to include RelayData and new behaviors. |
| docs/reference/modules.md | Updates module map to reflect new relay/rendezvous responsibilities. |
| docs/concepts/wire-protocol.md | Documents RelayData and token-bound holepunch probe wire formats. |
| docs/concepts/nat-traversal.md | Documents multi-STUN classification, token-bound punching, and relay fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const Noise = lib.wireguard.Noise; | ||
|
|
||
| var tun_buf: [65536]u8 = undefined; | ||
| var udp_recv_buf: [2048]u8 = undefined; |
| const Noise = lib.wireguard.Noise; | ||
|
|
||
| var tun_buf: [65536]u8 = undefined; | ||
| var udp_recv_buf: [2048]u8 = undefined; |
| fn findOrInsert(self: *IdentityRateLimiter, pubkey: [32]u8, now_ns: i128) ?usize { | ||
| var empty: ?usize = null; | ||
| for (&self.entries, 0..) |*entry, i| { | ||
| if (entry.*) |e| { | ||
| if (std.mem.eql(u8, &e.pubkey, &pubkey)) return i; | ||
| } else if (empty == null) { | ||
| empty = i; | ||
| } | ||
| } | ||
| const slot = empty orelse return null; | ||
| self.entries[slot] = .{ | ||
| .pubkey = pubkey, | ||
| .tokens = BURST, | ||
| .last_refill_ns = now_ns, | ||
| }; | ||
| return slot; | ||
| } |
| if (path == .direct or path == .holepunch or !relay_available) { | ||
| if (direct_ep.addr6 == null) { | ||
| tx.queue(payload, direct_ep.addr, direct_ep.port); | ||
| send_idx.* += 1; | ||
| } | ||
| return; | ||
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 35a403fccb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| if (relay_ep) |ep| { | ||
| if (ep.addr6 != null) return; | ||
| const relay_len = lib.nat.Relay.encodeRelayData(relay_buf, swim.our_pubkey, peer.identity_key, payload) catch return; |
There was a problem hiding this comment.
Route pipeline packets through relay too
When users enable --encrypt-workers, userspaceEventLoop uses the pipeline path instead of dataPlaneWorker, so this relay wrapping is never executed; the pipeline still stages only peers with endpoint_addr[0] != 0 and flushes raw WG bytes to peer.endpoint_addr/port in flushPeerTxRing. Relay-only/symmetric peers therefore stop carrying TUN traffic in pipeline mode even though handshakes can use relay, so the relay endpoint/encoded frame needs to be carried through the pipeline send path too.
Useful? React with 👍 / 👎.
| ) void { | ||
| if (!swim.isAllowedRelayParticipant(frame.sender_pubkey)) return; | ||
| if (!swim.isAllowedRelayParticipant(frame.target_pubkey)) return; | ||
| if (!swim.relay_limiter.allow(frame.sender_pubkey, nowAwakeNs())) return; |
There was a problem hiding this comment.
Rate-limit relayed packets by authenticated peer
Fresh evidence: even after the new participant checks, this target-side path consumes the bucket for unauthenticated frame.sender_pubkey before the WG payload is authenticated, and the decrypted identity from decryptTransport or the handshake slot is never compared with it. In an enforce_trust mesh, any admitted peer can wrap its own valid WG packets while naming another admitted peer, draining that peer's relay quota or rotating labels to bypass its own per-identity limit; reject mismatches or rate-limit by the authenticated WG identity after successful decrypt/handshake.
Useful? React with 👍 / 👎.
Closes #123.
Summary
(pubkey, endpoint, challenge), lookup storage, and per-identity rate limiting.RelayDataframes that carry sender/target identity metadata plus opaque WireGuard/Noise packet bytes, accepting only WG message types 1-4.Validation
zig build --summary allzig build test --summary all(132/132 tests passed)zig build -Dtarget=x86_64-linux-gnu -Dno-sodium=true --summary allzig build -Dtarget=aarch64-macos -Dno-sodium=true --summary allzig build -Dtarget=x86_64-freebsd -Dno-sodium=true --summary allbuild+build test -Dno-sodium=true(270/270 tests passed)build+build test -Dno-sodium=true(270/270 tests passed)git diff --check(only existing CRLF normalization warnings)Note: the Linux WSL/WSLC test runs still print the pre-existing
unexpected errno: 9stack traces from inert/fake UDP socket SWIM tests, but both runs finish with successful build summaries and all tests passing.