Net v0.27.7 — "Purple Rain"
A NAT-traversal & port-discovery security-hardening release. Two focused code reviews — the rendezvous/hole-punch runtime (adapter/net/traversal/** + mesh.rs) and the port-discovery surfaces (UPnP-IGD + the NAT-classification reflex sweep) — surfaced 13 findings; 11 landed across 17 commits and three review rounds, with the remaining 2 deliberately deferred. The headline fix closes an unauthenticated UDP-reflection vector on the rendezvous coordinator; the rest harden the NAT-classification sweep against flapping and tighten the discovery surfaces.
No wire-format change, no C-ABI change, no public API change. Drop-in against honest v0.27.6 and earlier peers. Full audits: docs/misc/CODE_REVIEW_2026_06_21_NAT_TRAVERSAL.md and docs/misc/CODE_REVIEW_2026_06_21_PORT_SCANNING.md.
[!IMPORTANT] NAT traversal is an optimization, not a correctness contract — and that held under review. Every finding below falls back to the routed handshake; none of them can corrupt or block a session. The fixes matter most to nodes that hole-punch through a public rendezvous coordinator: the reflector was reachable by an authenticated mesh peer, with the value to an attacker being source obfuscation rather than amplification.
Highlights
- Closed the coordinator-mediated UDP reflector —
PunchRequest.self_reflexis now bound to the requester's session source IP, so an attacker can no longer name a third-party victim address for the responder to fire keep-alives at. - Made NAT classification single-flight (
SweepGuard) and flap-resistant — a sweep with fewer than two successful probes no longer downgrades a goodCone/OpentoUnknown. - Validated the keep-alive
sender_node_id(the documented-but-dead anti-spoof field) and clamped the trustedfire_at_msso a malicious coordinator can't park keep-alive sender tasks unbounded. - Hardened the UPnP-IGD discovery wrapper (local-IP contract enforced in debug + release) plus a set of docstring-accuracy corrections across the classification FSM and reflex-echo paths.
- Confirmed the dual-use "scanner" question: none of the port-discovery surfaces can be aimed at an attacker-chosen host — SSDP hits only the fixed multicast group, NAT-PMP only the OS routing-table gateway, reflex probes only authenticated connected peers.
- Rust changes are
cargo check+clippyclean (default andport-mappingconfigs) with per-finding regression tests; new suites inpunch_keepalive.rsandrendezvous_coordinator.rs.
High-severity fixes
- Rendezvous UDP reflector — bind
self_reflexto the requester's session IP (Medium). The punch coordinator took requester A's reflex verbatim from the unsignedPunchRequestbody and forwarded it to responder B as the keep-alive target — A could put a victim'sip:portthere, and B would emit three UDP keep-alives at it with A's identity hidden behind B.handle_punch_requestnow resolves A's session up-front and drops the request whenreq.self_reflex.ip() != a_addr.ip(), before the reflex is ever read. Symmetric-NAT port shifts stay honoured (the guard keys on IP only). This closes the coordinator-mediated path; the direct unsolicited-introduce path is tracked separately as deferred Finding 4 below. - NAT classification single-flight (
SweepGuard) (Medium).reclassify_nat's docstring promised "at most one sweep at a time," but the body had no guard — and the method ispuband FFI-exported (net_mesh_reclassify_nat). An operator call concurrent with the background tick ran two sweeps that collided on thepending_reflex_probesmap, silently starving the earlier sweep to aReflexTimeout. Now serialized by anAtomicBoolcompare-exchange. (A follow-up review note clarified the gate is sweep-level — a standaloneprobe_reflexcan still race a sweep's probe, but the collision is benign: waiters are generation-stamped, the loser resolves asReflexTimeout, and the sub-2-observation guard below keeps such a sweep on its prior class rather than flapping.) - Sub-2-observation sweep no longer downgrades a good class to
Unknown(Medium). A sweep that landed only one successful probe (routine packet loss on a two-peer sweep) fed a single observation into the FSM, which returnsUnknownfor< 2observations — and the commit path only guardedlatest_reflex == None, so it overwrote a previously-goodCone/Openwithnat:unknownfor a ~60 s window. The commit now keeps prior state when successful observations< 2, mirroring the existing deadline-expired anti-flap branch. A torn-input guard ((class, None)claiming ≥2 observations) is retained as defense-in-depth and pinned by test.
Other fixes (MEDIUM / LOW)
- rendezvous keep-alive — the
sender_node_idfield, documented as load-bearing against "a stray packet on the right source addr falsely signalling punch-succeeded," was decoded but never validated. It is now checked in the receive loop, before the observer is removed —punch_observerscarry the expected counterpartnode_idand aremove_iffires only on a match. (A first cut put the check after the observer fired; cubic flagged it as a P2 DoS — a single stray packet burned the observer and failed the punch permanently — so the check was moved ahead of removal: a wrong-sender packet is now dropped without consuming the observer, and a later valid keep-alive still completes the punch.) - rendezvous
fire_at_msclamp — the offset math was extracted into a pure, unit-testedkeepalive_send_offsets(fire_at_ms, now_ms, deadline)that clampsbase_leadtopunch_deadlineand uses saturating adds for the+100/+250 msspacing. A malicious or buggy coordinator can no longer park a keep-alive sender task (holding a socketArc+ payload) for an unbounded duration, and theInstant + Durationoverflow panic risk is gone. Far-future andu64::MAXinputs covered for both clamping and panic-freedom. - UPnP-IGD discovery —
UpnpMapper::newnow enforces its documentedlocal_ipcontract (debug_assertin dev,tracing::warn!in release) so a non-routable bind IP can't silently produce a route-nowhere mapping;add_port_err_to_port_mapping(dead in production —installusesadd_any_port) is marked test-only; and the intentionalget_external_ipre-read oninstallis documented (the WAN IP can change between probe and install). - classification / reflex docstring accuracy — corrected the over-claims that drew the review: a wildcard bind (
0.0.0.0) behind a port-preserving restricted-cone NAT is now documented as potentially over-classifiedOpen(advertisesnat:open→ peer picksDirect→ drops the unsolicited inbound → relay fallback; correctness holds, optimization lost); peer selection's lack of destination diversity (two node ids on one public IP can misread a symmetric NAT asCone) is now caveated; and the reflex echo is documented as using the cached authenticated handshake addr (spoof-resistant, but stale on a mid-session NAT rebind) rather than the live — spoofable — packet source.
Investigated / deferred (not shipped)
- Finding 4 — the direct unsolicited
PunchIntroducereflector (Low–Medium, Open). Finding 1's fix guards only the coordinator-mediated path. An authenticated session peer can still send responder B an unsolicitedPunchIntroduce{ peer: <any>, peer_reflex: <victim> }; with no waiter for<any>, dispatch falls through toschedule_punch, which fires the keep-alive train at the wire-suppliedpeer_reflexunconditionally. Thesender_node_idcheck gates the returnPunchAck, not the keep-alive train. Lower-severity than the headline: reachable only by an authenticated mesh member, tiny payload, no amplification, and Finding 3's clamp now bounds each parked sender task to≤ punch_deadline + 250 ms(bounded-lifetime churn, no unbounded accumulation). The fix — drop whenintro.peer_reflexdisagrees with the cached announced reflex ofintro.peer— is promoted to a tracked follow-up. - Finding 5 — rate-limit budgets +
RendezvousRejectedwiring (Low, Open). There is still no per-requester budget onPunchRequestand no per-peer budget on responder keep-alive trains, so volume abuse over the still-open direct path is uncapped in count.TraversalError::RendezvousRejected/RendezvousNoRelayremain defined and FFI-mapped but never constructed — Finding 1's guard surfaces as a silent drop →PunchFailedtimeout. Adding the planned budgets (theis_auth_throttledsubscribe-auth infrastructure is the model) and surfacing both the rate-limit and IP-mismatch rejections as typed errors is deferred as a non-trivial change unsuitable for a hardening point release.
Dependencies
All in net/crates/net/Cargo.lock — no Cargo.toml constraint change (only the workspace version stamp 0.27.6 → 0.27.7), so crates.io library consumers resolve identically:
- Deck / TUI:
ratatui0.30.1 → 0.30.2, pulling its component crates (ratatui-core,ratatui-crossterm,ratatui-termwiz,ratatui-widgets,ratatui-macros) and adding theratatui-termina/terminaterminal backend. Reaches only the operator cyberdeck binary; nothing on the datapath or wire. - Transitive bumps:
arrayvec0.7.7,cc1.2.65,log0.4.33,redis1.2.4. Build/utility crates and the optional Redis adapter — none reach the datapath, crypto, or wire.
Upgrade notes
- Breaking changes: none on the wire, in the C ABI, or in the public Go/Python/Rust API. All changes are internal to the NAT-traversal and port-discovery paths, behind unchanged signatures.
- One behavioural change to note: a
PunchRequestwhoseself_reflexIP doesn't match the requester's session source IP is now silently dropped at an honest coordinator (the requester'srequest_punchtimes out asPunchFailedand falls back to relay). This is correctness-preserving — relay fallback is the documented path — but two acknowledged edges can drop a legitimate requester to relay: IPv4-mapped-IPv6 / multi-public-IP CGNAT pools, and a requester R reaches via a relay (the guard would compare against the relay IP). Both are inherent to "bind IP, allow any port for symmetric NAT" and fall back cleanly. - No general-purpose port scanner exists or was added. The
net portCLI remains an explicit design stub; the reviewed "scanning" surfaces (UPnP SSDP, NAT-PMP, reflex probes) cannot be aimed at an attacker-chosen target.
Full Changelog: https://github.com/ai-2070/net/compare/v0.27.6...v0.27.7