From 389a69dddf8d91e77fbc7e0c5004e7161754c893 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 11 Jul 2026 14:28:04 -0700 Subject: [PATCH 1/5] feat(p2p): use the full dig-nat traversal ladder + STUN reflexive discovery (#385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every node peer dial (DHT lookups, multi-source range fetches, PEX candidate verification) enabled only [Direct, Relayed] — a NAT'd peer skipped UPnP/NAT-PMP/ PCP + hole-punch and jumped straight to the relay, over-loading relay.dig.net and defeating the "attempt direct traversal before relaying" intent of the IPv6-first rule (§5.2). dig-nat already implements the full ladder; the node just capped it. - Add `net::full_nat_config(timeout, stun_server)` — the ONE shared NatConfig (full ladder from `NatConfig::default`, relay last) used by all call sites. - Wire it at `dht::NatDhtTransport::nat_config`, `download::NodeContent::for_dht`, and `pex::GossipPexPool::offer_candidates` (the 4th site, `find_holders`, is orphaned dead code removed in #386). - Resolve the relay-colocated STUN server once at bring-up (`:3478`) and thread it through so dig-nat's hole-punch tier has a reflexive-address input. - Discover this node's server-reflexive (public) address via STUN and merge it into the advertised candidate set IPv6-first (`net::merge_reflexive`), so a remote peer behind a different NAT can dial/hole-punch to it. IPv6-first preserved: the shared config inherits dig-nat's IPv6-first candidate ordering; `merge_reflexive` keeps IPv6 ahead of IPv4; advertised discovery unchanged. Tests: full ladder enabled (regression guard vs Direct+Relayed), STUN set only when provided, relay-host parsing, reflexive merge ordering (IPv6-first + dedup). Refs #376, #381, #384, #385, #386, #387 Co-Authored-By: Claude --- crates/dig-node-core/src/dht.rs | 28 ++-- crates/dig-node-core/src/download.rs | 14 +- crates/dig-node-core/src/net.rs | 204 +++++++++++++++++++++++++++ crates/dig-node-core/src/peer.rs | 56 ++++++-- crates/dig-node-core/src/pex.rs | 37 +++-- 5 files changed, 293 insertions(+), 46 deletions(-) diff --git a/crates/dig-node-core/src/dht.rs b/crates/dig-node-core/src/dht.rs index 53be77c..f3de703 100644 --- a/crates/dig-node-core/src/dht.rs +++ b/crates/dig-node-core/src/dht.rs @@ -83,6 +83,10 @@ pub struct NatDhtTransport { network_id: String, /// Per-RPC timeout (dial + exchange). rpc_timeout: Duration, + /// The STUN server (co-located with the relay) dig-nat's hole-punch tier queries for this node's + /// server-reflexive address (#385). `None` leaves STUN unconfigured; set at bring-up via + /// [`Self::with_stun_server`]. + stun_server: Option, } impl NatDhtTransport { @@ -97,9 +101,18 @@ impl NatDhtTransport { identity, network_id: network_id.into(), rpc_timeout, + stun_server: None, } } + /// Set the STUN server used by the full-ladder [`Self::nat_config`]'s hole-punch tier (#385). The + /// node resolves it once at bring-up from the relay endpoint (`:3478`). + #[must_use] + pub fn with_stun_server(mut self, stun_server: Option) -> Self { + self.stun_server = stun_server; + self + } + /// Build the dig-nat [`PeerTarget`](dig_nat::PeerTarget) for `peer`: its verified `peer_id` plus /// its FULL list of directly-dialable candidate addresses (via [`candidate_socket_addrs`]). The /// candidates are passed to [`PeerTarget::with_addrs`](dig_nat::PeerTarget::with_addrs), which @@ -126,17 +139,12 @@ impl NatDhtTransport { } } - /// The dig-nat config for a DHT dial: bound each traversal method by the RPC timeout, and offer - /// Direct + Relayed (a direct candidate is tried first, falling back to the relay for a NAT'd - /// peer). Kept minimal + honest — the same tiers the peer-RPC client dials with. + /// The dig-nat config for a DHT dial: the FULL traversal ladder (Direct → UPnP → NAT-PMP → PCP → + /// hole-punch → Relayed), bounded per-tier by the RPC timeout, with the relay reached only after + /// every direct + port-mapping + hole-punch tier fails (#385). Shared with every other node dial + /// via [`crate::net::full_nat_config`]. fn nat_config(&self) -> dig_nat::NatConfig { - dig_nat::NatConfig::builder() - .enabled_methods(vec![ - dig_nat::TraversalKind::Direct, - dig_nat::TraversalKind::Relayed, - ]) - .per_method_timeout(self.rpc_timeout) - .build() + crate::net::full_nat_config(self.rpc_timeout, self.stun_server) } } diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 4c1dfdb..7d21baf 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -665,7 +665,10 @@ impl NodeContent { /// The PRODUCTION constructor — wire the engine from the live DHT + the node's mTLS identity, /// exactly as dig-download's implementers' note prescribes: [`DhtProviderLocator`] over the /// bootstrapped [`DhtService`](dig_dht::DhtService), [`NatRangeTransport`] dialing providers - /// over the same Direct→Relayed NAT tiers the rest of the peer network uses. + /// over the FULL NAT traversal ladder (Direct → UPnP → NAT-PMP → PCP → hole-punch → Relayed) the + /// rest of the peer network now uses, so a range fetch reaches a NAT'd provider directly whenever + /// possible and relays only as the last resort (#385). `stun_server` (when `Some`) feeds the + /// hole-punch tier's reflexive-address discovery. pub fn for_dht( dht: Arc, identity: dig_nat::LocalIdentity, @@ -673,15 +676,10 @@ impl NodeContent { miss_mode: MissMode, self_peer_id: Option, cache_dir: &Path, + stun_server: Option, ) -> Arc { let locator = Arc::new(DhtProviderLocator::new(dht)); - let nat_config = dig_nat::NatConfig::builder() - .enabled_methods(vec![ - dig_nat::TraversalKind::Direct, - dig_nat::TraversalKind::Relayed, - ]) - .per_method_timeout(crate::dht::default_rpc_timeout()) - .build(); + let nat_config = crate::net::full_nat_config(crate::dht::default_rpc_timeout(), stun_server); let transport = Arc::new(NatRangeTransport::new(identity, nat_config, network_id)); Self::new(locator, transport, miss_mode, self_peer_id, cache_dir) } diff --git a/crates/dig-node-core/src/net.rs b/crates/dig-node-core/src/net.rs index e2397a0..4532563 100644 --- a/crates/dig-node-core/src/net.rs +++ b/crates/dig-node-core/src/net.rs @@ -21,6 +21,7 @@ use std::io; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::time::Duration; use socket2::{Domain, Protocol, Socket, Type}; use tokio::net::TcpListener; @@ -163,6 +164,121 @@ pub fn advertise_loopback_from_env() -> bool { ) } +// -- Shared NAT-traversal config (#385) -------------------------------------------------------------- + +/// The RFC-5389 STUN port the DIG relay co-locates with its relay host (`relay.dig.net:3478`). A node +/// derives its STUN server from the relay endpoint (`:STUN_PORT`) — dig-nat L7 spec §3. +pub const STUN_PORT: u16 = 3478; + +/// The shared [`dig_nat::NatConfig`] for EVERY node peer dial (DHT lookups, multi-source range +/// fetches, PEX candidate verification): the **FULL** traversal ladder — Direct → UPnP → NAT-PMP → +/// PCP → hole-punch → Relayed — with `Relayed` (the relay/TURN-last tier) reached ONLY after every +/// direct + port-mapping + hole-punch tier has failed (dig-nat tries the enabled methods in canonical +/// rank order, relay last). +/// +/// This replaces the former `[Direct, Relayed]`-only config every node call site used, which skipped +/// UPnP/NAT-PMP/PCP + hole-punch and jumped straight to the relay — over-loading `relay.dig.net` and +/// defeating the "attempt direct traversal before relaying" intent of the ecosystem IPv6-first rule +/// (§5.2). The method set comes from [`dig_nat::NatConfig::default`] (the full ladder) rather than an +/// explicit list, so a future dig-nat tier is picked up automatically here + at every call site. +/// +/// `per_method_timeout` bounds each tier so a dial never hangs (a dig-nat guarantee). `stun_server`, +/// when `Some`, is the STUN server dig-nat's hole-punch tier queries for this node's server-reflexive +/// (public) address; `None` leaves STUN unconfigured (the ladder still falls through to the relay). +pub fn full_nat_config( + per_method_timeout: Duration, + stun_server: Option, +) -> dig_nat::NatConfig { + let mut builder = dig_nat::NatConfig::builder().per_method_timeout(per_method_timeout); + if let Some(stun) = stun_server { + builder = builder.stun_server(stun); + } + builder.build() +} + +/// Extract the host from a relay endpoint URL so the node can derive the co-located STUN server +/// (`:STUN_PORT`). Pure: strips the scheme (`wss://`), any `:port`, and any trailing path/query. +/// A bracketed IPv6 literal (`wss://[2001:db8::1]:9450`) yields the literal without brackets. Returns +/// `None` for an empty/unparseable host. +pub fn parse_relay_host(endpoint: &str) -> Option { + let s = endpoint.trim(); + let s = s.split_once("://").map(|(_, rest)| rest).unwrap_or(s); + // Drop any path / query. + let s = s.split(['/', '?']).next().unwrap_or(s); + if s.is_empty() { + return None; + } + // Bracketed IPv6 literal: [addr]:port + if let Some(rest) = s.strip_prefix('[') { + let host = rest.split(']').next().unwrap_or(""); + return (!host.is_empty()).then(|| host.to_string()); + } + let host = s.split(':').next().unwrap_or(""); + (!host.is_empty()).then(|| host.to_string()) +} + +/// Resolve the DIG STUN server (`:STUN_PORT`) from the relay endpoint URL, IPv6-first when +/// the host resolves to both families (ecosystem rule). Best-effort blocking DNS resolution; `None` +/// when the host can't be parsed/resolved. Call off the async runtime (e.g. via `spawn_blocking`). +pub fn stun_server_from_relay(relay_endpoint: &str) -> Option { + use std::net::ToSocketAddrs; + let host = parse_relay_host(relay_endpoint)?; + let mut addrs: Vec = (host.as_str(), STUN_PORT).to_socket_addrs().ok()?.collect(); + // IPv6-first: `false` (IPv6) sorts before `true` (IPv4). + addrs.sort_by_key(SocketAddr::is_ipv4); + addrs.into_iter().next() +} + +/// Merge a STUN-discovered server-reflexive candidate into the advertised set, preserving IPv6-first +/// ordering + dedup. The reflexive (public) address is the most-dialable candidate for a NAT'd node, so +/// it leads its family group: an IPv6 reflexive leads the whole list; an IPv4 reflexive leads the IPv4 +/// fallback group (after any IPv6). A reflexive already present is not duplicated. Pure so the ordering +/// is unit-tested without a socket. +pub fn merge_reflexive(local: Vec, reflexive: Option) -> Vec { + let Some(r) = reflexive else { + return local; + }; + if local.contains(&r) { + return local; + } + let mut out = Vec::with_capacity(local.len() + 1); + if r.is_ipv6() { + out.push(r); + out.extend(local); + return out; + } + // IPv4 reflexive: insert before the first IPv4 (after all IPv6), else append. + let mut inserted = false; + for a in local { + if a.is_ipv4() && !inserted { + out.push(r); + inserted = true; + } + out.push(a); + } + if !inserted { + out.push(r); + } + out +} + +/// Best-effort discover this node's server-reflexive (public) address via STUN against `stun_server`, +/// so the node advertises a candidate a remote peer can dial / hole-punch to (not just its LAN-local +/// address). Binds an ephemeral UDP socket in the STUN server's address family and runs ONE bounded +/// Binding transaction ([`dig_nat::stun::query_reflexive_address`]); any failure (timeout, unreachable, +/// no route) returns `None` and the node advertises its local addresses only. +pub async fn reflexive_via_stun(stun_server: SocketAddr, timeout: Duration) -> Option { + let bind = if stun_server.is_ipv6() { + SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0) + } else { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) + }; + let socket = tokio::net::UdpSocket::bind(bind).await.ok()?; + dig_nat::stun::query_reflexive_address(&socket, stun_server, timeout) + .await + .ok() +} + #[cfg(test)] mod tests { use super::*; @@ -266,4 +382,92 @@ mod tests { let addrs = order_advertised(None, Some(v4), 9444, false); assert_eq!(addrs, vec![SocketAddr::new(IpAddr::V4(v4), 9444)]); } + + // -- #385: full NAT traversal ladder + STUN reflexive discovery ---------------------------------- + + /// The shared config enables the WHOLE ladder — not just `Direct` + `Relayed`. This is the + /// regression guard for the bug the ticket fixes: every node dial now attempts UPnP/NAT-PMP/PCP + + /// hole-punch BEFORE the relay, so `relay.dig.net` is a genuine last resort. + #[test] + fn full_nat_config_enables_the_whole_ladder_not_just_direct_relayed() { + use dig_nat::TraversalKind::*; + let cfg = full_nat_config(Duration::from_secs(3), None); + for k in [Direct, Upnp, NatPmp, Pcp, HolePunch, Relayed] { + assert!(cfg.is_enabled(k), "{k:?} must be enabled (full ladder)"); + } + // The port-mapping + hole-punch tiers that the old `[Direct, Relayed]` config skipped: + assert!( + cfg.is_enabled(Upnp) && cfg.is_enabled(NatPmp) && cfg.is_enabled(Pcp) && cfg.is_enabled(HolePunch), + "UPnP/NAT-PMP/PCP/hole-punch must be tried before falling back to the relay" + ); + } + + #[test] + fn full_nat_config_sets_stun_server_only_when_provided() { + let stun: SocketAddr = "203.0.113.5:3478".parse().unwrap(); + assert_eq!( + full_nat_config(Duration::from_secs(3), Some(stun)).stun_server, + Some(stun) + ); + assert_eq!( + full_nat_config(Duration::from_secs(3), None).stun_server, + None + ); + } + + #[test] + fn parse_relay_host_strips_scheme_port_and_path() { + assert_eq!( + parse_relay_host("wss://relay.dig.net:9450").as_deref(), + Some("relay.dig.net") + ); + assert_eq!( + parse_relay_host("relay.dig.net").as_deref(), + Some("relay.dig.net") + ); + assert_eq!( + parse_relay_host("wss://relay.dig.net/introducer?x=1").as_deref(), + Some("relay.dig.net") + ); + // Bracketed IPv6 literal. + assert_eq!( + parse_relay_host("wss://[2001:db8::1]:9450").as_deref(), + Some("2001:db8::1") + ); + assert_eq!(parse_relay_host(""), None); + assert_eq!(parse_relay_host("wss://"), None); + } + + #[test] + fn merge_reflexive_ipv6_leads_and_dedups() { + let v6: SocketAddr = "[2001:db8::1]:9444".parse().unwrap(); + let v4: SocketAddr = "203.0.113.7:9444".parse().unwrap(); + let reflexive_v6: SocketAddr = "[2606:4700::1]:9444".parse().unwrap(); + // IPv6 reflexive leads the whole list. + let merged = merge_reflexive(vec![v6, v4], Some(reflexive_v6)); + assert_eq!(merged, vec![reflexive_v6, v6, v4]); + // Already-present reflexive is not duplicated. + assert_eq!(merge_reflexive(vec![v6, v4], Some(v6)), vec![v6, v4]); + // No reflexive → unchanged. + assert_eq!(merge_reflexive(vec![v6, v4], None), vec![v6, v4]); + } + + #[test] + fn merge_reflexive_ipv4_leads_ipv4_group_after_ipv6() { + let v6: SocketAddr = "[2001:db8::1]:9444".parse().unwrap(); + let v4: SocketAddr = "203.0.113.7:9444".parse().unwrap(); + let reflexive_v4: SocketAddr = "198.51.100.9:9444".parse().unwrap(); + // IPv4 reflexive sits after IPv6, before the local IPv4 fallback. + assert_eq!( + merge_reflexive(vec![v6, v4], Some(reflexive_v4)), + vec![v6, reflexive_v4, v4] + ); + // With no local IPv6, the IPv4 reflexive leads. + assert_eq!( + merge_reflexive(vec![v4], Some(reflexive_v4)), + vec![reflexive_v4, v4] + ); + // With no local addresses at all, the reflexive is the sole candidate. + assert_eq!(merge_reflexive(vec![], Some(reflexive_v4)), vec![reflexive_v4]); + } } diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 0c86b88..7b92e0c 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1252,7 +1252,24 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // hold content this node wants, and keeps this node's OWN held-inventory provider records // CURRENT so other nodes can find it. Best-effort — a DHT bring-up failure logs + leaves the // node serving without the DHT (the pool + §21 read path still work). - let dht = match bring_up_dht(&node, &identity, &network_id_str, &handle).await { + // Resolve the STUN server co-located with the relay (`:3478`) ONCE — it feeds both the + // node's own reflexive-address discovery (advertised-candidate set) and the hole-punch tier of the + // FULL NAT ladder every node dial now uses (#385). Blocking DNS resolution is moved off the async + // runtime; a failure leaves STUN unconfigured (the ladder still falls through to the relay). + let stun_server = if relay_enabled() { + let ep = relay_endpoint.clone(); + tokio::task::spawn_blocking(move || crate::net::stun_server_from_relay(&ep)) + .await + .ok() + .flatten() + } else { + None + }; + if let Some(stun) = stun_server { + println!("dig-node peer network: STUN server for reflexive discovery: {stun}"); + } + + let dht = match bring_up_dht(&node, &identity, &network_id_str, &handle, stun_server).await { Ok(dht) => Some(dht), Err(e) => { tracing::warn!(error = %e, "dig-node DHT bring-up failed; continuing without the DHT"); @@ -1275,6 +1292,7 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { crate::download::miss_mode_from_env(), Some(peer_id_hex.clone()), node.cache_dir_path(), + stun_server, ); content.spawn_gc(); // Feed the selector's registry from the connected pool (#178, SPEC §2.3): seed from the current @@ -1358,7 +1376,7 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { crate::pex::spawn_tick_loop(pex_engine.clone()); let pex = crate::pex::PexServing::new( pex_engine, - Arc::new(crate::pex::GossipPexPool::new(handle.clone())), + Arc::new(crate::pex::GossipPexPool::new(handle.clone(), stun_server)), ); // The served responder carries the LIVE pool handle so `dig.getPeers` reflects connected peers, @@ -1382,17 +1400,18 @@ async fn bring_up_dht( identity: &dig_nat::LocalIdentity, network_id: &str, pool: &dig_gossip::GossipHandle, + stun_server: Option, ) -> Result, String> { use dig_dht::{CandidateAddr, DhtConfig, DhtService}; let config = DhtConfig::default(); // The transport dials peers as THIS node (client cert = our identity), scoping relay lookups to - // our network id, bounding each RPC by the config's per-RPC timeout. - let transport = Arc::new(crate::dht::NatDhtTransport::new( - identity.clone(), - network_id.to_string(), - config.rpc_timeout, - )); + // our network id, bounding each RPC by the config's per-RPC timeout, over the FULL NAT ladder with + // the relay's STUN server feeding its hole-punch tier (#385). + let transport = Arc::new( + crate::dht::NatDhtTransport::new(identity.clone(), network_id.to_string(), config.rpc_timeout) + .with_stun_server(stun_server), + ); // Our own advertised addresses: the node's REAL routable address(es) at the P2P listen port, // ordered IPv6-first (ecosystem HARD RULE) — a global-unicast IPv6 address (when the host has one) // precedes the IPv4 fallback, so a peer's happy-eyeballs dialer prefers IPv6. The wildcard bind @@ -1400,11 +1419,22 @@ async fn bring_up_dht( // with no routable address advertises nothing here and stays reachable via the relay tiers dig-nat // composes; loopback/in-process setups opt into a loopback candidate via DIG_NODE_ADVERTISE_LOOPBACK. let port = peer_port_from_env(); - let local_addresses: Vec = - crate::net::advertised_socket_addrs(port, crate::net::advertise_loopback_from_env()) - .into_iter() - .map(|sa| CandidateAddr::direct(sa.ip().to_string(), sa.port())) - .collect(); + let local = crate::net::advertised_socket_addrs(port, crate::net::advertise_loopback_from_env()); + // Add this node's STUN-discovered server-reflexive (public) address to the advertised set (#385) + // so a remote peer behind a different NAT can dial / hole-punch to it, not just to a LAN-local + // address. Best-effort + bounded: a failure (no STUN server, timeout) advertises the local + // addresses only. The reflexive candidate is merged IPv6-first (§5.2) by `merge_reflexive`. + let reflexive = match stun_server { + Some(stun) => crate::net::reflexive_via_stun(stun, config.rpc_timeout).await, + None => None, + }; + if let Some(r) = reflexive { + println!("dig-node peer network: STUN reflexive address {r} added to advertised candidates"); + } + let local_addresses: Vec = crate::net::merge_reflexive(local, reflexive) + .into_iter() + .map(|sa| CandidateAddr::direct(sa.ip().to_string(), sa.port())) + .collect(); let service = Arc::new(DhtService::new( identity.peer_id, local_addresses, diff --git a/crates/dig-node-core/src/pex.rs b/crates/dig-node-core/src/pex.rs index bf3ecb3..7f1eaca 100644 --- a/crates/dig-node-core/src/pex.rs +++ b/crates/dig-node-core/src/pex.rs @@ -495,13 +495,23 @@ pub fn spawn_pool_feeder( /// SPEC §11.1); a muted misbehaving peer is disconnected. All best-effort. pub struct GossipPexPool { handle: dig_gossip::GossipHandle, + /// The STUN server (co-located with the relay) dig-nat's hole-punch tier queries for this node's + /// server-reflexive address when verifying a PEX candidate over the FULL traversal ladder (#385). + stun_server: Option, } impl GossipPexPool { - /// A sink over the live gossip handle. + /// A sink over the live gossip handle. `stun_server` (when `Some`) is passed to the full-ladder + /// NAT config used to dial+verify each PEX candidate (#385). #[must_use] - pub fn new(handle: dig_gossip::GossipHandle) -> Self { - GossipPexPool { handle } + pub fn new( + handle: dig_gossip::GossipHandle, + stun_server: Option, + ) -> Self { + GossipPexPool { + handle, + stun_server, + } } } @@ -519,20 +529,17 @@ impl PexPool for GossipPexPool { continue; }; let handle = self.handle.clone(); - // Verify by DIALING (mTLS proves the peer_id) and adopt the verified connection into the - // pool — exactly how the pool's own maintenance turns a candidate into a member. Spawned so - // one slow dial never stalls the inbound PEX read loop. + // Verify by DIALING over the FULL NAT ladder (Direct → UPnP → NAT-PMP → PCP → hole-punch → + // Relayed, #385) — mTLS proves the peer_id — and adopt the verified connection into the + // pool, exactly how the pool's own maintenance turns a candidate into a member. The relay + // tier is reached only after every direct + hole-punch tier fails. Spawned so one slow dial + // never stalls the inbound PEX read loop. + let methods = crate::net::full_nat_config(CANDIDATE_DIAL_TIMEOUT, self.stun_server) + .enabled_methods + .clone(); tokio::spawn(async move { match handle - .connect_via_nat( - peer_id, - Some(addr), - &[ - dig_nat::TraversalKind::Direct, - dig_nat::TraversalKind::Relayed, - ], - CANDIDATE_DIAL_TIMEOUT, - ) + .connect_via_nat(peer_id, Some(addr), &methods, CANDIDATE_DIAL_TIMEOUT) .await { Ok(conn) => { From 64e4c6789fa62036650e2bc357d03ef87465848d Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 11 Jul 2026 14:34:04 -0700 Subject: [PATCH 2/5] refactor(p2p): remove orphaned pool-based find_holders seam; dig-dht is the sole locator (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PeerRpcClient` + `find_holders` + `PeerAvailability` predated the DHT and had ZERO live callers (only definitions + a stale doc claiming "#163 dig-dht plugs in behind Self::find_holders" — an integration that never landed; the DHT went in via `NodeContent` instead). Removing the dead seam leaves ONE unambiguous provider path: the dig-dht provider index (`DhtProviderLocator` -> `find_providers`) inside `NodeContent`, used by both the redirect-on-miss and multi-source fetch paths. Rewrites the module doc's stale "clean seam for #163" note accordingly. Refs #376, #253 Co-Authored-By: Claude --- crates/dig-node-core/src/peer.rs | 88 +++----------------------------- 1 file changed, 6 insertions(+), 82 deletions(-) diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 7b92e0c..b17383c 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -30,12 +30,13 @@ //! so the byte-exact §21/FFI contract is untouched. `control.peerStatus` is always safe to call (it //! reports "not running" when no network is up). //! -//! ## The clean seam for #163 (dig-dht) +//! ## Content location — the dig-dht provider index (#163) //! -//! Peer DISCOVERY here is pool + `dig.getPeers` (the introducer/gossip sources). The provider-lookup -//! seam — "who holds capsule X?" beyond the local pool — is [`PeerRpcClient::find_holders`], which -//! today queries the connected pool via availability; #163 (dig-dht) slots in as an additional -//! provider source behind the SAME interface without touching the serve path. +//! Peer DISCOVERY here is the connected pool + `dig.getPeers` (the introducer/gossip sources). Content +//! LOCATION — "who holds capsule X?" beyond the local pool — is the **dig-dht provider index**, wired as +//! the live locator inside [`crate::download::NodeContent`] (`DhtProviderLocator` → `find_providers`); +//! the redirect-on-miss and multi-source fetch paths both resolve holders through it. There is exactly +//! ONE provider path — no separate pool-availability seam. //! //! ## Address-family policy — IPv6-first, IPv4-fallback (ecosystem HARD RULE) //! @@ -984,83 +985,6 @@ async fn stream_fetched_range( } } -// -- Outgoing L7 peer RPC (the multi-source download seam, #163 dig-dht clean seam) ------------------ - -/// Outcome of an availability check against one pool peer, for the multi-source download planner. -#[derive(Debug, Clone)] -pub struct PeerAvailability { - /// The peer's `peer_id` (64-hex). - pub peer_id: String, - /// The raw availability answers (one per queried item), from the peer. - pub answers: Vec, -} - -/// ISSUE the L7 peer RPC to pool peers — the outgoing client path (multi-source download seam). Given -/// a running [`dig_gossip::GossipHandle`], this lets the node ASK pool peers for availability and -/// FETCH byte ranges of a resource it lacks, verifying each range against the chain-anchored root -/// before use. Today the provider set is the connected pool ([`dig_gossip::GossipHandle`]'s -/// `connected_pool_peers`) + `dig.getPeers`; #163 (dig-dht) plugs in as an additional provider source -/// behind [`Self::find_holders`] WITHOUT touching this fetch/verify path. -pub struct PeerRpcClient { - handle: dig_gossip::GossipHandle, - per_method_timeout: std::time::Duration, -} - -impl PeerRpcClient { - /// Build a client over a running gossip handle. `per_method_timeout` bounds each NAT-traversal - /// tier so a dial never hangs (a dig-nat guarantee). - pub fn new(handle: dig_gossip::GossipHandle, per_method_timeout: std::time::Duration) -> Self { - PeerRpcClient { - handle, - per_method_timeout, - } - } - - /// Find pool peers that HOLD a capsule/resource by asking each connected peer for availability. - /// This is the discovery step of the multi-source download flow; #163 (dig-dht) extends the - /// candidate set behind this same signature. Returns per-peer availability answers. - /// - /// `items` is the availability batch (store/root/resource granularity items). Only currently - /// connected pool peers are queried (already-authenticated mTLS links); a peer that errors is - /// skipped (best-effort). Dial-and-query of not-yet-connected candidates is a follow-up seam. - pub async fn find_holders( - &self, - items: Vec, - ) -> Vec { - let mut out = Vec::new(); - for (peer_id, addr, _outbound) in self.handle.connected_pool_peers() { - // Dial the peer over the NAT ladder (direct-first) and query availability. The pool peer - // is already known + authenticated; this opens a control stream to it. - match self - .handle - .connect_via_nat( - peer_id, - Some(addr), - &[ - dig_nat::TraversalKind::Direct, - dig_nat::TraversalKind::Relayed, - ], - self.per_method_timeout, - ) - .await - { - Ok(mut conn) => match conn.query_availability(items.clone()).await { - Ok(resp) => out.push(PeerAvailability { - // gossip PeerId is a chia Bytes32 (no to_hex) — render as 64-hex. - peer_id: hex::encode(peer_id), - answers: resp.items, - }), - Err(e) => { - tracing::debug!(peer = %peer_id, error = %e, "availability query failed") - } - }, - Err(e) => tracing::debug!(peer = %peer_id, error = %e, "connect_via_nat failed"), - } - } - out - } -} - // -- Peer-network bring-up: the connected pool + discovery + the mTLS peer-RPC server ----------------- /// Spawn the node's L7 peer network in the background (the OS-service bring-up calls this — #213; From 12e1ceab0d3954bdac122d49e1a57036227f8785 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 11 Jul 2026 14:43:41 -0700 Subject: [PATCH 3/5] feat(p2p): durable IPv6-first peer address book (#381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learned peer addresses (PEX, dig.getPeers, introducer, observed pool peers) were dialed-immediately-or-discarded — nothing accumulated a durable, ordered address book to seed later candidate selection. Adds `address_book::AddressBook`: one entry per peer_id, unions dialable addresses IPv6-first (§5.2), records provenance (a first-hand sighting is not downgraded by a later PEX hint) + a freshen timestamp, persists relay-only / not-currently-dialable hints, and reads back a ranked, non-stale candidate list (IPv6-dialable first, then dialable, then relay-only) with a staleness TTL + capacity eviction. Crate-API note (release-first follow-up): dig-gossip owns the canonical AddressManager but exposes NO public production ingest API (only a #[doc(hidden)] test hook), so this book lives node-side for now; when dig-gossip ships a public `offer_addresses` ingest API the book flushes into the crate AddressManager (one source of truth). Documented in the module doc + SPEC §19.7. Tests: IPv6-first ordering + dedup, relay-only persistence, upsert union/freshen, provenance non-downgrade, readback ordering, TTL staleness, capacity eviction. Refs #376 Co-Authored-By: Claude --- crates/dig-node-core/src/address_book.rs | 359 +++++++++++++++++++++++ crates/dig-node-core/src/lib.rs | 1 + 2 files changed, 360 insertions(+) create mode 100644 crates/dig-node-core/src/address_book.rs diff --git a/crates/dig-node-core/src/address_book.rs b/crates/dig-node-core/src/address_book.rs new file mode 100644 index 0000000..6012e52 --- /dev/null +++ b/crates/dig-node-core/src/address_book.rs @@ -0,0 +1,359 @@ +//! Node-side peer address book (#381) — a durable, **IPv6-first**, provenance + staleness-TTL store +//! of learned peer candidates. +//! +//! ## What it does +//! +//! Every peer address the node learns — from PEX (dig-pex, #387), from `dig.getPeers`, from the relay +//! introducer, or from an observed pool peer — is OFFERED here instead of being dialed-and-dropped. +//! The book keeps one entry per `peer_id`, unions each peer's dialable candidate addresses ordered +//! IPv6-first (ecosystem HARD RULE §5.2), records the provenance + a freshen timestamp, and reads back +//! a **ranked, non-stale** candidate list (peers that carry a directly-dialable IPv6 address lead) that +//! seeds future dial selection. A per-peer capacity bound evicts the stalest entry so the book can +//! never grow unbounded from a chatty peer. +//! +//! ## Why this lives in the node (interim) — the missing dig-gossip crate API +//! +//! dig-gossip owns the canonical `AddressManager` (candidate store + IPv6-first ordering). The +//! intended design (issue #381) is a PUBLIC crate ingest API on `GossipHandle` — e.g. +//! `offer_addresses(candidates, provenance)` — so learned addresses upsert into that ONE store and the +//! pool's own dial loop selects from it. The pinned dig-gossip rev exposes **no such production API** +//! (only a `#[doc(hidden)]` `__seed_address_book_for_tests` hook), so the node cannot feed the crate's +//! AddressManager without a release-first crate change. Until that lands, THIS module is the node's +//! durable address book; when the crate ships the ingest API, this book becomes the bridge that flushes +//! into the crate AddressManager (a single source of truth). See the crate-API note in the P2P section +//! of `SPEC.md`. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Mutex; + +/// How the node learned a candidate address. Provenance is retained so a later selection can trust / +/// prioritize a first-hand-observed peer over an unverified PEX hint (SPEC §5.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddrProvenance { + /// Discovered via peer exchange (dig-pex) — a HINT, proven only by a successful dial. + Pex, + /// Reported by a peer's `dig.getPeers` response. + GetPeers, + /// Learned from the relay introducer. + Introducer, + /// A directly-observed connected pool peer (first-hand). + PoolDirect, +} + +/// A learned peer candidate: its `peer_id` (64-hex), its directly-dialable candidate addresses ordered +/// **IPv6-first**, how + when it was learned, and whether it is relay-only (no dialable address). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateAddr { + /// The peer identity, 64-hex. + pub peer_id: String, + /// Directly-dialable candidate addresses, ordered IPv6-first. Empty for a relay-only peer. + pub addrs: Vec, + /// How the node learned this candidate. + pub provenance: AddrProvenance, + /// Unix seconds when the candidate was last (re)learned — drives staleness. + pub learned_at: u64, +} + +impl CandidateAddr { + /// Build a candidate from its parts, ordering the addresses IPv6-first + de-duplicating. A + /// candidate with no dialable address is a relay-only hint (still worth persisting). + #[must_use] + pub fn new( + peer_id: impl Into, + addrs: Vec, + provenance: AddrProvenance, + learned_at: u64, + ) -> Self { + let mut c = CandidateAddr { + peer_id: peer_id.into(), + addrs, + provenance, + learned_at, + }; + c.normalize_addrs(); + c + } + + /// Whether this candidate has NO directly-dialable address (reached only via the relay tiers). + #[must_use] + pub fn is_relay_only(&self) -> bool { + self.addrs.is_empty() + } + + /// Whether this candidate carries at least one directly-dialable IPv6 address (leads the ordering). + #[must_use] + pub fn has_ipv6(&self) -> bool { + self.addrs.first().is_some_and(SocketAddr::is_ipv6) + } + + /// Sort addresses IPv6-first (ecosystem §5.2) + de-dup, preserving relative order within a family. + fn normalize_addrs(&mut self) { + self.addrs.sort_by_key(SocketAddr::is_ipv4); // false (IPv6) < true (IPv4) — stable + self.addrs.dedup(); + } +} + +/// The default per-peer staleness TTL (seconds): a learned candidate not refreshed within this window +/// is treated as stale and dropped from the read-back set. 1 hour — long enough that a transiently +/// unreachable peer survives to be retried, short enough that a churned-away peer ages out. +pub const DEFAULT_TTL_SECS: u64 = 3600; + +/// The default capacity (number of distinct peers). Bounds the book so a chatty PEX source cannot make +/// it grow unbounded; when full, the stalest entry is evicted on insert. +pub const DEFAULT_CAPACITY: usize = 4096; + +/// A durable, IPv6-first, provenance + TTL address book. Cheap to clone-share via `Arc`; all mutation +/// is behind an internal mutex (address traffic is low-rate, so contention is negligible). +pub struct AddressBook { + ttl_secs: u64, + capacity: usize, + entries: Mutex>, +} + +impl std::fmt::Debug for AddressBook { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AddressBook") + .field("ttl_secs", &self.ttl_secs) + .field("capacity", &self.capacity) + .field("len", &self.len()) + .finish() + } +} + +impl Default for AddressBook { + fn default() -> Self { + Self::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY) + } +} + +impl AddressBook { + /// A book with an explicit staleness TTL + capacity. + #[must_use] + pub fn new(ttl_secs: u64, capacity: usize) -> Self { + AddressBook { + ttl_secs, + capacity, + entries: Mutex::new(HashMap::new()), + } + } + + /// Ingest a learned candidate (#381 ingest seam): upsert by `peer_id`, unioning its dialable + /// addresses IPv6-first, freshening `learned_at` to the newer value, and keeping the more-trusted + /// provenance (a first-hand `PoolDirect`/`GetPeers` observation is not downgraded by a later PEX + /// hint). When the book is at capacity for a NEW peer, the stalest entry is evicted first. + pub fn offer(&self, candidate: CandidateAddr) { + let mut g = self.entries.lock().expect("address_book mutex poisoned"); + match g.get_mut(&candidate.peer_id) { + Some(existing) => { + // Union addresses, re-order IPv6-first, dedup. + existing.addrs.extend(candidate.addrs); + existing.normalize_addrs(); + // Freshen to the newer sighting. + existing.learned_at = existing.learned_at.max(candidate.learned_at); + // Keep the more-trusted provenance (lower rank == more trusted). + if provenance_rank(candidate.provenance) < provenance_rank(existing.provenance) { + existing.provenance = candidate.provenance; + } + } + None => { + if g.len() >= self.capacity { + evict_stalest(&mut g); + } + g.insert(candidate.peer_id.clone(), candidate); + } + } + } + + /// All non-stale candidates as of `now` (unix secs), ordered best-first for dial selection: + /// candidates with a directly-dialable **IPv6** address lead, then other directly-dialable + /// candidates, then relay-only hints; ties break by most-recently-learned. This is the read-back + /// that seeds future candidate selection (#381) — IPv6-first per §5.2. + #[must_use] + pub fn candidates(&self, now: u64) -> Vec { + let g = self.entries.lock().expect("address_book mutex poisoned"); + let mut out: Vec = g + .values() + .filter(|c| !self.is_stale(c, now)) + .cloned() + .collect(); + out.sort_by(|a, b| { + dial_class(a) + .cmp(&dial_class(b)) + .then(b.learned_at.cmp(&a.learned_at)) + .then(a.peer_id.cmp(&b.peer_id)) + }); + out + } + + /// The non-stale candidates that carry at least one directly-dialable address (relay-only hints + /// excluded), ordered best-first as in [`Self::candidates`]. The dial-selection source (#384). + #[must_use] + pub fn dialable_candidates(&self, now: u64) -> Vec { + self.candidates(now) + .into_iter() + .filter(|c| !c.is_relay_only()) + .collect() + } + + /// Number of entries currently held (incl. stale-but-not-yet-evicted). + #[must_use] + pub fn len(&self) -> usize { + self.entries.lock().expect("address_book mutex poisoned").len() + } + + /// Whether the book holds no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether the book knows `peer_id` (any freshness). + #[must_use] + pub fn contains(&self, peer_id: &str) -> bool { + self.entries + .lock() + .expect("address_book mutex poisoned") + .contains_key(peer_id) + } + + fn is_stale(&self, c: &CandidateAddr, now: u64) -> bool { + now.saturating_sub(c.learned_at) > self.ttl_secs + } +} + +/// Provenance trust rank — LOWER is more trusted. A first-hand pool sighting outranks a `getPeers` +/// answer, which outranks an introducer entry, which outranks an unverified PEX hint. +fn provenance_rank(p: AddrProvenance) -> u8 { + match p { + AddrProvenance::PoolDirect => 0, + AddrProvenance::GetPeers => 1, + AddrProvenance::Introducer => 2, + AddrProvenance::Pex => 3, + } +} + +/// Dial-preference class — LOWER dials first: a directly-dialable IPv6 candidate, then another +/// directly-dialable candidate (IPv4), then a relay-only hint. +fn dial_class(c: &CandidateAddr) -> u8 { + if c.has_ipv6() { + 0 + } else if !c.is_relay_only() { + 1 + } else { + 2 + } +} + +/// Evict the single stalest (oldest `learned_at`) entry — the capacity backstop on insert. +fn evict_stalest(map: &mut HashMap) { + if let Some(key) = map + .iter() + .min_by_key(|(_, c)| c.learned_at) + .map(|(k, _)| k.clone()) + { + map.remove(&key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hexid(b: u8) -> String { + format!("{b:02x}").repeat(32) + } + fn v6(port: u16) -> SocketAddr { + SocketAddr::new("2001:db8::1".parse().unwrap(), port) + } + fn v4(port: u16) -> SocketAddr { + SocketAddr::new("203.0.113.7".parse().unwrap(), port) + } + + #[test] + fn new_orders_addresses_ipv6_first_and_dedups() { + let c = CandidateAddr::new(hexid(1), vec![v4(9), v6(9), v4(9)], AddrProvenance::Pex, 100); + assert_eq!(c.addrs, vec![v6(9), v4(9)], "IPv6 leads, duplicate dropped"); + assert!(c.has_ipv6()); + assert!(!c.is_relay_only()); + } + + #[test] + fn relay_only_candidate_has_no_addrs() { + let c = CandidateAddr::new(hexid(2), vec![], AddrProvenance::Pex, 100); + assert!(c.is_relay_only()); + assert!(!c.has_ipv6()); + } + + /// #387 regression: a relay-only PEX hint SURVIVES into the book (it is not dial-and-dropped). + #[test] + fn relay_only_hint_persists_in_the_book() { + let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); + book.offer(CandidateAddr::new(hexid(3), vec![], AddrProvenance::Pex, 100)); + assert!(book.contains(&hexid(3)), "relay-only hint must persist"); + // It appears in the full candidate list (seeding future selection) but NOT the dialable set. + assert_eq!(book.candidates(100).len(), 1); + assert!(book.dialable_candidates(100).is_empty()); + } + + #[test] + fn offer_upserts_unions_addresses_and_freshens() { + let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); + book.offer(CandidateAddr::new(hexid(4), vec![v4(9)], AddrProvenance::Pex, 100)); + book.offer(CandidateAddr::new(hexid(4), vec![v6(9)], AddrProvenance::GetPeers, 200)); + assert_eq!(book.len(), 1, "same peer upserts, not duplicates"); + let c = &book.candidates(200)[0]; + assert_eq!(c.addrs, vec![v6(9), v4(9)], "addresses unioned + IPv6-first"); + assert_eq!(c.learned_at, 200, "freshened to newer sighting"); + assert_eq!( + c.provenance, + AddrProvenance::GetPeers, + "more-trusted provenance kept" + ); + } + + #[test] + fn later_pex_hint_does_not_downgrade_first_hand_provenance() { + let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); + book.offer(CandidateAddr::new(hexid(5), vec![v6(9)], AddrProvenance::PoolDirect, 100)); + book.offer(CandidateAddr::new(hexid(5), vec![v6(9)], AddrProvenance::Pex, 150)); + assert_eq!(book.candidates(150)[0].provenance, AddrProvenance::PoolDirect); + } + + #[test] + fn candidates_are_ipv6_first_then_dialable_then_relay_only() { + let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); + book.offer(CandidateAddr::new(hexid(0x10), vec![], AddrProvenance::Pex, 100)); // relay-only + book.offer(CandidateAddr::new(hexid(0x11), vec![v4(9)], AddrProvenance::Pex, 100)); // v4 + book.offer(CandidateAddr::new(hexid(0x12), vec![v6(9)], AddrProvenance::Pex, 100)); // v6 + let ordered: Vec = book.candidates(100).into_iter().map(|c| c.peer_id).collect(); + assert_eq!(ordered, vec![hexid(0x12), hexid(0x11), hexid(0x10)]); + // dialable_candidates drops the relay-only tail, keeps IPv6-first. + let dialable: Vec = book + .dialable_candidates(100) + .into_iter() + .map(|c| c.peer_id) + .collect(); + assert_eq!(dialable, vec![hexid(0x12), hexid(0x11)]); + } + + #[test] + fn stale_candidates_are_dropped_from_readback() { + let book = AddressBook::new(10, DEFAULT_CAPACITY); + book.offer(CandidateAddr::new(hexid(6), vec![v6(9)], AddrProvenance::Pex, 100)); + assert_eq!(book.candidates(105).len(), 1, "within TTL: present"); + assert!(book.candidates(200).is_empty(), "past TTL: dropped from readback"); + assert!(book.contains(&hexid(6)), "still held until evicted/refreshed"); + } + + #[test] + fn capacity_evicts_the_stalest_on_new_insert() { + let book = AddressBook::new(DEFAULT_TTL_SECS, 2); + book.offer(CandidateAddr::new(hexid(7), vec![v6(9)], AddrProvenance::Pex, 100)); // stalest + book.offer(CandidateAddr::new(hexid(8), vec![v6(9)], AddrProvenance::Pex, 200)); + book.offer(CandidateAddr::new(hexid(9), vec![v6(9)], AddrProvenance::Pex, 300)); // evicts 7 + assert_eq!(book.len(), 2); + assert!(!book.contains(&hexid(7)), "stalest evicted"); + assert!(book.contains(&hexid(8)) && book.contains(&hexid(9))); + } +} diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index c036be1..038043e 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -45,6 +45,7 @@ use fs4::FileExt; use serde_json::{json, Value}; use tokio::sync::Mutex; +pub mod address_book; pub mod bandwidth; pub mod chainwatch; /// Local plaintext content-serve (#289/#290): server-side verify+decrypt for the loopback From 16282071bd18db96040d3641e6f5c3dc9b14fdd2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 11 Jul 2026 14:43:57 -0700 Subject: [PATCH 4/5] feat(p2p): feed PEX peers into the address book + selector-driven dial ordering (#387, #384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes to the PEX pool sink (`GossipPexPool::offer_candidates`): #387 — feed PEX-discovered peers into the address book, not dial-and-drop. Every learned candidate (INCLUDING relay-only / not-currently-dialable hints) is offered into the durable address book (#381) with provenance PEX, so nothing is discarded; a hint that can't be reached now persists to seed a later dial. Dials are now sourced from the accumulated book (a superset of this batch), so previously-learned + relay-only-turned-dialable hints are retried; a failed dial keeps the hint in the book. #384 — let dig-peer-selector drive dial choice. Adds a `DialRanker` seam (`SelectorDialRanker` over the shared PeerSelector via its content-agnostic peer_snapshot) that orders which book candidates are dialed first — reliability blended with throughput, banned peers last, cold peers explored at a neutral rank. Reuses the ONE selector instance already ranking download sources (never a second); IPv6-first order preserved among equally-ranked peers. Crate-API note (release-first follow-up): dig-gossip's PeerPoolConfig exposes NO dial-priority hook, so selector ordering applies to the node's PEX candidate dials; when dig-gossip ships a pool dial-priority hook the same ranking drives the pool's own maintenance dial loop. Documented in SPEC §19.6/§19.7. Removes the now-unused `best_socket_addr` helper (superseded by the book's IPv6-first CandidateAddr ordering). Tests: entry→candidate conversion (dialable IPs, relay-only), ranker ordering (good→neutral→bad, None ranker no-op). Refs #376, #381 Co-Authored-By: Claude --- crates/dig-node-core/src/download.rs | 46 +++++++ crates/dig-node-core/src/peer.rs | 20 ++- crates/dig-node-core/src/pex.rs | 191 ++++++++++++++++++++------- 3 files changed, 211 insertions(+), 46 deletions(-) diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 7d21baf..729b629 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -454,6 +454,52 @@ impl ProviderLocator for SelectorLocator { } } +// -- Selector-driven DIAL ordering (#384) ------------------------------------------------------------ + +/// A [`DialRanker`](crate::pex::DialRanker) over the shared [`PeerSelector`] — so the SAME learned +/// peer-quality model that ranks download SOURCES also drives which PEX candidates the node DIALS +/// first (#384). Reuses the one `PeerSelector` instance in [`NodeContent`]; a second is never spun up. +/// +/// The dial score is CONTENT-AGNOSTIC (dialing is not per-content), read from the selector's per-peer +/// [`peer_snapshot`](PeerSelector::peer_snapshot): a banned peer sinks to the bottom; a measured peer +/// scores by reliability (primary) blended with normalized throughput (secondary); a cold peer (no +/// measured outcomes yet) returns `None` so the dialer explores it at a neutral rank (SPEC §5.2 — in +/// PRIVACY mode the selector does not apply; the onion path uses its own selector, so this ranker is +/// simply not wired there). +pub struct SelectorDialRanker { + selector: Arc, +} + +impl SelectorDialRanker { + /// Wrap the shared selector as a dial ranker. + #[must_use] + pub fn new(selector: Arc) -> Self { + SelectorDialRanker { selector } + } +} + +impl crate::pex::DialRanker for SelectorDialRanker { + fn score(&self, peer_id_hex: &str) -> Option { + // 64-hex → the selector's 32-byte PeerId (SHA-256(SPKI DER), same identity as dig-nat/gossip). + let bytes = hex::decode(peer_id_hex).ok()?; + let arr: [u8; 32] = bytes.try_into().ok()?; + let snapshot = self.selector.peer_snapshot(&PeerId::from_bytes(arr))?; + if snapshot.banned { + // Proven-bad: dial only after every neutral/good peer. + return Some(f64::MIN); + } + if snapshot.samples == 0 { + // Cold peer — no measured model yet; let the dialer explore it at the neutral rank. + return None; + } + let reliability = snapshot.reliability.unwrap_or(0.0); + // Normalize throughput to [0,1] with a ~1 MB/s midpoint (bps / (bps + 1e6)); missing → 0. + let throughput = snapshot.throughput_bps.unwrap_or(0.0); + let throughput_norm = (throughput / (throughput + 1_000_000.0)).clamp(0.0, 1.0); + Some(reliability * 0.8 + throughput_norm * 0.2) + } +} + /// Current unix seconds (the `at` timestamp on a [`TransferOutcome`]). fn now_unix() -> u64 { std::time::SystemTime::now() diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index b17383c..84c2cf6 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1193,6 +1193,13 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { println!("dig-node peer network: STUN server for reflexive discovery: {stun}"); } + // The durable, IPv6-first peer address book (#381): every PEX-learned + otherwise-learned candidate + // accumulates here (incl. relay-only hints) instead of being dial-and-dropped, seeding future dials. + // The selector-driven dial ranker (#384) is wired below once the content engine (the shared + // selector) is up; until then dials keep the book's IPv6-first order. + let address_book = Arc::new(crate::address_book::AddressBook::default()); + let mut dial_ranker: Option> = None; + let dht = match bring_up_dht(&node, &identity, &network_id_str, &handle, stun_server).await { Ok(dht) => Some(dht), Err(e) => { @@ -1224,6 +1231,12 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // live peer set. The selector already drives dig-download's source choice + learns from every // range outcome inside `NodeContent`; this keeps its candidate registry current. spawn_selector_registry_feed(content.clone(), handle.clone()); + // #384: the SAME self-optimizing selector that ranks download SOURCES also drives PEX dial + // ORDERING — reuse the ONE selector instance (never a second) so a high-quality peer is dialed + // before a low-quality one. + dial_ranker = Some(Arc::new(crate::download::SelectorDialRanker::new( + content.selector().clone(), + ))); node.set_p2p_content(content); println!( "dig-node peer network: P2P content engine up (selector-driven, miss mode: {:?})", @@ -1300,7 +1313,12 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { crate::pex::spawn_tick_loop(pex_engine.clone()); let pex = crate::pex::PexServing::new( pex_engine, - Arc::new(crate::pex::GossipPexPool::new(handle.clone(), stun_server)), + Arc::new(crate::pex::GossipPexPool::new( + handle.clone(), + stun_server, + address_book, + dial_ranker, + )), ); // The served responder carries the LIVE pool handle so `dig.getPeers` reflects connected peers, diff --git a/crates/dig-node-core/src/pex.rs b/crates/dig-node-core/src/pex.rs index 7f1eaca..f32ecee 100644 --- a/crates/dig-node-core/src/pex.rs +++ b/crates/dig-node-core/src/pex.rs @@ -498,45 +498,124 @@ pub struct GossipPexPool { /// The STUN server (co-located with the relay) dig-nat's hole-punch tier queries for this node's /// server-reflexive address when verifying a PEX candidate over the FULL traversal ladder (#385). stun_server: Option, + /// The durable, IPv6-first address book EVERY learned PEX candidate is offered into (#387), and the + /// SOURCE the dialer selects from (#381) — so relay-only + not-currently-dialable hints persist and + /// seed future dials instead of being dial-and-dropped. + book: Arc, + /// The shared peer-quality ranker (dig-peer-selector) that orders which book candidates to dial + /// first (#384). `None` (e.g. no content engine up) → dials keep the book's IPv6-first order. + ranker: Option>, } impl GossipPexPool { - /// A sink over the live gossip handle. `stun_server` (when `Some`) is passed to the full-ladder - /// NAT config used to dial+verify each PEX candidate (#385). + /// A sink over the live gossip handle. `stun_server` (when `Some`) feeds the full-ladder NAT config + /// used to dial+verify each candidate (#385); `book` is the durable address book (#381/#387); + /// `ranker` (when `Some`) drives selector-ranked dial ordering (#384). #[must_use] pub fn new( handle: dig_gossip::GossipHandle, stun_server: Option, + book: Arc, + ranker: Option>, ) -> Self { GossipPexPool { handle, stun_server, + book, + ranker, } } } +/// Content-agnostic peer-quality ranker for DIAL choice (#384). Implemented over the shared +/// dig-peer-selector `PeerSelector` (see [`crate::download::SelectorDialRanker`]); a `None` ranker on +/// the pool leaves dials in the book's IPv6-first order (no quality ranking). +pub trait DialRanker: Send + Sync { + /// A dial-preference score for `peer_id_hex` — HIGHER dials first. `None` for a peer the selector + /// has no measured model of yet (a cold peer), which is dialed at a neutral rank so it is still + /// explored (after proven-good peers, before proven-bad ones). + fn score(&self, peer_id_hex: &str) -> Option; +} + +/// The neutral dial score assigned to a cold/unknown peer when ranking (#384): between a proven-good +/// (→1.0) and a banned/proven-bad peer (→ very negative), so unmeasured peers are still explored. +const NEUTRAL_DIAL_SCORE: f64 = 0.5; + +/// Order dialable candidates best-first by the ranker's score (HIGHER first), STABLY — so the book's +/// IPv6-first order (§5.2) is preserved among equally-scored peers. A `None` ranker leaves the order +/// untouched (the book is already IPv6-first). Pure so the ordering is unit-tested without a handle. +fn order_by_ranker(cands: &mut [crate::address_book::CandidateAddr], ranker: Option<&dyn DialRanker>) { + let Some(r) = ranker else { + return; + }; + cands.sort_by(|a, b| { + let sa = r.score(&a.peer_id).unwrap_or(NEUTRAL_DIAL_SCORE); + let sb = r.score(&b.peer_id).unwrap_or(NEUTRAL_DIAL_SCORE); + sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) + }); +} + +/// Convert a PEX [`PeerEntry`] into an address-book [`CandidateAddr`](crate::address_book::CandidateAddr): +/// collect its directly-dialable IP-literal addresses (IPv6-first ordering handled by the book), or +/// none for a relay-only / hostname-only hint (still persisted). Provenance is always PEX (a HINT). Pure. +fn book_candidate_from_entry( + entry: &PeerEntry, + now_secs: u64, +) -> crate::address_book::CandidateAddr { + let addrs: Vec = entry + .addresses + .iter() + .filter(|a| a.port != 0) + .filter_map(|a| { + a.host + .parse::() + .ok() + .map(|ip| std::net::SocketAddr::new(ip, a.port)) + }) + .collect(); + crate::address_book::CandidateAddr::new( + entry.peer_id.clone(), + addrs, + crate::address_book::AddrProvenance::Pex, + now_secs, + ) +} + #[async_trait] impl PexPool for GossipPexPool { async fn offer_candidates(&self, _source_peer_id: &str, candidates: Vec) { - for entry in candidates.into_iter().take(MAX_CANDIDATE_DIALS) { - let (Some(peer_id), Some(addr)) = ( - parse_gossip_peer_id(&entry.peer_id), - best_socket_addr(&entry), - ) else { - // Relay-only or unparseable candidate: nothing to dial directly here. (A relay-only - // peer is reached via the relay tiers when the pool later selects it; the address book - // already knows it through the introducer.) + let now = now_ms() / 1000; + // #387: PERSIST every learned candidate — including relay-only + not-currently-dialable hints — + // into the durable address book (IPv6-first, provenance PEX). Nothing is dial-and-dropped; a + // hint that can't be reached now survives to seed a later dial. + for entry in &candidates { + self.book.offer(book_candidate_from_entry(entry, now)); + } + // #381/#384: DIAL from the ranked, IPv6-first, non-stale BOOK (the accumulated learned set, not + // just this batch), preferring high-quality peers per the shared selector — so previously- + // learned + relay-only-turned-dialable hints are retried and proven-good peers are dialed + // first. Each dial verifies over the FULL NAT ladder (#385). + let mut dialable = self.book.dialable_candidates(now); + order_by_ranker(&mut dialable, self.ranker.as_deref()); + let methods = crate::net::full_nat_config(CANDIDATE_DIAL_TIMEOUT, self.stun_server) + .enabled_methods + .clone(); + for cand in dialable.into_iter().take(MAX_CANDIDATE_DIALS) { + let (Some(peer_id), Some(addr)) = + (parse_gossip_peer_id(&cand.peer_id), cand.addrs.first().copied()) + else { continue; }; + // Skip a peer already in the connected pool — the book is a superset of the live pool. + if self.handle.is_pool_peer(&peer_id) { + continue; + } let handle = self.handle.clone(); - // Verify by DIALING over the FULL NAT ladder (Direct → UPnP → NAT-PMP → PCP → hole-punch → - // Relayed, #385) — mTLS proves the peer_id — and adopt the verified connection into the - // pool, exactly how the pool's own maintenance turns a candidate into a member. The relay - // tier is reached only after every direct + hole-punch tier fails. Spawned so one slow dial - // never stalls the inbound PEX read loop. - let methods = crate::net::full_nat_config(CANDIDATE_DIAL_TIMEOUT, self.stun_server) - .enabled_methods - .clone(); + let methods = methods.clone(); + // Verify by DIALING over the FULL NAT ladder (mTLS proves the peer_id) and adopt the + // verified connection into the pool — exactly how the pool's own maintenance turns a + // candidate into a member; the relay tier is reached only after every direct + hole-punch + // tier fails. Spawned so one slow dial never stalls the inbound PEX read loop. tokio::spawn(async move { match handle .connect_via_nat(peer_id, Some(addr), &methods, CANDIDATE_DIAL_TIMEOUT) @@ -547,7 +626,8 @@ impl PexPool for GossipPexPool { let _ = handle.adopt_nat_connection(conn).await; } Err(e) => { - tracing::debug!(peer = %peer_id, error = %e, "pex candidate dial failed (hint discarded)"); + // The hint PERSISTS in the book (retried later); only this dial attempt failed. + tracing::debug!(peer = %peer_id, error = %e, "pex candidate dial attempt failed (kept in address book)"); } } }); @@ -579,20 +659,6 @@ fn parse_gossip_peer_id(peer_id_hex: &str) -> Option { Some(dig_gossip::PeerId::from(arr)) } -/// The first dialable [`std::net::SocketAddr`] in a candidate's addresses (an IP literal + non-zero -/// port), or `None` for a relay-only / hostname-only entry. Pure so it is unit-tested without a socket. -fn best_socket_addr(entry: &PeerEntry) -> Option { - for a in &entry.addresses { - if a.port == 0 { - continue; - } - if let Ok(ip) = a.host.parse::() { - return Some(std::net::SocketAddr::new(ip, a.port)); - } - } - None -} - #[cfg(test)] mod tests { use super::*; @@ -1022,23 +1088,58 @@ mod tests { assert!(parse_gossip_peer_id(&"zz".repeat(32)).is_none()); } + /// #387: a PEX entry converts to a book candidate that keeps only IP-literal, non-zero-port + /// addresses (IPv6-first, via the book), skipping hostnames + zero ports; provenance is PEX. #[test] - fn best_socket_addr_picks_first_dialable_ip() { + fn book_candidate_from_entry_collects_dialable_ips() { let e = PeerEntry::new(hexid(0x01), "mainnet", 1, Provenance::Direct) .with_address(Address::new("not-an-ip", 9444, AddressKind::Direct)) - .with_address(Address::direct("198.51.100.7", 9444)); + .with_address(Address::new("198.51.100.7", 0, AddressKind::Direct)) // zero port skipped + .with_address(Address::direct("198.51.100.7", 9444)) + .with_address(Address::direct("2001:db8::9", 9444)); + let c = book_candidate_from_entry(&e, 1000); assert_eq!( - best_socket_addr(&e), - Some("198.51.100.7:9444".parse().unwrap()), - "skips the hostname, takes the IP literal" + c.addrs, + vec![ + "[2001:db8::9]:9444".parse().unwrap(), // IPv6 leads + "198.51.100.7:9444".parse().unwrap(), + ] ); - // A relay-only / addressless entry has nothing to dial directly. - let relay_only = PeerEntry::new(hexid(0x01), "mainnet", 1, Provenance::Direct); - assert_eq!(best_socket_addr(&relay_only), None); - // A zero port is not dialable. - let zero = PeerEntry::new(hexid(0x01), "mainnet", 1, Provenance::Direct) - .with_address(Address::new("198.51.100.7", 0, AddressKind::Direct)); - assert_eq!(best_socket_addr(&zero), None); + assert_eq!(c.provenance, crate::address_book::AddrProvenance::Pex); + assert!(!c.is_relay_only()); + // A relay-only / addressless entry becomes a persisted relay-only candidate. + let relay_only = PeerEntry::new(hexid(0x02), "mainnet", 1, Provenance::Direct); + assert!(book_candidate_from_entry(&relay_only, 1000).is_relay_only()); + } + + /// #384: `order_by_ranker` puts the higher-scored peer first (stable; a `None` ranker is a no-op). + #[test] + fn order_by_ranker_prefers_higher_scored_peers() { + use crate::address_book::{AddrProvenance, CandidateAddr}; + struct FixedRanker; + impl DialRanker for FixedRanker { + fn score(&self, peer_id_hex: &str) -> Option { + // Peer 0xaa is proven-good; 0xbb is proven-bad; anything else is unknown (neutral). + if peer_id_hex == hexid(0xaa) { + Some(0.9) + } else if peer_id_hex == hexid(0xbb) { + Some(0.1) + } else { + None + } + } + } + let addr: std::net::SocketAddr = "198.51.100.7:9444".parse().unwrap(); + let mk = |b: u8| CandidateAddr::new(hexid(b), vec![addr], AddrProvenance::Pex, 100); + let mut cands = vec![mk(0xbb), mk(0xcc), mk(0xaa)]; // bad, unknown, good + order_by_ranker(&mut cands, Some(&FixedRanker)); + let order: Vec = cands.iter().map(|c| c.peer_id.clone()).collect(); + // good (0.9) → unknown (neutral 0.5) → bad (0.1) + assert_eq!(order, vec![hexid(0xaa), hexid(0xcc), hexid(0xbb)]); + // A None ranker leaves the (book) order untouched. + let mut cands2 = vec![mk(0xbb), mk(0xaa)]; + order_by_ranker(&mut cands2, None); + assert_eq!(cands2[0].peer_id, hexid(0xbb)); } #[test] From 6c4f670034b410a9007a89137cd3d1babab1d6a8 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 11 Jul 2026 14:54:27 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs(p2p):=20SPEC=20=C2=A719=20peer-network?= =?UTF-8?q?=20integration=20+=20bump=20v0.19.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SPEC §19 (peer network: full NAT ladder, STUN reflexive discovery, DHT-only locator, durable address book, PEX handling, selector-driven dial ordering, and the release-first crate-API integration status). Applies rustfmt across the P2P-wiring changes and bumps the workspace to v0.19.0 (dig-node-core 0.6.0) — MINOR: new node-side capability (address book + dial ordering), backwards-compatible. Refs #376, #381, #384, #385, #386, #387 Co-Authored-By: Claude --- Cargo.lock | 4 +- Cargo.toml | 2 +- SPEC.md | 88 ++++++++++++++++ crates/dig-node-core/Cargo.toml | 2 +- crates/dig-node-core/src/address_book.rs | 123 +++++++++++++++++++---- crates/dig-node-core/src/download.rs | 3 +- crates/dig-node-core/src/net.rs | 10 +- crates/dig-node-core/src/peer.rs | 15 ++- crates/dig-node-core/src/pex.rs | 12 ++- 9 files changed, 225 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11a415e..a12fd6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1900,7 +1900,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-trait", "axum", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.18.0" +version = "0.19.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 4890a20..7f236a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.18.0" +version = "0.19.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index 07cdce0..ff501d9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1707,3 +1707,91 @@ the authorized-extension path (distinct from the `DIG_WALLET_ALLOW_BROADCAST` da an unconsented op builds + signs + validates but does NOT broadcast (nothing is spent). CI NEVER broadcasts to mainnet — tests drive the `chia-sdk-test` simulator (real consensus incl. BLS) or the recording `MockBroadcaster`; a real mainnet broadcast is a separate, explicitly-gated live pass. + +## 19. Peer network — NAT traversal, discovery, address book, and content location + +The standalone `dig-node` binary runs an L7 peer network (the in-process FFI/browser host does not — +§1, §15): a dig-gossip connected peer pool + relay reservation + introducer, a dig-dht content-location +index, node↔node PEX, and multi-source download — all over ONE mTLS identity +(`peer_id = SHA-256(TLS SPKI DER)`) on the dual-stack `[::]` listener (§4.1). This section is the +normative contract for how the node USES its P2P crates. All peer communication is IPv6-first with IPv4 +as the fallback (§5.2 ecosystem HARD RULE). + +### 19.1. NAT traversal — the full ladder (dig-nat) + +Every outbound peer dial (DHT RPCs, multi-source range fetches, PEX candidate verification) MUST use the +FULL dig-nat traversal ladder, tried in canonical rank order: + +> Direct → UPnP → NAT-PMP → PCP → hole-punch → Relayed + +The relay tier (`Relayed`, via `relay.dig.net`) is the LAST resort — reached only after every direct + +port-mapping + hole-punch tier has failed. A node MUST NOT cap dials to `[Direct, Relayed]` (which skips +port-mapping + hole-punch and over-loads the relay). One shared config constructor +(`net::full_nat_config(per_method_timeout, stun_server)`, built from `dig_nat::NatConfig::default`) is +used at every dial site, so a new dig-nat tier is picked up everywhere at once. Each tier is bounded by a +per-method timeout so a dial never hangs. + +### 19.2. STUN reflexive-address discovery + +The node discovers its server-reflexive (public) transport address via STUN (RFC 5389) against the STUN +server co-located with the relay (`:3478`, derived from `DIG_RELAY_URL`). The reflexive +address is (a) configured on the NAT config so dig-nat's hole-punch tier can use it, and (b) merged into +the node's advertised DHT candidate set **IPv6-first** — a reflexive IPv6 address leads the whole set; a +reflexive IPv4 address leads the IPv4 fallback group — so a peer behind a different NAT can dial or +hole-punch to it. Discovery is best-effort + bounded; on failure the node advertises its local addresses +only. The wildcard bind address (`[::]`/`0.0.0.0`) is never advertised as a candidate. + +### 19.3. Content location — dig-dht is the sole locator + +Content location ("which peers hold capsule X?") is the dig-dht provider index, and ONLY that: the live +locator is `DhtProviderLocator → find_providers` inside the content engine (`NodeContent`), used by both +the redirect-on-miss and the multi-source fetch paths. There is NO separate pool-availability provider +seam. The node keeps its own held-inventory provider records current (announce / republish / refresh / +gc) and withdraws them on shutdown. + +### 19.4. Address book — durable, IPv6-first, provenance + TTL + +The node maintains a durable peer address book: every learned peer candidate — from PEX, `dig.getPeers`, +the relay introducer, or an observed pool peer — is INGESTED into the book (keyed by `peer_id`) rather +than dialed-and-dropped. The book: + +- unions each peer's directly-dialable addresses, ordered **IPv6-first**; +- records provenance (a first-hand pool / `getPeers` sighting is not downgraded by a later PEX hint) and + a freshen timestamp; +- persists relay-only / not-currently-dialable hints (they survive to seed a later dial); +- reads back a ranked, non-stale candidate list (IPv6-dialable peers first, then other dialable, then + relay-only; ties by recency), evicting the stalest entry at a capacity bound and dropping entries past + a staleness TTL. + +### 19.5. PEX candidate handling + +PEX-discovered candidates are HINTS (proven only by a successful mTLS dial). On receiving a PEX candidate +batch the node offers EVERY candidate (including relay-only) to the address book (§19.4), then dials a +bounded number selected from the book — verifying each over the full ladder (§19.1) and adopting the +verified connection into the pool. A failed dial keeps the hint in the book for a later retry; a peer +already in the pool is skipped. + +### 19.6. Selector-driven dial ordering + +The shared self-optimizing peer selector (dig-peer-selector) that ranks download SOURCES also orders +which address-book candidates the node DIALS first: dials are ranked by the selector's content-agnostic +per-peer quality (reliability blended with throughput; a banned peer sinks to the bottom; a cold peer is +explored at a neutral rank). The node reuses the ONE selector instance; IPv6-first order is preserved +among equally-ranked peers. In PRIVACY mode the selector does not apply (the onion path uses its own). + +### 19.7. Crate-API integration status (release-first follow-ups) + +Two intended crate-side integrations are pending a release-first dig-gossip change and are realized +node-side in the interim: + +- dig-gossip exposes no PUBLIC production API to ingest external addresses into its `AddressManager` + (only a hidden test hook), so the durable address book (§19.4) lives in the node; when dig-gossip ships + a public `offer_addresses` ingest API the book flushes into the crate `AddressManager` (one source of + truth). +- dig-gossip's `PeerPoolConfig` exposes no dial-priority hook, so selector-driven ordering (§19.6) + applies to the node's PEX candidate dials; when dig-gossip ships a pool dial-priority hook the same + ranking drives the pool's own maintenance dial loop. + +Exercising the connected pool end-to-end is gated on the network-genesis bring-up (the pre-launch +placeholder genesis is rejected by `GossipService::start`); these behaviors are unit-tested +independently of a live pool. diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 0a7c178..27d1a5f 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-node-core" -version = "0.5.0" +version = "0.6.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-node-core/src/address_book.rs b/crates/dig-node-core/src/address_book.rs index 6012e52..b5cae4c 100644 --- a/crates/dig-node-core/src/address_book.rs +++ b/crates/dig-node-core/src/address_book.rs @@ -199,7 +199,10 @@ impl AddressBook { /// Number of entries currently held (incl. stale-but-not-yet-evicted). #[must_use] pub fn len(&self) -> usize { - self.entries.lock().expect("address_book mutex poisoned").len() + self.entries + .lock() + .expect("address_book mutex poisoned") + .len() } /// Whether the book holds no entries. @@ -272,7 +275,12 @@ mod tests { #[test] fn new_orders_addresses_ipv6_first_and_dedups() { - let c = CandidateAddr::new(hexid(1), vec![v4(9), v6(9), v4(9)], AddrProvenance::Pex, 100); + let c = CandidateAddr::new( + hexid(1), + vec![v4(9), v6(9), v4(9)], + AddrProvenance::Pex, + 100, + ); assert_eq!(c.addrs, vec![v6(9), v4(9)], "IPv6 leads, duplicate dropped"); assert!(c.has_ipv6()); assert!(!c.is_relay_only()); @@ -289,7 +297,12 @@ mod tests { #[test] fn relay_only_hint_persists_in_the_book() { let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); - book.offer(CandidateAddr::new(hexid(3), vec![], AddrProvenance::Pex, 100)); + book.offer(CandidateAddr::new( + hexid(3), + vec![], + AddrProvenance::Pex, + 100, + )); assert!(book.contains(&hexid(3)), "relay-only hint must persist"); // It appears in the full candidate list (seeding future selection) but NOT the dialable set. assert_eq!(book.candidates(100).len(), 1); @@ -299,11 +312,25 @@ mod tests { #[test] fn offer_upserts_unions_addresses_and_freshens() { let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); - book.offer(CandidateAddr::new(hexid(4), vec![v4(9)], AddrProvenance::Pex, 100)); - book.offer(CandidateAddr::new(hexid(4), vec![v6(9)], AddrProvenance::GetPeers, 200)); + book.offer(CandidateAddr::new( + hexid(4), + vec![v4(9)], + AddrProvenance::Pex, + 100, + )); + book.offer(CandidateAddr::new( + hexid(4), + vec![v6(9)], + AddrProvenance::GetPeers, + 200, + )); assert_eq!(book.len(), 1, "same peer upserts, not duplicates"); let c = &book.candidates(200)[0]; - assert_eq!(c.addrs, vec![v6(9), v4(9)], "addresses unioned + IPv6-first"); + assert_eq!( + c.addrs, + vec![v6(9), v4(9)], + "addresses unioned + IPv6-first" + ); assert_eq!(c.learned_at, 200, "freshened to newer sighting"); assert_eq!( c.provenance, @@ -315,18 +342,50 @@ mod tests { #[test] fn later_pex_hint_does_not_downgrade_first_hand_provenance() { let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); - book.offer(CandidateAddr::new(hexid(5), vec![v6(9)], AddrProvenance::PoolDirect, 100)); - book.offer(CandidateAddr::new(hexid(5), vec![v6(9)], AddrProvenance::Pex, 150)); - assert_eq!(book.candidates(150)[0].provenance, AddrProvenance::PoolDirect); + book.offer(CandidateAddr::new( + hexid(5), + vec![v6(9)], + AddrProvenance::PoolDirect, + 100, + )); + book.offer(CandidateAddr::new( + hexid(5), + vec![v6(9)], + AddrProvenance::Pex, + 150, + )); + assert_eq!( + book.candidates(150)[0].provenance, + AddrProvenance::PoolDirect + ); } #[test] fn candidates_are_ipv6_first_then_dialable_then_relay_only() { let book = AddressBook::new(DEFAULT_TTL_SECS, DEFAULT_CAPACITY); - book.offer(CandidateAddr::new(hexid(0x10), vec![], AddrProvenance::Pex, 100)); // relay-only - book.offer(CandidateAddr::new(hexid(0x11), vec![v4(9)], AddrProvenance::Pex, 100)); // v4 - book.offer(CandidateAddr::new(hexid(0x12), vec![v6(9)], AddrProvenance::Pex, 100)); // v6 - let ordered: Vec = book.candidates(100).into_iter().map(|c| c.peer_id).collect(); + book.offer(CandidateAddr::new( + hexid(0x10), + vec![], + AddrProvenance::Pex, + 100, + )); // relay-only + book.offer(CandidateAddr::new( + hexid(0x11), + vec![v4(9)], + AddrProvenance::Pex, + 100, + )); // v4 + book.offer(CandidateAddr::new( + hexid(0x12), + vec![v6(9)], + AddrProvenance::Pex, + 100, + )); // v6 + let ordered: Vec = book + .candidates(100) + .into_iter() + .map(|c| c.peer_id) + .collect(); assert_eq!(ordered, vec![hexid(0x12), hexid(0x11), hexid(0x10)]); // dialable_candidates drops the relay-only tail, keeps IPv6-first. let dialable: Vec = book @@ -340,18 +399,44 @@ mod tests { #[test] fn stale_candidates_are_dropped_from_readback() { let book = AddressBook::new(10, DEFAULT_CAPACITY); - book.offer(CandidateAddr::new(hexid(6), vec![v6(9)], AddrProvenance::Pex, 100)); + book.offer(CandidateAddr::new( + hexid(6), + vec![v6(9)], + AddrProvenance::Pex, + 100, + )); assert_eq!(book.candidates(105).len(), 1, "within TTL: present"); - assert!(book.candidates(200).is_empty(), "past TTL: dropped from readback"); - assert!(book.contains(&hexid(6)), "still held until evicted/refreshed"); + assert!( + book.candidates(200).is_empty(), + "past TTL: dropped from readback" + ); + assert!( + book.contains(&hexid(6)), + "still held until evicted/refreshed" + ); } #[test] fn capacity_evicts_the_stalest_on_new_insert() { let book = AddressBook::new(DEFAULT_TTL_SECS, 2); - book.offer(CandidateAddr::new(hexid(7), vec![v6(9)], AddrProvenance::Pex, 100)); // stalest - book.offer(CandidateAddr::new(hexid(8), vec![v6(9)], AddrProvenance::Pex, 200)); - book.offer(CandidateAddr::new(hexid(9), vec![v6(9)], AddrProvenance::Pex, 300)); // evicts 7 + book.offer(CandidateAddr::new( + hexid(7), + vec![v6(9)], + AddrProvenance::Pex, + 100, + )); // stalest + book.offer(CandidateAddr::new( + hexid(8), + vec![v6(9)], + AddrProvenance::Pex, + 200, + )); + book.offer(CandidateAddr::new( + hexid(9), + vec![v6(9)], + AddrProvenance::Pex, + 300, + )); // evicts 7 assert_eq!(book.len(), 2); assert!(!book.contains(&hexid(7)), "stalest evicted"); assert!(book.contains(&hexid(8)) && book.contains(&hexid(9))); diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 729b629..52a2b51 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -725,7 +725,8 @@ impl NodeContent { stun_server: Option, ) -> Arc { let locator = Arc::new(DhtProviderLocator::new(dht)); - let nat_config = crate::net::full_nat_config(crate::dht::default_rpc_timeout(), stun_server); + let nat_config = + crate::net::full_nat_config(crate::dht::default_rpc_timeout(), stun_server); let transport = Arc::new(NatRangeTransport::new(identity, nat_config, network_id)); Self::new(locator, transport, miss_mode, self_peer_id, cache_dir) } diff --git a/crates/dig-node-core/src/net.rs b/crates/dig-node-core/src/net.rs index 4532563..23f97f2 100644 --- a/crates/dig-node-core/src/net.rs +++ b/crates/dig-node-core/src/net.rs @@ -397,7 +397,10 @@ mod tests { } // The port-mapping + hole-punch tiers that the old `[Direct, Relayed]` config skipped: assert!( - cfg.is_enabled(Upnp) && cfg.is_enabled(NatPmp) && cfg.is_enabled(Pcp) && cfg.is_enabled(HolePunch), + cfg.is_enabled(Upnp) + && cfg.is_enabled(NatPmp) + && cfg.is_enabled(Pcp) + && cfg.is_enabled(HolePunch), "UPnP/NAT-PMP/PCP/hole-punch must be tried before falling back to the relay" ); } @@ -468,6 +471,9 @@ mod tests { vec![reflexive_v4, v4] ); // With no local addresses at all, the reflexive is the sole candidate. - assert_eq!(merge_reflexive(vec![], Some(reflexive_v4)), vec![reflexive_v4]); + assert_eq!( + merge_reflexive(vec![], Some(reflexive_v4)), + vec![reflexive_v4] + ); } } diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 84c2cf6..1cbceff 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1351,8 +1351,12 @@ async fn bring_up_dht( // our network id, bounding each RPC by the config's per-RPC timeout, over the FULL NAT ladder with // the relay's STUN server feeding its hole-punch tier (#385). let transport = Arc::new( - crate::dht::NatDhtTransport::new(identity.clone(), network_id.to_string(), config.rpc_timeout) - .with_stun_server(stun_server), + crate::dht::NatDhtTransport::new( + identity.clone(), + network_id.to_string(), + config.rpc_timeout, + ) + .with_stun_server(stun_server), ); // Our own advertised addresses: the node's REAL routable address(es) at the P2P listen port, // ordered IPv6-first (ecosystem HARD RULE) — a global-unicast IPv6 address (when the host has one) @@ -1361,7 +1365,8 @@ async fn bring_up_dht( // with no routable address advertises nothing here and stays reachable via the relay tiers dig-nat // composes; loopback/in-process setups opt into a loopback candidate via DIG_NODE_ADVERTISE_LOOPBACK. let port = peer_port_from_env(); - let local = crate::net::advertised_socket_addrs(port, crate::net::advertise_loopback_from_env()); + let local = + crate::net::advertised_socket_addrs(port, crate::net::advertise_loopback_from_env()); // Add this node's STUN-discovered server-reflexive (public) address to the advertised set (#385) // so a remote peer behind a different NAT can dial / hole-punch to it, not just to a LAN-local // address. Best-effort + bounded: a failure (no STUN server, timeout) advertises the local @@ -1371,7 +1376,9 @@ async fn bring_up_dht( None => None, }; if let Some(r) = reflexive { - println!("dig-node peer network: STUN reflexive address {r} added to advertised candidates"); + println!( + "dig-node peer network: STUN reflexive address {r} added to advertised candidates" + ); } let local_addresses: Vec = crate::net::merge_reflexive(local, reflexive) .into_iter() diff --git a/crates/dig-node-core/src/pex.rs b/crates/dig-node-core/src/pex.rs index f32ecee..78be192 100644 --- a/crates/dig-node-core/src/pex.rs +++ b/crates/dig-node-core/src/pex.rs @@ -544,7 +544,10 @@ const NEUTRAL_DIAL_SCORE: f64 = 0.5; /// Order dialable candidates best-first by the ranker's score (HIGHER first), STABLY — so the book's /// IPv6-first order (§5.2) is preserved among equally-scored peers. A `None` ranker leaves the order /// untouched (the book is already IPv6-first). Pure so the ordering is unit-tested without a handle. -fn order_by_ranker(cands: &mut [crate::address_book::CandidateAddr], ranker: Option<&dyn DialRanker>) { +fn order_by_ranker( + cands: &mut [crate::address_book::CandidateAddr], + ranker: Option<&dyn DialRanker>, +) { let Some(r) = ranker else { return; }; @@ -601,9 +604,10 @@ impl PexPool for GossipPexPool { .enabled_methods .clone(); for cand in dialable.into_iter().take(MAX_CANDIDATE_DIALS) { - let (Some(peer_id), Some(addr)) = - (parse_gossip_peer_id(&cand.peer_id), cand.addrs.first().copied()) - else { + let (Some(peer_id), Some(addr)) = ( + parse_gossip_peer_id(&cand.peer_id), + cand.addrs.first().copied(), + ) else { continue; }; // Skip a peer already in the connected pool — the book is a superset of the live pool.