Milestone 2a: multi-peer data plane + self-certifying addresses - #35
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns
yipdfrom 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
bin/yipd/src/addr.rs):node_addr(pubkey) = 0xfd || BLAKE2s("yip-addr-v1"||pubkey)[0..15]→ a/128infd00::/8. Newyipd --addr <pubkey-hex>prints it.config.rs):[peer]blocks (public_key/endpoint); legacy single-peer keys fold intopeers[0];initiateremoved (lazy handshake replaces it); unknown keys tolerated.crates/yip-io):Dispatch::on_udp(src: SocketAddr, …),EgressDatagram{fate,dst,bytes},recvfrom/sendto(poll) +recvmsg/sendmsg(uring).unsafestays confined toyip-io.handshake.rs): non-blockingHandshakeState::{start_initiator, read_response, start_responder};start_responderrecovers the initiator's static key for admission.peer_manager.rs): oneDataPlaneper peer; routes TUN egress by inner-IPv6-dst → peernode_addr; demuxes UDP ingress byPacketTypethen source-address; lazy WireGuard-style in-loop handshake with deterministic glare resolution, duplicate-Init handling, and loss-resilient retransmission.sock.connect+ the pre-loop blocking handshake; buildsPeerManager, assigns the localnode_addr/128+ mesh route.docs/configuration.mdrewritten for the 2a model.Testing
yipd+ 25yip-io(routing/demux, glare convergence, duplicate-Init, loss-retransmit window, pending cap,--addr).pollandio_uringdrivers), all green: single-peerping/ping_under_loss/arq_recovers_bulk_loss/l2_tap(no regression via the new lazy-handshake path) + the newtriangle_full_mesh_ping(real multi-peerby_addrrouting).fmt/clippy -D warningsclean.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_uringdual-stack source-address truncation (msg_namelenreset), 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