From 4ad48609b2c695802641f58153485929cbba73ed Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 22 Jul 2026 19:05:55 -0700 Subject: [PATCH 1/5] chore(deps): bump dig-nat 0.11 + dig-gossip v0.14.0 (#1532 Leg B WIP) Co-Authored-By: Claude --- Cargo.toml | 6 +++--- crates/dig-node-core/Cargo.toml | 28 +++++++++++++++------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fe06150..47c59ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[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.54.1" +version = "0.55.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over @@ -54,8 +54,8 @@ overflow-checks = true # standalone. `native-tls` is NOT patched: dig-gossip is pulled with # `default-features = false, features = ["rustls", "relay"]`, so the OpenSSL/native-tls path is off. [patch.crates-io] -chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "5bb335360ee16a856b217b429f205eead31dee80" } -chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "5bb335360ee16a856b217b429f205eead31dee80" } +chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "31f3f33dad0285c0e079fa5dad5e939c2df8081e" } +chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "31f3f33dad0285c0e079fa5dad5e939c2df8081e" } # The dig-nat unification patch is RETIRED (#1280 crates.io cascade): dig-nat 0.7 is on crates.io and # the ENTIRE peer stack (dig-gossip 0.7.1, dig-dht 0.2.2, dig-download 0.2.1, dig-peer-selector 0.2.1, diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index df611d7..35cd14f 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -106,23 +106,25 @@ getrandom = "0.2" # consumes both to auto-discover peers via relay.dig.net, maintain a pool, and serve/issue the L7 peer # RPC over mTLS (peer_id = SHA256(TLS SPKI DER)). # -# Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.10 is on crates.io — the whole peer stack -# (dig-gossip 0.12.0, dig-dht 0.5.0, dig-download 0.6.0, dig-peer-selector 0.5.0) now depends on dig-nat -# "0.10" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE graph on -# ONE dig-nat 0.10 instance (the old bare-git unification patch is retired). 0.10 ships the SPKI-pinned -# dialer (#1422, the relayed-connect fix) on top of 0.8's STUN happy-eyeballs reflexive discovery, and -# auto-composes the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. -dig-nat = "0.10" -# dig-gossip is the ONE peer-stack exception: at v0.13.0 (on dig-nat 0.10 + dig-tls 0.3, crates.io +# Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.11 is on crates.io — the whole peer stack +# (dig-gossip 0.14.0, dig-dht 0.5.0, dig-download 0.6.0, dig-peer-selector 0.5.0) now depends on dig-nat +# ">=0.10, <0.12" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE +# graph on ONE dig-nat 0.11 instance (the old bare-git unification patch is retired). 0.11 adds the +# relayed RESPONDER path — `RelayStatus::enable_accept` surfaces an introduced circuit as a +# `RelayTunnel`, and `RelayAcceptor` runs the mTLS SERVER half over it (#1536, Leg B) — on top of +# 0.10's SPKI-pinned dialer (#1422) and the STUN happy-eyeballs reflexive discovery, and auto-composes +# the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. +dig-nat = "0.11" +# dig-gossip is the ONE peer-stack exception: at v0.14.0 (on dig-nat 0.11 + dig-tls 0.3, crates.io # deps) but NOT yet published to crates.io — its own publish is guarded pending dig-peer-protocol (#681). -# So it stays a git dependency, PINNED to the v0.13.0 tag commit (rev 5bb335360ee1, #1517 relay-circuit -# auto-dial: threads the discovered peer_id into the mTLS SPKI pin + dials the full NAT ladder incl. the -# relay circuit after Direct fails). Pulled rustls-only (no +# So it stays a git dependency, PINNED to the v0.14.0 tag commit (rev 31f3f33dad02, #1541 — injects the +# node's PERSISTENT `nat_identity` NodeCert into the dig-nat transport so every transport presents the +# ONE advertised peer_id, on top of #1517's relay-circuit auto-dial). Pulled rustls-only (no # native-tls). Its dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the # graph still unifies to ONE of each. The vendored chia-protocol/chia-sdk-client forks it path-patches # are re-declared at this SAME rev in the workspace `[patch.crates-io]` (a git dep's own patches do not -# apply transitively). -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "5bb335360ee16a856b217b429f205eead31dee80", default-features = false, features = ["rustls", "relay"] } +# apply transitively — the #1529 3-rev lockstep: this dep rev + the two patch revs move together). +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "31f3f33dad0285c0e079fa5dad5e939c2df8081e", default-features = false, features = ["rustls", "relay"] } # The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. From # crates.io: dig-nat 0.7 requires `>=0.4, <0.6` and dig-gossip 0.7.1 requires `>=0.2, <0.5`, so `0.4` # sits in both windows and the whole graph resolves to ONE crates.io dig-constants 0.4.x. From b1dbad3f41a21c2cf8c0f3c443eb2b6f3c5d3d8b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 22 Jul 2026 19:19:59 -0700 Subject: [PATCH 2/5] feat(peer): Leg B relay responder + nat_identity injection (#1532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the responder half of relayed connect (Leg B, #1532/#1536): - inject the persistent NodeCert into GossipConfig::nat_identity so every transport presents the ONE advertised peer_id (#1541) — extracted into the tested apply_persistent_identity helper. - enable the dig-nat relay accept path (RelayStatus::enable_accept) BEFORE the reservation registers, and spawn an accept-loop handing each introduced RelayTunnel to RelayAcceptor::accept, serving the resulting PeerConnection identically to a direct inbound (serve_accepted_relay_conn), bounded by the same accepted-connection semaphore. - bump dig-nat 0.11 + dig-gossip v0.14.0 (rev 31f3f33) + 3-rev lockstep; version 0.55.0. - TDD: apply_persistent_identity one-identity invariant + accept-serve over a real authenticated mTLS session. BLOCKED: the peer stack (dig-gossip 0.14/dig-dht 0.5/dig-download 0.6/ dig-peer-selector 0.5) still pins dig-nat "0.10" (^0.10 excludes 0.11) → two dig-nat versions → RelayStatus/NatConfig type mismatch at the dig-gossip boundary. Needs the release-first dig-nat-0.11 widen+re-release cascade first. Co-Authored-By: Claude --- Cargo.lock | 66 ++++++--- crates/dig-node-core/src/peer.rs | 246 ++++++++++++++++++++++++++++--- 2 files changed, 276 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff3b11f..22dc59e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "chia-protocol" version = "0.26.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=5bb335360ee16a856b217b429f205eead31dee80#5bb335360ee16a856b217b429f205eead31dee80" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=31f3f33dad0285c0e079fa5dad5e939c2df8081e#31f3f33dad0285c0e079fa5dad5e939c2df8081e" dependencies = [ "chia-bls 0.26.0", "chia-serde", @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "chia-sdk-client" version = "0.28.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=5bb335360ee16a856b217b429f205eead31dee80#5bb335360ee16a856b217b429f205eead31dee80" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=31f3f33dad0285c0e079fa5dad5e939c2df8081e#31f3f33dad0285c0e079fa5dad5e939c2df8081e" dependencies = [ "aws-lc-rs", "chia-protocol", @@ -1973,7 +1973,7 @@ checksum = "81f67fbe1aa581bad22d96e6ca3af06b3f490676c404f500fa545676568ac2f4" dependencies = [ "async-trait", "dig-ip", - "dig-nat", + "dig-nat 0.10.0", "rand 0.8.7", "serde", "serde_json", @@ -1991,7 +1991,7 @@ checksum = "846b543c3d5b7d54debbf0127e4a167d322e21892c3765c42f8d59555f439cbd" dependencies = [ "async-trait", "dig-dht", - "dig-nat", + "dig-nat 0.10.0", "futures", "serde", "serde_json", @@ -2003,8 +2003,8 @@ dependencies = [ [[package]] name = "dig-gossip" -version = "0.13.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=5bb335360ee16a856b217b429f205eead31dee80#5bb335360ee16a856b217b429f205eead31dee80" +version = "0.14.0" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=31f3f33dad0285c0e079fa5dad5e939c2df8081e#31f3f33dad0285c0e079fa5dad5e939c2df8081e" dependencies = [ "bincode", "chia-protocol", @@ -2013,7 +2013,7 @@ dependencies = [ "chia_streamable_macro 0.26.0", "dig-constants 0.4.0", "dig-ip", - "dig-nat", + "dig-nat 0.10.0", "dig-peer-protocol", "dig-tls", "futures-util", @@ -2161,6 +2161,36 @@ dependencies = [ "yamux", ] +[[package]] +name = "dig-nat" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5074965e7951a0bbf8afc754a2179ddc0969190ed970bbb7648435c4b64eaca2" +dependencies = [ + "arc-swap", + "async-trait", + "dig-constants 0.5.1", + "dig-identity 0.4.2", + "dig-ip", + "dig-tls", + "futures", + "futures-util", + "igd-next", + "ring", + "rustls-pki-types", + "serde", + "serde_bytes", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "tracing", + "yamux", +] + [[package]] name = "dig-node-core" version = "0.16.1" @@ -2181,7 +2211,7 @@ dependencies = [ "dig-identity 0.4.0", "dig-ip", "dig-ipc-protocol", - "dig-nat", + "dig-nat 0.11.0", "dig-peer-selector", "dig-pex", "dig-rpc-protocol", @@ -2214,7 +2244,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.54.1" +version = "0.55.0" dependencies = [ "axum", "axum-server", @@ -2269,7 +2299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31070e3c364c8f789158ec97d29361e36f1822ac2f633ca931ebe4b28cd5c7e9" dependencies = [ "dig-dht", - "dig-nat", + "dig-nat 0.10.0", "rand 0.8.7", "rand_pcg 0.3.1", "serde", @@ -2700,7 +2730,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2848,7 +2878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3623,7 +3653,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3951,7 +3981,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4536,7 +4566,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4905,7 +4935,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5564,7 +5594,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6622,7 +6652,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index bedc974..e8d75e2 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1333,7 +1333,30 @@ fn map_gossip_pool_event(ev: &dig_gossip::PoolEvent) -> dig_peer_selector::PoolE /// status. The gossip pool and the reservation loop therefore observe ONE shared status: without this /// single shared `Arc`, discovered peers never reach the pool. When the relay is disabled /// (`DIG_RELAY_URL=off`) the status is marked [`RelayStatus::set_disabled`] and no socket is opened. -/// Returns the shared status so the node can report the REAL reservation state (#872). +/// Bind a [`GossipConfig`](dig_gossip::GossipConfig) to the node's ONE persistent `identity` so the +/// pool presents the SAME `peer_id = SHA-256(SPKI DER)` on EVERY transport (#1532/#1541): +/// +/// * `peer_id` — the pool's advertised/handshake/self-dial-guard identity, derived from the persistent +/// NodeCert's SPKI (dig-gossip's own derivation, so the type + hashing match the pool's internal id). +/// * `nat_identity` — the persistent NodeCert INJECTED as the dig-nat transport identity. Without it +/// dig-gossip mints a RANDOM per-boot ephemeral NodeCert for its DigPeer/relay dials, so the +/// transport `peer_id` differed from the advertised/registered/pinned one and Leg B's relayed circuit +/// failed closed with `peer_id mismatch`. Injecting THIS `Arc` — the same one the chia-ssl +/// path, the mTLS listener, the DHT dials, and the relay reservation present — makes ONE identity +/// span all transports, the invariant that closes the Leg-B mismatch. +/// +/// Pure (mutates only the config), so the one-identity invariant is unit-tested without a live pool. +fn apply_persistent_identity(cfg: &mut dig_gossip::GossipConfig, identity: &Arc) { + cfg.peer_id = dig_gossip::peer_id_from_tls_spki_der(identity.spki_der()); + cfg.nat_identity = Some(identity.clone()); +} + +/// Returns the shared status so the node can report the REAL reservation state (#872), plus — when the +/// relay is enabled — the receiver of INTRODUCED inbound relay circuits (Leg B's responder half): the +/// accept path is turned on ([`RelayStatus::enable_accept`](dig_nat::relay::RelayStatus::enable_accept)) +/// BEFORE the reservation loop starts registering, so no circuit a NAT'd peer relays to us is dropped as +/// unknown-peer before the accept channel exists. The caller drains it via [`spawn_relay_accept_loop`]. +/// `None` when the relay is disabled (no reservation, so no inbound circuits). fn wire_relay_reservation( handle: &dig_gossip::GossipHandle, enabled: bool, @@ -1341,10 +1364,16 @@ fn wire_relay_reservation( peer_id: String, network_id: String, listen_addrs: Vec, -) -> Arc { +) -> ( + Arc, + Option>, +) { let status = dig_nat::relay::RelayStatus::new(); handle.attach_relay_status(status.clone()); if enabled { + // Enable the RESPONDER path first: install the accept channel so the reservation loop hands us + // every introduced circuit from a peer we have no open outbound tunnel to (#1536, Leg B). + let inbound = status.enable_accept(); let status = status.clone(); tokio::spawn(async move { // B1 (#870): advertise the node's gossip listen candidates so the relay's reflexive @@ -1358,10 +1387,71 @@ fn wire_relay_reservation( ) .await; }); + (status, Some(inbound)) } else { status.set_disabled(); + (status, None) } - status +} + +/// Spawn the RESPONDER half of Leg B: for every INTRODUCED relay circuit surfaced by +/// [`RelayStatus::enable_accept`](dig_nat::relay::RelayStatus::enable_accept) (drained from `inbound`), +/// run the mTLS SERVER handshake ([`dig_nat::RelayAcceptor`], presenting THIS node's persistent +/// `identity`) and serve the resulting authenticated session exactly like a direct inbound connection +/// ([`serve_accepted_relay_conn`]). This is what lets a NAT'd peer's relayed circuit be ACCEPTED — the +/// missing counterpart to the dialer that closes Leg B (#1532/#1536). +/// +/// Bounded by its own accepted-connection semaphore (mirroring the direct listener, audit #179): a +/// relay cannot make us spawn unbounded serve tasks. `relay_addr` (observability only) is recorded as +/// the accepted [`PeerConnection`](dig_nat::PeerConnection)'s remote address. +fn spawn_relay_accept_loop( + mut inbound: tokio::sync::mpsc::Receiver, + identity: Arc, + responder: Arc, + relay_addr: Option, +) { + let mut acceptor = dig_nat::RelayAcceptor::new(identity); + if let Some(addr) = relay_addr { + acceptor = acceptor.with_relay_endpoint(addr); + } + tokio::spawn(async move { + let conn_permits = Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_PEER_CONNECTIONS)); + while let Some(tunnel) = inbound.recv().await { + let acceptor = acceptor.clone(); + let responder = responder.clone(); + let spawned = spawn_with_permit(&conn_permits, async move { + match acceptor.accept(tunnel).await { + Ok(conn) => serve_accepted_relay_conn(conn, responder).await, + Err(e) => { + tracing::debug!(error = %e, "relayed circuit mTLS accept failed; dropped") + } + } + }); + if !spawned { + tracing::debug!("relayed circuit shed: global connection cap reached"); + } + } + }); +} + +/// Serve one ACCEPTED relayed circuit exactly like a direct inbound connection: build the authenticated +/// caller [`dig_dht::Contact`] from the mTLS-verified `peer_id` + relay endpoint (identity comes from +/// the certificate the handshake verified, never the wire body), then serve the muxed session against +/// `responder` via [`serve_peer_session_from`]. Identical downstream handling to a direct inbound (§the +/// accepted [`PeerConnection`](dig_nat::PeerConnection) carries the SAME authentication), so a NAT'd +/// peer reaching us over a relay circuit gets the full L7 peer RPC (availability / range / DHT). +async fn serve_accepted_relay_conn( + conn: dig_nat::PeerConnection, + responder: Arc, +) { + let dig_nat::PeerConnection { + peer_id, + remote_addr, + mut session, + .. + } = conn; + let caller = Some(crate::dht::caller_contact(&peer_id, remote_addr)); + serve_peer_session_from(caller, &mut session, responder).await; } /// Bring up the peer network (the fallible body of [`spawn_peer_network`]). @@ -1429,11 +1519,6 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { let (gossip_cert_path, gossip_key_path) = gossip_identity_paths(&node.node_cert_dir()); let mut cfg = dig_gossip::GossipConfig { network_id: genesis, - // The pool's OWN network fingerprint — derived from the SAME NodeCert SPKI the listener now - // presents, so `config.peer_id`, the on-wire handshake, and the presented leaf all agree on - // ONE identity (the pool's self-dial guard compares against this). Uses dig-gossip's own - // derivation so the type + hashing match the pool's internal `peer_id` exactly (#1532). - peer_id: dig_gossip::peer_id_from_tls_spki_der(identity.spki_der()), cert_path: gossip_cert_path, key_path: gossip_key_path, peers_file_path: gossip_dir.join("peers.json"), @@ -1443,6 +1528,8 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { listen_addr: crate::net::dual_stack_listen_addr(gossip_port_from_env()), ..Default::default() }; + // Bind the pool to the node's ONE persistent identity across every transport (#1532/#1541). + apply_persistent_identity(&mut cfg, &identity); if relay_enabled() { cfg.relay = Some(dig_gossip::RelayConfig { endpoint: relay_endpoint.clone(), @@ -1475,7 +1562,7 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // maintenance loop reads the attached status each pass). Without sharing ONE status, discovery // never reaches the pool. Returns the shared status so the node reports the REAL reservation // state (#872) rather than a "relay configured" proxy. - let relay_status = wire_relay_reservation( + let (relay_status, relay_inbound) = wire_relay_reservation( &handle, relay_enabled(), relay_endpoint.clone(), @@ -1544,21 +1631,24 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { std::time::Duration::from_secs(2), ) .await; - let relayed_dialer: Option> = if relay_enabled() { + // The resolved relay socket address, shared by the relayed dialer (Leg B initiator) AND the relay + // accept loop below (Leg B responder, observability-only remote addr on accepted circuits). + let relay_socket_addr: Option = if relay_enabled() { let ep = relay_endpoint.clone(); - let relay_addr = tokio::task::spawn_blocking(move || crate::net::relay_socket_addr(&ep)) + tokio::task::spawn_blocking(move || crate::net::relay_socket_addr(&ep)) .await .ok() - .flatten(); - relay_addr.map(|addr| { + .flatten() + } else { + None + }; + let relayed_dialer: Option> = + relay_socket_addr.map(|addr| { Arc::new(dig_nat::ReservationRelayedTransport::new( relay_status.clone(), addr, )) as Arc - }) - } else { - None - }; + }); let nat_runtime = Arc::new(crate::net::build_node_nat_runtime( peer_port_from_env(), reflexive, @@ -1711,6 +1801,20 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { node_responder = node_responder.with_dht(dht); } let responder: Arc = Arc::new(node_responder); + + // Leg B responder half (#1532/#1536): drain the introduced relay circuits the reservation surfaces + // and serve each — over THIS node's persistent identity — exactly like a direct inbound. Wired only + // when the relay is enabled (else `relay_inbound` is `None`). This runs alongside the direct mTLS + // listener below so a NAT'd peer that could only reach us over a relay circuit is now ACCEPTED. + if let Some(inbound) = relay_inbound { + spawn_relay_accept_loop( + inbound, + identity.clone(), + responder.clone(), + relay_socket_addr, + ); + } + serve_peer_rpc_listener_with(listener, identity, responder, Some(pex)).await } @@ -2066,7 +2170,7 @@ mod tests { .expect("gossip start"); // Wire with the relay DISABLED so no real socket is opened; we drive the shared status by hand. - let status = wire_relay_reservation( + let (status, inbound) = wire_relay_reservation( &handle, false, DEFAULT_RELAY_URL.to_string(), @@ -2074,6 +2178,8 @@ mod tests { DEFAULT_NETWORK_ID.to_string(), gossip_listen_candidates(0), ); + // Relay disabled → no accept path is enabled (no reservation, so no inbound circuits). + assert!(inbound.is_none()); // Before: no reservation held → the pool reports the relay disconnected. assert!(!handle.stats().await.relay_connected); @@ -2993,6 +3099,110 @@ mod tests { server.abort(); } + /// #1532/#1541 — injecting the persistent identity makes ONE `peer_id` span every transport. + /// [`apply_persistent_identity`] must (a) set the advertised pool `peer_id` from the persistent + /// NodeCert's SPKI AND (b) inject that SAME NodeCert as `nat_identity`, so the dig-nat transport + /// presents the advertised id rather than a random per-boot ephemeral one — the invariant that + /// closes the Leg-B `peer_id mismatch`. + #[test] + fn apply_persistent_identity_makes_one_identity_span_all_transports() { + let dir = tempfile::tempdir().expect("cert dir"); + let identity = load_or_generate_node_cert(dir.path(), &node_seed("legb-identity")) + .expect("persistent identity"); + + let mut cfg = dig_gossip::GossipConfig::default(); + apply_persistent_identity(&mut cfg, &identity); + + // (a) The advertised pool peer_id is derived from the persistent NodeCert's SPKI. + assert_eq!( + cfg.peer_id, + dig_gossip::peer_id_from_tls_spki_der(identity.spki_der()) + ); + // (b) The SAME NodeCert is injected as the dig-nat transport identity... + let injected = cfg.nat_identity.as_ref().expect("nat_identity injected"); + assert!( + Arc::ptr_eq(injected, &identity), + "the transport identity must be the node's persistent NodeCert, not a copy/ephemeral" + ); + // ...so the transport's peer_id == the advertised peer_id (one identity, all transports). + assert_eq!( + dig_gossip::peer_id_from_tls_spki_der(injected.spki_der()), + cfg.peer_id + ); + } + + /// Leg B responder half (#1532/#1536): an ACCEPTED relayed circuit is served exactly like a direct + /// inbound. `RelayAcceptor::accept` (which needs a live relay circuit) yields a server-role + /// [`dig_nat::PeerConnection`]; here we produce that SAME connection by running the identical mTLS + /// SERVER handshake over a real TCP link, then serve it via [`serve_accepted_relay_conn`]. The + /// client (dialing over dig-nat Direct) gets a real peer-RPC answer, proving the served session is a + /// fully-authenticated peer channel — a NAT'd peer's relayed circuit gets the full L7 peer RPC. + #[tokio::test] + async fn serve_accepted_relay_conn_serves_a_peer_rpc_over_an_authenticated_session() { + use std::time::Duration; + install_crypto_provider(); + + let server_dir = tempfile::tempdir().expect("server cert dir"); + let server_identity = + load_or_generate_node_cert(server_dir.path(), &node_seed("legb-server")) + .expect("server identity"); + let server_peer_id = server_identity.peer_id(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_cert = server_identity.clone(); + let server = tokio::spawn(async move { + let (tcp, peer_addr) = listener.accept().await.unwrap(); + // The SAME server handshake `RelayAcceptor::accept` runs over an introduced circuit. + let server_tls = + dig_tls::server_config(&server_cert, dig_nat::BindingPolicy::Opportunistic) + .expect("server config"); + let captured = server_tls.captured_peer_id; + let captured_bls = server_tls.captured_bls; + let acceptor = tokio_rustls::TlsAcceptor::from(server_tls.config); + let tls = acceptor.accept(tcp).await.expect("mtls accept"); + let verified = captured.get().expect("client presented a cert"); + let conn = dig_nat::PeerConnection { + peer_id: verified, + method: dig_nat::TraversalKind::Relayed, + remote_addr: peer_addr, + peer_bls_pub: captured_bls.get(), + session: dig_nat::mux::PeerSession::server(tls), + }; + let responder: Arc = Arc::new(StubResponder); + serve_accepted_relay_conn(conn, responder).await; + }); + + let client_dir = tempfile::tempdir().expect("client cert dir"); + let client_identity = + load_or_generate_node_cert(client_dir.path(), &node_seed("legb-client")) + .expect("client identity"); + let target = dig_nat::PeerTarget::with_addr(server_peer_id, addr, "DIG_MAINNET"); + let config = dig_nat::NatConfig::builder() + .enabled_methods(vec![dig_nat::TraversalKind::Direct]) + .per_method_timeout(Duration::from_secs(5)) + .build(); + let mut conn = dig_nat::connect(&target, &client_identity, &config) + .await + .expect("client dials the accepted-circuit server"); + + let mut stream = conn.session.open_stream().await.expect("open stream"); + write_framed( + &mut stream, + &json!({"jsonrpc":"2.0","id":7,"method":"dig.getNetworkInfo"}), + ) + .await + .unwrap(); + let resp = read_framed(&mut stream).await.unwrap().expect("a frame"); + assert_eq!(resp["id"], json!(7)); + assert_eq!( + resp["result"]["echo_method"], + json!("dig.getNetworkInfo"), + "the accepted relayed circuit must serve the peer RPC like a direct inbound" + ); + let _ = server.await; + } + #[tokio::test] async fn node_responder_returns_method_not_found_for_management_methods() { // End-to-end over the responder: a peer JSON-RPC frame naming a management/mutation From 294f00c58f7ac8a8ab05fe7f8085a12fe08ff6f8 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 23 Jul 2026 18:55:46 -0700 Subject: [PATCH 3/5] chore(deps): adopt dig-nat 0.11 cascade final (dig-gossip v0.15.0) (#1532) The dig-nat 0.11 release-first cascade is complete on crates.io, so bump the whole peer stack onto the unified dig-nat 0.11 graph and retire the stale v0.14.0 pin that caused the two-dig-nat-version E0308 mismatches: - dig-gossip git dep v0.14.0 -> v0.15.0 (rev 90c12ac, #1541 nat_identity injection seam), with the 3-rev lockstep chia-protocol/chia-sdk-client [patch.crates-io] revs moved to match. - dig-dht 0.5.0 -> 0.5.1, dig-download 0.6 -> 0.7 (0.7.1), dig-peer-selector -> 0.5.2, dig-peer 0.4.1 (all on crates.io dig-nat 0.11). - cargo update refreshes the lock; the entire graph now unifies on ONE dig-nat 0.11 instance, so RelayStatus/NatConfig types line up at the dig-gossip boundary and the Leg B responder compiles across platforms. - rustfmt: wrap apply_persistent_identity + relayed_dialer per the pool refactor. Co-Authored-By: Claude --- Cargo.lock | 167 ++++++++++++++++++++++--------- Cargo.toml | 4 +- crates/dig-node-core/Cargo.toml | 10 +- crates/dig-node-core/src/peer.rs | 22 ++-- 4 files changed, 140 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22dc59e..2ef77f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chia" version = "0.26.0" @@ -774,7 +787,7 @@ dependencies = [ [[package]] name = "chia-protocol" version = "0.26.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=31f3f33dad0285c0e079fa5dad5e939c2df8081e#31f3f33dad0285c0e079fa5dad5e939c2df8081e" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=90c12ac5a89a4d143ed86cc755cd42678aa1aa4f#7ce3bb4c2adc1d5c5e4ce907eefb56abff3d0d8c" dependencies = [ "chia-bls 0.26.0", "chia-serde", @@ -867,7 +880,7 @@ dependencies = [ [[package]] name = "chia-sdk-client" version = "0.28.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=31f3f33dad0285c0e079fa5dad5e939c2df8081e#31f3f33dad0285c0e079fa5dad5e939c2df8081e" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=90c12ac5a89a4d143ed86cc755cd42678aa1aa4f#7ce3bb4c2adc1d5c5e4ce907eefb56abff3d0d8c" dependencies = [ "aws-lc-rs", "chia-protocol", @@ -1260,6 +1273,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1965,15 +1979,26 @@ dependencies = [ "hex-literal", ] +[[package]] +name = "dig-constants" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c8b18e4c82af7bd809037195f3c8af5c58fe6cbe4387ab4d2defbc9790c062" +dependencies = [ + "chia-consensus", + "chia-protocol", + "hex-literal", +] + [[package]] name = "dig-dht" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f67fbe1aa581bad22d96e6ca3af06b3f490676c404f500fa545676568ac2f4" +checksum = "954561d8eef53884b9ff07b1a34ff245b277b5db7e8b35712e11de4246c61090" dependencies = [ "async-trait", "dig-ip", - "dig-nat 0.10.0", + "dig-nat", "rand 0.8.7", "serde", "serde_json", @@ -1985,13 +2010,15 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "846b543c3d5b7d54debbf0127e4a167d322e21892c3765c42f8d59555f439cbd" +checksum = "5b73b49c71982f637e8842487d91f676376b5669d4ac0cb829ff021b45525e57" dependencies = [ "async-trait", + "dig-constants 0.8.0", "dig-dht", - "dig-nat 0.10.0", + "dig-nat", + "dig-peer", "futures", "serde", "serde_json", @@ -2003,8 +2030,8 @@ dependencies = [ [[package]] name = "dig-gossip" -version = "0.14.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=31f3f33dad0285c0e079fa5dad5e939c2df8081e#31f3f33dad0285c0e079fa5dad5e939c2df8081e" +version = "0.15.0" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=90c12ac5a89a4d143ed86cc755cd42678aa1aa4f#7ce3bb4c2adc1d5c5e4ce907eefb56abff3d0d8c" dependencies = [ "bincode", "chia-protocol", @@ -2013,7 +2040,7 @@ dependencies = [ "chia_streamable_macro 0.26.0", "dig-constants 0.4.0", "dig-ip", - "dig-nat 0.10.0", + "dig-nat", "dig-peer-protocol", "dig-tls", "futures-util", @@ -2132,33 +2159,23 @@ dependencies = [ ] [[package]] -name = "dig-nat" -version = "0.10.0" +name = "dig-message" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6a47bc31d0f1559076a85a01a2334755b99079504140202e8f6271798e7ddfb" +checksum = "13efe0cfe2a6ab661f2c4f8dd947360a34c47f86118621721dfa7d21aa2dcdae" dependencies = [ - "arc-swap", - "async-trait", - "dig-constants 0.5.1", + "chacha20poly1305", + "chia-bls 0.26.0", + "chia-protocol", + "chia-sha2 0.26.0", + "chia-traits 0.26.0", + "chia_streamable_macro 0.26.0", "dig-identity 0.4.2", - "dig-ip", - "dig-tls", - "futures", - "futures-util", - "igd-next", - "ring", - "rustls-pki-types", - "serde", - "serde_bytes", - "serde_json", + "getrandom 0.2.17", + "hkdf", "sha2 0.10.9", - "thiserror 2.0.19", - "tokio", - "tokio-rustls", - "tokio-tungstenite", - "tokio-util", - "tracing", - "yamux", + "thiserror 1.0.69", + "zstd", ] [[package]] @@ -2211,7 +2228,7 @@ dependencies = [ "dig-identity 0.4.0", "dig-ip", "dig-ipc-protocol", - "dig-nat 0.11.0", + "dig-nat", "dig-peer-selector", "dig-pex", "dig-rpc-protocol", @@ -2277,6 +2294,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dig-peer" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548f3f44346cfc257d5a9b30ac115d4f2862bc0206eb5b15d34c3a9be4c99dc6" +dependencies = [ + "chia-protocol", + "chia-traits 0.26.0", + "dig-message", + "dig-nat", + "dig-rpc-protocol", + "dig-tls", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "dig-peer-protocol" version = "0.2.1" @@ -2294,12 +2330,12 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31070e3c364c8f789158ec97d29361e36f1822ac2f633ca931ebe4b28cd5c7e9" +checksum = "bbac4a0c49359b75c8ad3ce5d1def73535b1485e246361277f2e5d0242f57fc7" dependencies = [ "dig-dht", - "dig-nat 0.10.0", + "dig-nat", "rand 0.8.7", "rand_pcg 0.3.1", "serde", @@ -2730,7 +2766,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2878,7 +2914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3653,7 +3689,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3981,7 +4017,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4361,6 +4397,17 @@ dependencies = [ "time", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -4566,7 +4613,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4935,7 +4982,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5594,7 +5641,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6652,7 +6699,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7178,3 +7225,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 47c59ff..18ecc05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,8 +54,8 @@ overflow-checks = true # standalone. `native-tls` is NOT patched: dig-gossip is pulled with # `default-features = false, features = ["rustls", "relay"]`, so the OpenSSL/native-tls path is off. [patch.crates-io] -chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "31f3f33dad0285c0e079fa5dad5e939c2df8081e" } -chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "31f3f33dad0285c0e079fa5dad5e939c2df8081e" } +chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "90c12ac5a89a4d143ed86cc755cd42678aa1aa4f" } +chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "90c12ac5a89a4d143ed86cc755cd42678aa1aa4f" } # The dig-nat unification patch is RETIRED (#1280 crates.io cascade): dig-nat 0.7 is on crates.io and # the ENTIRE peer stack (dig-gossip 0.7.1, dig-dht 0.2.2, dig-download 0.2.1, dig-peer-selector 0.2.1, diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 35cd14f..4ad50a3 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -107,7 +107,7 @@ getrandom = "0.2" # RPC over mTLS (peer_id = SHA256(TLS SPKI DER)). # # Sourcing (#1280/#1393/#1494 crates.io cascade): dig-nat 0.11 is on crates.io — the whole peer stack -# (dig-gossip 0.14.0, dig-dht 0.5.0, dig-download 0.6.0, dig-peer-selector 0.5.0) now depends on dig-nat +# (dig-gossip 0.15.0, dig-dht 0.5.1, dig-download 0.7.1, dig-peer-selector 0.5.2) now depends on dig-nat # ">=0.10, <0.12" from crates.io, so consuming it here as a plain crates.io version unifies the ENTIRE # graph on ONE dig-nat 0.11 instance (the old bare-git unification patch is retired). 0.11 adds the # relayed RESPONDER path — `RelayStatus::enable_accept` surfaces an introduced circuit as a @@ -115,16 +115,16 @@ getrandom = "0.2" # 0.10's SPKI-pinned dialer (#1422) and the STUN happy-eyeballs reflexive discovery, and auto-composes # the FULL NAT ladder via `connect_with_runtime` + `NatRuntime`, taking a CA-signed NodeCert. dig-nat = "0.11" -# dig-gossip is the ONE peer-stack exception: at v0.14.0 (on dig-nat 0.11 + dig-tls 0.3, crates.io +# dig-gossip is the ONE peer-stack exception: at v0.15.0 (on dig-nat 0.11 + dig-tls 0.3, crates.io # deps) but NOT yet published to crates.io — its own publish is guarded pending dig-peer-protocol (#681). -# So it stays a git dependency, PINNED to the v0.14.0 tag commit (rev 31f3f33dad02, #1541 — injects the +# So it stays a git dependency, PINNED to the v0.15.0 tag commit (rev 90c12ac5a89a, #1541 — injects the # node's PERSISTENT `nat_identity` NodeCert into the dig-nat transport so every transport presents the # ONE advertised peer_id, on top of #1517's relay-circuit auto-dial). Pulled rustls-only (no # native-tls). Its dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the # graph still unifies to ONE of each. The vendored chia-protocol/chia-sdk-client forks it path-patches # are re-declared at this SAME rev in the workspace `[patch.crates-io]` (a git dep's own patches do not # apply transitively — the #1529 3-rev lockstep: this dep rev + the two patch revs move together). -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "31f3f33dad0285c0e079fa5dad5e939c2df8081e", default-features = false, features = ["rustls", "relay"] } +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "90c12ac5a89a4d143ed86cc755cd42678aa1aa4f", default-features = false, features = ["rustls", "relay"] } # The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. From # crates.io: dig-nat 0.7 requires `>=0.4, <0.6` and dig-gossip 0.7.1 requires `>=0.2, <0.5`, so `0.4` # sits in both windows and the whole graph resolves to ONE crates.io dig-constants 0.4.x. @@ -168,7 +168,7 @@ dig-pex = "0.1" # dig-dht types). 0.6 keeps the injectable `SourceSelector` seam (dig-node implements it over # dig-peer-selector, see `seams/dig_peer/selector_adapter.rs`), records range outcomes internally, and the # fetch leg composes the FULL NAT ladder from an injected shared `NatRuntime` (`NatRangeTransport::new_with_runtime`). -dig-download = "0.6" +dig-download = "0.7" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index e8d75e2..392c13c 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1346,7 +1346,10 @@ fn map_gossip_pool_event(ev: &dig_gossip::PoolEvent) -> dig_peer_selector::PoolE /// span all transports, the invariant that closes the Leg-B mismatch. /// /// Pure (mutates only the config), so the one-identity invariant is unit-tested without a live pool. -fn apply_persistent_identity(cfg: &mut dig_gossip::GossipConfig, identity: &Arc) { +fn apply_persistent_identity( + cfg: &mut dig_gossip::GossipConfig, + identity: &Arc, +) { cfg.peer_id = dig_gossip::peer_id_from_tls_spki_der(identity.spki_der()); cfg.nat_identity = Some(identity.clone()); } @@ -1374,7 +1377,7 @@ fn wire_relay_reservation( // Enable the RESPONDER path first: install the accept channel so the reservation loop hands us // every introduced circuit from a peer we have no open outbound tunnel to (#1536, Leg B). let inbound = status.enable_accept(); - let status = status.clone(); + let reservation_status = status.clone(); tokio::spawn(async move { // B1 (#870): advertise the node's gossip listen candidates so the relay's reflexive // substitution can hand another peer a DIALABLE candidate for this node. @@ -1383,7 +1386,7 @@ fn wire_relay_reservation( peer_id, network_id, listen_addrs, - status, + reservation_status, ) .await; }); @@ -1642,13 +1645,12 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { } else { None }; - let relayed_dialer: Option> = - relay_socket_addr.map(|addr| { - Arc::new(dig_nat::ReservationRelayedTransport::new( - relay_status.clone(), - addr, - )) as Arc - }); + let relayed_dialer: Option> = relay_socket_addr.map(|addr| { + Arc::new(dig_nat::ReservationRelayedTransport::new( + relay_status.clone(), + addr, + )) as Arc + }); let nat_runtime = Arc::new(crate::net::build_node_nat_runtime( peer_port_from_env(), reflexive, From e944821fe27d5005121f37fe0ed6b7753def7127 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 23 Jul 2026 21:23:20 -0700 Subject: [PATCH 4/5] test(peer): fix deadlocking Leg B relay-accept test + nextest hang backstop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve_accepted_relay_conn_serves_a_peer_rpc_over_an_authenticated_session` hung CI's `Test + coverage` job indefinitely (SLOW >84min). Reproduced deterministically off-CI too — it is not env-specific. Root cause: `serve_accepted_relay_conn` runs an inbound accept-loop that only returns once the client session closes, but the test did `let _ = server.await;` while the client `conn` was still alive. The RPC itself succeeded; the test then deadlocked forever awaiting a serve-loop that could never terminate. The passing loopback-duplex tests use the single-shot `serve_one_stream`, so none surfaced it. Fix: - Drop the client `conn` before joining the server, so the mux driver ends, the server's `accept_stream` yields `None`, and the serve loop returns; join under a bounded timeout. - Wrap the client RPC exchange in a 10s timeout so any future mux/transport regression fails LOUDLY instead of hanging. - Add `.config/nextest.toml` slow-timeout `terminate-after` so no test can ever pin a CI run indefinitely again. Closes #1532 Co-Authored-By: Claude --- .config/nextest.toml | 16 ++++++++++++++++ crates/dig-node-core/src/peer.rs | 29 +++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..1e2fbef --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,16 @@ +# nextest configuration — https://nexte.st/docs/configuration/ +# +# Defense-in-depth against a single test hanging CI forever (issue dig_ecosystem#1532): a network / +# async test that deadlocks used to sit in `SLOW` indefinitely and pin the whole `Test + coverage` +# job until the job-level timeout. `terminate-after` hard-kills any test still running after N slow +# periods, so a future hang FAILS the run in minutes with a clear "terminated after timeout" instead +# of stalling for hours. Individual tests that legitimately need longer must be given an explicit +# per-test override here rather than removing this backstop. +[profile.default] +slow-timeout = { period = "60s", terminate-after = 3 } + +# CI runs `cargo llvm-cov nextest` with `--retries 2`; the same backstop must apply there so a hang +# on CI is terminated identically to a local run. +[profile.ci] +slow-timeout = { period = "60s", terminate-after = 3 } +retries = 2 diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 392c13c..b29a025 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -3188,21 +3188,34 @@ mod tests { .await .expect("client dials the accepted-circuit server"); - let mut stream = conn.session.open_stream().await.expect("open stream"); - write_framed( - &mut stream, - &json!({"jsonrpc":"2.0","id":7,"method":"dig.getNetworkInfo"}), - ) + // Bound the whole client exchange in an explicit timeout so a mux/transport regression fails + // LOUDLY (a clear timeout panic) instead of hanging the test — and CI — forever. + let resp = tokio::time::timeout(Duration::from_secs(10), async { + let mut stream = conn.session.open_stream().await.expect("open stream"); + write_framed( + &mut stream, + &json!({"jsonrpc":"2.0","id":7,"method":"dig.getNetworkInfo"}), + ) + .await + .unwrap(); + read_framed(&mut stream).await.unwrap().expect("a frame") + }) .await - .unwrap(); - let resp = read_framed(&mut stream).await.unwrap().expect("a frame"); + .expect("the accepted relayed circuit answered within 10s"); assert_eq!(resp["id"], json!(7)); assert_eq!( resp["result"]["echo_method"], json!("dig.getNetworkInfo"), "the accepted relayed circuit must serve the peer RPC like a direct inbound" ); - let _ = server.await; + + // Teardown: `serve_accepted_relay_conn` runs an inbound accept-loop that only returns once the + // client session closes. Drop the client `conn` to close the mux session (its driver ends, the + // server's `accept_stream` yields `None`, the serve loop returns), THEN bounded-join the + // server task. Awaiting `server` while `conn` was still alive was the deadlock: the loop never + // ended, so the test hung after the RPC had already succeeded. + drop(conn); + let _ = tokio::time::timeout(Duration::from_secs(5), server).await; } #[tokio::test] From cc674435acc8e4361b2cc9d34b136c8ae0c815ec Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 23 Jul 2026 21:48:04 -0700 Subject: [PATCH 5/5] fix(peer): relayed inbound caller carries no direct DHT candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serve_accepted_relay_conn` folded the connection's `remote_addr` — the RELAY's socket (shared across every relayed peer) — into the DHT routing table as a `direct` dial candidate via `caller_contact`. A relayed peer is NAT'd and not directly dialable, so every relayed peer advertised the SAME relay IP:port under a different peer_id, fanning the relay address network-wide as bogus direct candidates that then fail the SPKI/peer_id pin on dial and degrade the discover→connect flywheel (review DiD-1). Add `relayed_caller_contact(peer_id)`, which builds the caller with a single relay-typed `CandidateAddr::relay_marker()` (non-dialable) and NO direct candidate — reusing dig-dht's existing relay-marker shape, not a new wire form. The peer still populates the routing table and stays reachable for response routing on the live session; only the bogus direct-address advertisement stops. The direct-inbound listener (`caller_from_tls`) is unchanged — its `remote_addr` is the peer's own dialable socket. Co-Authored-By: Claude --- crates/dig-node-core/src/peer.rs | 7 ++-- .../dig-node-core/src/seams/dig_peer/dht.rs | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index b29a025..7fa1b90 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -1449,11 +1449,14 @@ async fn serve_accepted_relay_conn( ) { let dig_nat::PeerConnection { peer_id, - remote_addr, mut session, .. } = conn; - let caller = Some(crate::dht::caller_contact(&peer_id, remote_addr)); + // A relayed peer is NAT'd and NOT directly dialable, and `remote_addr` here is the RELAY's socket + // (shared across every relayed peer). Record a relay-typed caller with NO direct candidate so we + // never fan the relay address network-wide as a bogus direct-dial target under this peer_id + // (DiD-1 / #1532) — the peer stays reachable for response routing on the live session. + let caller = Some(crate::dht::relayed_caller_contact(&peer_id)); serve_peer_session_from(caller, &mut session, responder).await; } diff --git a/crates/dig-node-core/src/seams/dig_peer/dht.rs b/crates/dig-node-core/src/seams/dig_peer/dht.rs index ba44a11..63418e7 100644 --- a/crates/dig-node-core/src/seams/dig_peer/dht.rs +++ b/crates/dig-node-core/src/seams/dig_peer/dht.rs @@ -274,6 +274,20 @@ pub fn caller_contact(peer_id: &PeerId, remote_addr: std::net::SocketAddr) -> Co ) } +/// Build the DHT [`Contact`] for an authenticated caller that reached us over a **relay** circuit. +/// +/// Unlike [`caller_contact`], this records NO direct candidate: a relayed peer is NAT'd and not +/// directly dialable, and the socket the connection arrived on is the RELAY's address — shared by +/// every peer the relay forwards. Folding that address in as a `direct` candidate would fan ONE relay +/// IP:port across the network under many different peer_ids as bogus direct-dial targets that every +/// dialer then fails on the SPKI/peer_id pin, degrading the discover→connect flywheel (DiD-1 / #1532). +/// Instead the caller carries a single relay-typed (non-dialable) marker: the peer still populates the +/// routing table for response routing over the live session, but is never advertised as directly +/// dialable. `peer_id` MUST come from the authenticated transport, never the request body. +pub fn relayed_caller_contact(peer_id: &PeerId) -> Contact { + Contact::new(peer_id, vec![CandidateAddr::relay_marker()]) +} + /// Decode + dispatch one inbound DHT-RPC frame against `dht`, folding the authenticated `caller` into /// the routing table, and return the framed [`DhtResponse`] bytes ready to write back on the stream. /// @@ -684,6 +698,26 @@ mod tests { assert!(a.kind.is_dialable()); } + #[test] + fn relayed_caller_contact_records_no_direct_candidate() { + // A peer that reached us over a RELAY circuit is NAT'd and NOT directly dialable; the socket + // we received it from is the RELAY's address (shared by every relayed peer). Recording that as + // a `direct` candidate would fan the relay IP:port network-wide under many peer_ids as bogus + // dial targets that fail the SPKI/peer_id pin (DiD-1 / #1532). The relayed caller MUST carry + // ONLY a relay-typed (non-dialable) hint — reachable for response routing, never a direct dial. + let pid = PeerId::from_bytes([9u8; 32]); + let c = relayed_caller_contact(&pid); + assert_eq!(c.peer_id, pid.to_hex(), "peer_id from the certificate"); + assert!( + c.best_address().is_none(), + "a relayed caller must expose NO directly-dialable candidate" + ); + assert!( + c.addresses.iter().all(|a| !a.kind.is_dialable()), + "every candidate on a relayed caller is a non-dialable relay hint" + ); + } + // -- candidate_to_socket_addr -------------------------------------------------------------- #[test]