diff --git a/Cargo.lock b/Cargo.lock index 0eef617..88820ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2751,6 +2751,17 @@ dependencies = [ "hex-literal", ] +[[package]] +name = "dig-constants" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddae5ea66f2a07ac28241eac51a0a7f6221b1b1262ed4d7043c3233fa227c9d3" +dependencies = [ + "chia-consensus 0.36.1", + "chia-protocol 0.36.1", + "hex-literal", +] + [[package]] name = "dig-dht" version = "0.11.1" @@ -3000,7 +3011,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.50.1" +version = "0.51.0" dependencies = [ "async-trait", "axum", @@ -3013,6 +3024,7 @@ dependencies = [ "chia-traits 0.26.0", "dig-chainsource-interface 0.1.0", "dig-chat-protocol", + "dig-constants 0.11.0", "dig-constants 0.9.0", "dig-dht", "dig-download", @@ -3061,7 +3073,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.136.3" +version = "0.137.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5b6d0fb..d8665e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,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.136.3" +version = "0.137.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 7478b78..c339527 100644 --- a/SPEC.md +++ b/SPEC.md @@ -228,6 +228,32 @@ peer network) and `DIG_RELAY_URL` (override or disable the relay), which gate th Parsed as `u16`; unparsable/unset ⇒ the default **`9444`** (`peer::DEFAULT_P2P_PORT`). Bound dual-stack IPv6-first with an IPv4 fallback, per §5.2. +**`DIG_BOOTSTRAP_PEERS`** — the always-on peer anchors dialled at startup, as a comma-separated +`peer_id@host:port` list; `off`/`disabled` (case-insensitive) opts out entirely, and blank/unset falls +back to the canonical compiled-in set. + +The canonical set is `dig_constants::DIG_BOOTSTRAP_PEERS` and MUST NOT be re-declared here. It names +the PEER interface host `node-rpc.dig.net:9444` — NOT `rpc.dig.net`, which is a CloudFront +distribution that terminates HTTPS, cannot carry the mTLS peer protocol, and whose peer ports are +closed. `rpc.dig.net` appears in this system only as the §5.3 client→node READ gateway +(`RPC_DIG_NET_URL`); the two roles MUST NOT be collapsed. + +A bootstrap anchor exists because every other peer input presupposes a peer the node already has: +peer exchange spreads what a live link's far end knows, the DHT answers through peers already in the +table, and a relay reservation only makes this node reachable. Normative properties: + +- Each entry MUST carry a 64-hex `peer_id`, pinned as `SHA-256(TLS SPKI DER)`. An entry without one + is SKIPPED, never dialled unpinned — dialling unpinned would accept whatever identity answered at + that address, which is what the pinning exists to deny. A malformed entry is dropped without + discarding its well-formed neighbours. +- Dials are IPv6-first with an IPv4 fallback, per §5.2, and run the full traversal ladder. +- An anchor is an UNTRUSTED peer (NC-12). Being well-known is not being trusted: it acquires no trust + flag, bypasses no corroboration, and counts as exactly one voice — identical in every respect to a + peer learned by exchange. +- Bring-up MUST NOT block on or fail from a bootstrap dial. A node whose anchors are all unreachable + MUST still start and still operate from relay- and exchange-discovered peers; a hard dependency + would make one host a single point of failure for every fresh node in the network. + **`DIG_GOSSIP_PORT`** — the gossip pool listen port (distinct from the mTLS peer-RPC port above, #871). Parsed as `u16`; unparsable/unset ⇒ the default **`9445`** (`peer::DEFAULT_GOSSIP_PORT`). The peer-RPC (9444) is the node's advertised peer-network identity and the route peers dial to fetch diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index cb25795..911eb13 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -23,7 +23,7 @@ name = "dig-node-core" # out-of-workspace implementor; this crate is consumed in-workspace only and is pre-1.0, so it is a # MINOR bump under SemVer's 0.x rule -- recorded here rather than letting the number imply the trait # surface held still. -version = "0.50.1" +version = "0.51.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." @@ -257,6 +257,26 @@ dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "51054a4 # the release-first cascade on #2072. Do NOT pin this BACK to widen a window: the fix is upstream # moving forward, never dig-node drifting backward. dig-constants = "0.9" +# The canonical bootstrap peer anchors (`DIG_BOOTSTRAP_PEERS`, dig_ecosystem#923), read from the +# CURRENT dig-constants line under an alias while `dig-constants = "0.9"` above stays on dig-node's +# chia line. +# +# Why an alias rather than bumping the dep above: the 0.9 pin exists because `DIG_MAINNET` is a +# `Bytes32`, and on 0.10+ that type arrives from chia-protocol 0.36 while the rest of this workspace +# reads `Bytes32` from 0.26 via dig-gossip — the unification split the pin comment describes. That +# argument is specific to the chia-typed constants. `DIG_BOOTSTRAP_PEERS` is a `&[&str]` and carries +# no chia type across the boundary at all, so it can be read from the current line with no +# unification surface. +# +# This adds NO new version to the graph: chia-protocol 0.36.1 is already resolved here through +# digstore-chain (see the dig-gossip entry above), which is exactly why the 0.36 migration is a +# cascade rather than a version-string edit. +# +# This is NOT pinning back to widen a window -- the anti-pattern that comment warns about. It pins +# FORWARD for one chia-free constant so the value is read from the SSOT instead of being hardcoded +# here, which is the drift class dig-constants exists to prevent. Collapse this alias into the dep +# above the moment the chia-0.36 cascade (#2761) lands; nothing else may use it. +dig-constants-net = { package = "dig-constants", version = "0.11" } # -- Portable profile body format (DPB) — epic #3008 W6 ----------------------------------------------- # # `default-features = false` deliberately: the default `chia` feature pulls dig-did/dig-store/ diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 0d9a5e6..7ea066e 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -88,7 +88,7 @@ pub use seams::content::{bandwidth, verification_ledger, ContentServer}; /// The `PeerNetwork` trait is seam 2's public surface (#1285 W1b-2) — bring it into scope to /// call `peer_status`/`set_inventory_refresher`/`set_gossip_handle`/`gossip_handle`/ /// `refresh_dht_inventory` on a `Node`. -pub use seams::dig_peer::{address_book, dht, net, pex, session, PeerNetwork}; +pub use seams::dig_peer::{address_book, bootstrap, dht, net, pex, session, PeerNetwork}; /// The `RpcDispatch` trait is seam 4's public surface (#1285 W1b-5) — the crate-root /// `handle_rpc`/`handle_rpc_json` free functions delegate to it; most callers keep using those /// stable entry points and never need this trait in scope directly. diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index c3dc0d7..9ee23f4 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -2802,6 +2802,21 @@ async fn run_peer_network(node: Arc) -> Result<(), String> { // The served responder carries the LIVE pool handle so `dig.getPeers` reflects connected peers, // and the DHT so inbound DHT RPCs are answered. + // Bootstrap dials (#923): a fresh install knows no peers — peer exchange and the DHT can only + // spread peers this node already has, and a relay reservation only makes this node reachable. + // Dial the canonical anchors so `connected_peers` has a floor above zero on a node that can + // reach the internet. + // + // SPAWNED, never awaited, and deliberately placed AFTER every other bring-up step: a fresh node + // must start and keep working with every anchor unreachable. Making this blocking or fallible + // would turn one host into a single point of failure for every fresh node in the network, which + // is the opposite of what the anchors are for. + crate::bootstrap::spawn_bootstrap_dials( + handle.clone(), + crate::bootstrap::bootstrap_targets_from_env(), + stun_server, + ); + let mut node_responder = NodeResponder::with_pool(node, handle); if let Some(dht) = dht { node_responder = node_responder.with_dht(dht); @@ -3499,6 +3514,79 @@ pub(crate) mod tests { let _ = handle.pool_stats(); } + /// #923: a node whose bootstrap anchors are ALL unreachable still starts and still works. + /// + /// This is the property that matters most about the bootstrap set, because getting it wrong is + /// invisible in the happy path and catastrophic in aggregate: if bring-up awaited or failed on an + /// anchor dial, one unreachable host would become a single point of failure for every fresh node + /// in the network — the exact opposite of what an anchor is for. + /// + /// # Why the fixture is an UNROUTABLE address and not a closed local port + /// + /// The nearest wrong implementation is awaiting the dial instead of spawning it. A closed + /// loopback port cannot see that: the kernel refuses instantly, so awaited and spawned both + /// return in microseconds and the test passes either way. `203.0.113.0/24` (RFC 5737 TEST-NET-3) + /// is not routable, so a dial to it HANGS until the module's own 10s `BOOTSTRAP_DIAL_TIMEOUT`. + /// That turns the distinction into a wall-clock one this test can actually observe, which is why + /// the elapsed budget below is well under that timeout rather than merely "fast". + /// + /// # Why the anchor is well-formed + /// + /// A malformed entry would be dropped by the parser and never dialled at all, so the test would + /// assert survival of an event that never happened. The identity is valid 64-hex and the + /// authority parses; the ONLY thing wrong with this anchor is that nothing answers there. + #[tokio::test] + async fn a_node_survives_every_bootstrap_anchor_being_unreachable() { + let dir = std::env::temp_dir().join(format!("dig-node-bootstrap-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let cfg = dig_gossip::GossipConfig { + network_id: chia_protocol::Bytes32::new([3u8; 32]), + cert_path: dir.join("node.cert").display().to_string(), + key_path: dir.join("node.key").display().to_string(), + peers_file_path: dir.join("peers.json"), + peer_pool: Some(dig_gossip::PeerPoolConfig::default()), + listen_addr: fresh_pool_listen_addr().await, + ..Default::default() + }; + let handle = dig_gossip::GossipService::new(cfg) + .expect("gossip config") + .start() + .await + .expect("gossip start"); + + let unreachable = crate::bootstrap::resolve_bootstrap_targets(Some(&format!( + "{}@203.0.113.1:9444,{}@203.0.113.2:9444", + "a".repeat(64), + "b".repeat(64) + ))); + assert_eq!( + unreachable.len(), + 2, + "the fixture must actually produce anchors to dial, else survival is vacuous" + ); + + let started = std::time::Instant::now(); + crate::bootstrap::spawn_bootstrap_dials(handle.clone(), unreachable, None); + let elapsed = started.elapsed(); + + // Bring-up did not wait on the network. An awaited dial would sit here for the full 10s + // BOOTSTRAP_DIAL_TIMEOUT per anchor before returning. + assert!( + elapsed < std::time::Duration::from_secs(3), + "bring-up blocked on an unreachable anchor for {elapsed:?}" + ); + + // ...and the node is still a working node afterwards: the pool is queryable and the service + // is still running. Asserted AFTER a pause long enough for the dial tasks to have failed and + // logged, so this observes the post-failure state rather than a state that merely predates it. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let _ = handle.pool_stats(); + assert!( + handle.health_check().await.is_ok(), + "the node must still be healthy after every bootstrap anchor failed" + ); + } + // #870 + #872: the node shares ONE `Arc` between the relay-reservation loop and the // gossip pool. Proven by attaching the status returned from `wire_relay_reservation` and mutating // THAT status: the change is visible through the gossip handle's stats, so the pool observes the diff --git a/crates/dig-node-core/src/seams/dig_peer/bootstrap.rs b/crates/dig-node-core/src/seams/dig_peer/bootstrap.rs new file mode 100644 index 0000000..cdeb003 --- /dev/null +++ b/crates/dig-node-core/src/seams/dig_peer/bootstrap.rs @@ -0,0 +1,430 @@ +//! Bootstrap peers — the always-on anchors a node dials at startup so a fresh install is never +//! stranded with zero peers (dig_ecosystem#923). +//! +//! # Why a node needs this at all +//! +//! Every other way this node learns peers requires already having one. Peer exchange spreads the +//! peers a link's far end knows, the DHT answers queries routed through peers already in the table, +//! and a relay reservation only makes this node *reachable* — it never populates an address book. +//! So a node installed onto a machine that has never run one has nothing to dial, and reports +//! `connected_peers = 0` for as long as it runs. The bootstrap set is the one input that does not +//! presuppose its own output. +//! +//! # Why an address is not enough +//! +//! The node↔node interface is mTLS with the peer's certificate SPKI pinned against an EXPECTED +//! identity ([`dig_gossip::GossipHandle::connect_via_nat`]), so a dial needs the `peer_id` as well as +//! the address. An entry with no identity is SKIPPED rather than dialed unpinned: dialing unpinned +//! would accept whatever identity answered at that address, which is precisely what the pinning +//! exists to deny. Skipping costs a node that has no other peers nothing it had. + +use std::time::Duration; + +/// How long a single bootstrap dial may run before it is abandoned. The ladder tries each traversal +/// tier in turn, so this bounds one tier, not the whole attempt. +const BOOTSTRAP_DIAL_TIMEOUT: Duration = Duration::from_secs(10); + +/// The environment variable overriding the compiled-in bootstrap set: a comma-separated +/// `peer_id@host:port` list, or `off`/`disabled` for an air-gapped node. +const BOOTSTRAP_ENV: &str = "DIG_BOOTSTRAP_PEERS"; + +/// A bootstrap peer this node can actually dial: a pinned identity plus the authority it answers on. +/// +/// Only entries carrying BOTH become a `BootstrapTarget`, so an unidentified entry is filtered out +/// once, here, rather than being re-checked at every use. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BootstrapTarget { + /// The peer's 64-hex identity, pinned against the certificate the handshake presents. + pub peer_id_hex: String, + /// The `host:port` authority the peer answers on. + pub authority: String, +} + +/// The bootstrap targets for this process: the `DIG_BOOTSTRAP_PEERS` override when set, else the +/// canonical compiled-in set. +pub fn bootstrap_targets_from_env() -> Vec { + resolve_bootstrap_targets(std::env::var(BOOTSTRAP_ENV).ok().as_deref()) +} + +/// Pure: resolve the bootstrap targets from an optional `DIG_BOOTSTRAP_PEERS` value. +/// +/// An explicit `off`/`disabled` yields NO targets, and so — for now — does an unset value: see +/// [`compiled_in_targets`]. Malformed entries and entries with no identity are dropped, so the +/// result is exactly the set that can be dialed. +pub fn resolve_bootstrap_targets(env: Option<&str>) -> Vec { + let configured = env.map(str::trim).filter(|s| !s.is_empty()); + match configured { + Some(v) if is_disabled(v) => Vec::new(), + Some(v) => v.split(',').filter_map(parse_bootstrap_target).collect(), + None => compiled_in_targets(), + } +} + +/// The canonical compiled-in bootstrap set, from `dig_constants::DIG_BOOTSTRAP_PEERS`. +/// +/// Read from the cross-repo SSOT rather than declared here, because a second repo hardcoding its own +/// copy of a shared literal is the drift class dig-constants exists to prevent — the same reason +/// `DIG_NODE_PORT` and `DIG_RELAY_URL` are not spelled out in this workspace either. +/// +/// The canonical entries are written in the SAME `peer_id@host:port` syntax an operator would type +/// into `DIG_BOOTSTRAP_PEERS`, so they flow through [`parse_bootstrap_target`] unchanged. That is +/// deliberate: the default set and the override cannot diverge in how they are interpreted, because +/// there is only one interpretation. An entry the parser rejects is dropped here exactly as it would +/// be from the override — a malformed canonical entry yields no anchor rather than a mis-dialled one. +fn compiled_in_targets() -> Vec { + dig_constants_net::DIG_BOOTSTRAP_PEERS + .iter() + .copied() + .filter_map(parse_bootstrap_target) + .collect() +} + +/// Whether the value explicitly disables bootstrapping (mirrors `DIG_RELAY_URL`'s opt-out). +fn is_disabled(value: &str) -> bool { + value.eq_ignore_ascii_case("off") || value.eq_ignore_ascii_case("disabled") +} + +/// Pure: parse one `peer_id@host:port` entry, or `None` if it is malformed. +/// +/// A malformed entry is dropped rather than defaulted. Guessing a value here would produce a target +/// the operator never wrote, and the failure would surface as an unreachable peer rather than as a +/// configuration error. +fn parse_bootstrap_target(entry: &str) -> Option { + let (peer_id, authority) = entry.trim().split_once('@')?; + let authority = authority.trim(); + let (host, port) = authority.rsplit_once(':')?; + if host.is_empty() || port.parse::().is_err() { + return None; + } + Some(BootstrapTarget { + peer_id_hex: validated_peer_id(peer_id.trim())?.to_string(), + authority: authority.to_string(), + }) +} + +/// Pure: the identity if it is a well-formed 64-hex `peer_id`, else `None`. +fn validated_peer_id(peer_id_hex: &str) -> Option<&str> { + let ok = peer_id_hex.len() == 64 && peer_id_hex.chars().all(|c| c.is_ascii_hexdigit()); + ok.then_some(peer_id_hex) +} + +/// Dial every bootstrap target once, in the background, adopting each verified connection into the +/// connected-peer pool. +/// +/// Spawned rather than awaited: bring-up must not block on a network round-trip to an anchor that +/// may be unreachable, and a node whose bootstrap dials all fail is still a working node in every +/// other respect. Each dial runs the FULL traversal ladder, so an anchor stays reachable from behind +/// a NAT that permits no direct path. +/// +/// A target already in the pool is skipped: it is already the outcome the dial exists to produce, +/// and adopting a second connection for a live identity supersedes and tears down the existing +/// session (dig-gossip 0.17.12 onward), taking any transfer over it with it. +pub fn spawn_bootstrap_dials( + handle: dig_gossip::GossipHandle, + targets: Vec, + stun_server: Option, +) { + if targets.is_empty() { + tracing::info!( + "dig-node peer network: no bootstrap peers configured; first peers must come from the relay or peer exchange" + ); + return; + } + let methods = crate::net::full_nat_config(BOOTSTRAP_DIAL_TIMEOUT, stun_server) + .enabled_methods + .clone(); + for target in targets { + let Some(peer_id) = peer_id_from_hex(&target.peer_id_hex) else { + continue; + }; + let handle = handle.clone(); + let methods = methods.clone(); + tokio::spawn(async move { + if handle.is_pool_peer(&peer_id) { + return; + } + let addr = resolve_authority(&target.authority); + match handle + .connect_via_nat(peer_id, addr, &methods, BOOTSTRAP_DIAL_TIMEOUT) + .await + { + Ok(conn) => { + // Re-check membership now the dial has resolved: it ran for up to + // BOOTSTRAP_DIAL_TIMEOUT, in which the identity may have joined the pool by + // another path, and adopting then would supersede that live session. + if handle.is_pool_peer(&peer_id) { + return; + } + let _ = handle.adopt_nat_connection(conn).await; + tracing::info!(peer = %peer_id, authority = %target.authority, "bootstrap peer connected"); + } + Err(e) => { + tracing::warn!(peer = %peer_id, authority = %target.authority, error = %e, "bootstrap peer dial failed") + } + } + }); + } +} + +/// Resolve a `host:port` authority to ONE socket address, IPv6-first (§5.2). +/// +/// `None` when the host does not resolve; the ladder can still reach the peer over the relay tier +/// using the pinned identity alone, so an unresolvable name is a lost direct path rather than a lost +/// peer. +fn resolve_authority(authority: &str) -> Option { + use std::net::ToSocketAddrs; + let resolved: Vec<_> = authority.to_socket_addrs().ok()?.collect(); + resolved + .iter() + .find(|a| a.is_ipv6()) + .or_else(|| resolved.first()) + .copied() +} + +/// Parse a 64-hex identity into a dig-gossip [`PeerId`](dig_gossip::PeerId). +fn peer_id_from_hex(peer_id_hex: &str) -> Option { + let mut bytes = [0u8; 32]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = u8::from_str_radix(peer_id_hex.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + Some(dig_gossip::PeerId::from(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A realistic 64-hex identity, and a second one differing from it, so a test that must tell two + /// bootstrap peers apart is not relying on a single fixture. + fn peer_id_a() -> String { + "a".repeat(64) + } + + fn peer_id_b() -> String { + "b".repeat(64) + } + + // -- the override ----------------------------------------------------------------------------- + + /// An explicit list is parsed into exactly its entries, in order. + /// + /// Two entries, not one: a single-entry fixture cannot distinguish "parses the list" from + /// "parses the first element and stops", which is the nearest wrong implementation. + #[test] + fn env_list_parses_every_entry() { + let env = format!( + "{}@anchor.invalid:9444,{}@[::1]:9779", + peer_id_a(), + peer_id_b() + ); + let targets = resolve_bootstrap_targets(Some(&env)); + assert_eq!( + targets, + vec![ + BootstrapTarget { + peer_id_hex: peer_id_a(), + authority: "anchor.invalid:9444".to_string() + }, + BootstrapTarget { + peer_id_hex: peer_id_b(), + authority: "[::1]:9779".to_string() + }, + ] + ); + } + + /// `off` / `disabled` yield no targets at all — the air-gapped opt-out. + #[test] + fn explicit_off_disables_bootstrapping() { + for value in ["off", "OFF", "disabled", " Disabled "] { + assert!( + resolve_bootstrap_targets(Some(value)).is_empty(), + "{value} must disable bootstrapping" + ); + } + } + + /// A blank value is treated as UNSET — it falls back rather than disabling. + /// + /// Blank is distinguished from `off` because an empty string is what an unset variable becomes + /// in most process managers: reading it as an explicit "no anchors" would make the fallback + /// unreachable on those hosts for a reason nobody wrote down. The control is a populated value + /// through the same argument position, so this cannot pass merely because both arms are empty + /// today. + #[test] + fn a_blank_value_falls_back_rather_than_disabling() { + assert_eq!( + resolve_bootstrap_targets(Some(" ")), + resolve_bootstrap_targets(None) + ); + let populated = format!("{}@anchor.invalid:9444", peer_id_a()); + assert_eq!( + resolve_bootstrap_targets(Some(&populated)).len(), + 1, + "the same argument position must be able to yield a target" + ); + } + + // -- malformed entries ------------------------------------------------------------------------ + + /// A malformed entry is dropped WITHOUT taking its well-formed neighbours with it. + /// + /// Each bad entry is paired with a good one in the same list, because a list containing only bad + /// entries yields an empty result either way — it cannot tell "drops the bad entry" from + /// "rejects the whole list", and the second would strand a node over one typo. + #[test] + fn a_malformed_entry_is_dropped_without_discarding_the_list() { + let good = format!("{}@anchor.invalid:9444", peer_id_a()); + let short_id = "b".repeat(63); + let non_hex_id = "z".repeat(64); + let malformed = [ + "anchor.invalid:9444".to_string(), // no identity at all + format!("{}@anchor.invalid", peer_id_b()), // no port + format!("{}@anchor.invalid:99999", peer_id_b()), // port out of range + format!("{}@:9778", peer_id_b()), // empty host + format!("{short_id}@anchor.invalid:9444"), // identity too short + format!("{non_hex_id}@anchor.invalid:9444"), // identity not hex + ]; + for bad in malformed { + let targets = resolve_bootstrap_targets(Some(&format!("{bad},{good}"))); + assert_eq!( + targets, + vec![BootstrapTarget { + peer_id_hex: peer_id_a(), + authority: "anchor.invalid:9444".to_string() + }], + "{bad} must be dropped while its neighbour survives" + ); + } + } + + // -- the compiled-in set ---------------------------------------------------------------------- + + /// A node with NO configuration at all is seeded with at least one dialable anchor. + /// + /// This is the whole point of #923: every other peer input (peer exchange, the DHT, the relay + /// reservation) presupposes a peer this node already has, so if this set is empty a fresh + /// install reports zero peers for as long as it runs. The PR that introduced this module shipped + /// `compiled_in_targets` returning an empty vec, which every other test in this file tolerated — + /// so the assertion is on NON-emptiness specifically, because that is the state that regressed. + #[test] + fn an_unconfigured_node_is_seeded_with_at_least_one_anchor() { + assert!( + !resolve_bootstrap_targets(None).is_empty(), + "an unconfigured node must have an anchor to dial" + ); + } + + /// The seeded anchor names the PEER host, never the CloudFront read gateway. + /// + /// `rpc.dig.net` terminates HTTPS at CloudFront and cannot carry the mTLS peer protocol; its + /// :9444 is closed, while `node-rpc.dig.net` (its origin) answers. The two names differ by one + /// label, so this asserts the gateway host is ABSENT rather than asserting some host is present: + /// a test that only checked "an anchor exists" — which is what + /// `an_unconfigured_node_is_seeded_with_at_least_one_anchor` above checks — passes with the + /// closed-port host in it. The port is pinned for the same reason: `DIG_NODE_PORT` (9778) is the + /// §5.3 client→node READ port and an anchor published there would never answer a peer dial. + #[test] + fn the_seeded_anchor_is_the_peer_host_and_port_not_the_read_gateway() { + for target in resolve_bootstrap_targets(None) { + let (host, port) = target.authority.rsplit_once(':').expect("explicit port"); + assert_ne!( + host, "rpc.dig.net", + "{target:?} dials the CloudFront gateway, whose peer ports are closed" + ); + assert_eq!(host, "node-rpc.dig.net", "{target:?} is not the peer host"); + assert_eq!(port, "9444", "{target:?} is not on the peer port"); + } + } + + /// Every seeded anchor carries a pinned identity that actually parses into a `PeerId`. + /// + /// A canonical entry the parser cannot use is worse than none: it would be silently dropped and + /// the node would be back to zero anchors while the constant looked populated. Parsing all the + /// way to `PeerId` — rather than only checking the string is 64 hex — is what makes this test + /// see a value that is well-formed but unusable by the transport. + #[test] + fn every_seeded_anchor_has_a_usable_pinned_identity() { + for target in resolve_bootstrap_targets(None) { + assert!( + peer_id_from_hex(&target.peer_id_hex).is_some(), + "{target:?} carries an identity the transport cannot pin" + ); + } + } + + /// An entry without a USABLE identity is skipped, and one with a usable identity at the same + /// authority is kept. + /// + /// Both ways an identity can be missing are covered, because they fail in different code and a + /// fixture with only the first is blind to the second: an entry with no `@` never reaches + /// identity validation at all, so it stays green even if validation is deleted outright (this + /// was caught by reverting `validated_peer_id` to `Some(_)` — the no-`@` case alone did not + /// notice). The identical authority across all three entries is deliberate: it forces the + /// distinction to be the identity rather than the address. + #[test] + fn an_entry_without_a_usable_identity_is_skipped_and_a_usable_one_is_kept() { + let unusable = [ + "anchor.invalid:9444".to_string(), + "@anchor.invalid:9444".to_string(), + ]; + for entry in unusable { + let env = format!("{entry},{}@anchor.invalid:9444", peer_id_a()); + assert_eq!( + resolve_bootstrap_targets(Some(&env)), + vec![BootstrapTarget { + peer_id_hex: peer_id_a(), + authority: "anchor.invalid:9444".to_string() + }], + "{entry} carries no usable identity and must be skipped" + ); + } + } + + /// A 64-hex identity round-trips into a `PeerId`, and a malformed one yields `None`. + /// + /// The reject arm is what keeps an unpinned dial unreachable: if a malformed identity parsed to + /// some default `PeerId`, the dial would proceed against an identity nobody configured. + #[test] + fn peer_id_parses_only_well_formed_hex() { + assert!(peer_id_from_hex(&peer_id_a()).is_some()); + assert_ne!( + peer_id_from_hex(&peer_id_a()), + peer_id_from_hex(&peer_id_b()), + "two distinct identities must not collapse to one PeerId" + ); + for bad in ["", "zz", &"a".repeat(63), &"g".repeat(64)] { + assert!(peer_id_from_hex(bad).is_none(), "{bad} must not parse"); + } + } + + /// Resolution prefers IPv6 when the host offers both families (§5.2). + /// + /// `localhost` is used because it is the one name guaranteed to resolve without a network, and + /// the assertion is conditional on it actually offering both families so the test cannot pass + /// vacuously on a host that publishes only one. + #[test] + fn authority_resolution_prefers_ipv6() { + use std::net::ToSocketAddrs; + let all: Vec<_> = "localhost:9444" + .to_socket_addrs() + .map(|i| i.collect()) + .unwrap_or_default(); + if all.iter().any(|a| a.is_ipv6()) && all.iter().any(|a| a.is_ipv4()) { + assert!( + resolve_authority("localhost:9444") + .expect("resolves") + .is_ipv6(), + "IPv6 must win when both families are available" + ); + } + assert_eq!(resolve_authority("no-such-host.invalid:9778"), None); + } + + /// The dial timeout is a real bound, not an accidental zero. + #[test] + fn bootstrap_dial_timeout_is_bounded_and_nonzero() { + assert!(BOOTSTRAP_DIAL_TIMEOUT > Duration::ZERO); + assert!(BOOTSTRAP_DIAL_TIMEOUT <= Duration::from_secs(60)); + } +} diff --git a/crates/dig-node-core/src/seams/dig_peer/mod.rs b/crates/dig-node-core/src/seams/dig_peer/mod.rs index 3e92f95..934ad30 100644 --- a/crates/dig-node-core/src/seams/dig_peer/mod.rs +++ b/crates/dig-node-core/src/seams/dig_peer/mod.rs @@ -11,6 +11,7 @@ //! (matching W1b-0's pattern) but is out of scope for this trait carve. pub mod address_book; +pub mod bootstrap; pub mod capsule_fallback; // The self-verifying tier-0 preimage resolver (#2033, PR-2). Its surface is exercised by its own // tests but not yet CALLED by production code — the tier-0 fetch loop wires it in PR-3 — so the