Skip to content

Milestone 2a: multi-peer data plane + self-certifying addresses - #35

Merged
vxfemboy merged 10 commits into
mainfrom
feat/multipeer-2a
Jul 6, 2026
Merged

Milestone 2a: multi-peer data plane + self-certifying addresses#35
vxfemboy merged 10 commits into
mainfrom
feat/multipeer-2a

Conversation

@vxfemboy

@vxfemboy vxfemboy commented Jul 6, 2026

Copy link
Copy Markdown
Member

Turns yipd from a hand-configured point-to-point tunnel into a multi-peer data plane with self-certifying, key-derived addresses and an in-loop lazy handshake. This is the foundation for the rest of sub-project #2 (rendezvous/NAT traversal 2b, discovery 2c) and unblocks multi-core sharding (#10). Design spec: docs/superpowers/specs/2026-07-04-multipeer-data-plane-design.md.

What's in it

  • Self-certifying addresses (bin/yipd/src/addr.rs): node_addr(pubkey) = 0xfd || BLAKE2s("yip-addr-v1"||pubkey)[0..15] → a /128 in fd00::/8. New yipd --addr <pubkey-hex> prints it.
  • Peer-list config (config.rs): [peer] blocks (public_key/endpoint); legacy single-peer keys fold into peers[0]; initiate removed (lazy handshake replaces it); unknown keys tolerated.
  • Addressed socket seam (crates/yip-io): Dispatch::on_udp(src: SocketAddr, …), EgressDatagram{fate,dst,bytes}, recvfrom/sendto (poll) + recvmsg/sendmsg (uring). unsafe stays confined to yip-io.
  • Handshake step-functions (handshake.rs): non-blocking HandshakeState::{start_initiator, read_response, start_responder}; start_responder recovers the initiator's static key for admission.
  • PeerManager (peer_manager.rs): one DataPlane per peer; routes TUN egress by inner-IPv6-dst → peer node_addr; demuxes UDP ingress by PacketType then source-address; lazy WireGuard-style in-loop handshake with deterministic glare resolution, duplicate-Init handling, and loss-resilient retransmission.
  • tunnel.rs: drops sock.connect + the pre-loop blocking handshake; builds PeerManager, assigns the local node_addr/128 + mesh route.
  • 3-peer full-mesh triangle netns test + CI gating under both drivers; docs/configuration.md rewritten for the 2a model.

Testing

  • Unit: 56 yipd + 25 yip-io (routing/demux, glare convergence, duplicate-Init, loss-retransmit window, pending cap, --addr).
  • netns (both poll and io_uring drivers), all green: single-peer ping / ping_under_loss / arq_recovers_bulk_loss / l2_tap (no regression via the new lazy-handshake path) + the new triangle_full_mesh_ping (real multi-peer by_addr routing). fmt/clippy -D warnings clean.

Review

Built task-by-task (subagent-driven), each task spec+quality reviewed, then a whole-branch review. Findings resolved in-branch: a loss-induced permanent handshake wedge (initiator now retransmits the same Init within a 90s window instead of resetting to a fresh ephemeral), a latent io_uring dual-stack source-address truncation (msg_namelen reset), and stale config docs. One design-level item — handshake anti-replay / authenticated endpoint learning — is deferred to the hardening milestone and tracked in #34.

Deferred (non-goals for 2a)

Decentralized discovery (2c), NAT traversal/relay (2b), per-peer subnets, dynamic admission, multi-core sharding (#10), anti-DPI hardening (#3), handshake anti-replay/rekey (#34).

🤖 Generated with Claude Code

vxfemboy and others added 10 commits July 5, 2026 02:39
Implement the addr module with node_addr() and verify_addr() functions that
derive IPv6 ULA addresses from X25519 public keys using BLAKE2s with domain
separation, enabling self-certification without an authority.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 2 prematurely dropped initiate and hardcoded if false, preventing
single-peer netns tests (Task 3's gates) from running. Restore initiate
to Config with bool parsing and default false; use it in tunnel.rs role
decision. Add DRY helper to extract duplicate peer-block-flush logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sendto (2a seam)

Dispatch::on_udp now takes src: SocketAddr and EgressDatagram carries a dst,
so PollDriver/UringDriver route by address instead of a connected socket.
PollDriver switches recv/send to recvfrom/sendto; UringDriver switches UDP
recv to single-shot recvmsg (recovering src per completion, since multishot
recv can't surface it) and every UDP send to sendmsg with an explicit
destination. DataPlane gains peer_addr, stamped on every egress datagram
(data, ARQ, tick) and ignores src on ingress, so single-peer behavior is
unchanged. GSO coalescing now also requires matching dst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add HandshakeState with start_initiator/start_responder/read_response,
factoring the exact write_message/read_message + finalize-into-Established
steps out of the existing blocking run_initiator/run_responder so Task 5's
PeerManager can drive handshakes in-band from the event loop instead of via
blocking socket I/O. The blocking functions are unchanged and keep their
existing tests.
…dshake (2a)

Introduces bin/yipd/src/peer_manager.rs: a PeerManager implementing
yip_io::poll::Dispatch that wraps one DataPlane per peer and drives a
lazy, WireGuard-style in-loop handshake. Routes TUN egress by inner IPv6
dst -> peer node_addr (single-peer fallback for the netns tests' plain IPs;
L2 forwards to the sole peer), demuxes ingress by PacketType then conn_tag,
and admits a HandshakeInit only when the recovered static key matches a
configured peer.

Deterministic glare resolution: when both peers initiate simultaneously
(the TUN's IPv6 autoconf multicast races peer traffic at startup), the
node with the smaller static key stays the initiator and ignores the
competing Init while the larger key adopts the responder role, so both
converge on one Noise session instead of two mismatched ones.

Supporting changes:
- handshake.rs: start_responder also returns the initiator's recovered
  static pubkey (for admission); blocking run_initiator/run_responder kept
  for their own unit tests.
- config.rs: remove `initiate` (lazy handshake replaces it); parser
  tolerates leftover initiate= keys.
- tunnel.rs: drop sock.connect + pre-loop handshake; build PeerManager,
  assign local node_addr/128 + mesh route, run the driver on it.
- uring.rs: flush_pending_gso groups egress by (fate, dst) so multi-peer
  batches never GSO-merge across peers (Task-3 carry-forward).

Single-peer netns suite stays green under both poll and io_uring drivers
via the new lazy-handshake path (ping, under-loss, ARQ, L2 TAP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t (2a)

The reviewer flagged (Critical) that handle_handshake_init accepted every
HandshakeInit whose glare-tiebreak allowed it, including when the peer was
already Established. Because start_responder draws a fresh Noise ephemeral,
a duplicated/retransmitted Init arriving after establishment rebuilt a
different session while the already-Established initiator dropped the new
HandshakeResp (its position filter requires Handshaking state) — stranding
the two peers on mismatched keys with no recovery. On lossy/adversarial
links (this project's target) UDP duplication is routine, so the arq bulk-
loss netns test failed intermittently with "control open: decryption failed".

Branch on the peer's state via the authenticated remote_static:
- Established: never rebuild. Cache the HandshakeResp bytes at establish
  time and re-send them verbatim on a repeated Init, so a peer whose reply
  was lost completes on the SAME session; an already-established peer
  harmlessly ignores it. (2a has no rekey; restart/rekey deferred to M7.)
- Handshaking + smaller local key: ignore the competing Init (glare tiebreak).
- Idle / Handshaking + larger key: admit (adopt responder role).

Adds a `cached_resp` field to Peer and two unit tests: glare convergence
(both sides initiate -> exactly one responder -> one shared conn_tag) and
duplicate-init-after-established (session untouched, cached reply re-sent).

Also from the review (Minor): assign_mesh_address now logs non-zero `ip`
exit statuses (not just spawn failures); by_tag's M7-eviction requirement
is documented on the field.

Netns suite green under both poll and io_uring (arq stable across repeats;
was flaky before). fmt/clippy/50 yipd + 25 yip-io unit tests all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 6 review (Minor): main.rs's hex_decode_32 duplicated config.rs's
hex_to_32/hex_nibble verbatim. Make config::hex_to_32 pub(crate) and turn
hex_decode_32 into a thin wrapper over it (mapping io::Error -> String for
the CLI path), so the two decoders can't drift. Behavior unchanged; the
--addr tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…; docs (2a)

Whole-branch review findings.

I1 (Important) — loss-induced permanent wedge. The initiator gave up after
5 retries (reverting to Idle + clearing pending), and its next TUN packet
started a FRESH handshake with a NEW ephemeral. Meanwhile a responder that
had established caches its HandshakeResp keyed to the OLD ephemeral and (with
no anti-replay in the handshake, it cannot safely rebuild) replays that stale
reply forever — so the re-initiating side could never complete. On a lossy
link (yip's target) this wedged ~1.5% of handshakes permanently. Fix: the
initiator now retransmits the SAME init (holding one ephemeral) for a long
total window (HANDSHAKE_TOTAL_MS = 90s, WireGuard's REKEY_ATTEMPT_TIME)
instead of a 5-retry cap, so the responder's cached reply stays valid and
ordinary loss is overcome by retransmission. (The review's suggested
"rebuild on a differing Init" was rejected: without handshake anti-replay it
would let a replayed old Init tear down a live session — a worse trade. That
hardening is tracked separately; see the HANDSHAKE_TOTAL_MS doc comment.)
pending_tun is now capped (MAX_PENDING_TUN=16) so retransmitting for 90s
while an app streams cannot grow memory unbounded. Two unit tests added.

I3 (Important, latent) — uring UDP recv slots re-armed without restoring
msg_namelen. recvmsg writes back the actual source-address size (16 v4 / 28
v6) on completion; without a reset, a slot that last received a v4 datagram
truncated a subsequent v6 source address to a wrong value on a dual-stack
underlay. arm_udp_recv_slot now restores full sockaddr_storage capacity
before re-submitting. (Opt-in driver + all-v4 tests, hence latent.)

M1 (Minor) — docs/configuration.md rewritten for the 2a model: [peer]
blocks, lazy handshake (no `initiate`), --addr subcommand, self-certifying
mesh addresses; legacy single-peer keys documented as still supported.

Netns suite green under both drivers; 56 yipd + 25 yip-io unit tests, clippy
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vxfemboy
vxfemboy merged commit fa59586 into main Jul 6, 2026
8 checks passed
@vxfemboy
vxfemboy deleted the feat/multipeer-2a branch July 6, 2026 04:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant