From 954bc22b05122842a58afc87aeabfc65d7f44475 Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 14:33:06 +0900 Subject: [PATCH 01/19] feat(p2p): add opt-in Tor and IVNP I2P routing --- .github/workflows/overlay-interop.yml | 39 ++ Cargo.lock | 23 + Cargo.toml | 2 + crates/mempool/src/admission.rs | 2 +- crates/mempool/src/gateway.rs | 2 +- crates/mempool/src/mutation.rs | 2 +- crates/mempool/src/orphan.rs | 2 +- crates/node/benches/sync_pipeline.rs | 2 +- crates/node/src/chain_effects.rs | 6 +- crates/node/src/config.rs | 21 + crates/node/src/options.rs | 67 ++- crates/node/src/state/open.rs | 4 + crates/node/tests/config_layered.rs | 51 ++ crates/node/tests/lease_registration_215.rs | 10 +- crates/node/tests/tx_ingress_e2e.rs | 31 +- .../tests/unit/state/tests/construction.rs | 2 +- crates/node/tests/unit/tx_ingress/tests.rs | 2 +- crates/p2p/Cargo.toml | 1 + crates/p2p/src/addrman.rs | 252 ++++---- crates/p2p/src/addrman/tests.rs | 121 ++-- crates/p2p/src/addrman/tests/asmap_tests.rs | 24 +- .../src/addrman/tests/eligibility_tests.rs | 19 +- .../src/addrman/tests/overlay_destinations.rs | 92 +++ crates/p2p/src/addrman/tests/probe_tests.rs | 40 +- .../p2p/src/addrman/tests/services_tests.rs | 46 +- crates/p2p/src/block_announce.rs | 39 +- crates/p2p/src/compact_blocks.rs | 2 +- crates/p2p/src/connection.rs | 37 +- crates/p2p/src/discouragement.rs | 48 +- crates/p2p/src/dispatch.rs | 2 +- crates/p2p/src/download_window.rs | 12 +- crates/p2p/src/download_window/tests.rs | 98 +-- crates/p2p/src/eviction.rs | 14 +- crates/p2p/src/inv.rs | 9 +- crates/p2p/src/lib.rs | 5 + crates/p2p/src/listener.rs | 555 ++++++++++------- .../src/listener/aggregate_budget_tests.rs | 14 +- .../src/listener/aggregate_filter_tests.rs | 4 +- .../p2p/src/listener/getdata_pause_tests.rs | 21 +- .../src/listener/loopback_serving_tests.rs | 23 +- crates/p2p/src/listener/v2_transport_tests.rs | 53 +- crates/p2p/src/net_trace.rs | 14 +- crates/p2p/src/netgroup.rs | 42 +- crates/p2p/src/overlay.rs | 558 ++++++++++++++++++ crates/p2p/src/overlay/identity.rs | 193 ++++++ crates/p2p/src/overlay/sam.rs | 313 ++++++++++ crates/p2p/src/overlay/tests.rs | 317 ++++++++++ crates/p2p/src/overlay/tor.rs | 138 +++++ crates/p2p/src/peer.rs | 21 +- crates/p2p/src/peer_info.rs | 100 +++- crates/p2p/src/peer_table.rs | 114 ++-- crates/p2p/src/service.rs | 383 +++++++----- crates/p2p/src/sync/receive.rs | 2 +- crates/p2p/src/sync/tests.rs | 40 +- crates/p2p/src/sync/tests/chain_sync.rs | 2 +- crates/p2p/src/sync/tests/download_batches.rs | 10 +- .../p2p/src/sync/tests/download_frontier.rs | 18 +- crates/p2p/src/sync/tests/fork_retargeting.rs | 4 +- crates/p2p/src/sync/tests/frontier_model.rs | 4 +- crates/p2p/src/sync/tests/header_progress.rs | 6 +- crates/p2p/src/sync/tests/header_rotation.rs | 8 +- crates/p2p/src/sync/tests/headers_presync.rs | 10 +- crates/p2p/src/sync/tests/limited_peers.rs | 4 +- .../p2p/src/sync/tests/peer_qualification.rs | 2 +- crates/p2p/src/sync/tests/pending_retries.rs | 10 +- .../p2p/src/sync/tests/settlement_faults.rs | 6 +- crates/p2p/src/sync/tests/stale_tip.rs | 10 +- crates/p2p/src/sync/tests/tip_alignment.rs | 2 +- crates/p2p/src/sync/tests/trickle_peers.rs | 4 +- crates/p2p/src/tx_policy.rs | 10 +- crates/p2p/src/tx_policy/preferred_tests.rs | 3 +- crates/p2p/src/tx_policy/relay.rs | 28 +- crates/p2p/src/tx_relay.rs | 6 +- crates/p2p/src/wire.rs | 4 +- crates/p2p/tests/core_compat.rs | 62 +- .../data/addrman-historical-v8-endpoints.dat | 1 + .../tests/data/addrman-historical.README.md | 17 + crates/p2p/tests/listener_ban.rs | 62 +- crates/p2p/tests/listener_shutdown.rs | 15 +- crates/primitives/Cargo.toml | 2 + crates/primitives/src/endpoint.rs | 358 +++++++++++ crates/primitives/src/endpoint/tests.rs | 55 ++ crates/primitives/src/lib.rs | 3 + crates/rpc/src/context.rs | 2 +- crates/rpc/src/handlers/network.rs | 191 ++++-- crates/rpc/tests/handler_smoke.rs | 2 +- docs/getting-started.md | 78 +++ docs/policies/p2p-compatibility.md | 58 +- e2e/tests/overlay_connectivity.rs | 135 +++++ scripts/overlay-interop.py | 102 ++++ 90 files changed, 4341 insertions(+), 987 deletions(-) create mode 100644 .github/workflows/overlay-interop.yml create mode 100644 crates/p2p/src/addrman/tests/overlay_destinations.rs create mode 100644 crates/p2p/src/overlay.rs create mode 100644 crates/p2p/src/overlay/identity.rs create mode 100644 crates/p2p/src/overlay/sam.rs create mode 100644 crates/p2p/src/overlay/tests.rs create mode 100644 crates/p2p/src/overlay/tor.rs create mode 100644 crates/p2p/tests/data/addrman-historical-v8-endpoints.dat create mode 100644 crates/primitives/src/endpoint.rs create mode 100644 crates/primitives/src/endpoint/tests.rs create mode 100644 e2e/tests/overlay_connectivity.rs create mode 100644 scripts/overlay-interop.py diff --git a/.github/workflows/overlay-interop.yml b/.github/workflows/overlay-interop.yml new file mode 100644 index 000000000..8e3df0e3f --- /dev/null +++ b/.github/workflows/overlay-interop.yml @@ -0,0 +1,39 @@ +name: overlay-interoperability + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + external-backends: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/native-deps + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - name: Install external backends + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y tor golang-go + git clone https://github.com/gosuda/IVNP.git "$RUNNER_TEMP/ivnp" + git -C "$RUNNER_TEMP/ivnp" checkout "$(python3 scripts/overlay-interop.py --ivnp-revision)" + cd "$RUNNER_TEMP/ivnp" + # Go's versioned toolchain mechanism fetches the module's required toolchain. + GOTOOLCHAIN=go1.27.0+auto go build -o "$RUNNER_TEMP/ivnpd" ./cmd/ivnpd + - run: cargo build --locked --bin bitcoin-rs + - name: Bitcoin handshake, block exchange, inbound admission, and identity restart + run: python3 scripts/overlay-interop.py --ivnpd "$RUNNER_TEMP/ivnpd" --tor /usr/bin/tor + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: overlay-interoperability + path: | + target/overlay-interop + target/process-harness/e2e diff --git a/Cargo.lock b/Cargo.lock index fbfac4067..059c5e3f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,7 @@ name = "bitcoin-rs-p2p" version = "0.14.0" dependencies = [ "arc-swap", + "base64 0.22.1", "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-chainstate", @@ -481,8 +482,10 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "serde_json", "sha2", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -1552,6 +1555,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -2604,6 +2617,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index baaa3ee97..25b75cb03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,8 @@ secp256k1 = { version = ">=0.29.1, <0.30", default-features = false, features = ] } # sha2 0.11: default features are alloc+oid; no std/asm feature is exposed. +base64 = "=0.22.1" +sha3 = { version = ">=0.11, <0.12", default-features = false } sha2 = { version = ">=0.11, <0.12", default-features = false } bitcoin_hashes = { version = ">=0.14, <0.15", default-features = false, features = [ "alloc", "std", diff --git a/crates/mempool/src/admission.rs b/crates/mempool/src/admission.rs index 1ee3c3c6d..336819068 100644 --- a/crates/mempool/src/admission.rs +++ b/crates/mempool/src/admission.rs @@ -907,7 +907,7 @@ mod tests { fn source() -> PeerToken { PeerToken { - addr: core::net::SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id: 7, } } diff --git a/crates/mempool/src/gateway.rs b/crates/mempool/src/gateway.rs index 99d888d87..68d369323 100644 --- a/crates/mempool/src/gateway.rs +++ b/crates/mempool/src/gateway.rs @@ -3705,7 +3705,7 @@ mod tests { candidate.inputs.push(candidate.inputs[0].clone()); candidate.inputs[0].witness = Witness::from_stack(vec![vec![1]]); let origin = AdmissionOrigin::Peer(crate::PeerToken { - addr: core::net::SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id: 7, }); let request = admit_request(&gateway, &candidate, origin); diff --git a/crates/mempool/src/mutation.rs b/crates/mempool/src/mutation.rs index 5ca84291c..46d16f6ac 100644 --- a/crates/mempool/src/mutation.rs +++ b/crates/mempool/src/mutation.rs @@ -142,7 +142,7 @@ impl MutationResult { #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct PeerToken { /// The peer's wire address. - pub addr: core::net::SocketAddr, + pub addr: bitcoin_rs_primitives::PeerEndpoint, /// The node-local id of the connection the peer arrived on. pub connection_id: u64, } diff --git a/crates/mempool/src/orphan.rs b/crates/mempool/src/orphan.rs index ba5305eb4..f2252f7eb 100644 --- a/crates/mempool/src/orphan.rs +++ b/crates/mempool/src/orphan.rs @@ -588,7 +588,7 @@ mod tests { }; fn source(id: u64) -> PeerToken { PeerToken { - addr: core::net::SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id: id, } } diff --git a/crates/node/benches/sync_pipeline.rs b/crates/node/benches/sync_pipeline.rs index 253477cb7..1df47c026 100644 --- a/crates/node/benches/sync_pipeline.rs +++ b/crates/node/benches/sync_pipeline.rs @@ -1230,7 +1230,7 @@ fn install_synthetic_peers( .unwrap_or_else(|error| panic!("invalid synthetic peer port: {error}")); let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let (outbound_tx, outbound_rx) = unbounded::(); - peer_table.register(addr, bitcoin_rs_p2p::PeerLease::new(outbound_tx)); + peer_table.register(addr.into(), bitcoin_rs_p2p::PeerLease::new(outbound_tx)); outbound_rxs.push(outbound_rx); } outbound_rxs diff --git a/crates/node/src/chain_effects.rs b/crates/node/src/chain_effects.rs index 3943a8ac0..915d8747c 100644 --- a/crates/node/src/chain_effects.rs +++ b/crates/node/src/chain_effects.rs @@ -784,7 +784,7 @@ mod tests { let available = orphan_child(OutPoint::new(parent, 0)); let missing = orphan_child(OutPoint::new(Hash256::from_le_bytes(&[43; 32]).into(), 0)); let source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)), + addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into(), connection_id: 7, }; let replacement = PeerToken { @@ -792,7 +792,7 @@ mod tests { ..source }; let missing_source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18445)), + addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18445)).into(), connection_id: 9, }; let chain = AdmissionCoins::default(); @@ -883,7 +883,7 @@ mod tests { let outpoint = OutPoint::new(parent, 0); let child = orphan_child(outpoint); let source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)), + addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into(), connection_id: 7, }; let chain = AdmissionCoins::default(); diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 9f16d4886..f4f9fbfe5 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -135,6 +135,8 @@ pub struct StorageConfig { /// Resolved P2P configuration. #[derive(Clone, Debug)] pub struct P2pConfig { + /// Opt-in Tor and IVNP I2P routing. + pub overlay: bitcoin_rs_p2p::OverlayConfig, /// P2P message-start bytes. pub magic: [u8; 4], /// P2P listener bind addresses. @@ -277,6 +279,24 @@ impl NodeConfig { prune_target_mb: settings.storage.prune_target_mb.unwrap_or(0), }, p2p: P2pConfig { + overlay: bitcoin_rs_p2p::OverlayConfig { + proxy: settings.p2p.proxy, + onion_proxy: settings.p2p.onion_proxy, + onlynet: settings.p2p.onlynet.clone().unwrap_or_default(), + randomize_credentials: settings.p2p.proxy_randomize_credentials.unwrap_or(true), + i2p_sam: settings.p2p.i2p_sam, + i2p_accept: settings.p2p.i2p_accept.unwrap_or(false), + tor_control: settings.p2p.tor_control, + tor_cookie: settings.p2p.tor_cookie.clone(), + identity_dir: PathBuf::from(format!( + "overlay-{:08x}", + u32::from_be_bytes(settings.p2p.magic.unwrap_or(profile.magic)) + )), + port: settings + .p2p + .overlay_port + .unwrap_or_else(|| selection.consensus_network().default_p2p_port()), + }, magic: settings.p2p.magic.unwrap_or(profile.magic), listen: settings.p2p.listen.clone().unwrap_or(profile.listen), dns_seeds_enabled: settings.p2p.dns_seeds.unwrap_or(profile.dns_seeds), @@ -377,6 +397,7 @@ impl NodeConfig { support is not compiled in (enable the `kernel` feature)", self.validation.engine ); + self.p2p.overlay.validate()?; if self.p2p.magic != self.network.magic() { anyhow::ensure!( self.network == Network::Mainnet, diff --git a/crates/node/src/options.rs b/crates/node/src/options.rs index e0a0664d5..30dbde9ec 100644 --- a/crates/node/src/options.rs +++ b/crates/node/src/options.rs @@ -34,7 +34,7 @@ use bitcoin_rs_consensus::ValidationEngine; use bitcoin_rs_storage::StorageBackend; /// Canonical subnet type shared by every process-input surface. -pub use bitcoin_rs_p2p::IpSubnet; +pub use bitcoin_rs_p2p::{IpSubnet, PeerNetwork}; /// Parses a network selection spelling. pub fn parse_network(value: &str) -> std::result::Result { @@ -103,6 +103,17 @@ pub fn parse_connect_endpoint(value: &str) -> std::result::Result std::result::Result { + value.parse().map_err(str::to_owned) +} +fn parse_peer_networks(value: &str) -> Result> { + value + .split(',') + .map(|network| parse_peer_network(network.trim()).map_err(anyhow::Error::msg)) + .collect() +} + /// Parses a comma-separated listener bind list. fn parse_socket_list(value: &str) -> Result> { value @@ -557,6 +568,60 @@ macro_rules! option_rows { env["BITCOIN_RS_CONNECT", parse_connect_list] toml each("connect", parse_connect_endpoint) } + /// Opt-in overlay routing: proxy. + proxy as proxy: Option { + cli[#[arg(long = "proxy")]] + env["BITCOIN_RS_PROXY", str::parse] + toml native("proxy") + } + /// Opt-in overlay routing: onion-proxy. + onion_proxy as onion_proxy: Option { + cli[#[arg(long = "onion-proxy")]] + env["BITCOIN_RS_ONION_PROXY", str::parse] + toml native("onion_proxy") + } + /// Opt-in overlay routing: i2p-sam. + i2p_sam as i2p_sam: Option { + cli[#[arg(long = "i2p-sam")]] + env["BITCOIN_RS_I2P_SAM", str::parse] + toml native("i2p_sam") + } + /// Opt-in overlay routing: tor-control. + tor_control as tor_control: Option { + cli[#[arg(long = "tor-control")]] + env["BITCOIN_RS_TOR_CONTROL", str::parse] + toml native("tor_control") + } + /// Opt-in overlay routing: tor-cookie. + tor_cookie as tor_cookie: Option { + cli[#[arg(long = "tor-cookie")]] + env["BITCOIN_RS_TOR_COOKIE", parse_path] + toml native("tor_cookie") + } + /// Opt-in overlay setting: i2p-accept. + i2p_accept as i2p_accept: Option { + cli[#[arg(long = "i2p-accept", num_args = 0..=1, default_missing_value = "true")]] + env["BITCOIN_RS_I2P_ACCEPT", parse_bool] + toml native("i2p_accept") + } + /// Opt-in overlay setting: proxy-randomize-credentials. + proxy_randomize_credentials as proxy_randomize_credentials: Option { + cli[#[arg(long = "proxy-randomize-credentials", num_args = 0..=1, default_missing_value = "true")]] + env["BITCOIN_RS_PROXY_RANDOMIZE_CREDENTIALS", parse_bool] + toml native("proxy_randomize_credentials") + } + /// Public Bitcoin service port for inbound overlay services. + overlay_port as overlay_port: Option { + cli[#[arg(long = "overlay-port")]] + env["BITCOIN_RS_OVERLAY_PORT", str::parse] + toml native("overlay_port") + } + /// Restrict every peer connection to these remote networks. + onlynet as onlynet: Option> { + cli[#[arg(long = "onlynet", value_delimiter = ',', value_parser = $crate::options::parse_peer_network)]] + env["BITCOIN_RS_ONLYNET", parse_peer_networks] + toml each("onlynet", parse_peer_network) + } /// Whether fast sync (shallow, early fan-out over a larger /// outbound set) is enabled. fast_sync as fast_sync: Option { diff --git a/crates/node/src/state/open.rs b/crates/node/src/state/open.rs index 21e6cd6bc..dee33c5cd 100644 --- a/crates/node/src/state/open.rs +++ b/crates/node/src/state/open.rs @@ -373,6 +373,10 @@ impl NodeState { }; let p2p = Arc::new(bitcoin_rs_p2p::P2pService::new( bitcoin_rs_p2p::P2pServiceConfig { + overlay: bitcoin_rs_p2p::OverlayConfig { + identity_dir: config.data_dir.join(&config.p2p.overlay.identity_dir), + ..config.p2p.overlay.clone() + }, listen_addrs: config.p2p.listen.clone(), address_book_path: Some(config.data_dir.join("peers.dat")), asmap_path: config.p2p.asmap.clone(), diff --git a/crates/node/tests/config_layered.rs b/crates/node/tests/config_layered.rs index 485f0bd92..a94979828 100644 --- a/crates/node/tests/config_layered.rs +++ b/crates/node/tests/config_layered.rs @@ -10,6 +10,57 @@ use bitcoin_rs_node::{ use bitcoin_rs_primitives::Network; use bitcoin_rs_rpc::zmq::ZmqTopic; +#[test] +fn overlay_routing_is_opt_in_and_layered_without_implicit_fallback() -> Result<()> { + let default = NodeConfig::default_for_network(Network::Regtest); + assert!(default.p2p.overlay.proxy.is_none()); + assert!(default.p2p.overlay.onion_proxy.is_none()); + assert!(default.p2p.overlay.i2p_sam.is_none()); + let file: UserConfig = toml::from_str( + r#" + network = "regtest" + onion_proxy = "127.0.0.1:9050" + onlynet = ["onion"] + proxy_randomize_credentials = false + overlay_port = 18445 + "#, + )?; + let mut environment = UserConfig::default(); + environment.apply_env("BITCOIN_RS_ONLYNET", std::ffi::OsStr::new("onion,i2p"))?; + environment.apply_env("BITCOIN_RS_I2P_SAM", std::ffi::OsStr::new("127.0.0.1:7656"))?; + let config = resolve(&[&file, &environment])?; + assert!( + config + .p2p + .overlay + .allows(bitcoin_rs_primitives::PeerNetwork::Onion) + ); + assert!( + config + .p2p + .overlay + .allows(bitcoin_rs_primitives::PeerNetwork::I2p) + ); + assert!( + !config + .p2p + .overlay + .allows(bitcoin_rs_primitives::PeerNetwork::Ipv4) + ); + assert!(!config.p2p.overlay.randomize_credentials); + assert_eq!(config.p2p.overlay.port, 18445); + for text in [ + "onlynet = ['onion']", + "i2p_accept = true", + "onion_proxy = '8.8.8.8:9050'", + "tor_control = '127.0.0.1:9051'", + ] { + let layer: UserConfig = toml::from_str(text)?; + assert!(resolve(&[&layer]).is_err(), "{text}"); + } + Ok(()) +} + #[test] fn silent_payments_toml_overlay_preserves_explicit_disable() -> Result<()> { assert!( diff --git a/crates/node/tests/lease_registration_215.rs b/crates/node/tests/lease_registration_215.rs index 2f0deec5a..f76fe0a95 100644 --- a/crates/node/tests/lease_registration_215.rs +++ b/crates/node/tests/lease_registration_215.rs @@ -52,7 +52,7 @@ fn info(addr: SocketAddr) -> PeerInfo { wtxid_relay: false, relay_transactions: true, compact_block_relay: false, - addr, + addr: addr.into(), send_headers: false, version: 70_016, services: 1, @@ -74,18 +74,18 @@ fn peer_table_owns_handshake_publication_and_replacement() { let addr = SocketAddr::from(([127, 0, 0, 1], 18_447)); let (old_tx, _old_rx) = unbounded::(); let old = PeerLease::new(old_tx); - assert!(!table.register(addr, old.clone())); - assert!(table.publish_info(addr, &old, info(addr))); + assert!(!table.register(addr.into(), old.clone())); + assert!(table.publish_info(addr.into(), &old, info(addr))); assert!(!old.is_cancelled()); assert_eq!(table.len(), 1); assert_eq!(table.infos().len(), 1); let (new_tx, _new_rx) = unbounded::(); let new = PeerLease::new(new_tx); - assert!(table.register(addr, new.clone())); + assert!(table.register(addr.into(), new.clone())); assert!(old.is_cancelled()); assert!(!new.is_cancelled()); - assert!(table.is_current(new.source(addr))); + assert!(table.is_current(new.source(addr.into()))); let sync = make_sync(Arc::clone(&table)); sync.tick(); diff --git a/crates/node/tests/tx_ingress_e2e.rs b/crates/node/tests/tx_ingress_e2e.rs index 5ff3c3270..f3bc21c55 100644 --- a/crates/node/tests/tx_ingress_e2e.rs +++ b/crates/node/tests/tx_ingress_e2e.rs @@ -253,7 +253,8 @@ impl LoopbackPeer { fn close(&mut self) -> bool { self.lease.cancel(); let _ = self.dialer.shutdown(Shutdown::Both); - self.peer_table.remove_current(self.peer_addr, &self.lease); + self.peer_table + .remove_current(self.peer_addr.into(), &self.lease); let service_ok = self .service .take() @@ -301,7 +302,7 @@ fn open_loopback_peer( pump: None, service: None, }; - wiring.peer_table.register(peer_addr, lease.clone()); + wiring.peer_table.register(peer_addr.into(), lease.clone()); let pump_lease = lease.clone(); peer.pump = Some( std::thread::Builder::new() @@ -371,7 +372,7 @@ fn serve_connection( return; }; let mut info = bitcoin_rs_p2p::PeerInfo::inbound_from_version( - peer_addr, + peer_addr.into(), addr_bind, version, 0, @@ -379,7 +380,10 @@ fn serve_connection( Arc::new(bitcoin_rs_p2p::PeerCounters::default()), ); info.wtxid_relay = peer.wtxid_relay.peer_supported(); - if !wiring.peer_table.publish_info(peer_addr, lease, info) { + if !wiring + .peer_table + .publish_info(peer_addr.into(), lease, info) + { return; } let _ = peer.stream.set_read_timeout(Some(READ_POLL)); @@ -393,7 +397,7 @@ fn serve_connection( match read_message(&mut peer.stream, magic) { Ok((message, _raw)) => { if let Message::Tx(tx) = message { - let source = lease.source(peer_addr); + let source = lease.source(peer_addr.into()); let _ = wiring.ingress_tx.try_send(InboundTx::new(tx, source)); continue; } @@ -418,7 +422,7 @@ fn serve_connection( &mut |items| { wiring .peer_table - .announce_transactions(lease.source(peer_addr), &items); + .announce_transactions(lease.source(peer_addr.into()), &items); wiring .peer_table .poll_transaction_requests(gateway.as_ref()); @@ -640,10 +644,13 @@ impl Harness { dial_handshake(&source.dialer, magic, false)?; dial_handshake(&bystander.dialer, magic, false)?; wait_until(OBSERVE_TIMEOUT, || { - wiring.peer_table.ready_source(source.peer_addr).is_some() + wiring + .peer_table + .ready_source(source.peer_addr.into()) + .is_some() && wiring .peer_table - .ready_source(bystander.peer_addr) + .ready_source(bystander.peer_addr.into()) .is_some() })?; @@ -694,7 +701,10 @@ impl Harness { let peer = open_loopback_peer(&wiring, Arc::clone(&self.gateway), name)?; dial_handshake(&peer.dialer, self.magic, wtxid_relay)?; wait_until(OBSERVE_TIMEOUT, || { - wiring.peer_table.ready_source(peer.peer_addr).is_some() + wiring + .peer_table + .ready_source(peer.peer_addr.into()) + .is_some() })?; Ok(peer) } @@ -740,7 +750,8 @@ fn full_relay_queue_does_not_block_peer_admission_or_mining_wake() -> anyhow::Re let tx = spending_tx(parent_txid(0xDD), 40_000); let txid = tx.txid(); let (outbound_tx, _outbound_rx) = crossbeam_channel::bounded(1); - let source = PeerLease::new(outbound_tx).source(SocketAddr::from((Ipv4Addr::LOCALHOST, 8333))); + let source = + PeerLease::new(outbound_tx).source(SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)).into()); let ingress = spawn_tx_ingress_consumer( &state, Arc::clone(&gateway), diff --git a/crates/node/tests/unit/state/tests/construction.rs b/crates/node/tests/unit/state/tests/construction.rs index 1cc9b0ea2..b54023847 100644 --- a/crates/node/tests/unit/state/tests/construction.rs +++ b/crates/node/tests/unit/state/tests/construction.rs @@ -104,7 +104,7 @@ fn inbound_tx_channel_is_bounded_against_flood() -> anyhow::Result<()> { let sender = state.inbound_tx_sender(); let (lease_tx, _lease_rx) = crossbeam_channel::unbounded(); let lease = bitcoin_rs_p2p::PeerLease::new(lease_tx); - let source = lease.source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let source = lease.source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333)).into()); let tx = bitcoin_rs_primitives::Tx { version: 1, inputs: Vec::new(), diff --git a/crates/node/tests/unit/tx_ingress/tests.rs b/crates/node/tests/unit/tx_ingress/tests.rs index 9bc92b1cb..8bbf57703 100644 --- a/crates/node/tests/unit/tx_ingress/tests.rs +++ b/crates/node/tests/unit/tx_ingress/tests.rs @@ -112,7 +112,7 @@ fn test_source() -> bitcoin_rs_p2p::PeerSource { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18_333); let (queue, _frames) = crossbeam_channel::unbounded::(); let lease = bitcoin_rs_p2p::PeerLease::new(queue); - lease.source(addr) + lease.source(addr.into()) } #[test] diff --git a/crates/p2p/Cargo.toml b/crates/p2p/Cargo.toml index e9933b861..18841e7d6 100644 --- a/crates/p2p/Cargo.toml +++ b/crates/p2p/Cargo.toml @@ -37,6 +37,7 @@ metrics.workspace = true thiserror.workspace = true tracing.workspace = true sha2.workspace = true +base64.workspace = true serde.workspace = true serde_json.workspace = true chacha20.workspace = true diff --git a/crates/p2p/src/addrman.rs b/crates/p2p/src/addrman.rs index 7e7678585..16078343c 100644 --- a/crates/p2p/src/addrman.rs +++ b/crates/p2p/src/addrman.rs @@ -1,9 +1,10 @@ //! P2P's single bounded AddrMan owner, following Core31.1 placement and selection. //! Membership is persisted with each endpoint; lookup tables are derived indexes. +use bitcoin_rs_primitives::PeerEndpoint; use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -27,7 +28,7 @@ const MAX_DNS_RESULTS: usize = 64; const MAX_GOSSIP: usize = 32; const GOSSIP_CACHE_TTL: Duration = Duration::from_hours(24); const MAX_TEMP_ATTEMPTS: usize = 8; -const VERSION: u32 = 8; +const VERSION: u32 = 9; const MAX_COLLISIONS: usize = 10; const REPLACEMENT_WINDOW: u64 = 4 * 60 * 60; const COLLISION_TEST_WINDOW: u64 = 40 * 60; @@ -40,6 +41,7 @@ const MAX_SELECTION_PROPOSALS: usize = 45; #[serde(rename_all = "snake_case", deny_unknown_fields)] enum Source { Ip(IpAddr), + Overlay(crate::PeerAddress), Internal([u8; 10]), // Historical formats did not retain the seed name or full Core internal hash. LegacyDns(u64), @@ -54,6 +56,7 @@ impl Source { fn group(&self, groups: &NetGroups) -> Vec { match self { Self::Ip(ip) => groups.group(*ip), + Self::Overlay(address) => groups.group(*address), Self::Internal(bytes) => { let mut group = vec![6]; group.extend_from_slice(bytes); @@ -77,7 +80,7 @@ impl Source { struct Candidate { #[serde(skip)] creation_id: u64, - addr: SocketAddr, + addr: PeerEndpoint, #[serde(deserialize_with = "required_services")] services: Option, source: Source, @@ -137,7 +140,7 @@ impl Candidate { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Anchor { - addr: SocketAddr, + addr: PeerEndpoint, confirmed_at: u64, } @@ -165,7 +168,7 @@ struct Stored { struct Manager { stored: Stored, groups: NetGroups, - by_addr: HashMap, + by_addr: HashMap, new: Vec, tried: Vec, rng: StdRng, @@ -182,9 +185,9 @@ struct Manager { saved_revision: u64, published: bool, gossip_cursor: usize, - gossip_cache: Option<(Instant, Vec<(u32, bitcoin::p2p::address::Address)>)>, - pending: HashMap, - collisions: Vec, + gossip_cache: Option<(Instant, Vec)>, + pending: HashMap, + collisions: Vec, } pub(crate) struct AddressBook { @@ -210,22 +213,27 @@ fn cheap_hash(bytes: &[u8]) -> u64 { first.copy_from_slice(&hash[..8]); u64::from_le_bytes(first) } -fn endpoint_key(addr: SocketAddr) -> Vec { - let mut bytes = match addr.ip() { - IpAddr::V4(ip) => ip.to_ipv6_mapped().octets().to_vec(), - IpAddr::V6(ip) => ip.octets().to_vec(), +fn endpoint_key(addr: PeerEndpoint) -> Vec { + let mut bytes = match addr.address() { + crate::PeerAddress::Ip(IpAddr::V4(ip)) => ip.to_ipv6_mapped().octets().to_vec(), + crate::PeerAddress::Ip(IpAddr::V6(ip)) => ip.octets().to_vec(), + crate::PeerAddress::Onion(key) => [vec![4], key.to_vec()].concat(), + crate::PeerAddress::I2p(key) => [vec![5], key.to_vec()].concat(), + crate::PeerAddress::AnonymousTor(_) => { + unreachable!("anonymous circuits never enter AddrMan") + } }; bytes.extend_from_slice(&addr.port().to_be_bytes()); bytes } fn new_bucket( secret: &[u8; 32], - addr: SocketAddr, + addr: PeerEndpoint, source_group: &[u8], groups: &NetGroups, ) -> usize { let mut bytes = secret.to_vec(); - vector(&groups.group(addr.ip()), &mut bytes); + vector(&groups.group(addr.address()), &mut bytes); vector(source_group, &mut bytes); let first = cheap_hash(&bytes) % 64; let mut bytes = secret.to_vec(); @@ -233,16 +241,16 @@ fn new_bucket( bytes.extend_from_slice(&first.to_le_bytes()); usize::try_from(cheap_hash(&bytes) % 1024).unwrap_or_default() } -fn tried_bucket(secret: &[u8; 32], addr: SocketAddr, groups: &NetGroups) -> usize { +fn tried_bucket(secret: &[u8; 32], addr: PeerEndpoint, groups: &NetGroups) -> usize { let mut bytes = secret.to_vec(); vector(&endpoint_key(addr), &mut bytes); let first = cheap_hash(&bytes) % 8; let mut bytes = secret.to_vec(); - vector(&groups.group(addr.ip()), &mut bytes); + vector(&groups.group(addr.address()), &mut bytes); bytes.extend_from_slice(&first.to_le_bytes()); usize::try_from(cheap_hash(&bytes) % 256).unwrap_or_default() } -fn bucket_position(secret: &[u8; 32], addr: SocketAddr, new: bool, bucket: usize) -> usize { +fn bucket_position(secret: &[u8; 32], addr: PeerEndpoint, new: bool, bucket: usize) -> usize { let mut bytes = secret.to_vec(); bytes.push(if new { b'N' } else { b'K' }); bytes.extend_from_slice(&i32::try_from(bucket).unwrap_or_default().to_le_bytes()); @@ -287,10 +295,10 @@ impl Manager { collisions: Vec::new(), } } - fn new_slot(&self, addr: SocketAddr, bucket: usize) -> usize { + fn new_slot(&self, addr: PeerEndpoint, bucket: usize) -> usize { bucket * BUCKET_SIZE + bucket_position(&self.stored.secret, addr, true, bucket) } - fn tried_slot(&self, addr: SocketAddr) -> usize { + fn tried_slot(&self, addr: PeerEndpoint) -> usize { let bucket = tried_bucket(&self.stored.secret, addr, &self.groups); bucket * BUCKET_SIZE + bucket_position(&self.stored.secret, addr, false, bucket) } @@ -354,7 +362,7 @@ impl Manager { } // Core MakeTried, with the local pending-identity guard checked before any // reference is changed. Indices are reloaded after ClearNew can swap_remove. - fn promote(&mut self, addr: SocketAddr) -> bool { + fn promote(&mut self, addr: PeerEndpoint) -> bool { let Some(&index) = self.by_addr.get(&addr) else { return false; }; @@ -397,7 +405,7 @@ impl Manager { self.tried[slot] = u32::try_from(index).unwrap_or(EMPTY_SLOT); true } - fn promotion_blocked(&self, addr: SocketAddr) -> bool { + fn promotion_blocked(&self, addr: PeerEndpoint) -> bool { let incumbent = self.tried[self.tried_slot(addr)]; if incumbent == EMPTY_SLOT { return false; @@ -448,7 +456,7 @@ impl Manager { self.revision = self.revision.wrapping_add(1); } } - fn set_services(&mut self, addr: SocketAddr, services: u64) { + fn set_services(&mut self, addr: PeerEndpoint, services: u64) { if let Some(index) = self.by_addr.get(&addr).copied() && self.stored.records[index].services != Some(services) { @@ -456,7 +464,7 @@ impl Manager { self.revision = self.revision.wrapping_add(1); } } - fn good(&mut self, addr: SocketAddr, test_before_evict: bool, now: u64) -> bool { + fn good(&mut self, addr: PeerEndpoint, test_before_evict: bool, now: u64) -> bool { self.last_good = now; let Some(index) = self.by_addr.get(&addr).copied() else { return false; @@ -492,8 +500,8 @@ impl Manager { } fn resolve_collisions( &mut self, - protected: &HashSet, - allowed: &HashSet, + protected: &HashSet, + allowed: &HashSet, now: u64, ) { // At most ten entries; snapshot endpoint identities, never Vec indices. @@ -540,7 +548,7 @@ impl Manager { } fn learn( &mut self, - addr: SocketAddr, + addr: PeerEndpoint, services: Option, source: Source, seen: u64, @@ -555,7 +563,7 @@ impl Manager { { return false; } - let time_penalty = if matches!(&source, Source::Ip(ip) if crate::netgroup::canonical_ip(*ip) == addr.ip()) + let time_penalty = if matches!(&source, Source::Ip(ip) if Some(crate::netgroup::canonical_ip(*ip)) == addr.address().ip()) { 0 } else { @@ -650,7 +658,7 @@ impl Manager { self.revision = self.revision.wrapping_add(1); true } - fn select(&mut self, eligible: &[bool], now: u64) -> Option { + fn select(&mut self, eligible: &[bool], now: u64) -> Option { #[cfg(test)] { self.selection_proposals = 0; @@ -718,21 +726,18 @@ impl Manager { } } -pub(crate) fn canonical(addr: SocketAddr) -> SocketAddr { - match addr.ip() { - IpAddr::V6(ip) => ip - .to_ipv4_mapped() - .map_or(addr, |ip| SocketAddr::new(ip.into(), addr.port())), - IpAddr::V4(_) => addr, - } +pub(crate) fn canonical(addr: PeerEndpoint) -> PeerEndpoint { + addr.to_canonical() } -fn routable(addr: SocketAddr, allow_local: bool) -> bool { - if addr.port() == 0 || addr.ip().is_unspecified() || addr.ip().is_multicast() { +fn routable(addr: PeerEndpoint, allow_local: bool) -> bool { + if addr.port() == 0 || addr.address().is_unspecified() || addr.address().is_multicast() { return false; } - match addr.ip() { - IpAddr::V4(ip) => { + match addr.address() { + crate::PeerAddress::Onion(_) | crate::PeerAddress::I2p(_) => true, + crate::PeerAddress::AnonymousTor(_) => false, + crate::PeerAddress::Ip(IpAddr::V4(ip)) => { let octets = ip.octets(); if ip.is_broadcast() || octets[0] == 0 || octets[0] >= 240 { return false; @@ -748,9 +753,9 @@ fn routable(addr: SocketAddr, allow_local: bool) -> bool { || octets[0] == 198 && (18..=19).contains(&octets[1]) || octets[0] == 192 && octets[1] == 0 && octets[2] == 0) } - IpAddr::V6(ip) => { + crate::PeerAddress::Ip(IpAddr::V6(ip)) => { if let Some(ip) = ip.to_ipv4_mapped() { - return routable(SocketAddr::new(ip.into(), addr.port()), allow_local); + return routable(PeerEndpoint::new(IpAddr::V4(ip), addr.port()), allow_local); } if allow_local { return true; @@ -860,7 +865,7 @@ impl AddressBook { publication: Mutex::new(()), }) } - pub(crate) fn connected(&self, addr: SocketAddr, now: u64) { + pub(crate) fn connected(&self, addr: PeerEndpoint, now: u64) { let addr = canonical(addr); let mut manager = self.state.lock(); if let Some(index) = manager.by_addr.get(&addr).copied() { @@ -874,7 +879,7 @@ impl AddressBook { pub(crate) fn len(&self) -> usize { self.state.lock().stored.records.len() } - pub(crate) fn learn_dns(&self, seed: &str, addresses: &[SocketAddr], now: u64) { + pub(crate) fn learn_dns(&self, seed: &str, addresses: &[PeerEndpoint], now: u64) { let source = Source::dns(seed); let mut manager = self.state.lock(); for &addr in addresses.iter().take(MAX_DNS_RESULTS) { @@ -883,16 +888,23 @@ impl AddressBook { } pub(crate) fn learn_peer( &self, - source: IpAddr, - addresses: &[(SocketAddr, u64, u64)], + source: impl Into, + addresses: &[(PeerEndpoint, u64, u64)], now: u64, ) { + let source = source.into(); + if matches!(source, crate::PeerAddress::AnonymousTor(_)) { + return; + } let mut manager = self.state.lock(); for &(addr, services, seen) in addresses.iter().take(MAX_GOSSIP) { manager.learn( addr, Some(services), - Source::Ip(source), + match source { + crate::PeerAddress::Ip(ip) => Source::Ip(ip), + address => Source::Overlay(address), + }, seen, now, 2 * 60 * 60, @@ -903,12 +915,12 @@ impl AddressBook { /// and currently pending work. Policy callbacks run outside the state lock. pub(crate) fn select( &self, - connected: &[SocketAddr], - grouped: &[SocketAddr], + connected: &[PeerEndpoint], + grouped: &[PeerEndpoint], now: u64, best_block_depth: u64, - mut allowed: impl FnMut(SocketAddr) -> bool, - ) -> Option { + mut allowed: impl FnMut(PeerEndpoint) -> bool, + ) -> Option { let connected: HashSet<_> = connected.iter().copied().map(canonical).collect(); let addresses: Vec<_> = { let manager = self.state.lock(); @@ -934,7 +946,7 @@ impl AddressBook { .iter() .filter_map(|(addr, claim)| (*claim != PendingClaim::Feeler).then_some(addr)), ) - .map(|addr| manager.groups.group(addr.ip())) + .map(|addr| manager.groups.group(addr.address())) .collect(); let eligible: Vec<_> = manager .stored @@ -945,7 +957,7 @@ impl AddressBook { && allowed.contains(&entry.addr) && !connected.contains(&entry.addr) && !manager.pending.contains_key(&entry.addr) - && !groups.contains(&manager.groups.group(entry.addr.ip())) + && !groups.contains(&manager.groups.group(entry.addr.address())) }) .collect(); manager.select(&eligible, now) @@ -954,7 +966,7 @@ impl AddressBook { /// Missing metadata remains permissive for non-book connection consumers. pub(crate) fn ordinary_services_eligible( &self, - addr: SocketAddr, + addr: PeerEndpoint, best_block_depth: u64, ) -> bool { let manager = self.state.lock(); @@ -967,7 +979,7 @@ impl AddressBook { /// `services`. An unknown address or service claim answers `false`. pub(crate) fn advertises( &self, - addr: SocketAddr, + addr: PeerEndpoint, services: bitcoin::p2p::ServiceFlags, ) -> bool { let manager = self.state.lock(); @@ -981,12 +993,12 @@ impl AddressBook { /// Applies Core's failure-count connectivity threshold to the caller's /// persistent outbound TCP snapshot using this book's configured classifier. /// Connection roles and leases remain owned by the caller. - pub(crate) fn count_failure(&self, outbound: &[SocketAddr], maximum: usize) -> bool { + pub(crate) fn count_failure(&self, outbound: &[PeerEndpoint], maximum: usize) -> bool { let threshold = maximum.saturating_sub(1).min(2); let manager = self.state.lock(); let mut groups = HashSet::new(); for address in outbound { - groups.insert(manager.groups.group(address.ip())); + groups.insert(manager.groups.group(address.address())); if groups.len() >= threshold { return true; } @@ -994,7 +1006,7 @@ impl AddressBook { threshold == 0 } - pub(crate) fn queued(&self, addr: SocketAddr) -> bool { + pub(crate) fn queued(&self, addr: PeerEndpoint) -> bool { use std::collections::hash_map::Entry; match self.state.lock().pending.entry(canonical(addr)) { Entry::Vacant(entry) => { @@ -1004,7 +1016,7 @@ impl AddressBook { Entry::Occupied(entry) => *entry.get() == PendingClaim::Dial, } } - pub(crate) fn queued_feeler(&self, addr: SocketAddr) -> bool { + pub(crate) fn queued_feeler(&self, addr: PeerEndpoint) -> bool { use std::collections::hash_map::Entry; let mut manager = self.state.lock(); if manager @@ -1022,13 +1034,13 @@ impl AddressBook { Entry::Occupied(_) => false, } } - pub(crate) fn is_pending(&self, addr: SocketAddr) -> bool { + pub(crate) fn is_pending(&self, addr: PeerEndpoint) -> bool { self.state.lock().pending.contains_key(&canonical(addr)) } - pub(crate) fn is_feeler(&self, addr: SocketAddr) -> bool { + pub(crate) fn is_feeler(&self, addr: PeerEndpoint) -> bool { self.state.lock().pending.get(&canonical(addr)) == Some(&PendingClaim::Feeler) } - pub(crate) fn queue_anchor(&self, addr: SocketAddr) -> bool { + pub(crate) fn queue_anchor(&self, addr: PeerEndpoint) -> bool { let mut manager = self.state.lock(); let Some(claim) = manager.pending.get_mut(&canonical(addr)) else { return false; @@ -1040,7 +1052,7 @@ impl AddressBook { false } } - pub(crate) fn defer_anchor(&self, addr: SocketAddr) { + pub(crate) fn defer_anchor(&self, addr: PeerEndpoint) { if let Some(claim) = self.state.lock().pending.get_mut(&canonical(addr)) && let PendingClaim::AnchorQueued(anchor) = claim { @@ -1050,7 +1062,7 @@ impl AddressBook { /// At-most-once dispatch: acceptance by the outbound worker consumes the /// reservation even if spawning/connect subsequently fails. Health Attempt /// remains at actual TCP completion and is independent of this boundary. - pub(crate) fn anchor_dispatched(&self, addr: SocketAddr) -> bool { + pub(crate) fn anchor_dispatched(&self, addr: PeerEndpoint) -> bool { if let Some(claim) = self.state.lock().pending.get_mut(&canonical(addr)) && matches!(claim, PendingClaim::AnchorQueued(_)) { @@ -1062,8 +1074,8 @@ impl AddressBook { } pub(crate) fn anchor_eligible( &self, - addr: SocketAddr, - active: &[SocketAddr], + addr: PeerEndpoint, + active: &[PeerEndpoint], now: u64, ) -> bool { let addr = canonical(addr); @@ -1078,13 +1090,14 @@ impl AddressBook { ) && !active.iter().any(|other| { canonical(*other) == addr - || manager.groups.group(other.ip()) == manager.groups.group(addr.ip()) + || manager.groups.group(other.address()) + == manager.groups.group(addr.address()) }) }) } /// Queue rejection releases only the attempted purpose, never a reservation /// or a probe that won concurrent admission for the same canonical endpoint. - pub(crate) fn reject_queued(&self, addr: SocketAddr, feeler: bool) { + pub(crate) fn reject_queued(&self, addr: PeerEndpoint, feeler: bool) { let addr = canonical(addr); let mut manager = self.state.lock(); let expected = if feeler { @@ -1096,10 +1109,10 @@ impl AddressBook { manager.pending.remove(&addr); } } - pub(crate) fn unqueue(&self, addr: SocketAddr) { + pub(crate) fn unqueue(&self, addr: PeerEndpoint) { self.state.lock().pending.remove(&canonical(addr)); } - pub(crate) fn pending_count_excluding(&self, active: &[SocketAddr]) -> usize { + pub(crate) fn pending_count_excluding(&self, active: &[PeerEndpoint]) -> usize { let active: HashSet<_> = active.iter().copied().map(canonical).collect(); self.state .lock() @@ -1111,7 +1124,7 @@ impl AddressBook { }) .count() } - pub(crate) fn attempted(&self, addr: SocketAddr, count_failure: bool, now: u64) { + pub(crate) fn attempted(&self, addr: PeerEndpoint, count_failure: bool, now: u64) { let addr = canonical(addr); let mut manager = self.state.lock(); let last_good = manager.last_good; @@ -1127,14 +1140,14 @@ impl AddressBook { } /// Record an accepted VERSION's current service claim without certifying /// success, promoting membership or changing any health timestamp. - pub(crate) fn set_services(&self, addr: SocketAddr, services: u64) { + pub(crate) fn set_services(&self, addr: PeerEndpoint, services: u64) { self.state.lock().set_services(canonical(addr), services); } /// The netgroup of `ip` under this book's classifier, `ASMap` or IP prefix. - pub(crate) fn netgroup(&self, ip: IpAddr) -> Vec { + pub(crate) fn netgroup(&self, ip: impl Into) -> Vec { self.state.lock().groups.group(ip) } - pub(crate) fn succeeded(&self, addr: SocketAddr, services: u64, now: u64) { + pub(crate) fn succeeded(&self, addr: PeerEndpoint, services: u64, now: u64) { let addr = canonical(addr); let mut manager = self.state.lock(); manager.set_services(addr, services); @@ -1142,9 +1155,9 @@ impl AddressBook { } pub(crate) fn resolve_collisions( &self, - active: &[SocketAddr], + active: &[PeerEndpoint], now: u64, - mut allowed: impl FnMut(SocketAddr) -> bool, + mut allowed: impl FnMut(PeerEndpoint) -> bool, ) { let addresses = { let manager = self.state.lock(); @@ -1171,14 +1184,17 @@ impl AddressBook { } pub(crate) fn feeler( &self, - active: &[SocketAddr], - connected: &[SocketAddr], + active: &[PeerEndpoint], + connected: &[PeerEndpoint], now: u64, - mut allowed: impl FnMut(SocketAddr) -> bool, - ) -> Option { + mut allowed: impl FnMut(PeerEndpoint) -> bool, + ) -> Option { // Core AlreadyConnectedToAddress takes CNetAddr: TCP presence at the // same canonical IP is sufficient even at a different port. - let connected: HashSet<_> = connected.iter().map(|addr| canonical(*addr).ip()).collect(); + let connected: HashSet<_> = connected + .iter() + .map(|addr| canonical(*addr).address()) + .collect(); let active: HashSet<_> = active.iter().copied().map(canonical).collect(); let addresses: Vec<_> = { let manager = self.state.lock(); @@ -1213,7 +1229,7 @@ impl AddressBook { if incumbent != EMPTY_SLOT { let addr = manager.stored.records[usize::try_from(incumbent).unwrap_or_default()].addr; - if connected.contains(&addr.ip()) { + if connected.contains(&addr.address()) { manager.good(addr, true, now); } else if manager.stored.records[usize::try_from(incumbent).unwrap_or_default()] .services @@ -1242,7 +1258,7 @@ impl AddressBook { .collect(); manager.select(&eligible, now) } - pub(crate) fn remember_anchors(&self, demonstrated: &[SocketAddr], now: u64) { + pub(crate) fn remember_anchors(&self, demonstrated: &[PeerEndpoint], now: u64) { let mut manager = self.state.lock(); let mut addresses: Vec<_> = demonstrated .iter() @@ -1284,7 +1300,7 @@ impl AddressBook { /// Consume before dialing; if publication fails, ordinary selection remains /// available but no restart anchor is reused without durable consumption. - pub(crate) fn take_restart_anchors(&self, now: u64) -> Vec { + pub(crate) fn take_restart_anchors(&self, now: u64) -> Vec { let (reserved, revision, persistent) = { let mut manager = self.state.lock(); let anchors = std::mem::take(&mut manager.stored.anchors); @@ -1346,7 +1362,7 @@ impl AddressBook { /// A skipped reservation returns immediately rather than retaining group /// exclusion until shutdown. An already dispatched Dial cannot be returned. - pub(crate) fn return_restart_anchor(&self, addr: SocketAddr, now: u64) { + pub(crate) fn return_restart_anchor(&self, addr: PeerEndpoint, now: u64) { { let addr = canonical(addr); let mut manager = self.state.lock(); @@ -1386,10 +1402,10 @@ impl AddressBook { self.save(); } - pub(crate) fn gossip(&self, now: u64) -> Vec<(u32, bitcoin::p2p::address::Address)> { + pub(crate) fn gossip(&self, now: u64) -> Vec { self.gossip_at(now, Instant::now()) } - fn gossip_at(&self, now: u64, tick: Instant) -> Vec<(u32, bitcoin::p2p::address::Address)> { + fn gossip_at(&self, now: u64, tick: Instant) -> Vec { let mut manager = self.state.lock(); if let Some((expires, response)) = &manager.gossip_cache { if tick < *expires { @@ -1406,13 +1422,19 @@ impl AddressBook { if entry.terrible(now) { continue; } - gossip.push(( - u32::try_from(entry.last_seen).unwrap_or(u32::MAX), - bitcoin::p2p::address::Address::new( - &entry.addr, - bitcoin::p2p::ServiceFlags::from(entry.services.unwrap_or(0)), - ), - )); + let addr = match entry.addr.address() { + crate::PeerAddress::Ip(IpAddr::V4(ip)) => bitcoin::p2p::address::AddrV2::Ipv4(ip), + crate::PeerAddress::Ip(IpAddr::V6(ip)) => bitcoin::p2p::address::AddrV2::Ipv6(ip), + crate::PeerAddress::Onion(key) => bitcoin::p2p::address::AddrV2::TorV3(key), + crate::PeerAddress::I2p(hash) => bitcoin::p2p::address::AddrV2::I2p(hash), + crate::PeerAddress::AnonymousTor(_) => continue, + }; + gossip.push(bitcoin::p2p::address::AddrV2Message { + time: u32::try_from(entry.last_seen).unwrap_or(u32::MAX), + services: bitcoin::p2p::ServiceFlags::from(entry.services.unwrap_or(0)), + addr, + port: entry.addr.port(), + }); if gossip.len() == MAX_GOSSIP { break; } @@ -1566,7 +1588,7 @@ fn read_anchors<'de, D: serde::Deserializer<'de>>( #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct LegacyCandidate { - addr: SocketAddr, + addr: PeerEndpoint, services: u64, source_group: u64, source_ip: Option, @@ -1598,7 +1620,7 @@ struct GroupedLegacyStored { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ReferencedCandidate { - addr: SocketAddr, + addr: PeerEndpoint, services: u64, source: Source, last_seen: u64, @@ -1745,10 +1767,10 @@ fn validate_current( } Ok(()) } -fn legacy_slot(secret: &[u8; 32], entry: &LegacyCandidate) -> u64 { +fn legacy_slot(secret: &[u8; 32], entry: &LegacyCandidate, ip: IpAddr) -> u64 { let mut hash = Sha256::new(); hash.update(secret); - hash.update(legacy_prefix(entry.addr.ip()).to_le_bytes()); + hash.update(legacy_prefix(ip).to_le_bytes()); if !entry.tried { hash.update(entry.source_group.to_le_bytes()); } @@ -1771,14 +1793,20 @@ fn convert_legacy(old: LegacyStored, allow_local: bool) -> io::Result { let mut sources = HashMap::new(); let mut slots = HashSet::new(); for entry in &old.records { - if !routable(entry.addr, allow_local) + let ip = entry + .addr + .address() + .ip() + .ok_or_else(|| invalid("overlay in legacy IP schema"))?; + if entry.addr.address().ip().is_none() + || !routable(entry.addr, allow_local) || canonical(entry.addr) != entry.addr || !addresses.insert(entry.addr) || entry .source_ip .is_some_and(|ip| legacy_prefix(ip) != entry.source_group) || entry.tried && entry.last_success == 0 - || old.version == 1 && !slots.insert(legacy_slot(&old.secret, entry)) + || old.version == 1 && !slots.insert(legacy_slot(&old.secret, entry, ip)) { return Err(invalid("invalid legacy address record")); } @@ -1862,6 +1890,10 @@ fn decode_legacy_book(payload: &[u8], version: u32, allow_local: bool) -> io::Re Ok(stored) } } +#[expect( + clippy::too_many_lines, + reason = "Historical schema admission and validation stay ordered before backed-up publication" +)] fn read_book( path: &Path, magic: Option<[u8; 4]>, @@ -1944,9 +1976,29 @@ fn read_book( anchors: old.anchors, } } - VERSION => serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?, + 8 | VERSION => { + let mut stored: Stored = + serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; + if header.version == 8 + && stored.records.iter().any(|entry| { + entry.addr.address().ip().is_none() + || matches!(entry.source, Source::Overlay(_)) + }) + { + return Err(invalid("overlay in historical IP schema")); + } + stored.version = VERSION; + stored + } _ => return Err(invalid("unsupported address book schema")), }; + if header.version < VERSION + && stored.records.iter().any(|entry| { + entry.addr.address().ip().is_none() || matches!(entry.source, Source::Overlay(_)) + }) + { + return Err(invalid("overlay identity in historical IP schema")); + } if header.version >= 5 { let prefix = NetGroups::default(); let classifier = if stored.asmap_id.is_none() { diff --git a/crates/p2p/src/addrman/tests.rs b/crates/p2p/src/addrman/tests.rs index 0250e8974..007d49a5f 100644 --- a/crates/p2p/src/addrman/tests.rs +++ b/crates/p2p/src/addrman/tests.rs @@ -1,8 +1,8 @@ use super::*; use std::net::Ipv4Addr; -fn addr(n: u8) -> SocketAddr { - SocketAddr::new(Ipv4Addr::new(8, n, 1, 1).into(), 8333) +fn addr(n: u8) -> PeerEndpoint { + PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(8, n, 1, 1)), 8333) } fn book() -> Arc { AddressBook::open(None, [1; 4], false, None) @@ -11,7 +11,7 @@ fn book() -> Arc { #[test] fn unproven_addresses_need_a_successful_outbound_handshake() { let book = book(); - book.learn_peer(addr(2).ip(), &[(addr(1), 9, 10_000)], 10_000); + book.learn_peer(addr(2).address(), &[(addr(1), 9, 10_000)], 10_000); assert!(!book.state.lock().stored.records[0].tried); book.queued(addr(1)); book.attempted(addr(1), true, 10_001); @@ -25,7 +25,7 @@ fn unproven_addresses_need_a_successful_outbound_handshake() { fn gossip_is_bounded_and_selection_respects_groups_pending_and_exclusion() { let book = book(); for n in 1..200 { - book.learn_peer(addr(200).ip(), &[(addr(n), 9, 10_000)], 10_000); + book.learn_peer(addr(200).address(), &[(addr(n), 9, 10_000)], 10_000); } assert!(book.len() <= 199); let picked = book @@ -38,7 +38,7 @@ fn gossip_is_bounded_and_selection_respects_groups_pending_and_exclusion() { ); assert_eq!(book.select(&[], &[], 10_000, u64::MAX, |_| false), None); book.unqueue(picked); - let same_group = SocketAddr::new(picked.ip(), 8334); + let same_group = PeerEndpoint::new(picked.address(), 8334); assert_ne!( book.select(&[], &[same_group], 10_000, u64::MAX, |_| true), Some(picked) @@ -59,7 +59,7 @@ fn gossip_is_bounded_and_selection_respects_groups_pending_and_exclusion() { fn invalid_and_duplicate_addresses_do_not_create_records() { let book = book(); book.learn_peer( - addr(2).ip(), + addr(2).address(), &[ ("127.0.0.1:1".parse().expect("addr"), 9, 10_000), (addr(1), 9, 20_000), @@ -67,7 +67,7 @@ fn invalid_and_duplicate_addresses_do_not_create_records() { 10_000, ); assert_eq!(book.len(), 0); - book.learn_peer(addr(2).ip(), &[(addr(1), 9, 10_000); 2], 10_000); + book.learn_peer(addr(2).address(), &[(addr(1), 9, 10_000); 2], 10_000); assert_eq!(book.len(), 1); } @@ -76,7 +76,7 @@ fn restart_roundtrip_and_corruption_preserves_operator_file() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("peers.dat"); let book = AddressBook::open(Some(path.clone()), [1; 4], false, None); - book.learn_peer(addr(2).ip(), &[(addr(1), 9, 10_000)], 10_000); + book.learn_peer(addr(2).address(), &[(addr(1), 9, 10_000)], 10_000); book.succeeded(addr(1), 9, 10_001); book.save(); let restored = AddressBook::open(Some(path.clone()), [1; 4], false, None); @@ -85,7 +85,7 @@ fn restart_roundtrip_and_corruption_preserves_operator_file() { for corrupt in [b"truncated".to_vec(), vec![0; 40]] { fs::write(network_path(&path, [1; 4]), &corrupt).expect("corrupt fixture"); let recovered = AddressBook::open(Some(path.clone()), [1; 4], false, None); - recovered.learn_peer(addr(2).ip(), &[(addr(3), 9, 10_000)], 10_000); + recovered.learn_peer(addr(2).address(), &[(addr(3), 9, 10_000)], 10_000); recovered.save(); assert_eq!( fs::read(network_path(&path, [1; 4])).expect("read"), @@ -98,18 +98,18 @@ fn restart_roundtrip_and_corruption_preserves_operator_file() { fn promotion_collision_never_overwrites_a_proven_peer() { let book = book(); let first = addr(1); - book.learn_peer(addr(2).ip(), &[(first, 9, 10_000)], 10_000); + book.learn_peer(addr(2).address(), &[(first, 9, 10_000)], 10_000); book.succeeded(first, 9, 10_001); let incumbent = book.state.lock().stored.records[0].clone(); let challenger = { let manager = book.state.lock(); let target = manager.tried_slot(incumbent.addr); (1..65535_u16) - .map(|port| SocketAddr::new(first.ip(), port)) + .map(|port| PeerEndpoint::new(first.address(), port)) .find(|candidate| *candidate != first && manager.tried_slot(*candidate) == target) .expect("slot collision") }; - book.learn_peer(addr(3).ip(), &[(challenger, 9, 10_000)], 10_000); + book.learn_peer(addr(3).address(), &[(challenger, 9, 10_000)], 10_000); book.succeeded(challenger, 9, 10_002); let manager = book.state.lock(); assert!( @@ -263,16 +263,16 @@ fn publication_retries_collisions_with_a_bound_and_preserves_unowned_temps() { fn duplicate_hearsay_only_dirties_persisted_fields_that_change() { let dir = tempfile::tempdir().expect("dir"); let book = AddressBook::open(Some(dir.path().join("peers.dat")), [1; 4], false, None); - book.learn_peer(addr(2).ip(), &[(addr(1), 1, 10_000)], 10_000); + book.learn_peer(addr(2).address(), &[(addr(1), 1, 10_000)], 10_000); book.save(); let clean = book.state.lock().saved_revision; - book.learn_peer(addr(2).ip(), &[(addr(1), 1, 9_999); 32], 10_000); + book.learn_peer(addr(2).address(), &[(addr(1), 1, 9_999); 32], 10_000); assert_eq!( book.state.lock().revision, clean, "unchanged duplicate reports cannot schedule disk writes" ); - book.learn_peer(addr(2).ip(), &[(addr(1), 8, 10_000)], 10_000); + book.learn_peer(addr(2).address(), &[(addr(1), 8, 10_000)], 10_000); assert_ne!( book.state.lock().revision, clean, @@ -281,7 +281,7 @@ fn duplicate_hearsay_only_dirties_persisted_fields_that_change() { book.succeeded(addr(1), 9, 10_001); book.save(); let clean = book.state.lock().saved_revision; - book.learn_peer(addr(2).ip(), &[(addr(1), 64, 10_001)], 10_001); + book.learn_peer(addr(2).address(), &[(addr(1), 64, 10_001)], 10_001); assert_ne!( book.state.lock().revision, clean, @@ -289,7 +289,7 @@ fn duplicate_hearsay_only_dirties_persisted_fields_that_change() { ); book.save(); let clean = book.state.lock().saved_revision; - book.learn_peer(addr(2).ip(), &[(addr(1), 9, 13_602)], 13_602); + book.learn_peer(addr(2).address(), &[(addr(1), 9, 13_602)], 13_602); assert_ne!( book.state.lock().revision, clean, @@ -313,13 +313,13 @@ fn gossip_cache_is_shared_stable_until_expiry_and_rotates_new_discoveries() { assert_eq!(first.len(), 32); assert_eq!(book.state.lock().revision, before); let fresh = (1..=255) - .map(|n| SocketAddr::from(([9, n, 1, 1], 8333))) + .map(|n| PeerEndpoint::from(([9, n, 1, 1], 8333))) .find(|candidate| { book.learn_dns("other-seed", &[*candidate], 10_001); book.len() > 64 }) .expect("new discovery"); - let updated = first[0].1.socket_addr().expect("endpoint"); + let updated = PeerEndpoint::from(first[0].socket_addr().expect("endpoint")); book.connected(updated, 20_000); let before = book.state.lock().revision; for seconds in 0..100 { @@ -333,10 +333,11 @@ fn gossip_cache_is_shared_stable_until_expiry_and_rotates_new_discoveries() { assert_ne!(first, second); let third = book.gossip_at(20_100, tick + 2 * GOSSIP_CACHE_TTL); assert!( - second - .iter() - .chain(&third) - .any(|(_, address)| address.socket_addr().ok() == Some(fresh)) + second.iter().chain(&third).any(|address| address + .socket_addr() + .ok() + .map(PeerEndpoint::from) + == Some(fresh)) ); assert_eq!(book.state.lock().revision, before, "cache is not persisted"); assert_eq!( @@ -349,10 +350,10 @@ fn gossip_cache_is_shared_stable_until_expiry_and_rotates_new_discoveries() { fn pending_attempts_keep_endpoint_and_network_group_ownership_until_released() { let book = book(); book.learn_dns("seed", &[addr(1)], 10_000); - let other = SocketAddr::new(addr(1).ip(), 18333); + let other = PeerEndpoint::new(addr(1).address(), 18333); // A vacant different new slot is needed for both same-group records. let candidate = (1..65535) - .map(|port| SocketAddr::new(other.ip(), port)) + .map(|port| PeerEndpoint::new(other.address(), port)) .find(|&candidate| { if candidate == addr(1) { return false; @@ -392,16 +393,16 @@ fn oracle_book() -> Arc { fn source(n: u8) -> Source { Source::Ip(Ipv4Addr::new(1, n, 1, 1).into()) } -fn target() -> SocketAddr { +fn target() -> PeerEndpoint { "8.8.8.8:8333".parse().expect("target") } -fn refs(manager: &Manager, addr: SocketAddr) -> usize { +fn refs(manager: &Manager, addr: PeerEndpoint) -> usize { manager .by_addr .get(&addr) .map_or(0, |index| manager.stored.records[*index].new_buckets.len()) } -fn add_ref(manager: &mut Manager, addr: SocketAddr, source: &Source, seen: u64) { +fn add_ref(manager: &mut Manager, addr: PeerEndpoint, source: &Source, seen: u64) { for _ in 0..4096 { if manager.learn(addr, Some(9), source.clone(), seen, EPOCH, 0) { return; @@ -454,9 +455,9 @@ fn placement_matches_68_unmodified_core_vectors() { let ip = row["address"] .as_str() .expect("address") - .parse() + .parse::() .expect("IP"); - let addr = canonical(SocketAddr::new( + let addr = canonical(PeerEndpoint::new( ip, u16::try_from(row["port"].as_u64().expect("port")).expect("port"), )); @@ -467,7 +468,9 @@ fn placement_matches_68_unmodified_core_vectors() { Source::Ip(origin.parse().expect("source IP")) }; assert_eq!( - NetGroups::default().group(addr.ip()).to_lower_hex_string(), + NetGroups::default() + .group(addr.address()) + .to_lower_hex_string(), row["address_group_hex"].as_str().expect("group") ); assert_eq!( @@ -570,7 +573,7 @@ fn corroboration_ref_cap_dirty_membership_and_promotion_follow_core() { #[test] fn new_collision_removes_only_one_reference_and_preserves_pending_identity() { - let other: SocketAddr = "8.8.9.89:8333".parse().expect("Core collision"); + let other: PeerEndpoint = "8.8.9.89:8333".parse().expect("Core collision"); for extra_ref in [false, true] { let book = oracle_book(); let mut manager = book.state.lock(); @@ -642,13 +645,25 @@ fn gossip_time_penalty_and_source_self_exception_follow_core() { EPOCH + 3601, "time update before freshness test" ); - let local: SocketAddr = "9.9.9.9:8333".parse().expect("addr"); - manager.learn(local, Some(9), Source::Ip(local.ip()), EPOCH, EPOCH, 7200); + let local: PeerEndpoint = "9.9.9.9:8333".parse().expect("addr"); + manager.learn( + local, + Some(9), + Source::Ip( + local + .address() + .ip() + .unwrap_or_else(|| panic!("expected IP fixture")), + ), + EPOCH, + EPOCH, + 7200, + ); assert_eq!( manager.stored.records[*manager.by_addr.get(&local).expect("self")].last_seen, EPOCH ); - let floor: SocketAddr = "11.11.11.11:8333".parse().expect("addr"); + let floor: PeerEndpoint = "11.11.11.11:8333".parse().expect("addr"); manager.learn(floor, Some(9), source(3), 100, 100, 7200); assert_eq!( manager.stored.records[*manager.by_addr.get(&floor).expect("floor")].last_seen, @@ -689,16 +704,16 @@ fn policy_callbacks_run_unlocked_and_selection_rechecks_pending() { fn one_source_flood_is_bucket_bounded_and_retains_legitimate_peers() { let book = oracle_book(); let mut manager = book.state.lock(); - let retained: SocketAddr = "9.9.9.9:8333".parse().expect("peer"); + let retained: PeerEndpoint = "9.9.9.9:8333".parse().expect("peer"); manager.learn(retained, Some(9), source(2), EPOCH, EPOCH, 0); - let proven: SocketAddr = "7.7.7.7:8333".parse().expect("peer"); + let proven: PeerEndpoint = "7.7.7.7:8333".parse().expect("peer"); manager.learn(proven, Some(9), source(3), EPOCH, EPOCH, 0); let proven_index = *manager.by_addr.get(&proven).expect("known"); manager.stored.records[proven_index].last_success = EPOCH; manager.promote(proven); for index in 0..32_768_u32 { let bytes = index.to_be_bytes(); - let peer = SocketAddr::from(([8, bytes[2], bytes[3], 1], 8333)); + let peer = PeerEndpoint::from(([8, bytes[2], bytes[3], 1], 8333)); manager.learn(peer, Some(0), source(1), EPOCH, EPOCH, 0); } let malicious: Vec<_> = manager @@ -721,7 +736,7 @@ fn one_source_flood_is_bucket_bounded_and_retains_legitimate_peers() { assert!(manager.stored.records[*manager.by_addr.get(&proven).expect("retained")].tried); let accepted = (1..=255).any(|n| { manager.learn( - SocketAddr::from(([11, n, 1, 1], 8333)), + PeerEndpoint::from(([11, n, 1, 1], 8333)), Some(9), source(4), EPOCH, @@ -897,7 +912,7 @@ fn stale_future_and_outage_candidates_survive_until_actual_new_slot_replacement( let bucket = usize::from(manager.stored.records[0].new_buckets[0]); let slot = manager.new_slot(target(), bucket); let other = (1..=65535) - .map(|port| SocketAddr::new(target().ip(), port)) + .map(|port| PeerEndpoint::new(target().address(), port)) .find(|candidate| { *candidate != target() && manager.new_slot(*candidate, bucket) == slot }) @@ -917,7 +932,7 @@ fn stale_future_and_outage_candidates_survive_until_actual_new_slot_replacement( assert_eq!(manager.select(&[true], EPOCH), Some(target())); } assert!(!manager.learn( - SocketAddr::from(([0, 0, 0, 0], 0)), + PeerEndpoint::from(([0, 0, 0, 0], 0)), Some(9), origin.clone(), EPOCH, @@ -937,7 +952,7 @@ fn capacity_and_file_budget_have_explicit_bounded_representations() { creation_id: 0, addr: "2fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" .parse::() - .map(|ip| SocketAddr::new(ip, u16::MAX)) + .map(|ip| PeerEndpoint::new(ip, u16::MAX)) .expect("IP"), services: Some(u64::MAX), source: Source::Internal([255; 10]), @@ -1123,7 +1138,7 @@ fn all_24_addsingle_attempt_good_observations_match_actual_core() { fn actual_core_attempt_good_sequence() { let book = oracle_book(); - let other: SocketAddr = "9.9.9.9:8333".parse().expect("peer"); + let other: PeerEndpoint = "9.9.9.9:8333".parse().expect("peer"); { let mut manager = book.state.lock(); manager.learn(target(), Some(0), source(1), EPOCH - 1000, EPOCH, 0); @@ -1161,7 +1176,7 @@ fn actual_core_attempt_good_sequence() { ); } -fn ring_manager(second_port: u16) -> (Manager, SocketAddr, SocketAddr) { +fn ring_manager(second_port: u16) -> (Manager, PeerEndpoint, PeerEndpoint) { let data: serde_json::Value = serde_json::from_str(include_str!( "../../tests/data/core-addrman-ring-v31.1.json" )) @@ -1179,8 +1194,12 @@ fn ring_manager(second_port: u16) -> (Manager, SocketAddr, SocketAddr) { .iter() .find(|row| row["port"] == port) .expect("Core endpoint"); - let peer = SocketAddr::new( - row["address"].as_str().expect("ip").parse().expect("ip"), + let peer = PeerEndpoint::new( + row["address"] + .as_str() + .expect("ip") + .parse::() + .expect("ip"), port, ); assert!(manager.learn(peer, Some(9), source(1), EPOCH, EPOCH, 0)); @@ -1297,7 +1316,7 @@ fn table_choice_is_half_even_when_new_health_is_bad_and_selection_work_is_bounde ); } -fn core_mapped_alias() -> SocketAddr { +fn core_mapped_alias() -> PeerEndpoint { let data: serde_json::Value = serde_json::from_str(include_str!("../../tests/data/core-addrman-v31.1.json")) .expect("actual Core identity vectors"); @@ -1316,7 +1335,7 @@ fn core_mapped_alias() -> SocketAddr { row("8.8.8.8")["endpoint_key_hex"], row("::ffff:8.8.8.8")["endpoint_key_hex"] ); - let alias: SocketAddr = "[::ffff:8.8.8.8]:8333".parse().expect("mapped endpoint"); + let alias: PeerEndpoint = "[::ffff:8.8.8.8]:8333".parse().expect("mapped endpoint"); assert_eq!(endpoint_key(alias), endpoint_key(target())); alias } @@ -1400,7 +1419,7 @@ fn mapped_alias_exact_connection_filter_does_not_block_distinct_endpoints() { let book = oracle_book(); book.learn_dns("seed", &[target()], EPOCH); assert_eq!(book.select(&[alias], &[], EPOCH, u64::MAX, |_| true), None); - let different_port = SocketAddr::new(alias.ip(), 8334); + let different_port = PeerEndpoint::new(alias.address(), 8334); assert_eq!( book.select(&[different_port], &[], EPOCH, u64::MAX, |_| true), Some(target()), @@ -1411,7 +1430,7 @@ fn mapped_alias_exact_connection_filter_does_not_block_distinct_endpoints() { None, "outbound diversity still excludes the group" ); - let linked: SocketAddr = "[2002:0808:0808::1]:8333" + let linked: PeerEndpoint = "[2002:0808:0808::1]:8333" .parse() .expect("different IPv6 transport endpoint"); assert_ne!(endpoint_key(linked), endpoint_key(target())); @@ -1428,3 +1447,5 @@ mod probe_tests; mod eligibility_tests; mod services_tests; + +mod overlay_destinations; diff --git a/crates/p2p/src/addrman/tests/asmap_tests.rs b/crates/p2p/src/addrman/tests/asmap_tests.rs index f5c8abd80..1f8e91e60 100644 --- a/crates/p2p/src/addrman/tests/asmap_tests.rs +++ b/crates/p2p/src/addrman/tests/asmap_tests.rs @@ -47,11 +47,11 @@ fn asmap_placement_matches_224_actual_core_vectors() { let groups = &classifiers[row["map"].as_str().expect("map")]; let secret = <[u8; 32]>::from_hex(row["secret_raw_hex"].as_str().expect("secret")).expect("key"); - let addr = canonical(SocketAddr::new( + let addr = canonical(PeerEndpoint::new( row["address"] .as_str() .expect("address") - .parse() + .parse::() .expect("IP"), u16::try_from(row["port"].as_u64().expect("port")).expect("port"), )); @@ -61,7 +61,7 @@ fn asmap_placement_matches_224_actual_core_vectors() { Source::dns, ); assert_eq!( - groups.group(addr.ip()).to_lower_hex_string(), + groups.group(addr.address()).to_lower_hex_string(), row["address_group_hex"].as_str().expect("group"), "{row}" ); @@ -109,14 +109,14 @@ fn incoming_source_asn_controls_corroboration_without_rewriting_primary_source() <[u8; 32]>::from_hex("41f758f2e5cc078d3795b4fc0cb60c2d735fa92cc020572bdc982dd2d564d11b") .expect("Core secret"); manager.rng = StdRng::seed_from_u64(17); - let addr = "4.4.4.4:8333".parse().expect("peer"); - let primary = Source::Ip("8.8.0.1".parse().expect("source")); + let addr = "4.4.4.4:8333".parse::().expect("peer"); + let primary = Source::Ip("8.8.0.1".parse::().expect("source")); add_ref(&mut manager, addr, &primary, EPOCH - 1); for _ in 0..512 { assert!(!manager.learn( addr, Some(9), - Source::Ip("9.9.0.1".parse().expect("same ASN")), + Source::Ip("9.9.0.1".parse::().expect("same ASN")), EPOCH, EPOCH, 0 @@ -125,7 +125,11 @@ fn incoming_source_asn_controls_corroboration_without_rewriting_primary_source() add_ref( &mut manager, addr, - &Source::Ip("8.8.1.1".parse().expect("different ASN")), + &Source::Ip( + "8.8.1.1" + .parse::() + .expect("different ASN"), + ), EPOCH, ); let entry = &manager.stored.records[manager.by_addr[&addr]]; @@ -156,11 +160,11 @@ fn historical_v2_v3_writers_migrate_with_exact_backups_and_health() { assert_eq!(manager.stored.secret, [7; 32]); assert_eq!(manager.stored.records.len(), 2); let successful = &manager.stored.records - [manager.by_addr[&"8.8.8.8:8333".parse().expect("peer")]]; + [manager.by_addr[&"8.8.8.8:8333".parse::().expect("peer")]]; assert_eq!(successful.last_success, 1_700_000_001); assert_eq!(successful.last_seen, 1_700_000_001); let failed = &manager.stored.records - [manager.by_addr[&"9.9.9.9:8333".parse().expect("peer")]]; + [manager.by_addr[&"9.9.9.9:8333".parse::().expect("peer")]]; assert_eq!(failed.failures, 3); assert_eq!(failed.source, Source::LegacyDns(14_378_457_816_419_903_907)); assert!( @@ -204,7 +208,7 @@ fn unchanged_v5_prefix_upgrade_preserves_all_eight_references() { assert_eq!(manager.stored.records[0].last_seen, 1_699_992_800); assert_eq!( manager.stored.records[0].source, - Source::Ip("1.1.1.1".parse().expect("source")) + Source::Ip("1.1.1.1".parse::().expect("source")) ); assert_eq!(manager.stored.secret, [7; 32]); assert_indexes(&manager); diff --git a/crates/p2p/src/addrman/tests/eligibility_tests.rs b/crates/p2p/src/addrman/tests/eligibility_tests.rs index add393407..c14fc243f 100644 --- a/crates/p2p/src/addrman/tests/eligibility_tests.rs +++ b/crates/p2p/src/addrman/tests/eligibility_tests.rs @@ -17,7 +17,7 @@ fn ordinary_selection_uses_existing_service_contract_for_new_and_tried() { (1033, u64::MAX, true), ] { let book = book(); - book.learn_peer(addr(2).ip(), &[(target(), services, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), services, EPOCH)], EPOCH); if tried { book.succeeded(target(), services, EPOCH + 1); } @@ -42,7 +42,7 @@ fn ordinary_selection_uses_existing_service_contract_for_new_and_tried() { #[test] fn current_services_are_rechecked_after_unlocked_policy_callback() { let book = book(); - book.learn_peer(addr(2).ip(), &[(target(), 9, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), 9, EPOCH)], EPOCH); assert_eq!( book.select(&[], &[], EPOCH, u64::MAX, |endpoint| { assert!(book.state.try_lock().is_some()); @@ -63,7 +63,7 @@ fn current_services_are_rechecked_after_unlocked_policy_callback() { #[test] fn accepted_version_service_update_is_metadata_only_canonical_and_idempotent() { let book = book(); - book.learn_peer(addr(2).ip(), &[(target(), 9, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), 9, EPOCH)], EPOCH); book.attempted(target(), true, EPOCH + 1); let (before, revision, last_good) = { let manager = book.state.lock(); @@ -73,7 +73,7 @@ fn accepted_version_service_update_is_metadata_only_canonical_and_idempotent() { manager.last_good, ) }; - let mapped: SocketAddr = "[::ffff:8.8.8.8]:8333".parse().expect("alias"); + let mapped: PeerEndpoint = "[::ffff:8.8.8.8]:8333".parse().expect("alias"); book.set_services(mapped, 1); assert!(!book.ordinary_services_eligible(mapped, u64::MAX)); { @@ -118,7 +118,12 @@ fn only_unknown_dns_services_keep_native_bootstrap_exception() { for source in [ Source::dns("seed"), Source::LegacyDns(77), - Source::Ip(addr(2).ip()), + Source::Ip( + addr(2) + .address() + .ip() + .unwrap_or_else(|| panic!("expected IP fixture")), + ), ] { let directory = tempfile::tempdir().expect("directory"); let path = directory.path().join("peers.dat"); @@ -155,10 +160,10 @@ fn only_unknown_dns_services_keep_native_bootstrap_exception() { #[test] fn later_gossip_service_upgrade_uses_the_single_current_field() { let book = book(); - book.learn_peer(addr(2).ip(), &[(target(), 1, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), 1, EPOCH)], EPOCH); book.succeeded(target(), 1, EPOCH + 1); assert!(!book.ordinary_services_eligible(target(), u64::MAX)); - book.learn_peer(addr(3).ip(), &[(target(), 8, EPOCH + 2)], EPOCH + 2); + book.learn_peer(addr(3).address(), &[(target(), 8, EPOCH + 2)], EPOCH + 2); assert!(book.ordinary_services_eligible(target(), u64::MAX)); assert_eq!( book.select(&[], &[], EPOCH + 2, u64::MAX, |_| true), diff --git a/crates/p2p/src/addrman/tests/overlay_destinations.rs b/crates/p2p/src/addrman/tests/overlay_destinations.rs new file mode 100644 index 000000000..6abb60417 --- /dev/null +++ b/crates/p2p/src/addrman/tests/overlay_destinations.rs @@ -0,0 +1,92 @@ +use super::*; + +#[test] +fn overlay_endpoints_sources_and_anchors_survive_restart_and_addrv2_gossip() { + let directory = tempfile::tempdir().expect("directory"); + let base = directory.path().join("peers.dat"); + let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); + let onion = PeerEndpoint::new(crate::PeerAddress::Onion([1; 32]), 8333); + let i2p = PeerEndpoint::new(crate::PeerAddress::I2p([2; 32]), 8333); + book.learn_peer( + onion.address(), + &[(onion, 9, EPOCH), (i2p, 9, EPOCH)], + EPOCH, + ); + book.succeeded(onion, 9, EPOCH + 1); + book.succeeded(i2p, 9, EPOCH + 1); + book.remember_anchors(&[onion, i2p], EPOCH + 2); + let before = serde_json::to_value(&book.state.lock().stored).expect("state"); + let rows = book.gossip(EPOCH + 3); + assert!(rows.iter().any( + |row| matches!(row.addr, bitcoin::p2p::address::AddrV2::TorV3(key) if key == [1; 32]) + )); + assert!( + rows.iter().any( + |row| matches!(row.addr, bitcoin::p2p::address::AddrV2::I2p(key) if key == [2; 32]) + ) + ); + book.save(); + let restored = AddressBook::open(Some(base), [1; 4], false, None); + assert_eq!( + serde_json::to_value(&restored.state.lock().stored).expect("restored"), + before + ); + assert_ne!( + restored.netgroup(onion.address()), + restored.netgroup(i2p.address()) + ); + assert_eq!( + restored.select(&[onion], &[], EPOCH + 10, u64::MAX, |_| true), + Some(i2p) + ); + let count = restored.len(); + restored.learn_peer( + crate::PeerAddress::AnonymousTor(7), + &[(addr(99), 9, EPOCH)], + EPOCH, + ); + assert_eq!( + restored.len(), + count, + "anonymous circuits are not durable learning sources" + ); +} + +#[test] +fn original_v8_writer_migrates_with_exact_backup_and_no_ip_membership_changes() { + let bytes = include_bytes!("../../../tests/data/addrman-historical-v8-endpoints.dat"); + let directory = tempfile::tempdir().expect("directory"); + let base = directory.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + fs::write(&path, bytes).expect("original writer bytes"); + let original: serde_json::Value = + serde_json::from_slice(&bytes[..bytes.len() - 32]).expect("v8 JSON"); + assert_eq!(original["version"], 8); + let mut expected = original; + expected["version"] = VERSION.into(); + let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert!(book.state.lock().writable); + assert_eq!( + serde_json::to_value(&book.state.lock().stored).expect("migrated"), + expected + ); + assert_eq!(fs::read(&path).expect("source before save"), bytes); + let backup = path.with_extension(format!( + "v8-{}.bak", + Sha256::digest(bytes)[..].to_lower_hex_string() + )); + assert_eq!(fs::read(&backup).expect("backup before publication"), bytes); + book.save(); + let restored = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert_eq!( + serde_json::to_value(&restored.state.lock().stored).expect("reopened"), + expected + ); + // A conflicting backup must preserve the old file and make writes unavailable. + fs::write(&path, bytes).expect("restore v8 fixture"); + fs::write(&backup, b"damaged backup").expect("conflict"); + let blocked = AddressBook::open(Some(base), [1; 4], false, None); + assert!(!blocked.state.lock().writable); + blocked.save(); + assert_eq!(fs::read(path).expect("preserved v8 source"), bytes); +} diff --git a/crates/p2p/src/addrman/tests/probe_tests.rs b/crates/p2p/src/addrman/tests/probe_tests.rs index a71b309f9..3306e8ea9 100644 --- a/crates/p2p/src/addrman/tests/probe_tests.rs +++ b/crates/p2p/src/addrman/tests/probe_tests.rs @@ -1,6 +1,6 @@ use super::*; -fn challenger() -> SocketAddr { +fn challenger() -> PeerEndpoint { "8.8.8.8:412".parse().expect("actual Core collision") } fn collision_book() -> Arc { @@ -20,7 +20,7 @@ fn collision_book() -> Arc { } book } -fn state(book: &AddressBook, addr: SocketAddr) -> Candidate { +fn state(book: &AddressBook, addr: PeerEndpoint) -> Candidate { let manager = book.state.lock(); manager.stored.records[manager.by_addr[&addr]].clone() } @@ -60,7 +60,7 @@ fn collision_pending_active_policy_and_callback_bounds_recheck_current_state() { fn make_tried_clears_all_refs_demotes_and_removes_only_colliding_new_reference() { for victim_refs in [1, 2] { let book = collision_book(); - let victim: SocketAddr = "8.8.9.89:8333".parse().expect("actual Core New collider"); + let victim: PeerEndpoint = "8.8.9.89:8333".parse().expect("actual Core New collider"); { let mut manager = book.state.lock(); for n in 2..=8 { @@ -95,7 +95,7 @@ fn make_tried_clears_all_refs_demotes_and_removes_only_colliding_new_reference() #[test] fn pending_final_demotion_victim_defers_all_membership_and_good_health() { let book = collision_book(); - let victim: SocketAddr = "8.8.9.89:8333".parse().expect("collider"); + let victim: PeerEndpoint = "8.8.9.89:8333".parse().expect("collider"); { let mut manager = book.state.lock(); manager.learn(victim, Some(9), source(1), EPOCH - 1000, EPOCH, 0); @@ -121,14 +121,14 @@ fn pending_final_demotion_victim_defers_all_membership_and_good_health() { #[test] fn feeler_connected_incumbent_gets_good_but_pending_attempt_does_not() { let book = collision_book(); - let mapped: SocketAddr = "[::ffff:8.8.8.8]:8333".parse().expect("alias"); + let mapped: PeerEndpoint = "[::ffff:8.8.8.8]:8333".parse().expect("alias"); assert_eq!( book.feeler(&[mapped], &[mapped], EPOCH, |_| true), Some(challenger()) ); assert_eq!(state(&book, target()).last_success, EPOCH); book.resolve_collisions(&[], EPOCH + 1, |_| true); - assert_eq!(book.state.lock().collisions, Vec::::new()); + assert_eq!(book.state.lock().collisions, Vec::::new()); let book = collision_book(); book.queued(mapped); let before = state(&book, target()).last_success; @@ -150,11 +150,11 @@ fn feeler_connected_incumbent_gets_good_but_pending_attempt_does_not() { #[test] fn connected_collision_good_uses_ip_not_port_but_not_linked_ipv6_group() { let book = collision_book(); - let other_port: SocketAddr = "[::ffff:8.8.8.8]:18444".parse().expect("TCP alias"); + let other_port: PeerEndpoint = "[::ffff:8.8.8.8]:18444".parse().expect("TCP alias"); book.feeler(&[other_port], &[other_port], EPOCH, |_| true); assert_eq!(state(&book, target()).last_success, EPOCH); let book = collision_book(); - let linked: SocketAddr = "[2002:808:808::1]:8333".parse().expect("linked IPv6"); + let linked: PeerEndpoint = "[2002:808:808::1]:8333".parse().expect("linked IPv6"); assert_eq!( book.feeler(&[linked], &[linked], EPOCH, |_| true), Some(target()) @@ -181,7 +181,7 @@ fn actual_historical_anchor_and_v6_files_migrate_with_exact_backup() { let manager = book.state.lock(); assert!(manager.writable); assert_eq!(manager.stored.version, VERSION); - assert_eq!(manager.collisions, Vec::::new()); + assert_eq!(manager.collisions, Vec::::new()); if schema == 6 { assert_eq!(refs(&manager, target()), 8); } else { @@ -263,7 +263,7 @@ fn failed_anchor_consumption_exposes_no_claims_and_newer_confirmation_wins_retur fs::create_dir(&path).expect("failed replace"); assert_eq!( book.take_restart_anchors(EPOCH + 1), - Vec::::new() + Vec::::new() ); assert!(book.state.lock().pending.is_empty()); assert_eq!( @@ -384,7 +384,7 @@ fn assert_collision_snapshot(manager: &Manager, row: &serde_json::Value) { if entry.is_null() { continue; } - let addr: SocketAddr = entry["endpoint"] + let addr: PeerEndpoint = entry["endpoint"] .as_str() .expect("endpoint") .parse() @@ -492,7 +492,7 @@ fn collision_queue_counts_challengers_and_caps_ten_even_for_one_incumbent() { .take(11) .enumerate() { - let addr: SocketAddr = endpoint + let addr: PeerEndpoint = endpoint .as_str() .expect("endpoint") .parse() @@ -583,7 +583,7 @@ fn historical_anchor_migration_obeys_configured_map_and_unknown_shape_barriers() assert!(!invalid.state.lock().writable); assert_eq!( invalid.take_restart_anchors(EPOCH + 10), - Vec::::new() + Vec::::new() ); assert_eq!(invalid.state.lock().stored.anchors.len(), 2); invalid.save(); @@ -657,7 +657,7 @@ fn anchor_reservation_precedes_publication_and_failed_commit_rolls_it_back() { book.queued(target()); assert_eq!( book.take_restart_anchors(EPOCH + 3), - Vec::::new() + Vec::::new() ); assert_eq!( book.state.lock().stored.anchors, @@ -672,7 +672,7 @@ fn anchor_reservation_precedes_publication_and_failed_commit_rolls_it_back() { #[test] fn collision_resolution_uses_creation_order_not_reversed_good_order() { let book = oracle_book(); - let second: SocketAddr = "8.8.8.8:1343".parse().expect("second actual Core collider"); + let second: PeerEndpoint = "8.8.8.8:1343".parse().expect("second actual Core collider"); let mut manager = book.state.lock(); manager.learn(target(), Some(9), source(1), EPOCH - 1000, EPOCH, 0); manager.good(target(), true, EPOCH - 20_000); @@ -692,7 +692,7 @@ fn collision_resolution_uses_creation_order_not_reversed_good_order() { manager.resolve_collisions(&HashSet::new(), &allowed, EPOCH); assert!(manager.stored.records[manager.by_addr[&challenger()]].tried); assert!(!manager.stored.records[manager.by_addr[&second]].tried); - assert_eq!(manager.collisions, Vec::::new()); + assert_eq!(manager.collisions, Vec::::new()); assert_indexes(&manager); assert!( !serde_json::to_string(&manager.stored) @@ -710,7 +710,7 @@ fn feelers_use_advertised_address_database_services_without_inventing_dns_bits() book.select(&[], &[], EPOCH, u64::MAX, |_| true), Some(target()) ); - book.learn_peer(addr(2).ip(), &[(target(), 1, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), 1, EPOCH)], EPOCH); assert_eq!(book.feeler(&[], &[], EPOCH, |_| true), Some(target())); book.state.lock().stored.records[0].services = Some(1024); assert_eq!(book.feeler(&[], &[], EPOCH, |_| true), Some(target())); @@ -721,7 +721,7 @@ fn feelers_use_advertised_address_database_services_without_inventing_dns_bits() #[test] fn regular_queue_admission_never_takes_probe_or_anchor_claims() { let book = book(); - let alias: SocketAddr = "[::ffff:8.8.8.8]:8333".parse().expect("alias"); + let alias: PeerEndpoint = "[::ffff:8.8.8.8]:8333".parse().expect("alias"); assert!(book.queued_feeler(target())); assert!(!book.queued(alias)); assert!(book.is_feeler(target())); @@ -752,8 +752,8 @@ fn returned_anchor_releases_same_as_group_and_rejection_preserves_other_purposes .expect("Core map"); let book = AddressBook::open(None, [1; 4], false, Some(&map)); book.state.lock().stored.secret = [1; 32]; - let anchor: SocketAddr = "8.8.0.1:8333".parse().expect("anchor"); - let other: SocketAddr = "9.9.0.1:8333".parse().expect("same Core ASN"); + let anchor: PeerEndpoint = "8.8.0.1:8333".parse().expect("anchor"); + let other: PeerEndpoint = "9.9.0.1:8333".parse().expect("same Core ASN"); book.learn_dns("seed", &[anchor, other], EPOCH); assert_eq!(book.len(), 2); book.succeeded(anchor, 9, EPOCH); diff --git a/crates/p2p/src/addrman/tests/services_tests.rs b/crates/p2p/src/addrman/tests/services_tests.rs index 3fd63be2e..881c2846d 100644 --- a/crates/p2p/src/addrman/tests/services_tests.rs +++ b/crates/p2p/src/addrman/tests/services_tests.rs @@ -70,7 +70,7 @@ fn unknown_dns_zero_version_is_durable_metadata_without_good() { book.learn_dns("another-seed", &[target()], EPOCH + 7200); assert_eq!(book.state.lock().stored.records[0].services, Some(0)); book.save(); - assert_eq!(stored_value(&path)["version"], 8); + assert_eq!(stored_value(&path)["version"], VERSION); assert_eq!(stored_value(&path)["records"][0]["services"], 0); let restored = AddressBook::open(Some(base), [1; 4], false, None); assert_eq!(restored.state.lock().stored.records[0].services, Some(0)); @@ -86,15 +86,15 @@ fn incoming_known_gossip_and_unknown_dns_merge_without_losing_observation() { let book = book(); book.learn_dns("seed", &[target()], EPOCH); let revision = book.state.lock().revision; - book.learn_peer(addr(2).ip(), &[(target(), 0, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), 0, EPOCH)], EPOCH); assert_eq!(book.state.lock().stored.records[0].services, None); assert_eq!( book.state.lock().revision, revision, "zero-bit gossip and same-time ref refusal add no new information" ); - book.learn_peer(addr(3).ip(), &[(target(), 1, EPOCH)], EPOCH); - book.learn_peer(addr(4).ip(), &[(target(), 8, EPOCH)], EPOCH); + book.learn_peer(addr(3).address(), &[(target(), 1, EPOCH)], EPOCH); + book.learn_peer(addr(4).address(), &[(target(), 8, EPOCH)], EPOCH); assert_eq!(book.state.lock().stored.records[0].services, Some(9)); book.learn_dns("seed", &[target()], EPOCH + 7200); assert_eq!(book.state.lock().stored.records[0].services, Some(9)); @@ -107,13 +107,21 @@ fn incoming_known_gossip_and_unknown_dns_merge_without_losing_observation() { "unknown incoming advertisement still refreshes known peer time" ); let ip_origin = addr(5); - book.learn_peer(addr(6).ip(), &[(ip_origin, 9, EPOCH)], EPOCH); + book.learn_peer(addr(6).address(), &[(ip_origin, 9, EPOCH)], EPOCH); book.succeeded(ip_origin, 9, EPOCH + 1); book.learn_dns("seed", &[ip_origin], EPOCH + 14_400); let manager = book.state.lock(); let entry = &manager.stored.records[manager.by_addr[&ip_origin]]; assert_eq!(entry.services, Some(9)); - assert_eq!(entry.source, Source::Ip(addr(6).ip())); + assert_eq!( + entry.source, + Source::Ip( + addr(6) + .address() + .ip() + .unwrap_or_else(|| panic!("expected IP fixture")) + ) + ); assert_eq!(entry.last_seen, EPOCH + 14_400); } @@ -280,7 +288,7 @@ fn actual_v7_writer_migrates_ambiguous_zero_honestly_and_preserves_all_membershi assert_eq!(current[field], old[field], "{field}"); } for entry in old["records"].as_array().expect("records") { - let address: SocketAddr = entry["addr"] + let address: PeerEndpoint = entry["addr"] .as_str() .expect("address") .parse() @@ -334,7 +342,7 @@ fn actual_v7_writer_migrates_ambiguous_zero_honestly_and_preserves_all_membershi assert_eq!(fs::read(&source).expect("legacy source retained"), bytes); } let migrated = stored_value(&path); - assert_eq!(migrated["version"], 8); + assert_eq!(migrated["version"], VERSION); let restored = AddressBook::open( Some(base), [1; 4], @@ -399,7 +407,7 @@ fn v7_migration_failure_and_invalid_map_preserve_original_observation_bytes() { Some(8) ); restored.save(); - assert_eq!(stored_value(&path)["version"], 8); + assert_eq!(stored_value(&path)["version"], VERSION); } #[test] @@ -411,7 +419,7 @@ fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { book.state.lock().stored.secret = [1; 32]; book.learn_dns("seed", &[target()], EPOCH); let revision = book.state.lock().revision; - book.learn_peer(addr(2).ip(), &[(target(), 0, EPOCH)], EPOCH); + book.learn_peer(addr(2).address(), &[(target(), 0, EPOCH)], EPOCH); assert_eq!(book.state.lock().stored.records[0].services, None); assert_eq!(book.state.lock().revision, revision); assert_eq!( @@ -425,14 +433,22 @@ fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { ); let restored = AddressBook::open(Some(base.clone()), [1; 4], false, None); restored.learn_dns("seed", &[target()], EPOCH + 7200); - restored.learn_peer(addr(3).ip(), &[(target(), 0, EPOCH + 7200)], EPOCH + 7200); + restored.learn_peer( + addr(3).address(), + &[(target(), 0, EPOCH + 7200)], + EPOCH + 7200, + ); restored.save(); let restored = AddressBook::open(Some(base.clone()), [1; 4], false, None); assert!(restored.ordinary_services_eligible(target(), u64::MAX)); let revision = restored.state.lock().revision; restored.set_services(target(), 0); assert_eq!(restored.state.lock().revision, revision + 1); - restored.learn_peer(addr(3).ip(), &[(target(), 0, EPOCH + 7200)], EPOCH + 7200); + restored.learn_peer( + addr(3).address(), + &[(target(), 0, EPOCH + 7200)], + EPOCH + 7200, + ); restored.learn_dns("seed", &[target()], EPOCH + 14_400); assert_eq!(restored.state.lock().stored.records[0].services, Some(0)); assert!(!restored.ordinary_services_eligible(target(), u64::MAX)); @@ -441,7 +457,11 @@ fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { let restored = AddressBook::open(Some(base), [1; 4], false, None); assert!(!restored.ordinary_services_eligible(target(), u64::MAX)); let fresh = addr(9); - restored.learn_peer(addr(7).ip(), &[(fresh, 0, EPOCH + 14_400)], EPOCH + 14_400); + restored.learn_peer( + addr(7).address(), + &[(fresh, 0, EPOCH + 14_400)], + EPOCH + 14_400, + ); let manager = restored.state.lock(); assert_eq!( manager.stored.records[manager.by_addr[&fresh]].services, diff --git a/crates/p2p/src/block_announce.rs b/crates/p2p/src/block_announce.rs index 1f53f235e..f4d346b95 100644 --- a/crates/p2p/src/block_announce.rs +++ b/crates/p2p/src/block_announce.rs @@ -19,7 +19,7 @@ //! - Coalesces wakeups under queue saturation and rereads the committed tip from chainstate. //! - Disconnect cleanly removes peer announcement state. -use std::net::SocketAddr; +use bitcoin_rs_primitives::PeerEndpoint; use std::sync::Arc; use std::time::Duration; @@ -194,7 +194,7 @@ impl BlockAnnouncer { /// Returns the current list of peers we requested high-bandwidth compact blocks from. #[cfg(test)] - pub fn high_bandwidth_requested_peers(&self) -> Vec { + pub fn high_bandwidth_requested_peers(&self) -> Vec { let state = self.state.lock(); state .high_bandwidth_requested_peers @@ -205,7 +205,7 @@ impl BlockAnnouncer { /// Returns the best block known for `addr`. #[cfg(test)] - pub fn peer_known_block(&self, addr: SocketAddr) -> Option { + pub fn peer_known_block(&self, addr: PeerEndpoint) -> Option { let source = self.peers.lease(addr)?.source(addr); self.peers .announcement_state(source) @@ -234,7 +234,7 @@ impl BlockAnnouncer { &self, state: &mut AnnounceState, eligible: &[(PeerSource, u64)], - sent: &mut Vec<(SocketAddr, Message)>, + sent: &mut Vec<(PeerEndpoint, Message)>, ) { // BIP152 asks for high-bandwidth slots to go to peers that recently // delivered blocks quickly. Filling strictly in session order @@ -267,7 +267,7 @@ impl BlockAnnouncer { /// Reconciles the selected high-bandwidth compact-relay peers against /// current ready sessions and IBD state. - pub fn reconcile_high_bandwidth_peers(&self) -> Vec<(SocketAddr, Message)> { + pub fn reconcile_high_bandwidth_peers(&self) -> Vec<(PeerEndpoint, Message)> { let eligible: Vec<_> = self .peers .sessions() @@ -485,7 +485,7 @@ impl BlockAnnouncer { block: &Block, hash: Hash256, height: u32, - ) -> Vec<(SocketAddr, Message)> { + ) -> Vec<(PeerEndpoint, Message)> { if self.in_ibd() { return Vec::new(); } @@ -627,7 +627,7 @@ impl BlockAnnouncer { } /// Rereads and processes the current committed tip. - pub fn process_tip(&self) -> Vec<(SocketAddr, Message)> { + pub fn process_tip(&self) -> Vec<(PeerEndpoint, Message)> { self.reconcile_high_bandwidth_peers(); if self.in_ibd() { return Vec::new(); @@ -683,7 +683,7 @@ impl BlockAnnouncer { /// processed again as if it had not been seen: peers that already know it /// are skipped by the planner, and an `inv` fallback may repeat. Without /// one, nothing happens. - pub fn process_tip_retry(&self) -> Vec<(SocketAddr, Message)> { + pub fn process_tip_retry(&self) -> Vec<(PeerEndpoint, Message)> { { let mut state = self.state.lock(); if !std::mem::take(&mut state.announcement_retry) { @@ -902,11 +902,11 @@ mod tests { } } - fn test_addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn test_addr(port: u16) -> PeerEndpoint { + PeerEndpoint::from(([127, 0, 0, 1], port)) } - fn test_peer_info(addr: SocketAddr, send_headers: bool) -> PeerInfo { + fn test_peer_info(addr: PeerEndpoint, send_headers: bool) -> PeerInfo { PeerInfo { addr, version: 70016, @@ -920,7 +920,9 @@ mod tests { best_known_height: 0, conn_time: 0, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: Arc::new(crate::counters::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -930,7 +932,7 @@ mod tests { fn setup_peer( table: &Arc, announcer: &BlockAnnouncer, - addr: SocketAddr, + addr: PeerEndpoint, send_headers: bool, ) -> (PeerLease, crate::test_support::MessageReceiver) { let (tx, rx) = crate::test_support::message_channel(); @@ -943,17 +945,22 @@ mod tests { (lease, rx) } - fn source(table: &PeerTable, addr: SocketAddr) -> PeerSource { + fn source(table: &PeerTable, addr: PeerEndpoint) -> PeerSource { table.lease(addr).expect("live test peer").source(addr) } - fn mark_known(table: &PeerTable, _announcer: &BlockAnnouncer, addr: SocketAddr, hash: Hash256) { + fn mark_known( + table: &PeerTable, + _announcer: &BlockAnnouncer, + addr: PeerEndpoint, + hash: Hash256, + ) { table.note_announced_block(source(table, addr), hash); } fn note_compact_preference( table: &PeerTable, - addr: SocketAddr, + addr: PeerEndpoint, high_bandwidth: bool, version: u64, ) { diff --git a/crates/p2p/src/compact_blocks.rs b/crates/p2p/src/compact_blocks.rs index 9240cc105..d3617a1fb 100644 --- a/crates/p2p/src/compact_blocks.rs +++ b/crates/p2p/src/compact_blocks.rs @@ -1338,7 +1338,7 @@ mod tests { fn peer_source(connection_id: u64) -> bitcoin_rs_mempool::PeerToken { bitcoin_rs_mempool::PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id, } } diff --git a/crates/p2p/src/connection.rs b/crates/p2p/src/connection.rs index c57ae29b4..aaf5265f8 100644 --- a/crates/p2p/src/connection.rs +++ b/crates/p2p/src/connection.rs @@ -1,6 +1,6 @@ //! Per-connection identity and cancellation. -use std::net::SocketAddr; +use bitcoin_rs_primitives::PeerEndpoint; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; @@ -42,8 +42,8 @@ impl ConnectionId { /// Attribution token for an event delivered by one connection. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct PeerSource { - /// Remote socket address at delivery time. - pub(crate) addr: SocketAddr, + /// Logical remote Bitcoin endpoint at delivery time. + pub(crate) addr: PeerEndpoint, connection_id: ConnectionId, } @@ -56,7 +56,7 @@ impl PeerSource { /// Mints a source with a fresh process-unique identity for tests. #[cfg(test)] - pub(crate) fn for_test(addr: SocketAddr) -> Self { + pub(crate) fn for_test(addr: PeerEndpoint) -> Self { Self { addr, connection_id: ConnectionId::allocate(), @@ -710,6 +710,7 @@ pub struct PeerLease { id: ConnectionId, outbound: Sender, cancel: Arc, + transport_alive: Option>, close_tx: Sender<()>, close_rx: Receiver<()>, budget: Arc, @@ -722,7 +723,7 @@ pub struct PeerLease { /// Whether the operator pinned this dial by hand (`--connect` or /// `addnode`). Core: `ConnectionType::MANUAL`. manual: bool, - /// Granted only from operator configuration using the actual socket IP. + /// Granted only when the logical remote IP matches operator configuration. no_ban: bool, /// Monotonic instant the connection was created. connected: Instant, @@ -816,6 +817,7 @@ impl PeerLease { id: ConnectionId::allocate(), outbound, cancel: Arc::new(AtomicBool::new(false)), + transport_alive: None, close_tx, close_rx, budget: Arc::new(budget), @@ -844,6 +846,11 @@ impl PeerLease { ) } + pub(crate) fn with_transport_alive(mut self, alive: Option>) -> Self { + self.transport_alive = alive; + self + } + pub(crate) fn with_no_ban(mut self, no_ban: bool) -> Self { self.no_ban = no_ban; self @@ -1000,7 +1007,7 @@ impl PeerLease { /// Stamps an inbound event with this connection's identity and address. #[must_use] - pub fn source(&self, addr: SocketAddr) -> PeerSource { + pub fn source(&self, addr: PeerEndpoint) -> PeerSource { PeerSource { addr, connection_id: self.id, @@ -1136,6 +1143,10 @@ impl PeerLease { #[must_use] pub fn is_cancelled(&self) -> bool { self.cancel.load(Ordering::Acquire) + || self + .transport_alive + .as_ref() + .is_some_and(|alive| !alive.load(Ordering::Acquire)) } /// The flag [`Self::cancel`] sets, for a socket wrapper to observe. @@ -1150,8 +1161,8 @@ mod tests { OutboundBudget, OutboundCredit, OutboundRefusal, PeerLease, QueuedFrame, Refusal, SharedOutboundBudget, }; + use bitcoin_rs_primitives::PeerEndpoint; use crossbeam_channel::Receiver; - use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -1200,7 +1211,7 @@ mod tests { (PeerLease::new(tx).with_shared_budget(shared), rx) } - fn peer_info(addr: SocketAddr, conn_time: u64) -> crate::PeerInfo { + fn peer_info(addr: PeerEndpoint, conn_time: u64) -> crate::PeerInfo { crate::PeerInfo { addr, version: 70_016, @@ -1214,7 +1225,9 @@ mod tests { best_known_height: 0, conn_time, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -1266,7 +1279,7 @@ mod tests { #[test] fn stale_connection_cannot_publish_over_replacement() { let table = crate::PeerTable::new(); - let addr = SocketAddr::from(([127, 0, 0, 1], 18_450)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], 18_450)); let (old_tx, _old_rx) = crossbeam_channel::unbounded(); let old = PeerLease::new(old_tx); assert!(!table.register(addr, old.clone())); @@ -1294,7 +1307,7 @@ mod tests { #[test] fn stale_disconnect_source_preserves_replacement() { let table = crate::PeerTable::new(); - let addr = SocketAddr::from(([127, 0, 0, 1], 18_451)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], 18_451)); let (old_tx, _old_rx) = crossbeam_channel::unbounded(); let old = PeerLease::new(old_tx); table.register(addr, old.clone()); @@ -1319,7 +1332,7 @@ mod tests { #[test] fn ready_snapshot_keeps_identity_across_same_address_replacement() { let table = crate::PeerTable::new(); - let addr = SocketAddr::from(([127, 0, 0, 1], 18_452)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], 18_452)); let (old_tx, _old_rx) = crossbeam_channel::unbounded(); let old = PeerLease::new(old_tx); table.register(addr, old.clone()); diff --git a/crates/p2p/src/discouragement.rs b/crates/p2p/src/discouragement.rs index e6cf18fdf..dd2130873 100644 --- a/crates/p2p/src/discouragement.rs +++ b/crates/p2p/src/discouragement.rs @@ -1,20 +1,21 @@ -//! Bounded, process-local avoidance of protocol-invalid remote IP addresses. +//! Bounded, process-local avoidance of protocol-invalid remote peer addresses. +use bitcoin_rs_primitives::PeerAddress; use std::collections::VecDeque; use std::net::IpAddr; use std::time::{Duration, Instant, SystemTime}; use hashbrown::{HashMap, HashSet}; -/// Maximum exact IPs retained independently of the auxiliary address book. +/// Maximum exact peer addresses retained independently of the auxiliary address book. pub const MAX_DISCOURAGED_IPS: usize = 4_096; /// A single violation temporarily avoids automatic reconnection for one hour. pub const DISCOURAGEMENT_LIFETIME: Duration = Duration::from_hours(1); #[derive(Debug, Default)] pub(crate) struct DiscouragedPeers { - deadlines: HashMap, - oldest: VecDeque, + deadlines: HashMap, + oldest: VecDeque, } impl DiscouragedPeers { @@ -30,9 +31,9 @@ impl DiscouragedPeers { } } - pub(crate) fn insert(&mut self, ip: IpAddr, now: Instant) { + pub(crate) fn insert(&mut self, ip: impl Into, now: Instant) { self.expire(now); - let ip = ip.to_canonical(); + let ip = ip.into().to_canonical(); if self.deadlines.contains_key(&ip) { return; } @@ -48,12 +49,12 @@ impl DiscouragedPeers { self.oldest.push_back(ip); } - pub(crate) fn contains(&mut self, ip: IpAddr, now: Instant) -> bool { + pub(crate) fn contains(&mut self, ip: impl Into, now: Instant) -> bool { self.expire(now); - self.deadlines.contains_key(&ip.to_canonical()) + self.deadlines.contains_key(&ip.into().to_canonical()) } - pub(crate) fn snapshot(&mut self, now: Instant) -> HashSet { + pub(crate) fn snapshot(&mut self, now: Instant) -> HashSet { self.expire(now); self.deadlines.keys().copied().collect() } @@ -63,24 +64,33 @@ impl DiscouragedPeers { #[derive(Clone, Debug)] pub struct AutomaticPeerPolicy { pub(crate) banned: Vec, - pub(crate) discouraged: HashSet, + pub(crate) discouraged: HashSet, pub(crate) protected: std::sync::Arc<[crate::IpSubnet]>, pub(crate) now: SystemTime, + pub(crate) routing: std::sync::Arc, } impl AutomaticPeerPolicy { /// Explicit operator bans take precedence over automatic protection. #[must_use] - pub fn allows(&self, ip: IpAddr) -> bool { - let ip = ip.to_canonical(); - !crate::subnet::is_banned(&self.banned, ip, self.now) + pub fn allows(&self, ip: impl Into) -> bool { + let ip = ip.into().to_canonical(); + self.routing.allows(ip.network()) + && !ip + .ip() + .is_some_and(|ip| crate::subnet::is_banned(&self.banned, ip, self.now)) && (!self.discouraged.contains(&ip) - || self.protected.iter().any(|subnet| subnet.contains(ip))) + || ip + .ip() + .is_some_and(|ip| self.protected.iter().any(|subnet| subnet.contains(ip)))) } } /// Core `CNetAddr::IsLocal`: IPv4 0/8, 127/8 and IPv6 loopback. -pub(crate) fn is_local(ip: IpAddr) -> bool { +pub(crate) fn is_local(address: impl Into) -> bool { + let Some(ip) = address.into().ip() else { + return false; + }; match ip.to_canonical() { IpAddr::V4(ip) => matches!(ip.octets()[0], 0 | 127), IpAddr::V6(ip) => ip.is_loopback(), @@ -120,8 +130,8 @@ mod tests { -> Result<(), Box> { let now = Instant::now(); let mut entries = DiscouragedPeers::default(); - entries.insert("::ffff:8.8.8.8".parse()?, now); - assert!(entries.contains("8.8.8.8".parse()?, now)); + entries.insert("::ffff:8.8.8.8".parse::()?, now); + assert!(entries.contains("8.8.8.8".parse::()?, now)); for ip in [ "127.0.0.1", "127.4.3.2", @@ -129,10 +139,10 @@ mod tests { "::1", "::ffff:127.0.0.1", ] { - assert!(is_local(ip.parse()?)); + assert!(is_local(ip.parse::()?)); } for ip in ["8.8.8.8", "10.1.2.3", "::", "2001:4860::1"] { - assert!(!is_local(ip.parse()?)); + assert!(!is_local(ip.parse::()?)); } Ok(()) } diff --git a/crates/p2p/src/dispatch.rs b/crates/p2p/src/dispatch.rs index cd719a4fa..88a23dd6f 100644 --- a/crates/p2p/src/dispatch.rs +++ b/crates/p2p/src/dispatch.rs @@ -2042,7 +2042,7 @@ mod tests { assert_ne!(txid.as_byte_array(), wtxid.as_byte_array()); if orphan { let source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id: 1, }; assert!(matches!( diff --git a/crates/p2p/src/download_window.rs b/crates/p2p/src/download_window.rs index 4d0622742..1607f2706 100644 --- a/crates/p2p/src/download_window.rs +++ b/crates/p2p/src/download_window.rs @@ -5,7 +5,7 @@ //! to manage the in-flight window budget. `crate::BlockStager` owns the //! matching inbound staging set. The node sync coordinator drives these //! types; it does not own the policy. -use std::net::SocketAddr; +use bitcoin_rs_primitives::PeerEndpoint; use std::time::{Duration, Instant}; use bitcoin_rs_chain::{BlockTree, TipSnapshot}; @@ -381,7 +381,7 @@ pub(crate) struct DownloadWindow { /// time. While inside `staller_cooldown` such a peer is not fan-out /// eligible and receives no block requests except as the last-resort peer. /// This prevents immediate re-acquisition of the same block stripe. - recent_stallers: HashMap, + recent_stallers: HashMap, } impl DownloadWindow { @@ -1153,7 +1153,7 @@ impl DownloadWindow { /// peer delivering each front block just under the adaptive threshold is /// never disconnected (same exposure as Core) but is visible here and on /// the `node.sync.stall_seconds` gauge. - pub(crate) fn stalling_peer(&self) -> Option<(SocketAddr, Instant)> { + pub(crate) fn stalling_peer(&self) -> Option<(PeerEndpoint, Instant)> { self.stall .map(|episode| (episode.owner.addr, episode.since)) } @@ -1342,7 +1342,7 @@ impl DownloadWindow { } /// Starts the re-acquisition cooldown for an unresponsive peer. - pub(crate) fn mark_peer_unresponsive(&mut self, peer_addr: SocketAddr, now: Instant) { + pub(crate) fn mark_peer_unresponsive(&mut self, peer_addr: PeerEndpoint, now: Instant) { let cooldown = self.budget.staller_cooldown; self.recent_stallers .retain(|_, fired_at| now.duration_since(*fired_at) < cooldown); @@ -1351,7 +1351,7 @@ impl DownloadWindow { /// Whether `peer_addr` is in cooldown; such peers are a last resort so a /// reconnecting staller cannot immediately reacquire the window front. - pub(crate) fn peer_in_staller_cooldown(&self, peer_addr: SocketAddr, now: Instant) -> bool { + pub(crate) fn peer_in_staller_cooldown(&self, peer_addr: PeerEndpoint, now: Instant) -> bool { self.recent_stallers .get(&peer_addr) .is_some_and(|fired_at| now.duration_since(*fired_at) < self.budget.staller_cooldown) @@ -2390,7 +2390,7 @@ impl DownloadWindow { /// POST: returns the number of distinct `pending.owner.addr` values. /// INVARIANT: each address counts once, however many requests it owns. fn owner_address_count(&self) -> usize { - let mut owners: SmallVec<[SocketAddr; 16]> = SmallVec::new(); + let mut owners: SmallVec<[PeerEndpoint; 16]> = SmallVec::new(); for pending in self.pending.values() { if !owners.contains(&pending.owner.addr) { owners.push(pending.owner.addr); diff --git a/crates/p2p/src/download_window/tests.rs b/crates/p2p/src/download_window/tests.rs index 9ab09a4ed..5a8018299 100644 --- a/crates/p2p/src/download_window/tests.rs +++ b/crates/p2p/src/download_window/tests.rs @@ -124,7 +124,7 @@ fn apply_staged(stager: &mut BlockStager, hash: &Hash256) { stager.retire_applied(hash); } -fn test_source(addr: std::net::SocketAddr) -> PeerSource { +fn test_source(addr: bitcoin_rs_primitives::PeerEndpoint) -> PeerSource { PeerSource::for_test(addr) } @@ -135,7 +135,7 @@ fn stall_owner( next_apply_height: u32, apply_side_busy: bool, now: Instant, -) -> Option { +) -> Option { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -149,7 +149,7 @@ fn timeout_owner( window: &mut DownloadWindow, apply_side_busy: bool, now: Instant, -) -> Option { +) -> Option { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -165,7 +165,7 @@ fn cold_front_owner( next_apply_height: u32, apply_side_busy: bool, now: Instant, -) -> Option<(std::net::SocketAddr, Hash256)> { +) -> Option<(bitcoin_rs_primitives::PeerEndpoint, Hash256)> { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -214,7 +214,10 @@ fn request_peer_scan_limit_accounts_for_pending_bytes_and_inflight_peers() { }); let stager = test_stager(&window); let now = Instant::now(); - let owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); insert_pending(&mut window, owner, hash(1), 1, now); insert_pending(&mut window, owner, hash(2), 2, now); window.pending_bytes = 256 * 1024; @@ -236,7 +239,7 @@ fn request_peer_scan_limit_counts_expired_pending_capacity() { ); let stager = test_stager(&window); let now = Instant::now(); - let peer_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); let owner = test_source(peer_addr); for (byte, height) in [(1, 1_u32), (2, 2)] { window.pending.insert( @@ -262,7 +265,7 @@ fn pending_timeout_waits_for_second_delivery_drain() { let mut stager = test_stager(&window); let requested_at = Instant::now(); let observed_at = requested_at + Duration::from_secs(10); - let peer_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); let block_hash = hash(0x90); let owner = test_source(peer_addr); window.pending.insert( @@ -319,8 +322,8 @@ fn retry_delivery_resolves_original_peer_timeout_without_blame() { DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let requested_at = Instant::now(); let observed_at = requested_at + Duration::from_secs(10); - let original_peer = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); - let retry_peer = std::net::SocketAddr::from(([127, 0, 0, 2], 8333)); + let original_peer = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let retry_peer = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 2], 8333)); let block_hash = hash(0x91); let original_owner = test_source(original_peer); window.pending.insert( @@ -355,7 +358,10 @@ fn batched_non_front_deliveries_do_not_postpone_the_owner_timeout() { let mut window = DownloadWindow::new(test_budget()); let stager = test_stager(&window); let now = Instant::now(); - let owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); let front = hash(0xe5); let first = super::non_empty_request( owner, @@ -412,7 +418,10 @@ fn tied_removal_never_rewinds_the_owner_queue_clock() { let mut window = DownloadWindow::new(test_budget()); let stager = test_stager(&window); let t0 = Instant::now(); - let owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); let batch_a = super::non_empty_request( owner, vec![ @@ -467,7 +476,7 @@ fn requeue_of_the_observed_block_clears_the_suspicion_without_blame() { DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let requested_at = Instant::now(); let observed_at = requested_at + Duration::from_secs(10); - let peer_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); let block_hash = hash(0x93); let owner = insert_pending( &mut window, @@ -510,8 +519,14 @@ fn releasing_a_dead_owner_drops_its_queue_start() { let mut window = DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let now = Instant::now(); - let stale_owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); - let live_owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 2], 8333))); + let stale_owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); + let live_owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 2], + 8333, + ))); let stale_requested_at = now .checked_sub(Duration::from_secs(9)) .unwrap_or_else(|| panic!("test instant underflow")); @@ -553,7 +568,10 @@ fn receiving_the_oldest_pending_resets_the_owner_queue_start() { DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let mut stager = test_stager(&window); let now = Instant::now(); - let owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); let earliest = hash(0x91); let later = hash(0x92); let earliest_requested_at = now @@ -610,7 +628,10 @@ fn slow_peer_timeout_uses_spacing_and_other_downloaders() { pending_timeout_override: None, ..test_budget() }); - let solo = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let solo = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); insert_pending(&mut window, solo, hash(0xb1), 1, requested_at); assert_eq!( timeout_owner( @@ -638,7 +659,10 @@ fn slow_peer_timeout_uses_spacing_and_other_downloaders() { }); let owners: Vec = (1..=3_u8) .map(|byte| { - let owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 20 + byte], 8333))); + let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 20 + byte], + 8333, + ))); insert_pending( &mut window, owner, @@ -682,7 +706,10 @@ fn pending_timeout_override_wins_over_spacing_policy() { .with_pending_timeout_override(Duration::from_secs(5)), ); let requested_at = Instant::now(); - let owner = test_source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); + let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))); insert_pending(&mut window, owner, hash(0xd1), 1, requested_at); assert_eq!( timeout_owner(&mut window, false, requested_at + Duration::from_secs(5)), @@ -696,7 +723,7 @@ fn retire_applied_leaves_pending_accounting_untouched() { let mut window = DownloadWindow::new(test_budget()); let mut stager = test_stager(&window); let now = Instant::now(); - let peer_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); let applied = hash(0xa1); let pending = hash(0xa2); let pending_bytes = 256 * 1024; @@ -856,7 +883,7 @@ fn expired_pendings_reopen_scan_limit_through_count_headroom() { ); let mut stager = test_stager(&window); let now = Instant::now(); - let peer_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); let owner = test_source(peer_addr); for (byte, height) in [(0xe1, 1_u32), (0xe2, 2)] { window.pending.insert( @@ -951,7 +978,7 @@ fn insert_pending( /// immediately. fn seed_front_cadence( window: &mut DownloadWindow, - peer: std::net::SocketAddr, + peer: bitcoin_rs_primitives::PeerEndpoint, t0: Instant, gap: Duration, ) -> Instant { @@ -974,8 +1001,8 @@ fn seed_front_cadence( /// `healthy` delivered heights 4..=6, leaving zero staged-count headroom — /// every stall-predicate term holds. fn window_blocked_on_staller( - staller: std::net::SocketAddr, - healthy: std::net::SocketAddr, + staller: bitcoin_rs_primitives::PeerEndpoint, + healthy: bitcoin_rs_primitives::PeerEndpoint, t0: Instant, ) -> (DownloadWindow, BlockStager, Instant) { let mut window = DownloadWindow::new(stall_budget()); @@ -990,12 +1017,12 @@ fn window_blocked_on_staller( (window, stager, t1) } -fn staller_addr() -> std::net::SocketAddr { - std::net::SocketAddr::from(([127, 0, 0, 1], 8333)) +fn staller_addr() -> bitcoin_rs_primitives::PeerEndpoint { + bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)) } -fn healthy_addr() -> std::net::SocketAddr { - std::net::SocketAddr::from(([127, 0, 0, 2], 8333)) +fn healthy_addr() -> bitcoin_rs_primitives::PeerEndpoint { + bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 2], 8333)) } #[test] @@ -1856,8 +1883,8 @@ fn at_most_one_action_per_tick_blame_outranks_hedge() { ); } -fn peer_addr(idx: u8) -> std::net::SocketAddr { - std::net::SocketAddr::from(([10, 0, 0, idx], 8333)) +fn peer_addr(idx: u8) -> bitcoin_rs_primitives::PeerEndpoint { + bitcoin_rs_primitives::PeerEndpoint::from(([10, 0, 0, idx], 8333)) } #[test] @@ -2191,7 +2218,7 @@ fn limit_cycle_window_state() -> ( BlockStager, u8, Instant, - std::net::SocketAddr, + bitcoin_rs_primitives::PeerEndpoint, ) { let mut window = DownloadWindow::new(stall_budget()); let healthy = test_source(healthy_addr()); @@ -2818,7 +2845,7 @@ fn stale_prefix_confirmation_after_release_does_not_install_probe_or_gate() #[test] fn release_of_a_dead_connection_clears_state_but_preserves_cooldown() { let mut window = DownloadWindow::new(test_budget()); - let peer_addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); let now = Instant::now(); let source = test_source(peer_addr); insert_pending(&mut window, source, hash(1), 1, now); @@ -3316,12 +3343,13 @@ fn owned_fetch_respects_window_capacity_and_frontier() { #[test] fn fanout_eligibility_requires_block_service_flags() { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{IpAddr, Ipv4Addr}; use crate::{PeerCounters, PeerInfo}; fn peer(services: u64, inbound: bool) -> PeerInfo { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); PeerInfo { addr, version: 70_016, @@ -3335,7 +3363,9 @@ fn fanout_eligibility_requires_block_service_flags() { best_known_height: 0, conn_time: 0, inbound, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(PeerCounters::default()), transport: crate::TransportProtocol::V1, diff --git a/crates/p2p/src/eviction.rs b/crates/p2p/src/eviction.rs index 4e30123b9..85c89a256 100644 --- a/crates/p2p/src/eviction.rs +++ b/crates/p2p/src/eviction.rs @@ -7,8 +7,8 @@ //! never loaded here. Core prefers peers it accepted while discouraged, and //! bitcoin-rs refuses a discouraged address at accept. +use bitcoin_rs_primitives::PeerEndpoint; use std::cmp::Ordering; -use std::net::SocketAddr; use std::time::Instant; use hashbrown::HashMap; @@ -17,7 +17,7 @@ use hashbrown::HashMap; /// `NodeEvictionCandidate` without the fields of the absent steps. #[derive(Clone, Copy, Debug)] pub(crate) struct EvictionCandidate { - pub(crate) addr: SocketAddr, + pub(crate) addr: PeerEndpoint, pub(crate) connected: Instant, /// Recency of the last block from this connection the node committed; /// 0 when there is none (Core `m_last_block_time`). @@ -45,7 +45,7 @@ pub(crate) struct EvictionCandidate { /// unprotected candidates. pub(crate) fn select_inbound_to_evict( mut candidates: Vec, -) -> Option { +) -> Option { // Core's `std::sort` leaves ties unordered; stable sorts from a // newest-first start make them deterministic. candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.connected)); @@ -138,7 +138,7 @@ fn protect_by_ratio(candidates: &mut Vec) { /// candidates, a size tie going to the group whose newest member is newer. /// /// PRE: `candidates` is sorted newest first. -fn newest_in_largest_netgroup(candidates: &[EvictionCandidate]) -> Option { +fn newest_in_largest_netgroup(candidates: &[EvictionCandidate]) -> Option { let mut groups: HashMap = HashMap::new(); let mut chosen: Option<(usize, &EvictionCandidate)> = None; for candidate in candidates { @@ -165,7 +165,7 @@ mod tests { /// A plain candidate: `order` sets its connection age, higher is newer. fn candidate(base: Instant, order: u16, keyed_netgroup: u64) -> EvictionCandidate { EvictionCandidate { - addr: SocketAddr::from(([203, 0, 113, 1], order)), + addr: PeerEndpoint::from(([203, 0, 113, 1], order)), connected: base + Duration::from_secs(u64::from(order)), last_block: 0, last_tx: 0, @@ -181,8 +181,8 @@ mod tests { (0..40).map(|order| candidate(base, order, 7)).collect() } - fn port(selected: Option) -> Option { - selected.map(|addr| addr.port()) + fn port(selected: Option) -> Option { + selected.map(bitcoin_rs_primitives::PeerEndpoint::port) } #[test] diff --git a/crates/p2p/src/inv.rs b/crates/p2p/src/inv.rs index ec8ad37f1..8cbbec755 100644 --- a/crates/p2p/src/inv.rs +++ b/crates/p2p/src/inv.rs @@ -118,7 +118,7 @@ pub(crate) const fn is_within_inventory_bound(items: &[Inventory]) -> bool { #[cfg(test)] mod tests { - use std::net::SocketAddr; + use bitcoin_rs_primitives::PeerEndpoint; use bitcoin::hashes::Hash as _; @@ -131,7 +131,7 @@ mod tests { fn source(lease: &PeerLease) -> PeerToken { lease - .source(SocketAddr::from(([127, 0, 0, 1], 8333))) + .source(PeerEndpoint::from(([127, 0, 0, 1], 8333))) .into() } @@ -183,7 +183,10 @@ mod tests { }; let mut info = crate::PeerInfo::inbound_from_version( source.addr, - source.addr, + source + .addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, diff --git a/crates/p2p/src/lib.rs b/crates/p2p/src/lib.rs index 178200a45..7cb279e86 100644 --- a/crates/p2p/src/lib.rs +++ b/crates/p2p/src/lib.rs @@ -36,6 +36,9 @@ pub mod inv; /// Inbound accept loop, outbound dial, and their shared start-epoch wiring. pub mod listener; pub(crate) mod netgroup; +/// Opt-in Tor and IVNP I2P transport configuration. +pub mod overlay; +pub use overlay::{OverlayConfig, OverlayNetworkState}; /// Bitcoin Core `net:*` tracepoint payload mapping. mod net_trace; @@ -101,3 +104,5 @@ pub use download_window::default_sync_budget; /// Bounded automatic reconnect avoidance; manual bans retain their owner. pub mod discouragement; + +pub use bitcoin_rs_primitives::{PeerAddress, PeerEndpoint, PeerNetwork}; diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index cf1ca3220..5bd86231a 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -1,5 +1,6 @@ +use bitcoin_rs_primitives::PeerEndpoint; use std::io; -use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant, SystemTime}; @@ -160,6 +161,7 @@ pub struct ConnectionShared { /// `NODE_P2P_V2`, falling back to v1 once when unanswered. `false` keeps /// every connection on v1. pub v2_transport: bool, + pub(crate) overlay: Arc, /// Aggregate outbound queued-byte budget every connection of this start /// epoch counts against. The P2P service owns the `Arc`; clones of this /// struct share it, so inbound, outbound, and manual connections of one @@ -229,6 +231,9 @@ impl ConnectionShared { max_inbound: crate::service::P2pServiceConfig::default().max_inbound(), local_services: ServiceFlags::NETWORK | ServiceFlags::WITNESS, v2_transport: false, + overlay: Arc::new(crate::overlay::OverlayTransport::new( + crate::OverlayConfig::default(), + )), shared_outbound: Arc::new(crate::connection::SharedOutboundBudget::new()), } } @@ -245,19 +250,25 @@ impl ConnectionShared { self } - fn check_destination(&self, addr: SocketAddr, manual: bool) -> Result<(), crate::PeerError> { - if self.banned.is_banned(addr.ip(), SystemTime::now()) { - return Err(crate::PeerError::BannedDestination(addr.ip())); + fn check_destination(&self, addr: PeerEndpoint, manual: bool) -> Result<(), crate::PeerError> { + if !self.overlay.config.allows(addr.address().network()) { + return Err(crate::PeerError::Io(io::Error::new( + io::ErrorKind::PermissionDenied, + "destination network disabled", + ))); + } + if self.banned.is_banned(addr.address(), SystemTime::now()) { + return Err(crate::PeerError::BannedDestination(addr.address())); } - if !manual && self.banned.is_discouraged(addr.ip()) { - return Err(crate::PeerError::DiscouragedDestination(addr.ip())); + if !manual && self.banned.is_discouraged(addr.address()) { + return Err(crate::PeerError::DiscouragedDestination(addr.address())); } Ok(()) } fn note_misbehavior( &self, - addr: SocketAddr, + addr: PeerEndpoint, lease: &crate::PeerLease, error: &crate::PeerError, ) { @@ -265,10 +276,10 @@ impl ConnectionShared { return; } let protected = lease.is_protected(); - let local = crate::discouragement::is_local(addr.ip()); + let local = crate::discouragement::is_local(addr.address()); let current = self.peer_table.with_current(lease.source(addr), || { if !protected && !local { - self.banned.discourage(addr.ip()); + self.banned.discourage(addr.address()); } }); if current { @@ -311,7 +322,7 @@ impl ConnectionShared { /// and stale-predecessor rules are owned by P2P-02. fn publish_info_and_notify_ready( &self, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, lease: &crate::PeerLease, info: &crate::PeerInfo, ) -> bool { @@ -334,7 +345,7 @@ impl ConnectionShared { } } - fn is_session_cancelled(&self) -> bool { + pub(crate) fn is_session_cancelled(&self) -> bool { self.session_cancel.load() } @@ -379,7 +390,7 @@ impl ConnectionShared { fn send_block( &self, lease: &crate::PeerLease, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, block: bitcoin_rs_primitives::Block, serialized: bytes::Bytes, ) { @@ -540,7 +551,7 @@ pub enum ListenerError { #[error("bind {addr}: {source}")] Bind { /// Address the listener attempted to bind. - addr: SocketAddr, + addr: std::net::SocketAddr, /// Underlying bind or listener setup failure. source: io::Error, }, @@ -557,7 +568,7 @@ pub enum ListenerError { /// PRE: `addr` identifies the requested local endpoint. /// POST: Return a nonblocking listener or [`ListenerError::Bind`]. /// INVARIANT: Bind before starting listener workers. -pub fn bind_listener(addr: SocketAddr) -> Result { +pub fn bind_listener(addr: std::net::SocketAddr) -> Result { let listener = TcpListener::bind(addr).map_err(|source| ListenerError::Bind { addr, source })?; listener @@ -610,7 +621,7 @@ pub fn serve( } fn accept_connections( - addr: SocketAddr, + addr: std::net::SocketAddr, listener: &TcpListener, shutdown: &AtomicBool, shared: &ConnectionShared, @@ -625,45 +636,9 @@ fn accept_connections( } match listener.accept() { Ok((stream, peer_addr)) => { + let peer_addr = PeerEndpoint::from(peer_addr); accept_backoff = POLL_INTERVAL; - if shared.check_destination(peer_addr, false).is_err() { - drop(stream); - tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected by address policy"); - continue; - } - if !shared.activity.is_active() { - drop(stream); - tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected: network inactive"); - continue; - } - // Reserve the inbound slot atomically with the capacity - // test and any eviction before any thread exists: a separate - // count followed by an unconstrained spawn races concurrent - // accepts. Registration stays identity-checked, so the - // reservation itself is the handshaking-peer accounting - // Core's connman performs (`net.cpp:1819-1826`). - let (outbound_tx, outbound_rx) = - crossbeam_channel::unbounded::(); - let lease = crate::PeerLease::new_inbound(outbound_tx) - .with_shared_budget(&shared.shared_outbound) - .with_no_ban(shared.banned.is_noban(peer_addr.ip())); - let netgroup = shared.address_book.as_ref().map_or_else( - || crate::netgroup::NetGroups::default().group(peer_addr.ip()), - |book| book.netgroup(peer_addr.ip()), - ); - let Some(lease) = shared.peer_table.try_register_inbound( - peer_addr, - lease, - &netgroup, - shared.max_inbound, - approximate_best_block_depth(shared.chain_query.as_deref()), - &shared.activity, - ) else { - drop(stream); - tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected: inactive or at capacity"); - continue; - }; - spawn_handshake_thread(stream, peer_addr, shared.clone(), lease, outbound_rx); + admit_inbound(stream, peer_addr, shared); } Err(error) if error.kind() == io::ErrorKind::WouldBlock => { std::thread::sleep(POLL_INTERVAL); @@ -682,6 +657,56 @@ fn accept_connections( } } +/// Admit every transport through the same policy, capacity and session path. +pub(crate) fn admit_inbound(stream: TcpStream, peer_addr: PeerEndpoint, shared: &ConnectionShared) { + admit_inbound_with_epoch(stream, peer_addr, shared, None); +} +pub(crate) fn admit_inbound_with_epoch( + stream: TcpStream, + peer_addr: PeerEndpoint, + shared: &ConnectionShared, + alive: Option>, +) { + if shared.check_destination(peer_addr, false).is_err() { + drop(stream); + tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected by address policy"); + return; + } + if !shared.activity.is_active() { + drop(stream); + tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected: network inactive"); + return; + } + // Reserve the inbound slot atomically with the capacity + // test and any eviction before any thread exists: a separate + // count followed by an unconstrained spawn races concurrent + // accepts. Registration stays identity-checked, so the + // reservation itself is the handshaking-peer accounting + // Core's connman performs (`net.cpp:1819-1826`). + let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded::(); + let lease = crate::PeerLease::new_inbound(outbound_tx) + .with_shared_budget(&shared.shared_outbound) + .with_no_ban(shared.banned.is_noban(peer_addr.address())) + .with_transport_alive(alive); + let netgroup = shared.address_book.as_ref().map_or_else( + || crate::netgroup::NetGroups::default().group(peer_addr.address()), + |book| book.netgroup(peer_addr.address()), + ); + let Some(lease) = shared.peer_table.try_register_inbound( + peer_addr, + lease, + &netgroup, + shared.max_inbound, + approximate_best_block_depth(shared.chain_query.as_deref()), + &shared.activity, + ) else { + drop(stream); + tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected: inactive or at capacity"); + return; + }; + spawn_handshake_thread(stream, peer_addr, shared.clone(), lease, outbound_rx); +} + /// Spawn one automatic outbound connection dial of `role`. /// /// The thread connects to `addr`, performs the outbound P2P handshake with @@ -697,7 +722,7 @@ fn accept_connections( /// decision reads it back from the lease. #[must_use] pub fn spawn_outbound_connection( - addr: SocketAddr, + addr: PeerEndpoint, shared: ConnectionShared, role: crate::peer_info::PeerRole, ) -> std::thread::JoinHandle> { @@ -715,7 +740,7 @@ pub fn spawn_outbound_connection( /// rules read it back from there. #[must_use] pub(crate) fn spawn_pinned_outbound_connection( - addr: SocketAddr, + addr: PeerEndpoint, shared: ConnectionShared, role: crate::peer_info::PeerRole, ) -> std::thread::JoinHandle> { @@ -723,7 +748,7 @@ pub(crate) fn spawn_pinned_outbound_connection( } pub(crate) fn spawn_dial( - addr: SocketAddr, + addr: PeerEndpoint, shared: ConnectionShared, role: crate::peer_info::PeerRole, pinned: bool, @@ -750,7 +775,7 @@ pub(crate) fn spawn_dial( /// Admits one outbound connection to `addr` and dials it, retrying once over /// v1 when a v2 attempt goes unanswered. fn run_outbound_connection( - addr: SocketAddr, + addr: PeerEndpoint, shared: &ConnectionShared, role: crate::peer_info::PeerRole, manual: bool, @@ -814,20 +839,27 @@ enum Dialed { reason = "One connection owner keeps admission, TCP health, lease publication, and handshake teardown ordered" )] fn dial( - addr: SocketAddr, + addr: PeerEndpoint, shared: &ConnectionShared, role: crate::peer_info::PeerRole, manual: bool, count_failure: bool, try_v2: bool, ) -> Result { - let connection = TcpStream::connect_timeout(&addr, Duration::from_secs(10)); + let connection = shared.overlay.connect(addr, &|| { + shared.is_session_cancelled() || !shared.activity.is_active() + }); if let Some(book) = &shared.address_book { // Like Core ConnectNode, record actual TCP attempts on success/failure, // after local admission guards. Spawn failures/cancellation are not peer evidence. - book.attempted(addr, count_failure && !manual, crate::addrman::now()); + if addr.address().ip().is_some() && shared.overlay.config.proxy.is_none() + || connection.is_ok() + { + book.attempted(addr, count_failure && !manual, crate::addrman::now()); + } } - let stream = connection.map_err(crate::wire::PeerError::Io)?; + let connected = connection.map_err(crate::wire::PeerError::Io)?; + let stream = connected.stream; configure_peer_stream(&stream).map_err(crate::wire::PeerError::Io)?; shared.check_destination(addr, manual)?; if shared.is_session_cancelled() { @@ -852,7 +884,8 @@ fn dial( (_, true) => crate::PeerLease::new_manual(outbound_tx, role), } .with_shared_budget(&shared.shared_outbound) - .with_no_ban(shared.banned.is_noban(addr.ip())); + .with_no_ban(shared.banned.is_noban(addr.address())) + .with_transport_alive(connected.alive); let Some(lease) = shared .peer_table .try_register_outbound(addr, lease, &shared.activity) @@ -996,7 +1029,7 @@ fn run_outbound_handshake( start_height: i32, lease: &crate::PeerLease, deadline: Instant, - addr: SocketAddr, + addr: PeerEndpoint, shared: &ConnectionShared, ) -> Result<(), crate::wire::PeerError> { let best_block_depth = approximate_best_block_depth(shared.chain_query.as_deref()); @@ -1087,7 +1120,7 @@ pub fn has_all_desirable_service_flags( fn spawn_handshake_thread( stream: TcpStream, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, shared: ConnectionShared, lease: crate::PeerLease, outbound_rx: crossbeam_channel::Receiver, @@ -1140,7 +1173,7 @@ fn negotiate_inbound( fn run_handshake( stream: TcpStream, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, shared: &ConnectionShared, lease: crate::PeerLease, outbound_rx: crossbeam_channel::Receiver, @@ -1272,7 +1305,7 @@ fn run_handshake( /// are intentionally not joined by outer listener shutdown. fn run_connected_session( peer: &mut Peer>, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, shared: &ConnectionShared, lease: crate::PeerLease, outbound_rx: crossbeam_channel::Receiver, @@ -1966,7 +1999,7 @@ fn retry_deferred_request( #[expect(clippy::too_many_lines)] fn run_message_loop( peer: &mut Peer, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, lease: &crate::PeerLease, shared: &ConnectionShared, ibd: Option<&(Arc, Network)>, @@ -2272,9 +2305,13 @@ fn run_message_loop( let addresses: Vec<_> = addresses .iter() .filter_map(|(seen, addr)| { - addr.socket_addr() - .ok() - .map(|socket| (socket, addr.services.to_u64(), u64::from(*seen))) + addr.socket_addr().ok().map(|socket| { + ( + PeerEndpoint::from(socket), + addr.services.to_u64(), + u64::from(*seen), + ) + }) }) .collect(); learn_addresses( @@ -2290,13 +2327,15 @@ fn run_message_loop( let addresses: Vec<_> = addresses .iter() .filter_map(|addr| { - let ip: std::net::IpAddr = match addr.addr { - bitcoin::p2p::address::AddrV2::Ipv4(ip) => ip.into(), - bitcoin::p2p::address::AddrV2::Ipv6(ip) => ip.into(), + let address = match addr.addr { + AddrV2::Ipv4(ip) => crate::PeerAddress::Ip(ip.into()), + AddrV2::Ipv6(ip) => crate::PeerAddress::Ip(ip.into()), + AddrV2::TorV3(key) => crate::PeerAddress::Onion(key), + AddrV2::I2p(hash) => crate::PeerAddress::I2p(hash), _ => return None, }; Some(( - SocketAddr::new(ip, addr.port), + PeerEndpoint::new(address, addr.port), addr.services.to_u64(), u64::from(addr.time), )) @@ -2324,9 +2363,9 @@ fn run_message_loop( // addresses, so every entry is IPv4/IPv6 either // way and both replies carry the same entries. let reply = if peer.capabilities.addr_v2 { - crate::Message::AddrV2(addr_v2_rows(&rows)) + crate::Message::AddrV2(rows) } else { - crate::Message::Addr(rows) + crate::Message::Addr(addr_v1_rows(&rows)) }; // Sent under the reservation taken before dispatch; a // closed lease ends the loop at its top. @@ -2589,26 +2628,22 @@ fn resume_parked_getdata( Ok(()) } -/// Renders one gossip round as BIP155 `addrv2` rows. -/// -/// Only IPv4 and IPv6 addresses exist in the book: every row comes from a -/// real socket address (`Address::socket_addr`), so the conversion is total -/// over what `gossip` returns. Tor and I2P networks are never served, and the -/// 32-entry cap is the book's own (`MAX_GOSSIP`), unchanged by the encoding. -fn addr_v2_rows(rows: &[(u32, bitcoin::p2p::address::Address)]) -> Vec { +/// Legacy addr messages can represent only IP endpoints; do not invent addresses. +fn addr_v1_rows(rows: &[AddrV2Message]) -> Vec<(u32, bitcoin::p2p::address::Address)> { rows.iter() - .filter_map(|(time, address)| { - let socket = address.socket_addr().ok()?; - let (addr, port) = match socket.ip() { - std::net::IpAddr::V4(ip) => (AddrV2::Ipv4(ip), socket.port()), - std::net::IpAddr::V6(ip) => (AddrV2::Ipv6(ip), socket.port()), + .filter_map(|row| { + let ip: std::net::IpAddr = match row.addr { + AddrV2::Ipv4(ip) => ip.into(), + AddrV2::Ipv6(ip) => ip.into(), + _ => return None, }; - Some(AddrV2Message { - time: *time, - services: address.services, - addr, - port, - }) + Some(( + row.time, + bitcoin::p2p::address::Address::new( + &std::net::SocketAddr::new(ip, row.port), + row.services, + ), + )) }) .collect() } @@ -2618,8 +2653,8 @@ fn addr_v2_rows(rows: &[(u32, bitcoin::p2p::address::Address)]) -> Vec, compact_hints: Option<&dyn crate::compact_blocks::CompactBlockHints>, sink: &ProductionSink<'_>, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, shared: &ConnectionShared, ) { let lease = sink.lease; @@ -2739,7 +2774,7 @@ fn process_compact_wire_message( fn handle_compact_outcome( outcome: crate::compact_blocks::Outcome, sink: &ProductionSink<'_>, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, shared: &ConnectionShared, ) -> bool { let lease = sink.lease; @@ -2813,7 +2848,7 @@ fn spawn_connection_writer( outbound_rx: crossbeam_channel::Receiver, close_rx: crossbeam_channel::Receiver<()>, budget: Arc, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, net_trace: Option, ) -> std::io::Result> { std::thread::Builder::new() @@ -2974,7 +3009,7 @@ fn forward_tx_if_relay_open( shared: &ConnectionShared, source: crate::PeerSource, tx: bitcoin_rs_primitives::Tx, - peer_addr: SocketAddr, + peer_addr: PeerEndpoint, relay_open: bool, ) { if relay_open { @@ -2990,7 +3025,7 @@ fn wake_sync(sync_wake_tx: Option<&Sender<()>>) { } } -fn generate_nonce(peer_addr: SocketAddr) -> u64 { +fn generate_nonce(peer_addr: PeerEndpoint) -> u64 { use std::hash::{BuildHasher, Hash, Hasher}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -3069,7 +3104,8 @@ mod transaction_poll_tests { &lease, crate::PeerInfo::outbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, @@ -3113,7 +3149,8 @@ mod transaction_poll_tests { #[cfg(test)] mod outbound_tests { - use std::net::{Ipv4Addr, SocketAddr, TcpListener}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{Ipv4Addr, TcpListener}; use std::sync::Arc; use super::{spawn_outbound_connection, spawn_pinned_outbound_connection, test_shared}; @@ -3123,7 +3160,11 @@ mod outbound_tests { #[test] fn spawn_outbound_connection_to_closed_port_fails_quickly() -> Result<(), Box> { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; drop(listener); @@ -3131,7 +3172,7 @@ mod outbound_tests { let (blocks_tx, _blocks_rx) = crossbeam_channel::unbounded(); let shared = test_shared(Arc::new(PeerTable::new()), headers_tx, blocks_tx); - let handle = spawn_outbound_connection(addr, shared, PeerRole::FullRelay); + let handle = spawn_outbound_connection(addr.into(), shared, PeerRole::FullRelay); let inner = match handle.join() { Ok(inner) => inner, Err(error) => std::panic::resume_unwind(error), @@ -3147,7 +3188,7 @@ mod outbound_tests { /// An outbound dial entry point: either origin the service can choose. type Dial = fn( - SocketAddr, + PeerEndpoint, crate::listener::ConnectionShared, PeerRole, ) -> std::thread::JoinHandle>; @@ -3162,8 +3203,12 @@ mod outbound_tests { fn registered_session(dial: Dial) -> crate::peer_table::PeerSession { use std::time::{Duration, Instant}; - let listener = - TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind listener"); + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("bind listener"); listener .set_nonblocking(true) .expect("nonblocking listener"); @@ -3173,13 +3218,13 @@ mod outbound_tests { let (headers_tx, _headers_rx) = crossbeam_channel::unbounded(); let (blocks_tx, _blocks_rx) = crossbeam_channel::unbounded(); let shared = test_shared(Arc::clone(&table), headers_tx, blocks_tx); - let handle = dial(addr, shared, PeerRole::FullRelay); + let handle = dial(addr.into(), shared, PeerRole::FullRelay); let deadline = Instant::now() + Duration::from_secs(10); let mut accepted = None; let session = loop { accepted = accepted.or_else(|| listener.accept().ok().map(|(stream, _)| stream)); - if let Some(session) = table.sessions().into_iter().find(|s| s.addr == addr) { + if let Some(session) = table.sessions().into_iter().find(|s| s.addr == addr.into()) { break session; } assert!(Instant::now() < deadline, "the dial never registered"); @@ -3280,13 +3325,19 @@ static ACCEPT_ERROR_INJECT: AtomicBool = AtomicBool::new(false); #[cfg(test)] #[expect(clippy::expect_used)] mod session_socket_tests { - use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{Ipv4Addr, TcpListener, TcpStream}; use super::{HANDSHAKE_TIMEOUT, STREAM_POLL_INTERVAL, configure_peer_stream}; #[test] fn session_sockets_disable_nagle() { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind"); + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("bind"); let addr = listener.local_addr().expect("local_addr"); let client = TcpStream::connect(addr).expect("connect"); let (server, _) = listener.accept().expect("accept"); @@ -3313,7 +3364,8 @@ static WRITER_SETUP_FAIL: AtomicBool = AtomicBool::new(false); #[cfg(test)] #[expect(clippy::expect_used)] mod resilient_accept_tests { - use std::net::{Ipv4Addr, SocketAddr, TcpStream}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{Ipv4Addr, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -3324,8 +3376,12 @@ mod resilient_accept_tests { /// logs, backs off, and continues until shutdown. #[test] fn serve_survives_transient_accept_error() { - let listener = - bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind listener"); + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("bind listener"); let addr = listener.local_addr().expect("local_addr"); let shutdown = Arc::new(AtomicBool::new(false)); @@ -3355,8 +3411,9 @@ mod resilient_accept_tests { #[cfg(test)] #[expect(clippy::expect_used)] mod inbound_admission_tests { + use bitcoin_rs_primitives::PeerEndpoint; use std::io::Read; - use std::net::{Ipv4Addr, SocketAddr, TcpStream}; + use std::net::{Ipv4Addr, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -3366,7 +3423,11 @@ mod inbound_admission_tests { #[test] fn inbound_admission_over_cap_drops_stream() -> Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let peer_table = Arc::new(PeerTable::new()); let (headers_tx, _headers_rx) = crossbeam_channel::unbounded(); @@ -3398,7 +3459,7 @@ mod inbound_admission_tests { "the admitted socket keeps its reservation" ); assert!( - !peer_table.is_connected(second_addr), + !peer_table.is_connected(second_addr.into()), "the refused socket registered no lease" ); @@ -3411,7 +3472,8 @@ mod inbound_admission_tests { #[cfg(test)] #[expect(clippy::expect_used)] mod writer_setup_cleanup_tests { - use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -3420,7 +3482,7 @@ mod writer_setup_cleanup_tests { use super::{WRITER_SETUP_FAIL, run_connected_session, test_shared}; use crate::peer::Peer; - fn peer_info(addr: SocketAddr, conn_time: u64) -> crate::PeerInfo { + fn peer_info(addr: PeerEndpoint, conn_time: u64) -> crate::PeerInfo { crate::PeerInfo { addr, version: 70_016, @@ -3434,7 +3496,9 @@ mod writer_setup_cleanup_tests { best_known_height: 0, conn_time, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -3443,7 +3507,12 @@ mod writer_setup_cleanup_tests { #[test] fn writer_setup_failure_cleans_up_lease() { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind"); + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("bind"); let addr = listener.local_addr().expect("local_addr"); let client = TcpStream::connect(addr).expect("connect"); @@ -3457,11 +3526,11 @@ mod writer_setup_cleanup_tests { let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(outbound_tx); let lease_probe = lease.clone(); - shared.peer_table.register(peer_addr, lease.clone()); + shared.peer_table.register(peer_addr.into(), lease.clone()); // Lease must be registered before the failure. assert!( - shared.peer_table.is_connected(peer_addr), + shared.peer_table.is_connected(peer_addr.into()), "lease must be registered before writer setup" ); @@ -3470,8 +3539,15 @@ mod writer_setup_cleanup_tests { let counters = std::sync::Arc::new(crate::PeerCounters::default()); let stream = crate::CountingStream::new(client, counters); let mut peer = Peer::new(stream, Magic::BITCOIN); - let info = peer_info(peer_addr, 0); - let result = run_connected_session(&mut peer, peer_addr, &shared, lease, outbound_rx, info); + let info = peer_info(peer_addr.into(), 0); + let result = run_connected_session( + &mut peer, + peer_addr.into(), + &shared, + lease, + outbound_rx, + info, + ); assert!(result.is_err(), "writer setup failure must return Err"); assert!( @@ -3488,9 +3564,10 @@ mod writer_setup_cleanup_tests { #[cfg(test)] #[expect(clippy::expect_used)] mod writer_shutdown_tests { + use bitcoin_rs_primitives::PeerEndpoint; use std::cell::Cell; use std::io; - use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; + use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -3548,12 +3625,17 @@ mod writer_shutdown_tests { ); } - fn loopback_pair() -> (TcpStream, TcpStream, SocketAddr) { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind"); + fn loopback_pair() -> (TcpStream, TcpStream, PeerEndpoint) { + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("bind"); let addr = listener.local_addr().expect("local_addr"); let client = TcpStream::connect(addr).expect("connect"); let (server, peer_addr) = listener.accept().expect("accept"); - (client, server, peer_addr) + (client, server, peer_addr.into()) } fn ping_len() -> usize { @@ -3575,7 +3657,7 @@ mod writer_shutdown_tests { ); } - fn peer_info(addr: SocketAddr, start_height: i32) -> crate::PeerInfo { + fn peer_info(addr: PeerEndpoint, start_height: i32) -> crate::PeerInfo { crate::PeerInfo { addr, version: 70_016, @@ -3589,7 +3671,9 @@ mod writer_shutdown_tests { conn_time: 0, best_known_height: start_height, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -3601,7 +3685,7 @@ mod writer_shutdown_tests { let (headers_tx, headers_rx) = crossbeam_channel::unbounded(); let (blocks_tx, blocks_rx) = crossbeam_channel::unbounded(); let shared = test_shared(Arc::new(crate::PeerTable::new()), headers_tx, blocks_tx); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_443)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_443)); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); @@ -3630,7 +3714,7 @@ mod writer_shutdown_tests { let (headers_tx, headers_rx) = crossbeam_channel::unbounded(); let (blocks_tx, _blocks_rx) = crossbeam_channel::unbounded(); let shared = test_shared(Arc::new(crate::PeerTable::new()), headers_tx, blocks_tx); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_449)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_449)); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); @@ -3659,7 +3743,7 @@ mod writer_shutdown_tests { let (blocks_tx, blocks_rx) = crossbeam_channel::bounded(1); let shared = test_shared(Arc::new(crate::PeerTable::new()), headers_tx, blocks_tx); let session_cancel = shared.session_cancel.clone(); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_448)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_448)); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest); @@ -3698,7 +3782,7 @@ mod writer_shutdown_tests { #[test] fn message_loop_exits_before_read_when_cancelled() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_444)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_444)); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); lease.cancel(); @@ -3720,7 +3804,7 @@ mod writer_shutdown_tests { #[test] fn message_loop_exits_after_replacement_during_read() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_445)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_445)); let table = Arc::new(crate::PeerTable::new()); let (old_tx, _old_rx) = crossbeam_channel::unbounded(); let old = crate::PeerLease::new(old_tx); @@ -3769,33 +3853,37 @@ mod writer_shutdown_tests { }), ) .expect("sendcmpct encodes"); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, port)); let table = Arc::new(crate::PeerTable::new()); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); table.register(addr, lease.clone()); - assert!(table.publish_info( - addr, - &lease, - crate::PeerInfo { + assert!( + table.publish_info( addr, - version: 70_016, - wtxid_relay: false, - relay_transactions: true, - compact_block_relay: false, - send_headers: false, - services: 0, - user_agent: String::from("/test/"), - start_height: 0, - best_known_height: 0, - conn_time: 0, - inbound: false, - addr_bind: addr, - time_offset: 0, - counters: std::sync::Arc::new(crate::PeerCounters::default()), - transport: crate::TransportProtocol::V1, - }, - )); + &lease, + crate::PeerInfo { + addr, + version: 70_016, + wtxid_relay: false, + relay_transactions: true, + compact_block_relay: false, + send_headers: false, + services: 0, + user_agent: String::from("/test/"), + start_height: 0, + best_known_height: 0, + conn_time: 0, + inbound: false, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + time_offset: 0, + counters: std::sync::Arc::new(crate::PeerCounters::default()), + transport: crate::TransportProtocol::V1, + }, + ) + ); let mut peer = Peer::new(ScriptedEof(io::Cursor::new(wire)), Magic::BITCOIN); peer.state = PeerState::Ready; let shared = test_shared( @@ -3865,7 +3953,7 @@ mod writer_shutdown_tests { #[test] fn message_loop_keeps_current_lease_running() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_446)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_446)); let reads = Arc::new(AtomicUsize::new(0)); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); @@ -3893,7 +3981,7 @@ mod writer_shutdown_tests { crossbeam_channel::unbounded().0, crossbeam_channel::unbounded().0, ); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_448)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_448)); assert!(run_message_loop(&mut peer, addr, &lease, &shared, None).is_err()); assert!( @@ -3907,7 +3995,7 @@ mod writer_shutdown_tests { #[test] fn registration_cancels_replaced_lease() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_447)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_447)); let table = crate::PeerTable::new(); let (old_tx, _old_rx) = crossbeam_channel::unbounded(); let old = crate::PeerLease::new(old_tx); @@ -3924,7 +4012,7 @@ mod writer_shutdown_tests { #[test] fn stale_release_preserves_replacement_and_current_release_removes_it() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_448)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_448)); let table = crate::PeerTable::new(); let (stale_tx, _stale_rx) = crossbeam_channel::unbounded(); let stale = crate::PeerLease::new(stale_tx); @@ -4232,7 +4320,9 @@ mod writer_shutdown_tests { best_known_height: 0, conn_time: 0, inbound: false, - addr_bind: peer_addr, + addr_bind: peer_addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -4318,7 +4408,9 @@ mod writer_shutdown_tests { best_known_height: 0, conn_time: 0, inbound: false, - addr_bind: peer_addr, + addr_bind: peer_addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -4333,7 +4425,7 @@ mod writer_shutdown_tests { ); let _ = run_connected_session(&mut peer, peer_addr, &shared, lease, outbound_rx, info); - let last_seen = book.gossip(10_000)[0].0; + let last_seen = book.gossip(10_000)[0].time; if block_relay || stale { assert_eq!( last_seen, 5000, @@ -4392,7 +4484,7 @@ mod writer_shutdown_tests { for announces_pending in [false, true] { let table = Arc::new(crate::PeerTable::new()); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_447)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_447)); let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(outbound_tx); table.register(addr, lease.clone()); @@ -4499,7 +4591,7 @@ mod writer_shutdown_tests { crossbeam_channel::unbounded().0, crossbeam_channel::unbounded().0, ); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_447)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_447)); // The first Pong response takes the one-frame budget, so the second // cannot be admitted and the saturation policy cancels the lease and @@ -4644,13 +4736,14 @@ mod writer_shutdown_tests { #[cfg(test)] mod ready_notify_tests { - use std::net::{Ipv4Addr, SocketAddr}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::Ipv4Addr; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use super::{ConnectionShared, test_shared}; - fn peer_info(addr: SocketAddr, start_height: i32) -> crate::PeerInfo { + fn peer_info(addr: PeerEndpoint, start_height: i32) -> crate::PeerInfo { crate::PeerInfo { addr, version: 70_016, @@ -4664,7 +4757,9 @@ mod ready_notify_tests { conn_time: 0, best_known_height: start_height, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -4675,7 +4770,7 @@ mod ready_notify_tests { fn manual_outbound_ready_updates_known_address_health_without_automatic_claims() { let mut shared = shared_with_notify_counter(&Arc::new(AtomicUsize::new(0))); let now = crate::addrman::now(); - let known = SocketAddr::from(([127, 0, 0, 1], 8333)); + let known = PeerEndpoint::from(([127, 0, 0, 1], 8333)); let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), true, None); book.learn_dns("seed", &[known], now); shared.address_book = Some(Arc::clone(&book)); @@ -4683,7 +4778,7 @@ mod ready_notify_tests { let lease = crate::PeerLease::new_manual(tx.clone(), crate::PeerRole::FullRelay); shared.peer_table.register(known, lease.clone()); assert!(shared.publish_info_and_notify_ready(known, &lease, &peer_info(known, 0))); - assert_eq!(book.gossip(now)[0].1.services.to_u64(), 1); + assert_eq!(book.gossip(now)[0].services.to_u64(), 1); assert_eq!(book.pending_count_excluding(&[]), 0); book.queued(known); assert!(shared.publish_info_and_notify_ready(known, &lease, &peer_info(known, 0))); @@ -4692,7 +4787,7 @@ mod ready_notify_tests { 1, "manual success does not transfer an existing automatic claim" ); - let unknown = SocketAddr::from(([127, 0, 0, 2], 8333)); + let unknown = PeerEndpoint::from(([127, 0, 0, 2], 8333)); let manual = crate::PeerLease::new_manual(tx, crate::PeerRole::FullRelay); shared.peer_table.register(unknown, manual.clone()); assert!(shared.publish_info_and_notify_ready(unknown, &manual, &peer_info(unknown, 0))); @@ -4715,7 +4810,7 @@ mod ready_notify_tests { #[test] fn stale_predecessor_does_not_notify_peer_ready() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_450)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_450)); let notified = Arc::new(AtomicUsize::new(0)); let shared = shared_with_notify_counter(¬ified); @@ -4740,7 +4835,7 @@ mod ready_notify_tests { #[test] fn current_lease_publishes_then_notifies_ready() { - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 18_451)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 18_451)); let notified = Arc::new(AtomicUsize::new(0)); let shared = shared_with_notify_counter(¬ified); @@ -4757,7 +4852,7 @@ mod ready_notify_tests { #[cfg(test)] #[expect(clippy::expect_used)] mod block_forward_tests { - use std::net::SocketAddr; + use bitcoin_rs_primitives::PeerEndpoint; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -4864,7 +4959,7 @@ mod block_forward_tests { "a delivery the window owns is never dropped for the unsolicited bound" ); - let other: SocketAddr = test_addr(9781, 0)?; + let other: PeerEndpoint = test_addr(9781, 0)?; let _other_rx = connect_peer(&peers, synthetic_peer(other, 3)); let other_lease = peers .lease(other) @@ -5062,12 +5157,18 @@ mod address_tests { let (blocks, _) = crossbeam_channel::unbounded(); let mut shared = test_shared(table, headers, blocks); let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), true, None); - book.learn_dns("seed", &[target], now - 31 * 86400); + book.learn_dns("seed", &[target.into()], now - 31 * 86400); shared.address_book = Some(Arc::clone(&book)); shared.session_cancel.store(cancelled); assert!( - run_outbound_connection(target, &shared, crate::PeerRole::FullRelay, false, false) - .is_err() + run_outbound_connection( + target.into(), + &shared, + crate::PeerRole::FullRelay, + false, + false + ) + .is_err() ); assert_eq!( book.gossip(now).is_empty(), @@ -5091,12 +5192,12 @@ mod address_tests { let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), true, None); book.learn_dns( "seed", - &[SocketAddr::from(([127, 0, 0, 2], 8333))], + &[PeerEndpoint::from(([127, 0, 0, 2], 8333))], crate::addrman::now(), ); shared.address_book = Some(book); let (sender, receiver) = crossbeam_channel::unbounded(); - let source = SocketAddr::from(([127, 0, 0, 1], 9000)); + let source = PeerEndpoint::from(([127, 0, 0, 1], 9000)); let lease = crate::PeerLease::new(sender); table.register(source, lease.clone()); let mut input = Vec::new(); @@ -5123,15 +5224,23 @@ mod address_tests { let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), true, None); shared.address_book = Some(Arc::clone(&book)); let (sender, receiver) = crossbeam_channel::unbounded(); - let source = SocketAddr::from(([127, 0, 0, 1], 9000)); - let candidate = SocketAddr::from(([127, 0, 0, 2], 8333)); + let source = PeerEndpoint::from(([127, 0, 0, 1], 9000)); + let candidate = PeerEndpoint::from(([127, 0, 0, 2], 8333)); let lease = crate::PeerLease::new_inbound(sender); table.register(source, lease.clone()); let timestamp = u32::try_from(crate::addrman::now()).expect("timestamp"); let services = ServiceFlags::NETWORK | ServiceFlags::WITNESS; let mut input = Vec::new(); for message in [ - Message::Addr(vec![(timestamp, Address::new(&candidate, services))]), + Message::Addr(vec![( + timestamp, + Address::new( + &candidate + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + services, + ), + )]), Message::AddrV2(vec![AddrV2Message { time: timestamp, services, @@ -5152,7 +5261,12 @@ mod address_tests { panic!("expected exactly one addr response: {replies:?}"); }; assert_eq!(addresses.len(), 1); - assert_eq!(addresses[0].1.socket_addr().expect("socket"), candidate); + assert_eq!( + addresses[0].1.socket_addr().expect("socket"), + candidate + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")) + ); } #[test] @@ -5164,15 +5278,23 @@ mod address_tests { let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), true, None); shared.address_book = Some(Arc::clone(&book)); let (sender, receiver) = crossbeam_channel::unbounded(); - let source = SocketAddr::from(([127, 0, 0, 1], 9000)); - let candidate = SocketAddr::from(([127, 0, 0, 2], 8333)); + let source = PeerEndpoint::from(([127, 0, 0, 1], 9000)); + let candidate = PeerEndpoint::from(([127, 0, 0, 2], 8333)); let lease = crate::PeerLease::new_inbound(sender); table.register(source, lease.clone()); let timestamp = u32::try_from(crate::addrman::now()).expect("timestamp"); let services = ServiceFlags::NETWORK | ServiceFlags::WITNESS; let mut input = Vec::new(); for message in [ - Message::Addr(vec![(timestamp, Address::new(&candidate, services))]), + Message::Addr(vec![( + timestamp, + Address::new( + &candidate + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + services, + ), + )]), Message::SendAddrV2, Message::GetAddr, Message::GetAddr, @@ -5240,13 +5362,13 @@ mod feeler_tests { crate::addrman::AddressBook::open(Some(base), shared.magic.to_bytes(), true, None); book.learn_peer( address.ip(), - &[(address, 9, unix_time_secs())], + &[(address.into(), 9, unix_time_secs())], unix_time_secs(), ); - book.set_services(address, 1); + book.set_services(address.into(), 1); shared.address_book = Some(Arc::clone(&book)); shared.feeler = true; - assert!(book.queued_feeler(address)); + assert!(book.queued_feeler(address.into())); let notifications = Arc::new(AtomicUsize::new(0)); let count = Arc::clone(¬ifications); shared.peer_ready = Some(Arc::new(move |_| { @@ -5278,7 +5400,7 @@ mod feeler_tests { remote_table.cancel_all(); } else if revoked == 2 { let replacement = crate::PeerLease::new(crossbeam_channel::unbounded().0); - remote_table.register(address, replacement); + remote_table.register(address.into(), replacement); } let sent = peer.send(&crate::Message::Version(crate::handshake::version_message( 123, @@ -5297,16 +5419,16 @@ mod feeler_tests { ); } }); - spawn_outbound_connection(address, shared, crate::PeerRole::BlockRelayOnly) + spawn_outbound_connection(address.into(), shared, crate::PeerRole::BlockRelayOnly) .join() .expect("thread") .expect("probe"); remote.join().expect("remote"); assert!( - book.is_feeler(address), + book.is_feeler(address.into()), "the service reaper owns claim release" ); - book.unqueue(address); + book.unqueue(address.into()); assert_eq!(notifications.load(Ordering::Relaxed), 0); assert_eq!(table.sessions().len(), usize::from(revoked == 2)); assert!( @@ -5369,7 +5491,7 @@ mod feeler_tests { fn rejected_ordinary_version_updates_services_without_good() { for (prior_good, services) in [(false, 1_u64), (false, 0), (true, 1), (true, 0)] { let directory = tempfile::tempdir().expect("directory"); - let address: SocketAddr = "127.0.0.1:8333".parse().expect("address"); + let address: PeerEndpoint = "127.0.0.1:8333".parse().expect("address"); let table = Arc::new(crate::PeerTable::new()); let mut shared = test_shared( Arc::clone(&table), @@ -5442,12 +5564,12 @@ mod feeler_tests { let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), true, None); book.learn_peer( address.ip(), - &[(address, 1, unix_time_secs())], + &[(address.into(), 1, unix_time_secs())], unix_time_secs(), ); shared.address_book = Some(book); let ordinary = - spawn_outbound_connection(address, shared.clone(), crate::PeerRole::FullRelay) + spawn_outbound_connection(address.into(), shared.clone(), crate::PeerRole::FullRelay) .join() .expect("ordinary thread"); assert!(matches!( @@ -5456,9 +5578,10 @@ mod feeler_tests { "outbound peer lacks desirable services" )) )); - let manual = spawn_pinned_outbound_connection(address, shared, crate::PeerRole::FullRelay) - .join() - .expect("manual thread"); + let manual = + spawn_pinned_outbound_connection(address.into(), shared, crate::PeerRole::FullRelay) + .join() + .expect("manual thread"); assert!( matches!(manual, Err(crate::PeerError::Io(_))), "explicit manual dial reaches TCP instead of applying stored-service policy" @@ -5491,14 +5614,14 @@ mod discouragement_tests { bytes: std::io::Cursor>, update_at: Option, book: Arc, - addr: SocketAddr, + addr: PeerEndpoint, } impl std::io::Read for ServiceUpdateScript { fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { if self.update_at.is_some_and(|at| self.bytes.position() >= at) { self.update_at = None; self.book.learn_peer( - self.addr.ip(), + self.addr.address(), &[(self.addr, 1024, unix_time_secs())], unix_time_secs(), ); @@ -5522,7 +5645,7 @@ mod discouragement_tests { [(true, false), (true, true), (false, false), (false, true)] { let directory = tempfile::tempdir()?; - let address: SocketAddr = "8.8.8.8:8333".parse()?; + let address: PeerEndpoint = "8.8.8.8:8333".parse()?; let mut shared = shared(); let book = crate::addrman::AddressBook::open( Some(directory.path().join("peers.dat")), @@ -5531,7 +5654,7 @@ mod discouragement_tests { None, ); book.learn_peer( - address.ip(), + address.address(), &[(address, 9, unix_time_secs())], unix_time_secs(), ); @@ -5615,14 +5738,14 @@ mod discouragement_tests { fn only_current_remote_protocol_faults_change_automatic_avoidance() -> Result<(), Box> { let shared = shared(); - let address: SocketAddr = "8.8.8.8:8333".parse()?; + let address: PeerEndpoint = "8.8.8.8:8333".parse()?; let stale = lease(false, false); shared.peer_table.register(address, stale.clone()); let current = lease(false, false); shared.peer_table.register(address, current.clone()); let fault = PeerError::Incoming(Box::new(PeerError::BadChecksum)); shared.note_misbehavior(address, &stale, &fault); - assert!(!shared.banned.is_discouraged(address.ip())); + assert!(!shared.banned.is_discouraged(address.address())); for error in [ PeerError::Protocol("outbound queue closed"), PeerError::Protocol("network inactive"), @@ -5639,11 +5762,11 @@ mod discouragement_tests { shared.note_misbehavior(address, ¤t, &error); } assert!( - !shared.banned.is_discouraged(address.ip()), + !shared.banned.is_discouraged(address.address()), "local/backpressure/policy failures are not remote misbehavior" ); shared.note_misbehavior(address, ¤t, &fault); - assert!(shared.banned.is_discouraged(address.ip())); + assert!(shared.banned.is_discouraged(address.address())); assert!( shared.banned.read().is_empty(), "automatic avoidance must not become a manual ban" @@ -5660,7 +5783,7 @@ mod discouragement_tests { !shared .banned .automatic_snapshot(SystemTime::now()) - .allows(address.ip()) + .allows(address.address()) ); Ok(()) } @@ -5672,7 +5795,7 @@ mod discouragement_tests { let shared = shared(); // Exercise real socket framing and teardown with a nonlocal // connection identity. The test never dials this public address. - let address: SocketAddr = "8.8.8.8:8333".parse()?; + let address: PeerEndpoint = "8.8.8.8:8333".parse()?; let listener = TcpListener::bind("127.0.0.1:0")?; let mut sender = TcpStream::connect(listener.local_addr()?)?; let (stream, _) = listener.accept()?; @@ -5733,12 +5856,12 @@ mod discouragement_tests { ("::1", false, false), ] { let shared = shared(); - let address = SocketAddr::new(ip.parse()?, 8333); + let address = PeerEndpoint::new(ip.parse::()?, 8333); let lease = lease(manual, noban); shared.peer_table.register(address, lease.clone()); let error = PeerError::Misbehavior("getheaders locator too large"); shared.note_misbehavior(address, &lease, &error); - assert!(!shared.banned.is_discouraged(address.ip()), "{ip}"); + assert!(!shared.banned.is_discouraged(address.address()), "{ip}"); assert_eq!(lease.ignores_protocol_error(&error), manual || noban); assert!( !lease.ignores_protocol_error(&PeerError::inbound(PeerError::WrongNetwork { diff --git a/crates/p2p/src/listener/aggregate_budget_tests.rs b/crates/p2p/src/listener/aggregate_budget_tests.rs index bc2789810..7b01070cc 100644 --- a/crates/p2p/src/listener/aggregate_budget_tests.rs +++ b/crates/p2p/src/listener/aggregate_budget_tests.rs @@ -165,9 +165,17 @@ fn reply_bound_covers_actual_replies() -> TestResult { // `getaddr` is reserved for only while it is answered from the book, in // either encoding, at its worst-case row size. assert_eq!(reply_bound(&Message::GetAddr, false), None); - let socket = std::net::SocketAddr::from(([1, 2, 3, 4], 8333)); + let socket = bitcoin_rs_primitives::PeerEndpoint::from(([1, 2, 3, 4], 8333)); let addr_v1 = Message::Addr(vec![ - (0, Address::new(&socket, ServiceFlags::NONE)); + ( + 0, + Address::new( + &socket + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ServiceFlags::NONE + ) + ); crate::wire::MAX_ADDR_MESSAGE_COUNT ]); let addr_v2 = Message::AddrV2(vec![ @@ -715,7 +723,7 @@ fn conditional_local_retry_defers_then_readies_with_no_reply() -> TestResult { assert!(matches!(outcome, crate::compact_blocks::Outcome::Idle)); let sink = ProductionSink::new(&lease, Some(credit)); let shared = shared_with(ScriptedChain { blocks: Vec::new() }); - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); + let addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); assert!(!super::handle_compact_outcome( outcome, &sink, addr, &shared )); diff --git a/crates/p2p/src/listener/aggregate_filter_tests.rs b/crates/p2p/src/listener/aggregate_filter_tests.rs index 1a9723455..8254ac0f1 100644 --- a/crates/p2p/src/listener/aggregate_filter_tests.rs +++ b/crates/p2p/src/listener/aggregate_filter_tests.rs @@ -5,8 +5,8 @@ //! listener decides which by recording why each pass stopped //! (`ParkedFilter::last`), which only a session can show. +use bitcoin_rs_primitives::PeerEndpoint; use std::io; -use std::net::SocketAddr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -226,7 +226,7 @@ fn the_first_filter_pass_records_an_aggregate_refusal() -> TestResult { crate::wire::write_message(&mut input, MAGIC, &get_cfilters())?; let mut peer = Peer::new(io::Cursor::new(input), MAGIC); peer.state = PeerState::Ready; - let addr = SocketAddr::from(([127, 0, 0, 1], 18_444)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], 18_444)); run_message_loop(&mut peer, addr, &lease, &shared, None)?; diff --git a/crates/p2p/src/listener/getdata_pause_tests.rs b/crates/p2p/src/listener/getdata_pause_tests.rs index 0daf0a5ec..4a4883528 100644 --- a/crates/p2p/src/listener/getdata_pause_tests.rs +++ b/crates/p2p/src/listener/getdata_pause_tests.rs @@ -2,8 +2,9 @@ //! the peer: the session reads nothing until the writer drains, answers in //! request order, and ends when the writer dies or the lease is cancelled. +use bitcoin_rs_primitives::PeerEndpoint; use std::io::{self, Write}; -use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::net::{Shutdown, TcpListener, TcpStream}; use std::sync::Arc; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -262,7 +263,11 @@ impl Served { aggregate: Option<&Arc>, writer: &WriterKind, ) -> Result> { - let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let listener = TcpListener::bind( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let client_stream = TcpStream::connect(listener.local_addr()?)?; let (server_stream, addr) = listener.accept()?; configure_peer_stream(&client_stream)?; @@ -316,7 +321,7 @@ impl Served { outbound_rx, close, Arc::clone(&handle), - addr, + addr.into(), None, )?; (None, thread) @@ -328,7 +333,7 @@ impl Served { let session_lease = lease.clone(); let (outcome_tx, outcome) = crossbeam_channel::bounded(1); let session = std::thread::spawn(move || { - let result = run_message_loop(&mut peer, addr, &session_lease, &shared, None); + let result = run_message_loop(&mut peer, addr.into(), &session_lease, &shared, None); let _ = outcome_tx.send(result); }); Ok(Self { @@ -734,7 +739,11 @@ fn a_drained_getdata_holds_reads_until_the_queue_can_take_a_reply() -> TestResul /// dead when a write fails: a peer that has gone away fails the second write. #[test] fn production_writer_marks_the_budget_dead_when_a_write_fails() -> TestResult { - let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let listener = TcpListener::bind( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let client = TcpStream::connect(listener.local_addr()?)?; let (server, addr) = listener.accept()?; drop(client); @@ -746,7 +755,7 @@ fn production_writer_marks_the_budget_dead_when_a_write_fails() -> TestResult { outbound_rx, lease.close_signal(), lease.budget_handle(), - addr, + addr.into(), None, )?; diff --git a/crates/p2p/src/listener/loopback_serving_tests.rs b/crates/p2p/src/listener/loopback_serving_tests.rs index c6864e61d..951b566eb 100644 --- a/crates/p2p/src/listener/loopback_serving_tests.rs +++ b/crates/p2p/src/listener/loopback_serving_tests.rs @@ -1,5 +1,6 @@ +use bitcoin_rs_primitives::PeerEndpoint; use std::io; -use std::net::{SocketAddr, TcpStream}; +use std::net::TcpStream; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::JoinHandle; @@ -150,7 +151,7 @@ fn chain_over(blocks: &[Block], recent: RecentBlockSlot) -> Fallible, aggregate: Arc, shutdown: Arc, @@ -167,13 +168,17 @@ impl Node { ); shared.chain_query = Some(Arc::new(chain)); let aggregate = Arc::clone(&shared.shared_outbound); - let listener = bind_listener(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let listener = bind_listener( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); let flag = Arc::clone(&shutdown); let thread = std::thread::spawn(move || serve(listener, flag, shared)); Ok(Self { - addr, + addr: addr.into(), table, aggregate, shutdown, @@ -221,13 +226,17 @@ impl Drop for Node { struct Client { peer: Peer, - local: SocketAddr, + local: PeerEndpoint, nonce: u64, } impl Client { fn connect(node: &Node) -> Fallible { - let stream = TcpStream::connect(node.addr)?; + let stream = TcpStream::connect( + node.addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; configure_peer_stream(&stream)?; let local = stream.local_addr()?; let mut peer = Peer::new(stream, MAGIC); @@ -248,7 +257,7 @@ impl Client { )?; let mut client = Self { peer, - local, + local: local.into(), nonce: 0, }; let barrier = client.ping()?; diff --git a/crates/p2p/src/listener/v2_transport_tests.rs b/crates/p2p/src/listener/v2_transport_tests.rs index 5925deb5e..10f411b39 100644 --- a/crates/p2p/src/listener/v2_transport_tests.rs +++ b/crates/p2p/src/listener/v2_transport_tests.rs @@ -1,7 +1,8 @@ //! BIP324 negotiation over real loopback sockets: inbound v1/v2 detection, //! outbound v2 with Core's single v1 retry, and the handshake deadline. -use std::net::{SocketAddr, TcpListener, TcpStream}; +use bitcoin_rs_primitives::PeerEndpoint; +use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -56,22 +57,29 @@ fn deadline() -> Instant { } /// A serving listener: its address, the flag that stops it, and its thread. -type Listening = (SocketAddr, Arc, std::thread::JoinHandle<()>); +type Listening = (PeerEndpoint, Arc, std::thread::JoinHandle<()>); /// A listener serving `shared` until the returned flag is raised. fn listen(shared: ConnectionShared) -> Result> { - let listener = bind_listener(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let listener = bind_listener( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); let flag = Arc::clone(&shutdown); let handle = std::thread::spawn(move || { let _ = serve(listener, flag, shared); }); - Ok((addr, shutdown, handle)) + Ok((addr.into(), shutdown, handle)) } -fn connect(addr: SocketAddr) -> Result> { - let stream = TcpStream::connect(addr)?; +fn connect(addr: PeerEndpoint) -> Result> { + let stream = TcpStream::connect( + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; configure_peer_stream(&stream)?; Ok(stream) } @@ -100,7 +108,7 @@ fn read_until( /// Completes the version handshake as an outbound peer of `addr` over /// `negotiated`, then proves the remote message loop answers over it. -fn handshake_and_ping(addr: SocketAddr, stream: TcpStream, negotiated: Negotiated) -> TestResult { +fn handshake_and_ping(addr: PeerEndpoint, stream: TcpStream, negotiated: Negotiated) -> TestResult { let (client, _) = shared(false); let lease = lease(); let mut peer = Peer::with_transport(stream, MAGIC, negotiated); @@ -140,7 +148,11 @@ fn a_v2_listener_still_serves_a_v1_peer() -> TestResult { #[test] fn an_outbound_v2_dial_negotiates_bip324() -> TestResult { - let server = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let server = TcpListener::bind( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = server.local_addr()?; let responder = std::thread::spawn(move || -> Result { let (stream, _) = server.accept().map_err(|error| error.to_string())?; @@ -168,7 +180,7 @@ fn an_outbound_v2_dial_negotiates_bip324() -> TestResult { }); let (dialer, dialer_table) = shared(true); - let dial = spawn_pinned_outbound_connection(addr, dialer, PeerRole::FullRelay); + let dial = spawn_pinned_outbound_connection(addr.into(), dialer, PeerRole::FullRelay); let protocol = responder.join().map_err(|_| "responder panicked")??; assert!(matches!(protocol, TransportProtocol::V2 { .. })); @@ -255,9 +267,13 @@ fn an_unanswered_v2_dial_retries_once_over_v1() -> TestResult { #[test] fn a_silent_responder_ends_at_the_handshake_deadline() -> TestResult { - let server = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let server = TcpListener::bind( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = server.local_addr()?; - let mut stream = connect(addr)?; + let mut stream = connect(addr.into())?; let (_held, _) = server.accept()?; let mut handshake = Handshake::initiator(MAGIC, &mut StdRng::seed_from_u64(3)); let started = Instant::now(); @@ -393,7 +409,11 @@ fn spawn_decoy_then_ping_writer( /// successful exit. #[test] fn a_decoy_only_peer_yields_and_lease_cancel_ends_the_loop() -> TestResult { - let server = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let server = TcpListener::bind( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = server.local_addr()?; // A nonblocking accept keeps a failed test from pinning the writer in // `accept` past cleanup. @@ -412,7 +432,7 @@ fn a_decoy_only_peer_yields_and_lease_cancel_ends_the_loop() -> TestResult { // The fallible body runs first so the writer is always stopped and joined, // even when the handshake or the loop fails. let result = (|| -> Result<(Result<(), PeerError>, Duration), Box> { - let mut stream = connect(addr)?; + let mut stream = connect(addr.into())?; let mut handshake = Handshake::responder(MAGIC, &mut StdRng::seed_from_u64(5)); let negotiated = establish(&mut stream, &mut handshake, MAGIC, &lease(), deadline())?; if !matches!(negotiated.protocol, TransportProtocol::V2 { .. }) { @@ -429,9 +449,10 @@ fn a_decoy_only_peer_yields_and_lease_cancel_ends_the_loop() -> TestResult { let (lease, replies) = lease_with_replies(); let cancel_handle = lease.clone(); let mut peer = Peer::with_transport(stream, MAGIC, negotiated); - run_outbound_handshake(&mut peer, 7, 0, &lease, deadline(), addr, &client)?; - let loop_handle = - std::thread::spawn(move || run_message_loop(&mut peer, addr, &lease, &client, None)); + run_outbound_handshake(&mut peer, 7, 0, &lease, deadline(), addr.into(), &client)?; + let loop_handle = std::thread::spawn(move || { + run_message_loop(&mut peer, addr.into(), &lease, &client, None) + }); // Liveness proof: the loop must dispatch the writer's ping through // the production path and queue the pong on the peer's reply queue. // A decoy drop, a v1 byte-count alias, or a loop that exits early diff --git a/crates/p2p/src/net_trace.rs b/crates/p2p/src/net_trace.rs index b43c666f9..2b4c6d091 100644 --- a/crates/p2p/src/net_trace.rs +++ b/crates/p2p/src/net_trace.rs @@ -10,7 +10,7 @@ //! preparation runs only while a consumer (bpftrace, BCC, DTrace) holds //! the probe's semaphore, so an untraced or unattached run pays nothing. -use std::net::SocketAddr; +use bitcoin_rs_primitives::PeerEndpoint; use crate::peer_info::PeerRole; use crate::wire::Message; @@ -27,14 +27,14 @@ pub(crate) struct NetTrace { /// Process-unique connection id (Core argument 1, `peerid`). node_id: u64, /// Remote address and port (Core argument 2). - peer: SocketAddr, + peer: PeerEndpoint, /// Core `ConnectionTypeAsString` label (Core argument 3). connection_type: &'static str, } impl NetTrace { /// Context for an accepted inbound connection. - pub(crate) fn inbound(node_id: u64, peer: SocketAddr) -> Self { + pub(crate) fn inbound(node_id: u64, peer: PeerEndpoint) -> Self { Self { node_id, peer, @@ -44,7 +44,7 @@ impl NetTrace { /// Context for a dialed connection, labelled per Core's /// `ConnectionTypeAsString` for the dial kind and role. - pub(crate) fn outbound(node_id: u64, peer: SocketAddr, role: PeerRole, manual: bool) -> Self { + pub(crate) fn outbound(node_id: u64, peer: PeerEndpoint, role: PeerRole, manual: bool) -> Self { let connection_type = if manual { "manual" } else { @@ -119,8 +119,8 @@ fn payload_len_u64(len: usize) -> u64 { mod tests { use super::*; - fn loopback() -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], 8333)) + fn loopback() -> PeerEndpoint { + PeerEndpoint::from(([127, 0, 0, 1], 8333)) } #[test] @@ -136,7 +136,7 @@ mod tests { message_args(&trace, "ping", payload); assert_eq!(node_id, 7); - let peer: SocketAddr = addr + let peer: PeerEndpoint = addr .parse() .unwrap_or_else(|error| panic!("a consumer parses the addr back: {error}")); assert_eq!(peer, loopback()); diff --git a/crates/p2p/src/netgroup.rs b/crates/p2p/src/netgroup.rs index 5f99e4c81..19bd10047 100644 --- a/crates/p2p/src/netgroup.rs +++ b/crates/p2p/src/netgroup.rs @@ -1,4 +1,5 @@ -//! Core31.1 IP netgroups. Classification never rewrites a dial endpoint. +//! Core31.1 IP and overlay netgroups. Classification never rewrites a dial endpoint. +use bitcoin_rs_primitives::PeerAddress; use std::net::{IpAddr, Ipv4Addr}; pub(crate) fn canonical_ip(ip: IpAddr) -> IpAddr { @@ -68,9 +69,16 @@ pub(crate) enum NetworkClass { Unroutable, Ipv4, Ipv6, + Onion, + I2p, } -pub(crate) fn network_class(ip: IpAddr) -> NetworkClass { +pub(crate) fn network_class(address: impl Into) -> NetworkClass { + let ip = match address.into() { + PeerAddress::Ip(ip) => ip, + PeerAddress::Onion(_) | PeerAddress::AnonymousTor(_) => return NetworkClass::Onion, + PeerAddress::I2p(_) => return NetworkClass::I2p, + }; if !routable(ip) { NetworkClass::Unroutable } else if linked_ipv4(ip).is_some() { @@ -156,7 +164,17 @@ impl NetGroups { self.identity } - pub(crate) fn group(&self, ip: IpAddr) -> Vec { + pub(crate) fn group(&self, address: impl Into) -> Vec { + let ip = match address.into() { + PeerAddress::Ip(ip) => ip, + PeerAddress::Onion(key) => return vec![4, key[0] | 15], + PeerAddress::I2p(key) => return vec![5, key[0] | 15], + PeerAddress::AnonymousTor(id) => { + let mut group = vec![4]; + group.extend_from_slice(&id.to_le_bytes()); + return group; + } + }; // Core applies ASMap only to IPv4/IPv6 network classes; local and // unroutable IPs keep the prefix owner's unclassified group. let asn = if self.bytes.is_empty() || !routable(ip) { @@ -357,7 +375,7 @@ mod tests { ("ffff:d499:8c4b:4941:bc81:d5b9:b51e:85a8", 824_019), ] { assert_eq!( - interpret(CORE_ASMAP, address.parse().expect("IP")), + interpret(CORE_ASMAP, address.parse::().expect("IP")), Some(expected), "{address}" ); @@ -407,7 +425,7 @@ mod tests { "2002:102:304:9999:9999:9999:9999:9999", "2001:0:9999:9999:9999:9999:FEFD:FCFB", ] { - let ip = encoded.parse().expect("Core vector"); + let ip = encoded.parse::().expect("Core vector"); assert_eq!(NetGroups::default().group(ip), vec![1, 1, 2]); } // A Core-generated map maps native IPv6 to 64500, while linked IPv4 @@ -421,7 +439,7 @@ mod tests { "::ffff:0:808:808", "2001:0:0:0:0:0:f7f7:f7f7", ] { - let ip = encoded.parse().expect("linked vector"); + let ip = encoded.parse::().expect("linked vector"); assert_eq!(interpret(LINKED_ASMAP, ip), Some(64501), "{encoded}"); assert_eq!(prefix_group(ip), vec![1, 8, 8]); } @@ -431,11 +449,17 @@ mod tests { assert_eq!(interpret(LINKED_ASMAP, ip), Some(64500)); } assert_eq!( - interpret(LINKED_ASMAP, "8.8.1.1".parse().expect("IPv4")), + interpret( + LINKED_ASMAP, + "8.8.1.1".parse::().expect("IPv4") + ), Some(15169) ); assert_eq!( - interpret(LINKED_ASMAP, "9.9.9.9".parse().expect("IPv4")), + interpret( + LINKED_ASMAP, + "9.9.9.9".parse::().expect("IPv4") + ), Some(19281) ); } @@ -469,7 +493,7 @@ mod tests { ("fe80:1::1", NetworkClass::Ipv6), ] { assert_eq!( - network_class(address.parse().expect("Core IP case")), + network_class(address.parse::().expect("Core IP case")), expected, "{address}" ); diff --git a/crates/p2p/src/overlay.rs b/crates/p2p/src/overlay.rs new file mode 100644 index 000000000..197a2275d --- /dev/null +++ b/crates/p2p/src/overlay.rs @@ -0,0 +1,558 @@ +//! Opt-in transport setup. Successful streams enter the ordinary P2P session owner. +use bitcoin_rs_primitives::{PeerAddress, PeerEndpoint, PeerNetwork}; +use parking_lot::RwLock; +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, TcpStream}, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +mod identity; +mod sam; +#[cfg(test)] +#[expect(clippy::expect_used)] +mod tests; +mod tor; + +/// Operator routing policy. No overlay service is started or contacted by default. +#[derive(Clone, Debug)] +pub struct OverlayConfig { + /// General SOCKS5 proxy for IP destinations. DNS seeds are disabled when set. + pub proxy: Option, + /// Separate SOCKS5 route for onion destinations; never inherited from `proxy`. + pub onion_proxy: Option, + /// Fresh SOCKS username/password per connection; requires proxy support. + pub randomize_credentials: bool, + /// Allowed remote networks. Empty means IP plus explicitly configured overlays. + pub onlynet: Vec, + /// Operator-managed IVNP SAM bridge. + pub i2p_sam: Option, + /// Enable I2P STREAM ACCEPT with a persistent application destination. + pub i2p_accept: bool, + /// External Tor control socket, enabling a persistent inbound onion service. + pub tor_control: Option, + /// Tor control cookie. Absence selects NULL authentication on a local control socket. + pub tor_cookie: Option, + /// Application identity directory, separate from IVNP router keys. + pub identity_dir: PathBuf, + /// Bitcoin service port advertised in overlay service endpoints. + pub port: u16, +} +impl Default for OverlayConfig { + fn default() -> Self { + Self { + proxy: None, + onion_proxy: None, + randomize_credentials: true, + onlynet: Vec::new(), + i2p_sam: None, + i2p_accept: false, + tor_control: None, + tor_cookie: None, + identity_dir: PathBuf::new(), + port: 8333, + } + } +} +impl OverlayConfig { + /// Validate routing and persistence requirements before starting workers. + pub fn validate(&self) -> io::Result<()> { + for socket in [self.proxy, self.onion_proxy, self.i2p_sam, self.tor_control] + .into_iter() + .flatten() + { + if socket.port() == 0 || !socket.ip().is_loopback() { + return Err(invalid( + "overlay services must use nonzero loopback socket endpoints", + )); + } + } + if self.i2p_accept && self.i2p_sam.is_none() { + return Err(invalid("i2p-accept requires i2p-sam")); + } + if self.tor_cookie.is_some() && self.tor_control.is_none() { + return Err(invalid("tor-cookie requires tor-control")); + } + if self.tor_control.is_some() && self.onion_proxy.is_none() { + return Err(invalid("tor-control requires onion-proxy")); + } + if (self.i2p_accept || self.tor_control.is_some()) + && (self.identity_dir.as_os_str().is_empty() || self.port == 0) + { + return Err(invalid( + "inbound overlay services require an identity directory and port", + )); + } + for network in &self.onlynet { + if !self.configured(*network) { + return Err(invalid("onlynet selects an unconfigured overlay route")); + } + } + if self.i2p_accept && !self.allows(PeerNetwork::I2p) + || self.tor_control.is_some() && !self.allows(PeerNetwork::Onion) + { + return Err(invalid("inbound overlay service is excluded by onlynet")); + } + Ok(()) + } + /// Whether this network has a route. + pub fn configured(&self, network: PeerNetwork) -> bool { + match network { + PeerNetwork::Ipv4 | PeerNetwork::Ipv6 => true, + PeerNetwork::Onion => self.onion_proxy.is_some(), + PeerNetwork::I2p => self.i2p_sam.is_some(), + } + } + /// Single allowed-network predicate, used at every outbound and inbound admission. + pub fn allows(&self, network: PeerNetwork) -> bool { + self.configured(network) && (self.onlynet.is_empty() || self.onlynet.contains(&network)) + } + pub(crate) fn local_dns_allowed(&self) -> bool { + self.proxy.is_none() && (self.allows(PeerNetwork::Ipv4) || self.allows(PeerNetwork::Ipv6)) + } +} + +/// One configured network's current transport/service state, independent of peer identity. +#[derive(Clone, Debug)] +pub struct OverlayNetworkState { + /// Remote network. + pub network: PeerNetwork, + /// Effective routing policy allows this network. + pub allowed: bool, + /// A live routed Bitcoin session or live inbound service currently exists. + pub operational: bool, + /// Configured local proxy/router endpoint. + pub proxy: Option, + /// Stable inbound service endpoint while its service is active. + pub local_address: Option, +} + +pub(crate) struct Connected { + pub(crate) stream: TcpStream, + pub(crate) alive: Option>, +} + +pub(crate) struct OverlayTransport { + pub(crate) config: Arc, + sam: RwLock>, + local_onion: RwLock>, + local_i2p: RwLock>, +} +impl OverlayTransport { + pub(crate) fn new(config: OverlayConfig) -> Self { + Self { + config: Arc::new(config), + sam: RwLock::new(None), + local_onion: RwLock::new(None), + local_i2p: RwLock::new(None), + } + } + + pub(crate) fn states(&self, table: &crate::PeerTable) -> Vec { + [ + PeerNetwork::Ipv4, + PeerNetwork::Ipv6, + PeerNetwork::Onion, + PeerNetwork::I2p, + ] + .into_iter() + .map(|network| { + let local_address = match network { + PeerNetwork::Onion => *self.local_onion.read(), + PeerNetwork::I2p => *self.local_i2p.read(), + _ => None, + }; + OverlayNetworkState { + network, + allowed: self.config.allows(network), + operational: local_address.is_some() + || table + .infos() + .iter() + .any(|peer| peer.addr.address().network() == network), + proxy: match network { + PeerNetwork::Onion => self.config.onion_proxy, + PeerNetwork::I2p => self.config.i2p_sam, + _ => self.config.proxy, + }, + local_address, + } + }) + .collect() + } + + pub(crate) fn connect( + &self, + endpoint: PeerEndpoint, + cancelled: &dyn Fn() -> bool, + ) -> io::Result { + if !self.config.allows(endpoint.address().network()) { + return Err(invalid("destination network disabled")); + } + let stream = match endpoint.address() { + PeerAddress::Ip(_) => { + if let Some(proxy) = self.config.proxy { + socks( + proxy, + endpoint, + self.config.randomize_credentials, + cancelled, + ) + } else { + TcpStream::connect_timeout( + &endpoint + .socket_addr() + .ok_or_else(|| invalid("missing IP socket"))?, + SETUP_TIMEOUT, + ) + } + } + PeerAddress::Onion(_) => socks( + self.config + .onion_proxy + .ok_or_else(|| invalid("onion route disabled"))?, + endpoint, + self.config.randomize_credentials, + cancelled, + ), + PeerAddress::I2p(_) => return self.sam_connect(endpoint, cancelled), + PeerAddress::AnonymousTor(_) => { + Err(invalid("anonymous inbound circuit is not dialable")) + } + }?; + Ok(Connected { + stream, + alive: None, + }) + } + + #[expect( + clippy::case_sensitive_file_extension_comparisons, + reason = "These normalized DNS suffixes identify protocols, not filesystem extensions" + )] + pub(crate) fn resolve( + &self, + text: &str, + cancelled: &dyn Fn() -> bool, + ) -> io::Result> { + use std::net::ToSocketAddrs as _; + if let Ok(endpoint) = text.parse::() { + return if self.config.allows(endpoint.address().network()) { + Ok(vec![endpoint]) + } else { + Err(invalid("destination network disabled")) + }; + } + let (host, port) = text + .rsplit_once(':') + .ok_or_else(|| invalid("peer requires host:port"))?; + let port = port + .parse::() + .map_err(|_| invalid("invalid peer port"))?; + let host = host.to_ascii_lowercase(); + let normalized = host.trim_end_matches('.'); + if normalized.ends_with(".onion") { + return Err(invalid("invalid Tor destination")); + } + if normalized.ends_with(".i2p") { + if !self.config.allows(PeerNetwork::I2p) + || host != normalized + || !host + .bytes() + .all(|ch| ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'.') + { + return Err(invalid("I2P naming disabled or invalid name")); + } + return Ok(vec![self.sam_lookup(&host, port, cancelled)?]); + } + if !self.config.local_dns_allowed() { + return Err(invalid( + "local DNS disabled by routing policy; use an IP or overlay destination", + )); + } + if cancelled() { + return Err(cancelled_error()); + } + Ok((host.as_str(), port) + .to_socket_addrs()? + .map(PeerEndpoint::from) + .filter(|endpoint| self.config.allows(endpoint.address().network())) + .collect()) + } + + pub(crate) fn start( + self: &Arc, + shared: &crate::listener::ConnectionShared, + shutdown: &Arc, + ) -> io::Result>>> { + self.config.validate()?; + // Read/validate existing keys synchronously: damaged/unreadable identities fail startup. + let i2p_key = if self.config.i2p_accept { + identity::read( + &self.config.identity_dir.join("i2p.key"), + identity::Kind::I2p, + )? + } else { + None + }; + let tor_key = if self.config.tor_control.is_some() { + identity::read( + &self.config.identity_dir.join("onion.key"), + identity::Kind::Tor, + )? + } else { + None + }; + let mut workers = Vec::new(); + if let Some(address) = self.config.i2p_sam + && self.config.allows(PeerNetwork::I2p) + { + let owner = Arc::clone(self); + let shared = shared.clone(); + let shutdown = Arc::clone(shutdown); + workers.push( + std::thread::Builder::new() + .name("bitcoin-rs-ivnp-sam".into()) + .spawn(move || { + owner.run_sam(address, i2p_key, &shared, &shutdown); + Ok(()) + })?, + ); + } + if let Some(address) = self.config.tor_control { + let owner = Arc::clone(self); + let shared = shared.clone(); + let tor_shutdown = Arc::clone(shutdown); + match std::thread::Builder::new() + .name("bitcoin-rs-tor-control".into()) + .spawn(move || { + owner.run_tor(address, tor_key, &shared, &tor_shutdown); + Ok(()) + }) { + Ok(worker) => workers.push(worker), + Err(error) => { + shutdown.store(true, Ordering::Release); + for worker in workers { + let _ = worker.join(); + } + return Err(error); + } + } + } + Ok(workers) + } +} + +pub(super) fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} +fn cancelled_error() -> io::Error { + io::Error::new(io::ErrorKind::Interrupted, "overlay operation cancelled") +} +const SETUP_TIMEOUT: Duration = Duration::from_secs(10); +const POLL: Duration = Duration::from_millis(100); +// All setup I/O has one absolute deadline, retains partial reads, and polls cancellation. +struct Setup<'a> { + stream: TcpStream, + deadline: Instant, + cancelled: &'a dyn Fn() -> bool, +} +impl<'a> Setup<'a> { + fn new(address: SocketAddr, cancelled: &'a dyn Fn() -> bool) -> io::Result { + let deadline = Instant::now() + SETUP_TIMEOUT; + let stream = connect(address, cancelled)?; + stream.set_read_timeout(Some(POLL))?; + stream.set_write_timeout(Some(POLL))?; + Ok(Self { + stream, + deadline, + cancelled, + }) + } + fn check(&self) -> io::Result<()> { + if (self.cancelled)() { + Err(cancelled_error()) + } else if Instant::now() >= self.deadline { + Err(io::Error::new( + io::ErrorKind::TimedOut, + "overlay setup deadline", + )) + } else { + Ok(()) + } + } + fn read(&mut self, bytes: &mut [u8]) -> io::Result<()> { + let mut offset = 0; + while offset < bytes.len() { + self.check()?; + match self.stream.read(&mut bytes[offset..]) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "overlay service closed", + )); + } + Ok(count) => offset += count, + Err(error) if poll_error(&error) => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + fn write(&mut self, bytes: &[u8]) -> io::Result<()> { + let mut offset = 0; + while offset < bytes.len() { + self.check()?; + match self.stream.write(&bytes[offset..]) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "overlay service closed", + )); + } + Ok(count) => offset += count, + Err(error) if poll_error(&error) => {} + Err(error) => return Err(error), + } + } + Ok(()) + } + fn line(&mut self) -> io::Result { + let mut line = Vec::new(); + let mut byte = [0]; + while line.len() < 16_384 { + self.read(&mut byte)?; + if byte[0] == b'\n' { + return String::from_utf8(line) + .map(|line| line.trim_end_matches('\r').to_owned()) + .map_err(|_| invalid("non-UTF8 overlay reply")); + } + line.push(byte[0]); + } + Err(invalid("oversized overlay reply")) + } + fn finish(self) -> io::Result { + self.stream.set_read_timeout(None)?; + self.stream.set_write_timeout(None)?; + Ok(self.stream) + } +} +fn poll_error(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted + ) +} +fn connect(address: SocketAddr, cancelled: &dyn Fn() -> bool) -> io::Result { + let deadline = Instant::now() + SETUP_TIMEOUT; + loop { + if cancelled() { + return Err(cancelled_error()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "transport connect deadline", + )); + } + match TcpStream::connect_timeout(&address, POLL.min(remaining)) { + Ok(stream) => return Ok(stream), + Err(error) if poll_error(&error) => {} + Err(error) => return Err(error), + } + } +} + +fn socks( + proxy: SocketAddr, + endpoint: PeerEndpoint, + isolate: bool, + cancelled: &dyn Fn() -> bool, +) -> io::Result { + use bitcoin::secp256k1::rand::RngCore as _; + let mut setup = Setup::new(proxy, cancelled)?; + setup.write(&[5, 1, if isolate { 2 } else { 0 }])?; + let mut response = [0; 2]; + setup.read(&mut response)?; + if response != [5, if isolate { 2 } else { 0 }] { + return Err(invalid("SOCKS authentication method refused")); + } + if isolate { + let mut credentials = [0_u8; 32]; + bitcoin::secp256k1::rand::thread_rng().fill_bytes(&mut credentials); + let mut authentication = vec![1, 16]; + authentication.extend_from_slice(&credentials[..16]); + authentication.push(16); + authentication.extend_from_slice(&credentials[16..]); + setup.write(&authentication)?; + setup.read(&mut response)?; + if response != [1, 0] { + return Err(invalid("SOCKS authentication refused")); + } + } + let mut request = vec![5, 1, 0]; + match endpoint.address() { + PeerAddress::Ip(std::net::IpAddr::V4(ip)) => { + request.push(1); + request.extend_from_slice(&ip.octets()); + } + PeerAddress::Ip(std::net::IpAddr::V6(ip)) => { + request.push(4); + request.extend_from_slice(&ip.octets()); + } + PeerAddress::Onion(_) => { + let host = endpoint.address().to_string(); + request.extend_from_slice(&[ + 3, + u8::try_from(host.len()).map_err(|_| invalid("SOCKS hostname too long"))?, + ]); + request.extend_from_slice(host.as_bytes()); + } + _ => return Err(invalid("unsupported SOCKS destination")), + } + if matches!(endpoint.address(), PeerAddress::Onion(_)) { + setup.deadline = Instant::now() + Duration::from_secs(60); + } + request.extend_from_slice(&endpoint.port().to_be_bytes()); + setup.write(&request)?; + let mut reply = [0; 4]; + setup.read(&mut reply)?; + if reply[..3] != [5, 0, 0] { + return Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + "SOCKS connect refused", + )); + } + let length = match reply[3] { + 1 => 4, + 4 => 16, + 3 => { + let mut count = [0]; + setup.read(&mut count)?; + usize::from(count[0]) + } + _ => return Err(invalid("malformed SOCKS bound address")), + }; + setup.read(&mut vec![0; length + 2])?; + setup.finish() +} + +fn pause(cancelled: &dyn Fn() -> bool, delay: Duration) { + let deadline = Instant::now() + delay; + while !cancelled() && Instant::now() < deadline { + std::thread::sleep(POLL.min(deadline.saturating_duration_since(Instant::now()))); + } +} +fn retire(table: &crate::PeerTable, network: PeerNetwork) { + for session in table.sessions() { + if session.addr.address().network() == network { + session.lease.cancel(); + } + } +} diff --git a/crates/p2p/src/overlay/identity.rs b/crates/p2p/src/overlay/identity.rs new file mode 100644 index 000000000..f714444a9 --- /dev/null +++ b/crates/p2p/src/overlay/identity.rs @@ -0,0 +1,193 @@ +//! Create-once application keys. Existing files are never replaced or repaired implicitly. +use super::invalid; +use base64::{ + Engine as _, alphabet, + engine::GeneralPurposeConfig, + engine::general_purpose::{GeneralPurpose, STANDARD}, +}; +use sha2::{Digest as _, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::Path, +}; +const LIMIT: u64 = 16_384; +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Stored { + version: u32, + key: String, + checksum: [u8; 32], +} +#[derive(Clone, Copy)] +pub(super) enum Kind { + Tor, + I2p, +} +#[expect( + clippy::expect_used, + reason = "The fixed I2P alphabet is a compile-time constant" +)] +pub(super) fn i2p_base64() -> GeneralPurpose { + GeneralPurpose::new( + &alphabet::Alphabet::new( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-~", + ) + .expect("I2P alphabet"), + GeneralPurposeConfig::new(), + ) +} +pub(super) fn public_destination(text: &str) -> io::Result> { + let bytes = i2p_base64() + .decode(text) + .map_err(|_| invalid("invalid I2P destination encoding"))?; + let length = destination_length(&bytes)?; + if bytes.len() != length { + return Err(invalid("invalid I2P public destination length")); + } + Ok(bytes) +} +fn destination_length(bytes: &[u8]) -> io::Result { + if bytes.len() < 387 { + return Err(invalid("truncated I2P destination")); + } + let length = 387 + usize::from(u16::from_be_bytes([bytes[385], bytes[386]])); + if length > bytes.len() { + return Err(invalid("truncated I2P certificate")); + } + Ok(length) +} +pub(super) fn validate(text: &str, kind: Kind) -> io::Result<()> { + match kind { + Kind::Tor => { + let key = text + .strip_prefix("ED25519-V3:") + .ok_or_else(|| invalid("invalid Tor service key type"))?; + if STANDARD + .decode(key) + .map_err(|_| invalid("invalid Tor service key encoding"))? + .len() + != 64 + { + return Err(invalid("invalid Tor service key length")); + } + } + Kind::I2p => { + let bytes = i2p_base64() + .decode(text) + .map_err(|_| invalid("invalid I2P private destination encoding"))?; + let public_length = destination_length(&bytes)?; + if bytes.len() < public_length + 32 || bytes.len() > 12_288 { + return Err(invalid("invalid I2P private destination length")); + } + } + } + Ok(()) +} +pub(super) fn read(path: &Path, kind: Kind) -> io::Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !metadata.is_file() || metadata.len() > LIMIT { + return Err(invalid("invalid overlay identity file type or size")); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(invalid("overlay identity file must be private (0600)")); + } + } + let mut text = String::new(); + File::open(path)? + .take(LIMIT + 1) + .read_to_string(&mut text)?; + let stored: Stored = + serde_json::from_str(&text).map_err(|_| invalid("damaged overlay identity file"))?; + if stored.version != 1 + || stored.checksum != <[u8; 32]>::from(Sha256::digest(stored.key.as_bytes())) + { + return Err(invalid("overlay identity version or checksum mismatch")); + } + validate(&stored.key, kind)?; + Ok(Some(stored.key)) +} +fn ensure_directory(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => return Ok(()), + Ok(_) => return Err(invalid("overlay identity directory is not a directory")), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let parent = path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + ensure_directory(parent)?; + let created = { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + fs::DirBuilder::new().mode(0o700).create(path) + } + #[cfg(not(unix))] + { + fs::create_dir(path) + } + }; + match created { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + ensure_directory(path)?; + } + Err(error) => return Err(error), + } + // A durable key also needs the newly created directory's name to survive. + bitcoin_rs_storage::checkpoint::fs::sync_dir( + &bitcoin_rs_storage::checkpoint::fs::open_data_dir(parent)?, + ) +} +pub(super) fn create(path: &Path, text: &str, kind: Kind) -> io::Result<()> { + use bitcoin::secp256k1::rand::RngCore as _; + validate(text, kind)?; + let bytes = serde_json::to_vec(&Stored { + version: 1, + key: text.to_owned(), + checksum: Sha256::digest(text.as_bytes()).into(), + }) + .map_err(|_| invalid("overlay identity serialization failed"))?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > LIMIT { + return Err(invalid("overlay identity file too large")); + } + let parent = path + .parent() + .ok_or_else(|| invalid("identity path has no parent"))?; + ensure_directory(parent)?; + let temporary = path.with_extension(format!( + "tmp-{}-{:016x}", + std::process::id(), + bitcoin::secp256k1::rand::thread_rng().next_u64() + )); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + let result = (|| { + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + // No replacement, including a key concurrently created by another process. + fs::hard_link(&temporary, path)?; + bitcoin_rs_storage::checkpoint::fs::sync_dir( + &bitcoin_rs_storage::checkpoint::fs::open_data_dir(parent)?, + ) + })(); + let _ = fs::remove_file(temporary); + result +} diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs new file mode 100644 index 000000000..7ed558bc6 --- /dev/null +++ b/crates/p2p/src/overlay/sam.rs @@ -0,0 +1,313 @@ +//! IVNP SAM v3 STREAM session lifecycle, naming, connect, and accept. +use super::{ + Arc, AtomicBool, Connected, Duration, Instant, Ordering, OverlayTransport, PeerAddress, + PeerEndpoint, PeerNetwork, SETUP_TIMEOUT, Setup, SocketAddr, cancelled_error, identity, + invalid, io, pause, retire, +}; +use sha2::{Digest, Sha256}; +pub(super) struct Session { + id: String, + alive: Arc, +} +fn hello(setup: &mut Setup<'_>) -> io::Result<()> { + setup.write(b"HELLO VERSION MIN=3.1 MAX=3.3\n")?; + let line = setup.line()?; + if !line.starts_with("HELLO REPLY ") + || field(&line, "RESULT") != Some("OK") + || !matches!(field(&line, "VERSION"), Some("3.1" | "3.2" | "3.3")) + { + return Err(invalid("unsupported or malformed SAM negotiation")); + } + Ok(()) +} +fn field<'a>(line: &'a str, name: &str) -> Option<&'a str> { + let mut matches = line + .split_ascii_whitespace() + .filter_map(|token| token.split_once('=')) + .filter(|(key, _)| *key == name); + let (_, value) = matches.next()?; + if matches.next().is_some() { + None + } else { + Some(value) + } +} +fn status(setup: &mut Setup<'_>, prefix: &str) -> io::Result { + let line = setup.line()?; + if !line.starts_with(prefix) || field(&line, "RESULT") != Some("OK") { + let result = match field(&line, "RESULT") { + Some("I2P_ERROR") => "I2P_ERROR", + Some("DUPLICATED_DEST") => "DUPLICATED_DEST", + Some("DUPLICATED_ID") => "DUPLICATED_ID", + Some("INVALID_KEY") => "INVALID_KEY", + Some("INVALID_ID") => "INVALID_ID", + Some("CANT_REACH_PEER") => "CANT_REACH_PEER", + Some("TIMEOUT") => "TIMEOUT", + Some("KEY_NOT_FOUND") => "KEY_NOT_FOUND", + _ => "malformed reply", + }; + return Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + format!("SAM {prefix}{result}"), + )); + } + Ok(line) +} +fn destination(text: &str) -> io::Result { + Ok(PeerAddress::I2p( + Sha256::digest(identity::public_destination(text)?).into(), + )) +} + +impl OverlayTransport { + fn sam_session(&self) -> io::Result<(String, Arc)> { + let (id, alive) = self + .sam + .read() + .as_ref() + .map(|session| (session.id.clone(), Arc::clone(&session.alive))) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::NotConnected, "IVNP SAM session unavailable") + })?; + if !alive.load(Ordering::Acquire) { + return Err(cancelled_error()); + } + Ok((id, alive)) + } + pub(super) fn sam_lookup( + &self, + name: &str, + port: u16, + cancelled: &dyn Fn() -> bool, + ) -> io::Result { + let (_, alive) = self.sam_session()?; + let stop = || cancelled() || !alive.load(Ordering::Acquire); + let mut setup = Setup::new( + self.config.i2p_sam.ok_or_else(|| invalid("SAM disabled"))?, + &stop, + )?; + hello(&mut setup)?; + setup.write(format!("NAMING LOOKUP NAME={name}\n").as_bytes())?; + let line = status(&mut setup, "NAMING REPLY ")?; + if !alive.load(Ordering::Acquire) { + return Err(cancelled_error()); + } + Ok(PeerEndpoint::new( + destination( + field(&line, "VALUE").ok_or_else(|| invalid("SAM naming lacks destination"))?, + )?, + port, + )) + } + pub(super) fn sam_connect( + &self, + endpoint: PeerEndpoint, + cancelled: &dyn Fn() -> bool, + ) -> io::Result { + let (id, alive) = self.sam_session()?; + let stop = || cancelled() || !alive.load(Ordering::Acquire); + let mut setup = Setup::new( + self.config.i2p_sam.ok_or_else(|| invalid("SAM disabled"))?, + &stop, + )?; + hello(&mut setup)?; + setup.deadline = Instant::now() + Duration::from_secs(60); + // IVNP resolves base32 names inside the router; no system DNS is involved. + setup.write( + format!( + "STREAM CONNECT ID={id} DESTINATION={} TO_PORT={} SILENT=false\n", + endpoint.address(), + endpoint.port() + ) + .as_bytes(), + )?; + status(&mut setup, "STREAM STATUS ")?; + if !alive.load(Ordering::Acquire) { + return Err(cancelled_error()); + } + Ok(Connected { + stream: setup.finish()?, + alive: Some(alive), + }) + } + pub(super) fn run_sam( + &self, + address: SocketAddr, + mut key: Option, + shared: &crate::listener::ConnectionShared, + shutdown: &AtomicBool, + ) { + use bitcoin::secp256k1::rand::RngCore as _; + let cancelled = || shutdown.load(Ordering::Acquire) || shared.is_session_cancelled(); + let mut backoff = Duration::from_secs(1); + while !cancelled() { + let result = (|| -> io::Result<()> { + let mut control = Setup::new(address, &cancelled)?; + hello(&mut control)?; + if self.config.i2p_accept && key.is_none() { + control.write(b"DEST GENERATE SIGNATURE_TYPE=7\n")?; + let line = control.line()?; + if !line.starts_with("DEST REPLY ") { + return Err(invalid("SAM destination generation refused")); + } + let private = field(&line, "PRIV") + .ok_or_else(|| invalid("SAM destination lacks private key"))?; + identity::create( + &self.config.identity_dir.join("i2p.key"), + private, + identity::Kind::I2p, + )?; + key = Some(private.to_owned()); + } + let id = format!( + "bitcoin-rs-{:016x}", + bitcoin::secp256k1::rand::thread_rng().next_u64() + ); + control.deadline = Instant::now() + Duration::from_secs(120); + control.write( + format!( + "SESSION CREATE STYLE=STREAM ID={id} DESTINATION={} TO_PORT={}\n", + key.as_deref().unwrap_or("TRANSIENT"), + self.config.port + ) + .as_bytes(), + )?; + status(&mut control, "SESSION STATUS ")?; + control.deadline = Instant::now() + SETUP_TIMEOUT; + control.write(b"NAMING LOOKUP NAME=ME\n")?; + let reply = status(&mut control, "NAMING REPLY ")?; + let local = PeerEndpoint::new( + destination( + field(&reply, "VALUE") + .ok_or_else(|| invalid("SAM local destination missing"))?, + )?, + self.config.port, + ); + let alive = Arc::new(AtomicBool::new(true)); + *self.sam.write() = Some(Session { + id: id.clone(), + alive: Arc::clone(&alive), + }); + if self.config.i2p_accept { + *self.local_i2p.write() = Some(local); + } + backoff = Duration::from_secs(1); + + std::thread::scope(|scope| { + let accept = self + .config + .i2p_accept + .then(|| scope.spawn(|| self.accept_sam(&id, &alive, shared, &cancelled))); + let monitored = (|| -> io::Result<()> { + while !cancelled() { + control.deadline = Instant::now() + SETUP_TIMEOUT; + control.write(b"PING bitcoin-rs\n")?; + if control.line()? != "PONG bitcoin-rs" { + return Err(invalid("SAM control heartbeat failed")); + } + pause(&cancelled, Duration::from_secs(1)); + } + Ok(()) + })(); + alive.store(false, Ordering::Release); + if let Some(accept) = accept { + let _ = accept.join(); + } + monitored + }) + })(); + let retired = self.sam.write().take(); + if let Some(session) = retired { + session.alive.store(false, Ordering::Release); + } + *self.local_i2p.write() = None; + retire(&shared.peer_table, PeerNetwork::I2p); + if let Err(error) = result { + if !cancelled() { + tracing::warn!(%error, "IVNP SAM unavailable; retiring streams and retrying"); + } + } + pause(&cancelled, backoff); + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } + fn accept_sam( + &self, + id: &str, + alive: &Arc, + shared: &crate::listener::ConnectionShared, + cancelled: &dyn Fn() -> bool, + ) { + let stop = || cancelled() || !alive.load(Ordering::Acquire); + while !stop() { + let result = (|| -> io::Result<()> { + let mut setup = Setup::new( + self.config.i2p_sam.ok_or_else(|| invalid("SAM disabled"))?, + &stop, + )?; + hello(&mut setup)?; + setup.write(format!("STREAM ACCEPT ID={id} SILENT=false\n").as_bytes())?; + // IVNP sends STATUS only after accepting a circuit. This is a listener wait, + // not an unbounded setup operation; cancellation still polls every 100ms. + setup.deadline = Instant::now() + Duration::from_hours(24); + status(&mut setup, "STREAM STATUS ")?; + setup.deadline = Instant::now() + SETUP_TIMEOUT; + let metadata = setup.line()?; + let public = metadata + .split_ascii_whitespace() + .next() + .ok_or_else(|| invalid("SAM missing remote destination"))?; + let remote = destination(public)?; + let port = field(&metadata, "FROM_PORT") + .ok_or_else(|| invalid("SAM missing remote port"))? + .parse::() + .map_err(|_| invalid("invalid SAM remote port"))?; + crate::listener::admit_inbound_with_epoch( + setup.finish()?, + PeerEndpoint::new(remote, port), + shared, + Some(Arc::clone(alive)), + ); + Ok(()) + })(); + if let Err(error) = result { + if !stop() { + tracing::warn!(%error, "IVNP inbound stream rejected"); + pause(&stop, Duration::from_secs(1)); + } + } + } + } +} + +#[cfg(test)] +#[expect(clippy::expect_used)] +mod tests { + use super::*; + use std::io::{BufRead as _, BufReader, Write as _}; + use std::net::TcpListener; + + #[test] + fn negotiation_refuses_unsupported_duplicate_and_malformed_replies() { + for reply in [ + "HELLO REPLY RESULT=OK VERSION=4.0\n", + "HELLO REPLY RESULT=NOVERSION\n", + "HELLO REPLY RESULT=OK RESULT=OK VERSION=3.3\n", + "SESSION STATUS RESULT=OK VERSION=3.3\n", + ] { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut line = String::new(); + BufReader::new(stream.try_clone().expect("clone")) + .read_line(&mut line) + .expect("request"); + stream.write_all(reply.as_bytes()).expect("reply"); + }); + let mut setup = Setup::new(address, &|| false).expect("setup"); + assert!(hello(&mut setup).is_err()); + fixture.join().expect("fixture"); + } + } +} diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs new file mode 100644 index 000000000..3876abd7b --- /dev/null +++ b/crates/p2p/src/overlay/tests.rs @@ -0,0 +1,317 @@ +use super::*; +use std::net::TcpListener; + +fn shared(owner: &Arc) -> crate::listener::ConnectionShared { + let mut shared = crate::listener::ConnectionShared::new( + Arc::new(crate::PeerTable::new()), + crate::BannedReader::fixture_empty(), + Arc::new(crate::NetworkActivity::from_shared(Arc::new( + AtomicBool::new(true), + ))), + Arc::new(AtomicBool::new(false)), + None, + bitcoin::p2p::Magic::REGTEST, + crossbeam_channel::unbounded().0, + crossbeam_channel::unbounded().0, + None, + None, + crate::ListenerExtras::default(), + ); + shared.overlay = Arc::clone(owner); + shared +} +fn until(predicate: impl Fn() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !predicate() { + assert!(Instant::now() < deadline, "fixture deadline"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn socks_routes_onion_as_a_domain_and_preserves_stream_bytes() { + let listener = TcpListener::bind("127.0.0.1:0").expect("proxy"); + let proxy = listener.local_addr().expect("address"); + let endpoint = PeerEndpoint::new(PeerAddress::Onion([7; 32]), 8333); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut hello = [0; 3]; + stream.read_exact(&mut hello).expect("hello"); + assert_eq!(hello, [5, 1, 0]); + stream.write_all(&[5, 0]).expect("method"); + let mut request = [0; 5]; + stream.read_exact(&mut request).expect("request"); + assert_eq!(request[..4], [5, 1, 0, 3]); + let mut domain = vec![0; usize::from(request[4])]; + stream.read_exact(&mut domain).expect("domain"); + assert_eq!(domain, endpoint.address().to_string().as_bytes()); + let mut port = [0; 2]; + stream.read_exact(&mut port).expect("port"); + assert_eq!(u16::from_be_bytes(port), 8333); + // Fragmenting the reply must not lose framing; bound IP is transport metadata only. + for byte in [5, 0, 0, 1, 127, 0, 0, 1, 0, 0] { + stream.write_all(&[byte]).expect("reply"); + } + stream.write_all(b"bitcoin").expect("P2P bytes"); + }); + let mut stream = socks(proxy, endpoint, false, &|| false).expect("SOCKS stream"); + let mut bytes = [0; 7]; + stream.read_exact(&mut bytes).expect("unconsumed P2P bytes"); + assert_eq!(&bytes, b"bitcoin"); + server.join().expect("fixture"); +} + +#[test] +fn socks_requires_the_selected_auth_policy_and_refuses_bad_replies() { + for response in [[5, 0], [4, 2], [5, 255]] { + let listener = TcpListener::bind("127.0.0.1:0").expect("proxy"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut hello = [0; 3]; + stream.read_exact(&mut hello).expect("hello"); + assert_eq!(hello, [5, 1, 2]); + stream.write_all(&response).expect("reply"); + }); + assert!( + socks( + address, + PeerEndpoint::new(PeerAddress::Onion([1; 32]), 8333), + true, + &|| false + ) + .is_err() + ); + fixture.join().expect("fixture"); + } +} + +#[test] +fn setup_retains_partial_reads_and_obeys_deadline_and_cancellation() { + let listener = TcpListener::bind("127.0.0.1:0").expect("proxy"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + stream.write_all(&[5]).expect("partial"); + std::thread::sleep(Duration::from_millis(250)); + stream.write_all(&[0]).expect("rest"); + std::thread::sleep(Duration::from_millis(300)); + }); + let stop = AtomicBool::new(false); + let cancelled = || stop.load(Ordering::Acquire); + let mut setup = Setup::new(address, &cancelled).expect("connect"); + let mut reply = [0; 2]; + setup.read(&mut reply).expect("partial read survives polls"); + assert_eq!(reply, [5, 0]); + setup.deadline = Instant::now(); + assert_eq!( + setup.read(&mut [0]).expect_err("deadline").kind(), + io::ErrorKind::TimedOut + ); + setup.deadline = Instant::now() + SETUP_TIMEOUT; + stop.store(true, Ordering::Release); + assert_eq!( + setup.read(&mut [0]).expect_err("cancel").kind(), + io::ErrorKind::Interrupted + ); + fixture.join().expect("fixture"); +} + +#[test] +fn overlay_only_resolution_and_admission_never_fall_back_to_ip_or_dns() { + let config = OverlayConfig { + onlynet: vec![PeerNetwork::Onion], + onion_proxy: Some("127.0.0.1:9050".parse().expect("proxy")), + ..OverlayConfig::default() + }; + config.validate().expect("config"); + let transport = OverlayTransport::new(config); + for text in [ + "127.0.0.1:8333", + "localhost:8333", + "bad.onion:8333", + "bad.onion.:8333", + "name.i2p:8333", + "name.i2p.:8333", + ] { + assert!(transport.resolve(text, &|| false).is_err(), "{text}"); + } + assert!( + transport + .resolve( + &PeerEndpoint::new(PeerAddress::Onion([2; 32]), 8333).to_string(), + &|| false + ) + .is_ok() + ); + assert!( + transport + .connect("127.0.0.1:1".parse().expect("IP"), &|| false) + .is_err() + ); +} + +#[test] +fn application_identity_is_create_once_and_corruption_is_explicit() { + use base64::Engine as _; + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("onion.key"); + assert!( + identity::read(&path, identity::Kind::Tor) + .expect("absent") + .is_none() + ); + let key = format!( + "ED25519-V3:{}", + base64::engine::general_purpose::STANDARD.encode([9; 64]) + ); + identity::create(&path, &key, identity::Kind::Tor).expect("create"); + assert_eq!( + identity::read(&path, identity::Kind::Tor).expect("reuse"), + Some(key.clone()) + ); + assert!(identity::create(&path, &key, identity::Kind::Tor).is_err()); + std::fs::write(&path, "damaged").expect("damage"); + assert!(identity::read(&path, identity::Kind::Tor).is_err()); + assert_eq!(std::fs::read_to_string(path).expect("preserved"), "damaged"); +} + +#[test] +#[expect( + clippy::too_many_lines, + reason = "One fixture follows control setup, loss, epoch revocation, reconnect, and shutdown" +)] +fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback() { + use base64::Engine as _; + use sha2::{Digest as _, Sha256}; + use std::io::{BufRead as _, BufReader}; + use std::sync::atomic::AtomicUsize; + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM fixture"); + listener.set_nonblocking(true).expect("nonblocking"); + let address = listener.local_addr().expect("address"); + let owner = Arc::new(OverlayTransport::new(OverlayConfig { + i2p_sam: Some(address), + onlynet: vec![PeerNetwork::I2p], + ..OverlayConfig::default() + })); + let shared = shared(&owner); + let shutdown = Arc::new(AtomicBool::new(false)); + let controls = Arc::new(AtomicUsize::new(0)); + let break_control = Arc::new(AtomicBool::new(false)); + let public = identity::i2p_base64().encode([0; 387]); + let endpoint = PeerEndpoint::new(PeerAddress::I2p(Sha256::digest([0; 387]).into()), 18444); + let fixture = + { + let stop = Arc::clone(&shutdown); + let controls = Arc::clone(&controls); + let break_control = Arc::clone(&break_control); + std::thread::spawn(move || { + let mut connections = Vec::new(); + while !stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_nonblocking(false) + .expect("blocking fixture stream"); + let stop = Arc::clone(&stop); + let controls = Arc::clone(&controls); + let break_control = Arc::clone(&break_control); + let public = public.clone(); + connections.push(std::thread::spawn(move || { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("hello"); + + assert_eq!(line, "HELLO VERSION MIN=3.1 MAX=3.3\n"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + let mut epoch = 0; + while !stop.load(Ordering::Acquire) { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + + if line.starts_with("SESSION CREATE STYLE=STREAM ") { + assert!(line.contains("DESTINATION=TRANSIENT")); + epoch = controls.fetch_add(1, Ordering::AcqRel) + 1; + stream + .write_all(b"SESSION STATUS RESULT=OK\n") + .expect("session"); + } else if line == "NAMING LOOKUP NAME=ME\n" { + stream + .write_all( + format!( + "NAMING REPLY RESULT=OK NAME=ME VALUE={public}\n" + ) + .as_bytes(), + ) + .expect("local destination"); + } else if line.starts_with("STREAM CONNECT ") { + assert!(line.contains(&format!( + "DESTINATION={}", + endpoint.address() + ))); + assert!(line.contains("TO_PORT=18444 SILENT=false")); + stream + .write_all(b"STREAM STATUS RESULT=OK\nbitcoin") + .expect("stream"); + while !stop.load(Ordering::Acquire) { + std::thread::sleep(POLL); + } + break; + } else if line == "PING bitcoin-rs\n" { + if epoch == 1 && break_control.load(Ordering::Acquire) { + break; + } + stream.write_all(b"PONG bitcoin-rs\n").expect("heartbeat"); + } else { + panic!("unexpected SAM command"); + } + } + })); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept: {error}"), + } + } + for connection in connections { + connection.join().expect("connection fixture"); + } + }) + }; + let workers = owner.start(&shared, &shutdown).expect("backend worker"); + until(|| owner.sam.read().is_some()); + let mut connected = owner.connect(endpoint, &|| false).expect("SAM stream"); + let mut bytes = [0; 7]; + connected.stream.read_exact(&mut bytes).expect("P2P bytes"); + assert_eq!(&bytes, b"bitcoin"); + let (tx, _rx) = crossbeam_channel::unbounded(); + let lease = crate::PeerLease::new(tx).with_transport_alive(connected.alive.clone()); + shared.peer_table.register(endpoint, lease.clone()); + assert!(!lease.is_cancelled()); + break_control.store(true, Ordering::Release); + until(|| lease.is_cancelled()); + until(|| controls.load(Ordering::Acquire) >= 2 && owner.sam.read().is_some()); + assert!( + connected + .alive + .is_some_and(|alive| !alive.load(Ordering::Acquire)), + "old stream never adopts new session" + ); + assert!( + !shared.banned.is_discouraged(endpoint.address()), + "local router loss is not remote misbehavior" + ); + shutdown.store(true, Ordering::Release); + for worker in workers { + worker.join().expect("worker").expect("backend"); + } + fixture.join().expect("SAM fixture"); +} diff --git a/crates/p2p/src/overlay/tor.rs b/crates/p2p/src/overlay/tor.rs new file mode 100644 index 000000000..6a1d54d0b --- /dev/null +++ b/crates/p2p/src/overlay/tor.rs @@ -0,0 +1,138 @@ +//! External Tor control service, with a dedicated unprivileged forwarding listener. +use super::{ + AtomicBool, Duration, Instant, Ordering, OverlayTransport, POLL, PeerAddress, PeerEndpoint, + SETUP_TIMEOUT, Setup, SocketAddr, identity, invalid, io, pause, +}; +use std::io::Read as _; +use std::net::TcpListener; +use std::sync::atomic::AtomicU64; +static NEXT_ANONYMOUS: AtomicU64 = AtomicU64::new(1); +fn reply(setup: &mut Setup<'_>) -> io::Result> { + let mut lines = Vec::new(); + for _ in 0..32 { + let line = setup.line()?; + if !line.starts_with("250") || line.len() < 4 { + return Err(invalid("Tor control request refused or malformed reply")); + } + let terminal = line.as_bytes()[3] == b' '; + if !terminal && line.as_bytes()[3] != b'-' { + return Err(invalid("unsupported Tor control reply")); + } + lines.push(line[4..].to_owned()); + if terminal { + return Ok(lines); + } + } + Err(invalid("oversized Tor control reply")) +} +impl OverlayTransport { + pub(super) fn run_tor( + &self, + address: SocketAddr, + mut key: Option, + shared: &crate::listener::ConnectionShared, + shutdown: &AtomicBool, + ) { + use bitcoin::hex::DisplayHex as _; + let cancelled = || shutdown.load(Ordering::Acquire) || shared.is_session_cancelled(); + let mut backoff = Duration::from_secs(1); + while !cancelled() { + let result = (|| -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let forwarding = listener.local_addr()?; + let mut control = Setup::new(address, &cancelled)?; + let authentication = if let Some(path) = &self.config.tor_cookie { + let mut bytes = Vec::with_capacity(33); + std::fs::File::open(path)? + .take(33) + .read_to_end(&mut bytes)?; + if bytes.len() != 32 { + return Err(invalid("Tor cookie must contain 32 bytes")); + } + format!("AUTHENTICATE {}\r\n", bytes.to_lower_hex_string()) + } else { + "AUTHENTICATE\r\n".to_owned() + }; + control.write(authentication.as_bytes())?; + reply(&mut control)?; + control.write( + format!( + "ADD_ONION {} Port={},{}\r\n", + key.as_deref().unwrap_or("NEW:ED25519-V3"), + self.config.port, + forwarding + ) + .as_bytes(), + )?; + let lines = reply(&mut control)?; + let service = lines + .iter() + .find_map(|line| line.strip_prefix("ServiceID=")) + .ok_or_else(|| invalid("Tor control missing service ID"))?; + let endpoint = format!("{service}.onion:{}", self.config.port) + .parse::() + .map_err(invalid)?; + if key.is_none() { + let private = lines + .iter() + .find_map(|line| line.strip_prefix("PrivateKey=")) + .ok_or_else(|| invalid("Tor control missing private key"))?; + identity::create( + &self.config.identity_dir.join("onion.key"), + private, + identity::Kind::Tor, + )?; + key = Some(private.to_owned()); + } + *self.local_onion.write() = Some(endpoint); + tracing::info!(service = %endpoint, %forwarding, "Tor inbound service active"); + backoff = Duration::from_secs(1); + let mut next_heartbeat = Instant::now(); + while !cancelled() { + if Instant::now() >= next_heartbeat { + control.deadline = Instant::now() + SETUP_TIMEOUT; + control.write(b"GETINFO version\r\n")?; + reply(&mut control)?; + next_heartbeat = Instant::now() + Duration::from_secs(1); + } + match listener.accept() { + Ok((stream, _forward_socket)) => { + let id = NEXT_ANONYMOUS + .try_update(Ordering::AcqRel, Ordering::Acquire, |id| { + id.checked_add(1) + }) + .map_err(|_| invalid("Tor inbound identity exhausted"))?; + crate::listener::admit_inbound( + stream, + PeerEndpoint::new(PeerAddress::AnonymousTor(id), 0), + shared, + ); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + pause(&cancelled, POLL); + } + Err(error) => return Err(error), + } + } + // No Detach flag: closing the control socket removes only this service. + Ok(()) + })(); + *self.local_onion.write() = None; + // Forwarded connections belong to the lost service, but outbound Tor circuits + // use the independently configured SOCKS route and need not be retired. + for session in shared.peer_table.sessions() { + if matches!(session.addr.address(), PeerAddress::AnonymousTor(_)) { + session.lease.cancel(); + } + } + if let Err(error) = result { + if !cancelled() { + tracing::warn!(%error, "Tor inbound service unavailable; retrying"); + } + } + pause(&cancelled, backoff); + backoff = (backoff * 2).min(Duration::from_secs(60)); + } + } +} diff --git a/crates/p2p/src/peer.rs b/crates/p2p/src/peer.rs index bbc2dfebb..2c4217849 100644 --- a/crates/p2p/src/peer.rs +++ b/crates/p2p/src/peer.rs @@ -1,5 +1,6 @@ +use bitcoin_rs_primitives::PeerEndpoint; use std::io::{Read, Write}; -use std::net::{SocketAddr, ToSocketAddrs}; +use std::net::ToSocketAddrs; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -351,7 +352,7 @@ impl Peer { /// DNS resolver injection point for peer discovery. pub(crate) trait DnsResolver: Send + Sync { /// Resolve a DNS seed name into socket addresses. - fn resolve(&self, seed: &str) -> Result, PeerError>; + fn resolve(&self, seed: &str) -> Result, PeerError>; } /// DNS resolver backed by the operating system resolver. @@ -369,11 +370,11 @@ impl SystemDnsResolver { } impl DnsResolver for SystemDnsResolver { - fn resolve(&self, seed: &str) -> Result, PeerError> { + fn resolve(&self, seed: &str) -> Result, PeerError> { let seed = seed.trim_end_matches('.'); (seed, self.port) .to_socket_addrs() - .map(std::iter::Iterator::collect) + .map(|addresses| addresses.map(PeerEndpoint::from).collect()) .map_err(PeerError::Io) } } @@ -521,7 +522,7 @@ mod tests { assert!( resolver .resolve("127.0.0.1.")? - .contains(&SocketAddr::from(([127, 0, 0, 1], 8333))) + .contains(&PeerEndpoint::from(([127, 0, 0, 1], 8333))) ); Ok(()) } @@ -533,7 +534,7 @@ mod tests { let mut peer = Peer::new(Cursor::new(Vec::new()), Magic::BITCOIN); peer.attach_net_trace(crate::net_trace::NetTrace::outbound( 4, - SocketAddr::from(([127, 0, 0, 1], 8333)), + PeerEndpoint::from(([127, 0, 0, 1], 8333)), crate::peer_info::PeerRole::FullRelay, false, )); @@ -563,7 +564,7 @@ mod tests { let mut peer = Peer::new(Cursor::new(frame), Magic::BITCOIN); peer.attach_net_trace(crate::net_trace::NetTrace::inbound( 2, - SocketAddr::from(([127, 0, 0, 1], 8333)), + PeerEndpoint::from(([127, 0, 0, 1], 8333)), )); let (message, payload) = peer @@ -581,7 +582,11 @@ mod tests { use std::net::{TcpListener, TcpStream}; use std::time::{Duration, Instant}; - let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let listener = TcpListener::bind( + PeerEndpoint::from(([127, 0, 0, 1], 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let mut sender = TcpStream::connect(listener.local_addr()?)?; let (receiver, _) = listener.accept()?; receiver.set_read_timeout(Some(Duration::from_millis(20)))?; diff --git a/crates/p2p/src/peer_info.rs b/crates/p2p/src/peer_info.rs index ae965000a..255e2fd00 100644 --- a/crates/p2p/src/peer_info.rs +++ b/crates/p2p/src/peer_info.rs @@ -1,6 +1,6 @@ //! Public peer metadata published after a successful handshake. -use std::net::SocketAddr; +use bitcoin_rs_primitives::PeerEndpoint; use std::sync::Arc; use bitcoin::p2p::message_network::VersionMessage; @@ -39,8 +39,8 @@ impl PeerRole { #[derive(Clone, Debug, PartialEq, Eq)] #[expect(clippy::struct_excessive_bools)] pub struct PeerInfo { - /// Remote socket address. - pub addr: SocketAddr, + /// Logical remote Bitcoin endpoint. + pub addr: PeerEndpoint, /// Protocol version advertised by the remote. pub version: u32, /// Whether this connection requested BIP339 witness-id announcements. @@ -77,7 +77,7 @@ pub struct PeerInfo { /// on this connection -- not the peer's. Reporting the peer's address /// twice, as this used to, tells an operator listening on several /// interfaces nothing about which one carried the connection. - pub addr_bind: SocketAddr, + pub addr_bind: std::net::SocketAddr, /// Seconds the peer's clock is ahead of this node's, from its version. pub time_offset: i64, /// Live traffic counters for the connection. @@ -90,8 +90,8 @@ impl PeerInfo { /// Constructs a `PeerInfo` for an inbound peer from the captured remote `VersionMessage`. #[must_use] pub fn inbound_from_version( - addr: SocketAddr, - addr_bind: SocketAddr, + addr: PeerEndpoint, + addr_bind: std::net::SocketAddr, version: &VersionMessage, conn_time: u64, version_received_time: u64, @@ -113,8 +113,8 @@ impl PeerInfo { /// Constructs a `PeerInfo` for an outbound peer from the captured remote `VersionMessage`. #[must_use] pub(crate) fn outbound_from_version( - addr: SocketAddr, - addr_bind: SocketAddr, + addr: PeerEndpoint, + addr_bind: std::net::SocketAddr, version: &VersionMessage, conn_time: u64, version_received_time: u64, @@ -171,7 +171,8 @@ pub fn service_flag_names(flags: u64) -> Vec<&'static str> { mod tests { use super::*; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{IpAddr, Ipv4Addr}; use bitcoin::p2p::ServiceFlags; use bitcoin::p2p::address::Address; @@ -183,11 +184,11 @@ mod tests { services: ServiceFlags::NETWORK, timestamp: 0, receiver: Address::new( - &SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333), + &std::net::SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333), ServiceFlags::NONE, ), sender: Address::new( - &SocketAddr::new(IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8)), 8333), + &std::net::SocketAddr::new(IpAddr::V4(Ipv4Addr::new(5, 6, 7, 8)), 8333), ServiceFlags::NETWORK, ), nonce: 0, @@ -198,10 +199,18 @@ mod tests { } fn peer_info_with_services(services: u64) -> PeerInfo { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); PeerInfo { services, - ..PeerInfo::outbound_from_version(addr, addr, &fake_version(), 0, 0, counters()) + ..PeerInfo::outbound_from_version( + addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + &fake_version(), + 0, + 0, + counters(), + ) } } @@ -219,12 +228,13 @@ mod tests { #[test] fn time_offset_is_the_peers_clock_against_ours() { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); let mut version = fake_version(); version.timestamp = 1_700_000_120; let info = PeerInfo::inbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 1_700_000_000, 1_700_000_000, @@ -235,7 +245,8 @@ mod tests { version.timestamp = 1_699_999_940; let behind = PeerInfo::inbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 1_700_000_000, 1_700_000_000, @@ -248,8 +259,8 @@ mod tests { fn peer_table_projection_keeps_remote_and_bind_apart() { use crate::peer_table::PeerTable; - let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); - let bind = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 51_234); + let peer = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let bind = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 51_234); let table = PeerTable::new(); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); @@ -258,7 +269,15 @@ mod tests { table.publish_info( peer, &lease, - PeerInfo::inbound_from_version(peer, bind, &fake_version(), 0, 0, counters()) + PeerInfo::inbound_from_version( + peer, + bind.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + &fake_version(), + 0, + 0, + counters() + ) ), "the live connection publishes" ); @@ -266,18 +285,37 @@ mod tests { let infos = table.infos(); assert_eq!(infos.len(), 1); assert_eq!(infos[0].addr, peer); - assert_eq!(infos[0].addr_bind, bind); - assert_ne!(infos[0].addr, infos[0].addr_bind); + assert_eq!( + infos[0].addr_bind, + bind.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")) + ); + assert_ne!(infos[0].addr.socket_addr(), Some(infos[0].addr_bind)); } #[test] fn constructors_preserve_direction_and_handshake_metadata() { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); let version = fake_version(); let counters = counters(); - let inbound = - PeerInfo::inbound_from_version(addr, addr, &version, 100, 50, Arc::clone(&counters)); - let outbound = PeerInfo::outbound_from_version(addr, addr, &version, 100, 50, counters); + let inbound = PeerInfo::inbound_from_version( + addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + &version, + 100, + 50, + Arc::clone(&counters), + ); + let outbound = PeerInfo::outbound_from_version( + addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + &version, + 100, + 50, + counters, + ); assert!(inbound.inbound); assert!(!outbound.inbound); assert_eq!(outbound.start_height, 7); @@ -298,8 +336,16 @@ mod tests { #[test] fn services_names_decodes_inbound_peer_with_network_witness() { let version = version_with_services(ServiceFlags::NETWORK | ServiceFlags::WITNESS); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); - let info = PeerInfo::inbound_from_version(addr, addr, &version, 0, 0, counters()); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let info = PeerInfo::inbound_from_version( + addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + &version, + 0, + 0, + counters(), + ); assert_eq!(info.services_names(), vec!["NETWORK", "WITNESS"]); } diff --git a/crates/p2p/src/peer_table.rs b/crates/p2p/src/peer_table.rs index 08f15169f..6b2853674 100644 --- a/crates/p2p/src/peer_table.rs +++ b/crates/p2p/src/peer_table.rs @@ -8,8 +8,8 @@ //! mutate peer sessions only through this type, so the registration, //! replacement, and cancellation rules have exactly one implementation. +use bitcoin_rs_primitives::PeerEndpoint; use std::hash::{BuildHasher, RandomState}; -use std::net::SocketAddr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -28,8 +28,8 @@ use crate::peer_info::PeerRole; /// One live connection joined with its handshake metadata. #[derive(Clone, Debug)] pub struct PeerSession { - /// Remote socket address. - pub addr: SocketAddr, + /// Logical remote Bitcoin endpoint. + pub addr: PeerEndpoint, /// Control handle for the connection. pub lease: PeerLease, /// Handshake metadata, `None` while the handshake is still in progress. @@ -95,7 +95,7 @@ impl Entry { } } - fn session(&self, addr: SocketAddr) -> PeerSession { + fn session(&self, addr: PeerEndpoint) -> PeerSession { PeerSession { addr, lease: self.lease.clone(), @@ -114,7 +114,7 @@ impl Entry { /// exactly once, either in `map` or in `retired`/`settled_*`. #[derive(Debug, Default)] struct TableView { - map: HashMap, + map: HashMap, /// Counter sets of dropped connections whose teardown may still be in /// flight. retired: Vec>, @@ -124,7 +124,7 @@ struct TableView { } impl std::ops::Deref for TableView { - type Target = HashMap; + type Target = HashMap; fn deref(&self) -> &Self::Target { &self.map @@ -168,11 +168,11 @@ impl PeerTable { /// replacing any predecessor. Returns whether a different connection was /// replaced; re-registering the same connection is a no-op that keeps its /// published metadata. - pub fn register(&self, addr: SocketAddr, lease: PeerLease) -> bool { + pub fn register(&self, addr: PeerEndpoint, lease: PeerLease) -> bool { Self::register_locked(&mut self.entries.write(), addr, Entry::fresh(lease)) } - fn register_locked(entries: &mut TableView, addr: SocketAddr, entry: Entry) -> bool { + fn register_locked(entries: &mut TableView, addr: PeerEndpoint, entry: Entry) -> bool { match entries.get(&addr) { Some(current) if current.lease.same_connection(&entry.lease) => false, Some(_) => { @@ -194,13 +194,13 @@ impl PeerTable { /// Activity admission and connection replacement share the table write lock. pub(crate) fn try_register_outbound( &self, - addr: SocketAddr, + addr: PeerEndpoint, lease: PeerLease, activity: &crate::NetworkActivity, ) -> Option { debug_assert!(!lease.is_inbound(), "only outbound leases register here"); let mut entries = self.entries.write(); - if !activity.is_active() { + if !activity.is_active() || lease.is_cancelled() { return None; } Self::register_locked(&mut entries, addr, Entry::fresh(lease.clone())); @@ -252,7 +252,7 @@ impl PeerTable { #[must_use] pub(crate) fn try_register_inbound( &self, - addr: SocketAddr, + addr: PeerEndpoint, lease: PeerLease, netgroup: &[u8], max_inbound: usize, @@ -261,7 +261,7 @@ impl PeerTable { ) -> Option { debug_assert!(lease.is_inbound(), "only inbound leases reserve here"); let mut entries = self.entries.write(); - if !activity.is_active() { + if !activity.is_active() || lease.is_cancelled() { return None; } let grows_count = match entries.get(&addr) { @@ -296,7 +296,7 @@ impl PeerTable { /// Returns whether one was removed. fn evict_inbound_locked( entries: &mut TableView, - addr: SocketAddr, + addr: PeerEndpoint, best_block_depth: u64, ) -> bool { // The newcomer would also replace a live outbound connection at its @@ -325,7 +325,7 @@ impl PeerTable { /// The eviction view of a live inbound connection without `NoBan`, the set /// Core's `ProtectNoBanConnections` and `ProtectOutboundConnections` leave. fn eviction_candidate( - addr: SocketAddr, + addr: PeerEndpoint, entry: &Entry, best_block_depth: u64, ) -> Option { @@ -347,7 +347,7 @@ impl PeerTable { }), relay_txs: info.is_some_and(|info| info.relay_transactions), keyed_netgroup: entry.keyed_netgroup, - is_local: crate::discouragement::is_local(addr.ip()), + is_local: crate::discouragement::is_local(addr.address()), }) } @@ -365,7 +365,7 @@ impl PeerTable { } /// Publishes handshake metadata for the connection `lease` refers to. - pub fn publish_info(&self, addr: SocketAddr, lease: &PeerLease, info: PeerInfo) -> bool { + pub fn publish_info(&self, addr: PeerEndpoint, lease: &PeerLease, info: PeerInfo) -> bool { let mut entries = self.entries.write(); match entries.get_mut(&addr) { Some(entry) if entry.lease.same_connection(lease) => { @@ -603,7 +603,7 @@ impl PeerTable { /// Reports whether the live published connection at `addr` requested /// compact-block relay. - pub(crate) fn compact_relay_of(&self, addr: SocketAddr) -> bool { + pub(crate) fn compact_relay_of(&self, addr: PeerEndpoint) -> bool { let entries = self.entries.read(); entries.get(&addr).is_some_and(|entry| { entry @@ -630,7 +630,7 @@ impl PeerTable { /// Removes and cancels the connection `lease` refers to. Returns `false` /// when a different connection is live at `addr`, leaving it untouched. - pub fn remove_current(&self, addr: SocketAddr, lease: &PeerLease) -> bool { + pub fn remove_current(&self, addr: PeerEndpoint, lease: &PeerLease) -> bool { self.remove_if(addr, |current| current.same_connection(lease)) } @@ -641,16 +641,16 @@ impl PeerTable { } /// Removes and cancels whichever connection is live at `addr`. - pub fn disconnect(&self, addr: SocketAddr) -> bool { + pub fn disconnect(&self, addr: PeerEndpoint) -> bool { self.remove_if(addr, |_| true) } /// Removes and cancels the live connection at `addr` only when its identity is `id`. - pub(crate) fn disconnect_connection(&self, addr: SocketAddr, id: ConnectionId) -> bool { + pub(crate) fn disconnect_connection(&self, addr: PeerEndpoint, id: ConnectionId) -> bool { self.remove_if(addr, |current| current.connection_id() == id) } - fn remove_if(&self, addr: SocketAddr, matches: impl FnOnce(&PeerLease) -> bool) -> bool { + fn remove_if(&self, addr: PeerEndpoint, matches: impl FnOnce(&PeerLease) -> bool) -> bool { let mut entries = self.entries.write(); if !entries .get(&addr) @@ -669,10 +669,10 @@ impl PeerTable { /// the affected addresses. pub(crate) fn disconnect_matching( &self, - predicate: impl Fn(&SocketAddr, &PeerLease) -> bool, - ) -> Vec { + predicate: impl Fn(&PeerEndpoint, &PeerLease) -> bool, + ) -> Vec { let mut entries = self.entries.write(); - let targets: Vec = entries + let targets: Vec = entries .iter() .filter(|(addr, entry)| predicate(addr, &entry.lease)) .map(|(addr, _)| *addr) @@ -757,7 +757,7 @@ impl PeerTable { /// Returns whether any connection is live at `addr`. #[must_use] - pub fn is_connected(&self, addr: SocketAddr) -> bool { + pub fn is_connected(&self, addr: PeerEndpoint) -> bool { self.entries.read().contains_key(&addr) } @@ -773,7 +773,7 @@ impl PeerTable { /// Clones the lease of the live, uncancelled connection at `addr`. #[must_use] - pub fn lease(&self, addr: SocketAddr) -> Option { + pub fn lease(&self, addr: PeerEndpoint) -> Option { self.entries .read() .get(&addr) @@ -821,7 +821,7 @@ impl PeerTable { /// operation, so replacement cannot change the target or relay preference. pub(crate) fn for_each_ready_lease( &self, - mut f: impl FnMut(SocketAddr, &PeerLease, &PeerInfo), + mut f: impl FnMut(PeerEndpoint, &PeerLease, &PeerInfo), ) { for (addr, entry) in self.entries.read().iter() { if entry.lease.is_cancelled() { @@ -853,7 +853,7 @@ impl PeerTable { /// Metadata of the one connection at `addr`, if it completed its handshake. #[must_use] - pub(crate) fn info_of(&self, addr: SocketAddr) -> Option { + pub(crate) fn info_of(&self, addr: PeerEndpoint) -> Option { self.entries .read() .get(&addr) @@ -917,7 +917,7 @@ impl PeerTable { /// metadata, so a handshaking replacement cannot inherit an old scheduler /// decision. #[must_use] - pub fn ready_source(&self, addr: SocketAddr) -> Option { + pub fn ready_source(&self, addr: PeerEndpoint) -> Option { let entries = self.entries.read(); let entry = entries.get(&addr)?; entry.info.as_ref()?; @@ -1018,10 +1018,40 @@ impl PeerTable { #[cfg(test)] mod tests { use super::*; + + #[test] + fn revoked_overlay_epoch_cannot_replace_a_successor_during_late_admission() { + let table = PeerTable::new(); + let address = PeerEndpoint::new(crate::PeerAddress::I2p([7; 32]), 8333); + let activity = crate::NetworkActivity::from_shared(Arc::new(AtomicBool::new(true))); + let (sender, _receiver) = crossbeam_channel::unbounded(); + let successor = PeerLease::new(sender.clone()); + table.register(address, successor.clone()); + let dead = Arc::new(AtomicBool::new(false)); + let stale_outbound = + PeerLease::new(sender.clone()).with_transport_alive(Some(Arc::clone(&dead))); + assert!( + table + .try_register_outbound(address, stale_outbound, &activity) + .is_none() + ); + let stale_inbound = PeerLease::new_inbound(sender).with_transport_alive(Some(dead)); + assert!( + table + .try_register_inbound(address, stale_inbound, &[5, 7], 10, u64::MAX, &activity) + .is_none() + ); + assert!(!successor.is_cancelled()); + assert!( + table + .lease(address) + .is_some_and(|lease| lease.same_connection(&successor)) + ); + } use crate::test_support::QueuedMessages as _; - fn addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn addr(port: u16) -> PeerEndpoint { + PeerEndpoint::from(([127, 0, 0, 1], port)) } fn lease() -> PeerLease { @@ -1029,7 +1059,7 @@ mod tests { PeerLease::new(tx) } - fn info(addr: SocketAddr, start_height: i32) -> PeerInfo { + fn info(addr: PeerEndpoint, start_height: i32) -> PeerInfo { info_with_counters( addr, start_height, @@ -1038,7 +1068,7 @@ mod tests { } fn info_with_counters( - addr: SocketAddr, + addr: PeerEndpoint, start_height: i32, counters: std::sync::Arc, ) -> PeerInfo { @@ -1055,7 +1085,9 @@ mod tests { best_known_height: start_height, conn_time: 0, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters, transport: crate::TransportProtocol::V1, @@ -1274,23 +1306,23 @@ mod tests { ); } - fn flooder(port: u16) -> SocketAddr { - SocketAddr::from(([1, 2, 3, 4], port)) + fn flooder(port: u16) -> PeerEndpoint { + PeerEndpoint::from(([1, 2, 3, 4], port)) } - fn newcomer() -> SocketAddr { - SocketAddr::from(([5, 6, 7, 8], 8333)) + fn newcomer() -> PeerEndpoint { + PeerEndpoint::from(([5, 6, 7, 8], 8333)) } - fn netgroup(addr: SocketAddr) -> Vec { - crate::netgroup::NetGroups::default().group(addr.ip()) + fn netgroup(addr: PeerEndpoint) -> Vec { + crate::netgroup::NetGroups::default().group(addr.address()) } /// Offers an inbound `lease` from `addr` to a table capped at /// `max_inbound`. fn offer( table: &PeerTable, - addr: SocketAddr, + addr: PeerEndpoint, lease: PeerLease, max_inbound: usize, ) -> Option { diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index bcd10fba7..35bec42fe 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -4,9 +4,9 @@ //! acts on it. The node supplies chain read/query and inbound event sinks, but //! does not construct listener, dial, DNS, or fixed-peer workers itself. +use bitcoin_rs_primitives::PeerEndpoint; use std::collections::VecDeque; use std::io; -use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::{self, JoinHandle}; @@ -67,7 +67,9 @@ const MAX_PARKED_DIALS: usize = 64; #[derive(Clone, Debug)] pub struct P2pServiceConfig { /// Addresses on which inbound P2P connections are accepted. - pub listen_addrs: Vec, + pub listen_addrs: Vec, + /// Opt-in overlay routing and service configuration. + pub overlay: crate::OverlayConfig, /// Network message-start bytes. pub magic: Magic, /// Auxiliary peer-book base/legacy path. The owner adds the P2P magic to @@ -116,6 +118,7 @@ impl Default for P2pServiceConfig { fn default() -> Self { Self { listen_addrs: Vec::new(), + overlay: crate::OverlayConfig::default(), magic: Magic::from_bytes([0; 4]), address_book_path: None, asmap_path: None, @@ -202,6 +205,9 @@ pub enum P2pJoinError { /// Errors returned by RPC-facing P2P control operations. #[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] pub enum P2pControlError { + /// Destination network excluded by routing configuration. + #[error("destination network disabled")] + NetworkDisabled, /// The destination is covered by an active manual ban. #[error("destination is banned")] Banned, @@ -233,7 +239,7 @@ struct Workers { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OutboundDial { /// The address to dial. - pub addr: SocketAddr, + pub addr: PeerEndpoint, /// Whether the operator asked for this address by name. pub manual: bool, purpose: DialPurpose, @@ -249,7 +255,7 @@ enum DialPurpose { impl OutboundDial { /// A dial the seed list or the address book produced. #[must_use] - pub const fn auto(addr: SocketAddr) -> Self { + pub const fn auto(addr: PeerEndpoint) -> Self { Self { addr, manual: false, @@ -259,7 +265,7 @@ impl OutboundDial { /// A dial the operator pinned with `--connect` or `addnode`. #[must_use] - pub const fn pinned(addr: SocketAddr) -> Self { + pub const fn pinned(addr: PeerEndpoint) -> Self { Self { addr, manual: true, @@ -285,6 +291,7 @@ pub struct BannedReader { inner: Arc>>, discouraged: Arc>, protected: Arc<[crate::IpSubnet]>, + routing: Arc, } impl BannedReader { @@ -304,6 +311,7 @@ impl BannedReader { crate::discouragement::DiscouragedPeers::default(), )), protected: protected.into(), + routing: Arc::new(crate::OverlayConfig::default()), } } @@ -320,23 +328,29 @@ impl BannedReader { discouraged, protected: Arc::clone(&self.protected), now, + routing: Arc::clone(&self.routing), } } - /// Whether operator configuration protects this actual socket address. + /// Whether operator configuration protects this logical remote IP. #[must_use] - pub fn is_noban(&self, ip: std::net::IpAddr) -> bool { - self.protected.iter().any(|subnet| subnet.contains(ip)) + pub fn is_noban(&self, ip: impl Into) -> bool { + ip.into() + .ip() + .is_some_and(|ip| self.protected.iter().any(|subnet| subnet.contains(ip))) } /// Exact-IP ephemeral avoidance, kept separate from the manual ban view. #[must_use] - pub fn is_discouraged(&self, ip: std::net::IpAddr) -> bool { - !self.is_noban(ip) && self.discouraged.lock().contains(ip, Instant::now()) + pub fn is_discouraged(&self, ip: impl Into) -> bool { + { + let ip = ip.into(); + !self.is_noban(ip) && self.discouraged.lock().contains(ip, Instant::now()) + } } // Only current, typed remote violations at the connection boundary call this. - pub(crate) fn discourage(&self, ip: std::net::IpAddr) { + pub(crate) fn discourage(&self, ip: impl Into) { self.discouraged.lock().insert(ip, Instant::now()); } @@ -347,8 +361,10 @@ impl BannedReader { /// Returns whether the given IP is banned at the given time. #[must_use] - pub fn is_banned(&self, ip: std::net::IpAddr, now: SystemTime) -> bool { - crate::subnet::is_banned(&self.inner.read(), ip, now) + pub fn is_banned(&self, ip: impl Into, now: SystemTime) -> bool { + ip.into() + .ip() + .is_some_and(|ip| crate::subnet::is_banned(&self.inner.read(), ip, now)) } /// Returns an empty fixture reader for tests. @@ -367,6 +383,7 @@ impl From>>> for BannedReader { /// The sole runtime owner of P2P control state and workers. pub struct P2pService { + overlay: Arc, config: P2pServiceConfig, shutdown: bitcoin_rs_chain::LatchReader, worker_shutdown: Arc, @@ -374,7 +391,7 @@ pub struct P2pService { peer_table: Arc, banned: BannedReader, address_book: Arc, - added_nodes: Arc>>, + added_nodes: Arc>>, outbound_tx: Sender, outbound_rx: Arc>>, inbound_headers_tx: Sender, @@ -419,11 +436,16 @@ impl P2pService { config.allow_local_addresses, config.asmap_path.as_deref(), ); - let banned = BannedReader::with_protection( + let overlay = Arc::new(crate::overlay::OverlayTransport::new( + config.overlay.clone(), + )); + let mut banned = BannedReader::with_protection( Arc::new(RwLock::new(Vec::new())), config.noban_subnets.clone(), ); + banned.routing = Arc::clone(&overlay.config); Self { + overlay, config, address_book, shutdown: shutdown.into(), @@ -444,6 +466,22 @@ impl P2pService { } } + /// SOCKS credential isolation policy. + pub fn proxy_randomize_credentials(&self) -> bool { + self.config.overlay.randomize_credentials + } + + /// Current configured routing and live operational state by remote network. + pub fn network_states(&self) -> Vec { + self.overlay.states(&self.peer_table) + } + + /// Resolve a manually supplied destination through its configured route. + pub fn resolve_peer(&self, endpoint: &str) -> io::Result> { + self.overlay + .resolve(endpoint, &|| self.shutdown.is_triggered()) + } + /// Returns the aggregate outbound queued-byte budget this service's /// connections count against. #[cfg(test)] @@ -472,8 +510,16 @@ impl P2pService { *self.session_cancel.lock() = Arc::clone(&session_cancel); self.worker_shutdown.store(false, Ordering::Release); + self.config.overlay.validate()?; let mut bound_listeners = Vec::with_capacity(self.config.listen_addrs.len()); for addr in &self.config.listen_addrs { + if !self + .config + .overlay + .allows(PeerEndpoint::from(*addr).address().network()) + { + continue; + } let listener = crate::listener::bind_listener(*addr)?; tracing::info!(addr = %addr, "p2p listener bound"); bound_listeners.push((*addr, listener)); @@ -497,10 +543,11 @@ impl P2pService { shared.max_inbound = self.config.max_inbound(); shared.local_services = self.config.local_services; shared.v2_transport = self.config.v2_transport; + shared.overlay = Arc::clone(&self.overlay); shared.address_book = Some(Arc::clone(&self.address_book)); shared = shared.with_shared_outbound(&self.shared_outbound); - let mut listeners = Vec::with_capacity(bound_listeners.len()); + let mut listeners = self.overlay.start(&shared, &self.worker_shutdown)?; for (listener_addr, listener) in bound_listeners { let shutdown = Arc::clone(&self.worker_shutdown); let shared = shared.clone(); @@ -580,11 +627,12 @@ impl P2pService { thread::Builder::new() .name("bitcoin-rs-p2p-outbound-drain".to_owned()) .spawn(move || { - let mut active: HashMap = HashMap::new(); + let mut active: HashMap = HashMap::new(); // Automatic dials that arrived while the automatic cap was // full, retried in order once a slot opens. let mut parked: VecDeque = VecDeque::new(); let mut handles = Vec::new(); + let mut manual_retry: HashMap = HashMap::new(); let mut next_extra_peer_check = Instant::now() + EXTRA_PEER_CHECK_INTERVAL; let mut next_feeler = Instant::now() + feeler_delay(bitcoin::secp256k1::rand::thread_rng().next_u64()); while !shutdown.load(Ordering::Acquire) @@ -645,6 +693,18 @@ impl P2pService { let manual = manual_nodes.read(); spawn_feeler(&shared, &address_book, &manual, &mut active, &mut handles, max_peer_connections); } + { + let manual = manual_nodes.read(); + manual_retry.retain(|endpoint, _| manual.contains(endpoint)); + for &endpoint in manual.iter().filter(|endpoint| endpoint.address().ip().is_none() || shared.overlay.config.proxy.is_some()) { + if peer_table.is_connected(endpoint) { manual_retry.remove(&endpoint); continue; } + let (next, delay) = manual_retry.entry(endpoint).or_insert((Instant::now() + Duration::from_secs(1), Duration::from_secs(1))); + if Instant::now() < *next || active.contains_key(&crate::addrman::canonical(endpoint)) { continue; } + spawn_outbound_dial(&OutboundDial::pinned(endpoint), &shared, &mut active, &mut handles, + full_relay_slots, block_relay_slots, extra_dial, max_peer_connections); + *delay = (*delay * 2).min(Duration::from_secs(60)); *next = Instant::now() + *delay; + } + } let received = outbound_rx.lock().recv_timeout(Duration::from_secs(1)); let Ok(dial) = received else { if matches!( @@ -697,6 +757,7 @@ impl P2pService { let peer_table = Arc::clone(&self.peer_table); let outbound_tx = self.outbound_tx.clone(); let endpoints = self.config.fixed_peers.clone(); + let overlay = Arc::clone(&self.overlay); return thread::Builder::new() .name("bitcoin-rs-fixed-peer-bootstrap".to_owned()) .spawn(move || { @@ -706,6 +767,7 @@ impl P2pService { peer_table, outbound_tx, endpoints, + overlay, ); }) .map(Some); @@ -715,7 +777,7 @@ impl P2pService { let peer_table = Arc::clone(&self.peer_table); let outbound_tx = self.outbound_tx.clone(); let port = self.config.dns_port; - let seeds = if self.config.dns_seeds_enabled { + let seeds = if self.config.dns_seeds_enabled && self.config.overlay.local_dns_allowed() { self.config.dns_seeds.clone() } else { Vec::new() @@ -925,13 +987,16 @@ impl P2pService { /// Returns configured addnode add addresses. #[must_use] - pub fn added_nodes(&self) -> Vec { + pub fn added_nodes(&self) -> Vec { self.added_nodes.read().clone() } /// Applies Core-like addnode state and requests a connection. - pub fn add_node(&self, addr: SocketAddr, persist: bool) -> Result<(), P2pControlError> { - if crate::subnet::is_banned(&self.banned.read(), addr.ip(), SystemTime::now()) { + pub fn add_node(&self, addr: PeerEndpoint, persist: bool) -> Result<(), P2pControlError> { + if !self.overlay.config.allows(addr.address().network()) { + return Err(P2pControlError::NetworkDisabled); + } + if self.banned.is_banned(addr.address(), SystemTime::now()) { return Err(P2pControlError::Banned); } if persist { @@ -952,12 +1017,12 @@ impl P2pService { } /// Removes one configured addnode add address. - pub fn remove_node(&self, addr: SocketAddr) { + pub fn remove_node(&self, addr: PeerEndpoint) { self.added_nodes.write().retain(|current| *current != addr); } /// Disconnects any active connection with the given address. - pub fn disconnect(&self, addr: SocketAddr) -> bool { + pub fn disconnect(&self, addr: PeerEndpoint) -> bool { self.peer_table.disconnect(addr) } @@ -966,7 +1031,7 @@ impl P2pService { #[cfg(test)] pub(crate) fn test_queue_automatic_dial( &self, - addr: SocketAddr, + addr: PeerEndpoint, ) -> Result<(), crossbeam_channel::SendError> { self.outbound_tx.send(OutboundDial::auto(addr)) } @@ -993,7 +1058,7 @@ impl P2pService { fn defer_rejected_dial( dial: &OutboundDial, book: &crate::addrman::AddressBook, - active: &HashMap, + active: &HashMap, ) { if dial.manual || active.iter().any(|(addr, running)| { @@ -1012,8 +1077,8 @@ fn defer_rejected_dial( fn finish_outbound_epoch( parked: VecDeque, - active: &HashMap, - handles: Vec<(SocketAddr, JoinHandle>)>, + active: &HashMap, + handles: Vec<(PeerEndpoint, JoinHandle>)>, book: &crate::addrman::AddressBook, ) { for dial in parked { @@ -1029,8 +1094,8 @@ fn finish_outbound_epoch( } fn reap_finished_outbound_connections( - active: &mut HashMap, - handles: &mut Vec<(SocketAddr, JoinHandle>)>, + active: &mut HashMap, + handles: &mut Vec<(PeerEndpoint, JoinHandle>)>, book: &crate::addrman::AddressBook, ) { let mut index = 0; @@ -1058,9 +1123,9 @@ fn reap_finished_outbound_connections( fn spawn_feeler( shared: &crate::listener::ConnectionShared, book: &crate::addrman::AddressBook, - manual: &[SocketAddr], - active: &mut HashMap, - handles: &mut Vec<(SocketAddr, JoinHandle>)>, + manual: &[PeerEndpoint], + active: &mut HashMap, + handles: &mut Vec<(PeerEndpoint, JoinHandle>)>, max_peer_connections: usize, ) { if active.values().any(|entry| entry.feeler) @@ -1083,7 +1148,7 @@ fn spawn_feeler( addresses.extend_from_slice(&connected); let policy = shared.banned.automatic_snapshot(SystemTime::now()); let Some(addr) = book.feeler(&addresses, &connected, crate::addrman::now(), |addr| { - !is_manual_endpoint(manual, addr) && policy.allows(addr.ip()) + !is_manual_endpoint(manual, addr) && policy.allows(addr.address()) }) else { return; }; @@ -1108,7 +1173,7 @@ fn spawn_feeler( /// Compare endpoint identities using the address book's canonical spelling. /// Keep the configured spelling in the operator-owned list itself. -fn is_manual_endpoint(manual: &[SocketAddr], addr: SocketAddr) -> bool { +fn is_manual_endpoint(manual: &[PeerEndpoint], addr: PeerEndpoint) -> bool { let addr = crate::addrman::canonical(addr); manual .iter() @@ -1134,6 +1199,7 @@ fn run_fixed_peer_bootstrap( peer_table: Arc, outbound_tx: Sender, endpoints: Vec, + overlay: Arc, ) { while !shutdown.load(Ordering::Acquire) { if !network_active.load(Ordering::Acquire) { @@ -1146,7 +1212,9 @@ fn run_fixed_peer_bootstrap( if !network_active.load(Ordering::Acquire) { break 'endpoints; } - let addresses = match endpoint.as_str().to_socket_addrs() { + let addresses = match overlay.resolve(endpoint, &|| { + shutdown.load(Ordering::Acquire) || !network_active.load(Ordering::Acquire) + }) { Ok(addresses) => addresses, Err(error) => { tracing::warn!(endpoint, %error, "fixed peer resolution failed"); @@ -1183,7 +1251,7 @@ fn run_fixed_peer_bootstrap( fn dial_outbound_role( dial: &OutboundDial, peer_table: &crate::PeerTable, - pending: &HashMap, + pending: &HashMap, full_relay_slots: usize, block_relay_slots: usize, extra_full_relay: bool, @@ -1232,8 +1300,8 @@ fn count_address_failure( fn spawn_outbound_dial( dial: &OutboundDial, shared: &crate::listener::ConnectionShared, - active: &mut HashMap, - handles: &mut Vec<(SocketAddr, JoinHandle>)>, + active: &mut HashMap, + handles: &mut Vec<(PeerEndpoint, JoinHandle>)>, full_relay_slots: usize, block_relay_slots: usize, extra_dial: bool, @@ -1251,12 +1319,14 @@ fn spawn_outbound_dial( book.ordinary_services_eligible(dial.addr, depth) }); let allowed = if dial.manual { - !shared.banned.is_banned(dial.addr.ip(), SystemTime::now()) + !shared + .banned + .is_banned(dial.addr.address(), SystemTime::now()) } else { shared .banned .automatic_snapshot(SystemTime::now()) - .allows(dial.addr.ip()) + .allows(dial.addr.address()) }; if !shared.activity.is_active() || shared.session_cancel.load() || !services_allowed || !allowed { @@ -1357,7 +1427,7 @@ fn spawn_outbound_dial( /// assigned full relay by the caller and never reaches this chooser. fn next_outbound_role( peer_table: &crate::PeerTable, - pending: &HashMap, + pending: &HashMap, full_relay_slots: usize, block_relay_slots: usize, extra_full_relay: bool, @@ -1506,7 +1576,7 @@ fn newest_excess_full_relay( fn dial_has_capacity( dial: &OutboundDial, - active: &HashMap, + active: &HashMap, total: usize, blocks: usize, extra: bool, @@ -1523,7 +1593,7 @@ fn dial_has_capacity( } fn has_automatic_outbound_capacity( - active: &HashMap, + active: &HashMap, active_limit: usize, extra_dial: bool, ) -> bool { @@ -1547,14 +1617,14 @@ struct AddressMaintenance { chain_query: Option>, address_book: Arc, banned: BannedReader, - manual_nodes: Arc>>, + manual_nodes: Arc>>, } fn park_automatic_dial( parked: &mut VecDeque, dial: OutboundDial, book: &crate::addrman::AddressBook, - active: &HashMap, + active: &HashMap, ) -> bool { if parked.len() >= MAX_PARKED_DIALS { defer_rejected_dial(&dial, book, active); @@ -1581,7 +1651,7 @@ fn ready_address_count(peer_table: &crate::PeerTable, book: &crate::addrman::Add .count() } -fn anchor_peers(table: &crate::PeerTable, book: &crate::addrman::AddressBook) -> Vec { +fn anchor_peers(table: &crate::PeerTable, book: &crate::addrman::AddressBook) -> Vec { table .sessions() .into_iter() @@ -1602,9 +1672,9 @@ fn anchor_peers(table: &crate::PeerTable, book: &crate::addrman::AddressBook) -> /// bounded channel and eligibility policy as automatic dialing. fn queue_restart_anchors( maintenance: &AddressMaintenance, - anchors: &mut VecDeque, - attempting: &mut Option, - active: &mut Vec, + anchors: &mut VecDeque, + attempting: &mut Option, + active: &mut Vec, now: u64, ) -> usize { if maintenance.shutdown.load(Ordering::Acquire) @@ -1657,7 +1727,7 @@ fn queue_restart_anchors( || !maintenance .address_book .ordinary_services_eligible(addr, depth) - || !policy.allows(addr.ip()) + || !policy.allows(addr.address()) { maintenance.address_book.return_restart_anchor(addr, now); continue; @@ -1741,7 +1811,7 @@ fn run_address_maintenance(maintenance: &AddressMaintenance) { maintenance .address_book .resolve_collisions(&connected, now, |addr| { - !is_manual_endpoint(&manual, addr) && policy.allows(addr.ip()) + !is_manual_endpoint(&manual, addr) && policy.allows(addr.address()) }); } // A fresh but unreachable book must not permanently suppress seed @@ -1876,7 +1946,7 @@ fn queue_address_candidates( let Some(addr) = maintenance .address_book .select(&active, &grouped, now, depth, |addr| { - !is_manual_endpoint(&manual, addr) && policy.allows(addr.ip()) + !is_manual_endpoint(&manual, addr) && policy.allows(addr.address()) }) else { break; @@ -1901,7 +1971,8 @@ fn queue_address_candidates( mod tests { use super::*; use crate::PeerSource; - use std::net::{Ipv4Addr, SocketAddr, TcpListener}; + use bitcoin_rs_primitives::PeerEndpoint; + use std::net::{Ipv4Addr, TcpListener}; #[test] fn feeler_poisson_delay_matches_actual_core_uniform_vectors() { @@ -1923,8 +1994,12 @@ mod tests { #[test] fn start_fails_when_listener_cannot_bind() { - let occupied = - TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("occupy port"); + let occupied = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("occupy port"); let addr = occupied.local_addr().expect("local_addr"); let service = P2pService::new( P2pServiceConfig { @@ -1997,7 +2072,10 @@ mod tests { let table = crate::PeerTable::new(); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); - table.register(SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)), lease.clone()); + table.register( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 8333)), + lease.clone(), + ); let flag = AtomicBool::new(true); table.set_network_active(&flag, true); @@ -2017,11 +2095,11 @@ mod tests { let table = crate::PeerTable::new(); let (manual_tx, _manual_rx) = crossbeam_channel::unbounded(); table.register( - SocketAddr::from(([127, 0, 0, 1], 1)), + PeerEndpoint::from(([127, 0, 0, 1], 1)), PeerLease::new_manual(manual_tx, PeerRole::FullRelay), ); let mut pending = HashMap::from([( - SocketAddr::from(([127, 0, 0, 1], 2)), + PeerEndpoint::from(([127, 0, 0, 1], 2)), ActiveOutbound { feeler: false, @@ -2029,7 +2107,7 @@ mod tests { manual: true, }, )]); - let automatic_dial = OutboundDial::auto(SocketAddr::from(([127, 0, 0, 1], 3))); + let automatic_dial = OutboundDial::auto(PeerEndpoint::from(([127, 0, 0, 1], 3))); assert_eq!( dial_outbound_role(&automatic_dial, &table, &pending, 1, 1, false), @@ -2042,7 +2120,7 @@ mod tests { ); pending.insert( - SocketAddr::from(([127, 0, 0, 1], 4)), + PeerEndpoint::from(([127, 0, 0, 1], 4)), ActiveOutbound { feeler: false, @@ -2070,7 +2148,7 @@ mod tests { let (full_tx, _full_rx) = crossbeam_channel::unbounded(); table.register( - SocketAddr::from(([127, 0, 0, 1], 1)), + PeerEndpoint::from(([127, 0, 0, 1], 1)), PeerLease::new(full_tx), ); assert!( @@ -2090,7 +2168,7 @@ mod tests { let (block_tx, _block_rx) = crossbeam_channel::unbounded(); table.register( - SocketAddr::from(([127, 0, 0, 1], 2)), + PeerEndpoint::from(([127, 0, 0, 1], 2)), PeerLease::new_block_relay(block_tx), ); assert!( @@ -2107,10 +2185,10 @@ mod tests { use crate::peer_info::PeerRole; let table = crate::PeerTable::new(); - let mut pending: HashMap = HashMap::new(); + let mut pending: HashMap = HashMap::new(); for port in 1..=8_u16 { pending.insert( - SocketAddr::from(([127, 0, 0, 1], port)), + PeerEndpoint::from(([127, 0, 0, 1], port)), ActiveOutbound { feeler: false, @@ -2128,7 +2206,7 @@ mod tests { ); for port in 9..=10_u16 { pending.insert( - SocketAddr::from(([127, 0, 0, 1], port)), + PeerEndpoint::from(([127, 0, 0, 1], port)), ActiveOutbound { feeler: false, @@ -2152,9 +2230,9 @@ mod tests { use crate::peer_info::PeerRole; let table = crate::PeerTable::new(); - let mut pending: HashMap = HashMap::new(); + let mut pending: HashMap = HashMap::new(); for port in 1..=5_u16 { - let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], port)); let (tx, _rx) = crossbeam_channel::unbounded(); table.register(addr, PeerLease::new(tx)); pending.insert( @@ -2182,8 +2260,8 @@ mod tests { use crate::download_window::MINIMUM_CONNECT_TIME; use crate::peer_info::PeerRole; - fn addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn addr(port: u16) -> PeerEndpoint { + PeerEndpoint::from(([127, 0, 0, 1], port)) } let now = Instant::now(); @@ -2247,7 +2325,7 @@ mod tests { use crate::peer_info::PeerRole; let table = crate::PeerTable::new(); - let addr = SocketAddr::from(([127, 0, 0, 1], 1)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], 1)); let (tx, _rx) = crossbeam_channel::unbounded(); table.register(addr, PeerLease::new(tx)); @@ -2261,7 +2339,7 @@ mod tests { assert!( matches!( dial_outbound_role( - &OutboundDial::pinned(SocketAddr::from(([127, 0, 0, 1], 2))), + &OutboundDial::pinned(PeerEndpoint::from(([127, 0, 0, 1], 2))), &table, &HashMap::new(), 1, @@ -2279,8 +2357,8 @@ mod tests { use crate::connection::PeerLease; use crate::download_window::MINIMUM_CONNECT_TIME; - fn addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn addr(port: u16) -> PeerEndpoint { + PeerEndpoint::from(([127, 0, 0, 1], port)) } let now = Instant::now(); @@ -2331,8 +2409,8 @@ mod tests { use crate::download_window::MINIMUM_CONNECT_TIME; use crate::peer_info::PeerRole; - fn addr(port: u16) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn addr(port: u16) -> PeerEndpoint { + PeerEndpoint::from(([127, 0, 0, 1], port)) } let now = Instant::now(); @@ -2380,8 +2458,8 @@ mod tests { }, Arc::new(AtomicBool::new(false)), ); - let addr1: SocketAddr = "127.0.0.1:8333".parse().expect("addr"); - let addr2: SocketAddr = "127.0.0.2:8333".parse().expect("addr"); + let addr1: PeerEndpoint = "127.0.0.1:8333".parse().expect("addr"); + let addr2: PeerEndpoint = "127.0.0.2:8333".parse().expect("addr"); assert!(service.add_node(addr1, true).is_ok()); assert_eq!(service.added_nodes().as_slice(), &[addr1]); @@ -2409,7 +2487,7 @@ mod tests { Arc::new(AtomicBool::new(false)), ); service.set_network_active(false); - let addr: SocketAddr = "127.0.0.1:8333".parse().expect("addr"); + let addr: PeerEndpoint = "127.0.0.1:8333".parse().expect("addr"); assert!(service.add_node(addr, true).is_ok()); assert_eq!(service.added_nodes().as_slice(), &[addr]); assert!(service.outbound_rx.lock().try_recv().is_err()); @@ -2428,14 +2506,14 @@ mod tests { banned_until: None, reason: String::new(), }); - let addr: SocketAddr = "127.0.0.1:8333".parse().expect("addr"); + let addr: PeerEndpoint = "127.0.0.1:8333".parse().expect("addr"); assert_eq!(service.add_node(addr, true), Err(P2pControlError::Banned)); assert_eq!(service.added_nodes().as_slice(), &[]); assert!(service.outbound_rx.lock().try_recv().is_err()); } #[test] fn parked_overflow_releases_the_address_for_another_selection() { - let address = SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)); + let address = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 8333)); let book = crate::addrman::AddressBook::open(None, [1; 4], true, None); book.learn_dns("seed", &[address], 10_000); book.queued(address); @@ -2456,9 +2534,9 @@ mod tests { let (tx, rx) = crossbeam_channel::bounded(16); let book = crate::addrman::AddressBook::open(None, [1; 4], false, None); for n in 1..100 { - let addr = SocketAddr::from(([8, n, 1, 1], 8333)); + let addr = PeerEndpoint::from(([8, n, 1, 1], 8333)); book.learn_dns("seed", &[addr], 10_000); - book.learn_peer(addr.ip(), &[(addr, 9, 10_000)], 10_000); + book.learn_peer(addr.address(), &[(addr, 9, 10_000)], 10_000); } ( AddressMaintenance { @@ -2519,10 +2597,10 @@ mod tests { #[test] fn block_relay_readiness_counts_for_dns_without_refreshing_gossip_time() { let book = crate::addrman::AddressBook::open(None, [1; 4], false, None); - let full = SocketAddr::from(([8, 1, 1, 1], 8333)); + let full = PeerEndpoint::from(([8, 1, 1, 1], 8333)); book.learn_dns("seed", &[full], 5000); let block = (2..=255) - .map(|n| SocketAddr::from(([8, n, 1, 1], 8333))) + .map(|n| PeerEndpoint::from(([8, n, 1, 1], 8333))) .find(|peer| { book.learn_dns("seed", &[*peer], 5000); book.len() == 2 @@ -2550,7 +2628,9 @@ mod tests { best_known_height: 0, conn_time: 0, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -2570,9 +2650,11 @@ mod tests { for (addr, expected) in [(full, 5000), (block, 5000)] { let timestamp = response .iter() - .find(|(_, advertised)| advertised.socket_addr().ok() == Some(addr)) + .find(|advertised| { + advertised.socket_addr().ok().map(PeerEndpoint::from) == Some(addr) + }) .expect("advertised") - .0; + .time; assert_eq!( timestamp, expected, "active connections never refresh gossip timestamp" @@ -2588,14 +2670,14 @@ mod tests { assert!(count_address_failure(&table, 1, &book)); let (tx, _) = crossbeam_channel::bounded(1); let automatic = crate::PeerLease::new(tx.clone()); - table.register(SocketAddr::from(([8, 1, 1, 1], 8333)), automatic); + table.register(PeerEndpoint::from(([8, 1, 1, 1], 8333)), automatic); assert!(!count_address_failure(&table, 125, &book)); assert!(count_address_failure(&table, 2, &book)); let same_group = crate::PeerLease::new_manual(tx.clone(), crate::PeerRole::FullRelay); - table.register(SocketAddr::from(([8, 1, 2, 2], 8333)), same_group); + table.register(PeerEndpoint::from(([8, 1, 2, 2], 8333)), same_group); assert!(!count_address_failure(&table, 125, &book)); let manual = crate::PeerLease::new_manual(tx.clone(), crate::PeerRole::FullRelay); - table.register(SocketAddr::from(([8, 2, 1, 1], 8333)), manual.clone()); + table.register(PeerEndpoint::from(([8, 2, 1, 1], 8333)), manual.clone()); assert!( table .sessions() @@ -2607,17 +2689,17 @@ mod tests { "Core counts established TCP sessions before handshake publication" ); manual.cancel(); - let probe = SocketAddr::from(([9, 4, 1, 1], 8333)); + let probe = PeerEndpoint::from(([9, 4, 1, 1], 8333)); assert!(book.queued_feeler(probe)); let probe_lease = register_ready_block_peer(&table, probe); assert!(!count_address_failure(&table, 125, &book)); assert!(count_address_failure(&table, 2, &book)); assert_eq!(ready_address_count(&table, &book), 0); - assert_eq!(anchor_peers(&table, &book), Vec::::new()); + assert_eq!(anchor_peers(&table, &book), Vec::::new()); table.remove_current(probe, &probe_lease); book.unqueue(probe); table.register( - SocketAddr::from(([8, 3, 1, 1], 8333)), + PeerEndpoint::from(([8, 3, 1, 1], 8333)), crate::PeerLease::new_inbound(tx), ); assert!( @@ -2681,7 +2763,7 @@ mod tests { let dir = tempfile::tempdir().expect("dir"); let base = dir.path().join("peers.dat"); let book = crate::addrman::AddressBook::open(Some(base.clone()), [1; 4], true, None); - let addr = SocketAddr::from(([127, 0, 0, 1], 8333)); + let addr = PeerEndpoint::from(([127, 0, 0, 1], 8333)); book.learn_dns("seed", &[addr], 10_000); let tick = Instant::now(); let mut next = tick + Duration::from_mins(15); @@ -2700,12 +2782,13 @@ mod tests { assert_eq!( crate::addrman::AddressBook::open(Some(base.clone()), [1; 4], true, None) .gossip(20_000)[0] - .0, + .time, 10_000 ); book.save(); assert_eq!( - crate::addrman::AddressBook::open(Some(base), [1; 4], true, None).gossip(20_000)[0].0, + crate::addrman::AddressBook::open(Some(base), [1; 4], true, None).gossip(20_000)[0] + .time, 20_000, "shutdown and anchor-consumption barriers bypass the periodic throttle" ); @@ -2724,7 +2807,13 @@ mod tests { ); let reader = service.banned_reader(); reader.discourage(ip); - assert!(reader.is_noban("::ffff:8.8.8.8".parse().expect("mapped ip"))); + assert!( + reader.is_noban( + "::ffff:8.8.8.8" + .parse::() + .expect("mapped ip") + ) + ); assert!(reader.automatic_snapshot(SystemTime::now()).allows(ip)); service.set_ban(crate::BannedSubnet { subnet: protected, @@ -2742,11 +2831,11 @@ mod tests { let (maintenance, queue) = maintenance_fixture(4); let now = crate::addrman::now(); for n in 1..100 { - let addr = SocketAddr::from(([8, n, 1, 1], 8333)); + let addr = PeerEndpoint::from(([8, n, 1, 1], 8333)); maintenance.address_book.learn_dns("seed", &[addr], now); maintenance .address_book - .learn_peer(addr.ip(), &[(addr, 9, now)], now); + .learn_peer(addr.address(), &[(addr, 9, now)], now); } assert!( maintenance @@ -2770,7 +2859,7 @@ mod tests { queue.is_empty(), "regular selection must apply the policy snapshot" ); - let address = SocketAddr::from(([8, 1, 1, 1], 8333)); + let address = PeerEndpoint::from(([8, 1, 1, 1], 8333)); maintenance.address_book.succeeded(address, 9, now); maintenance.address_book.remember_anchors(&[address], now); let mut anchors: VecDeque<_> = maintenance.address_book.take_restart_anchors(now).into(); @@ -2855,7 +2944,7 @@ mod tests { assert_eq!(rx.len(), 3, "the live session and its claim count once"); let (tx, _rx) = crossbeam_channel::unbounded(); maintenance.peer_table.register( - SocketAddr::from(([9, 9, 1, 1], 8333)), + PeerEndpoint::from(([9, 9, 1, 1], 8333)), crate::PeerLease::new_manual(tx, crate::PeerRole::FullRelay), ); maintenance.target = 5; @@ -2911,7 +3000,7 @@ mod tests { }, Arc::new(AtomicBool::new(false)), ); - let address = SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)); + let address = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 8333)); let now = crate::addrman::now(); service.address_book.learn_dns("seed", &[address], now); service.address_book.queued(address); @@ -2987,7 +3076,7 @@ mod tests { ); let book = crate::addrman::AddressBook::open(None, [1; 4], true, None); shared.address_book = Some(book.clone()); - let address = SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)); + let address = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 8333)); book.queued(address); let mut active = HashMap::from([( address, @@ -3041,7 +3130,7 @@ mod tests { let table = crate::PeerTable::new(); let mut leases = Vec::new(); for port in 1..=4_u16 { - let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let address = PeerEndpoint::from((Ipv4Addr::LOCALHOST, port)); let (sender, _receiver) = crossbeam_channel::unbounded(); let lease = match port { 1 | 2 => crate::PeerLease::new_block_relay(sender), @@ -3056,7 +3145,9 @@ mod tests { ); let info = crate::PeerInfo::outbound_from_version( address, - address, + address + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 10_000, 10_000, @@ -3075,16 +3166,16 @@ mod tests { } assert_eq!( anchor_peers(&table, &book), - vec![SocketAddr::from((Ipv4Addr::LOCALHOST, 2))] + vec![PeerEndpoint::from((Ipv4Addr::LOCALHOST, 2))] ); leases[1].cancel(); - assert_eq!(anchor_peers(&table, &book), Vec::::new()); + assert_eq!(anchor_peers(&table, &book), Vec::::new()); } #[test] fn anchor_dials_prefer_block_slots_without_displacing_manual_roles() { let table = crate::PeerTable::new(); - let address = SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)); + let address = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 8333)); let anchor = OutboundDial { addr: address, manual: false, @@ -3132,8 +3223,8 @@ mod tests { maintenance.outbound_tx = sender; let book = crate::addrman::AddressBook::open(None, [1; 4], false, None); maintenance.address_book = Arc::clone(&book); - let first: SocketAddr = "8.1.1.1:8333".parse().expect("first"); - let other: SocketAddr = "9.2.1.1:8333".parse().expect("other"); + let first: PeerEndpoint = "8.1.1.1:8333".parse().expect("first"); + let other: PeerEndpoint = "9.2.1.1:8333".parse().expect("other"); for addr in [first, other] { book.learn_dns("seed", &[addr], 10_000); book.succeeded(addr, 9, 10_001); @@ -3176,7 +3267,7 @@ mod tests { 10_003, ); assert_eq!(receiver.try_recv().expect("diverse second").addr, other); - assert_eq!(anchors, VecDeque::::new()); + assert_eq!(anchors, VecDeque::::new()); // A queued but never accepted anchor is returned, not discarded. book.defer_anchor(other); book.return_restart_anchors(10_004); @@ -3250,7 +3341,7 @@ mod tests { for n in 1..100 { maintenance.address_book.learn_dns( "seed", - &[SocketAddr::from(([8, n, 1, 1], 8333))], + &[PeerEndpoint::from(([8, n, 1, 1], 8333))], now, ); } @@ -3270,8 +3361,8 @@ mod tests { "unproven probe candidates exist" ); for n in 1..100 { - maintenance.manual_nodes.write().push(SocketAddr::new( - Ipv4Addr::new(8, n, 1, 1).to_ipv6_mapped().into(), + maintenance.manual_nodes.write().push(PeerEndpoint::new( + std::net::IpAddr::V6(Ipv4Addr::new(8, n, 1, 1).to_ipv6_mapped()), 8333, )); } @@ -3286,7 +3377,7 @@ mod tests { queue.try_recv().is_err(), "anchor selection must honor mapped manual pins" ); - assert_eq!(anchors, VecDeque::::new()); + assert_eq!(anchors, VecDeque::::new()); let (headers, _) = crossbeam_channel::bounded(1); let (blocks, _) = crossbeam_channel::bounded(1); // An inactive transport makes a regression fail locally before any TCP @@ -3329,15 +3420,15 @@ mod tests { .manual_nodes .read() .iter() - .all(SocketAddr::is_ipv6), + .all(PeerEndpoint::is_ipv6), "operator spelling is retained" ); assert!( - !is_manual_endpoint(&pins, SocketAddr::new(anchor.ip(), 8334)), + !is_manual_endpoint(&pins, PeerEndpoint::new(anchor.address(), 8334)), "a different port is a different endpoint" ); } - fn register_ready_block_peer(table: &crate::PeerTable, addr: SocketAddr) -> crate::PeerLease { + fn register_ready_block_peer(table: &crate::PeerTable, addr: PeerEndpoint) -> crate::PeerLease { let (sender, _receiver) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new_block_relay(sender); let version = crate::handshake::version_message( @@ -3348,7 +3439,8 @@ mod tests { ); let info = crate::PeerInfo::outbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 10_000, 10_000, @@ -3372,9 +3464,9 @@ mod tests { maintenance.block_slots = 1; let book = crate::addrman::AddressBook::open(None, [1; 4], false, None); maintenance.address_book = Arc::clone(&book); - let first: SocketAddr = "8.1.1.1:8333".parse().expect("first"); - let second: SocketAddr = "9.2.1.1:8333".parse().expect("second"); - let full: SocketAddr = "11.3.1.1:8333".parse().expect("ordinary full relay"); + let first: PeerEndpoint = "8.1.1.1:8333".parse().expect("first"); + let second: PeerEndpoint = "9.2.1.1:8333".parse().expect("second"); + let full: PeerEndpoint = "11.3.1.1:8333".parse().expect("ordinary full relay"); for addr in [first, second, full] { book.learn_dns("seed", &[addr], 10_000); } @@ -3460,8 +3552,8 @@ mod tests { #[test] fn a_queued_anchor_waits_for_its_block_slot_without_blocking_a_full_slot() { - let first: SocketAddr = "8.1.1.1:8333".parse().expect("first"); - let second: SocketAddr = "9.2.1.1:8333".parse().expect("second"); + let first: PeerEndpoint = "8.1.1.1:8333".parse().expect("first"); + let second: PeerEndpoint = "9.2.1.1:8333".parse().expect("second"); let busy = HashMap::from([( first, ActiveOutbound { @@ -3506,7 +3598,7 @@ mod tests { }, Arc::new(AtomicBool::new(false)), ); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)); + let addr = PeerEndpoint::from((Ipv4Addr::LOCALHOST, 8333)); let now = crate::addrman::now(); service.address_book.learn_dns("fixture", &[addr], now); service.address_book.succeeded(addr, 9, now); @@ -3525,7 +3617,7 @@ mod tests { #[test] fn rejected_duplicates_preserve_active_feeler_and_dispatched_anchor_claims_until_join() { for feeler in [false, true] { - let address: SocketAddr = "127.0.0.1:8333".parse().expect("address"); + let address: PeerEndpoint = "127.0.0.1:8333".parse().expect("address"); let book = crate::addrman::AddressBook::open(None, [1; 4], true, None); book.learn_dns("seed", &[address], 10_000); if feeler { @@ -3565,8 +3657,10 @@ mod tests { crate::listener::ListenerExtras::default(), ); shared.address_book = Some(Arc::clone(&book)); - let mapped = - SocketAddr::new(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped().into(), 8333); + let mapped = PeerEndpoint::new( + std::net::IpAddr::V6(std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped()), + 8333, + ); let duplicate = OutboundDial::auto(mapped); let mut handles = Vec::new(); spawn_outbound_dial( @@ -3628,8 +3722,8 @@ mod tests { let path = directory.path().join("peers-01010101.dat"); let book = crate::addrman::AddressBook::open(Some(base), [1; 4], false, Some(&map)); maintenance.address_book = Arc::clone(&book); - let anchor: SocketAddr = "8.8.0.1:8333".parse().expect("anchor"); - let same_as: SocketAddr = "9.9.0.1:8333".parse().expect("same ASN"); + let anchor: PeerEndpoint = "8.8.0.1:8333".parse().expect("anchor"); + let same_as: PeerEndpoint = "9.9.0.1:8333".parse().expect("same ASN"); book.learn_dns("seed", &[anchor], 10_000); book.succeeded(anchor, 9, 10_000); // Promotion frees New before learning the same-AS peer: two fresh @@ -3677,7 +3771,7 @@ mod tests { let book = crate::addrman::AddressBook::open(Some(base), [1; 4], false, None); maintenance.address_book = Arc::clone(&book); maintenance.network_active.store(false, Ordering::Release); - let address: SocketAddr = "8.8.8.8:8333".parse().expect("address"); + let address: PeerEndpoint = "8.8.8.8:8333".parse().expect("address"); let now = crate::addrman::now(); book.learn_dns("seed", &[address], now); book.succeeded(address, 9, now); @@ -3764,8 +3858,8 @@ mod tests { }); let query: Arc = tip.clone(); let book = crate::addrman::AddressBook::open(None, [1; 4], true, None); - book.learn_dns("seed", &[address], now); - book.succeeded(address, if tip_changes { 1032 } else { 9 }, now); + book.learn_dns("seed", &[address.into()], now); + book.succeeded(address.into(), if tip_changes { 1032 } else { 9 }, now); let (mut maintenance, queue) = maintenance_fixture(1); maintenance.address_book = Arc::clone(&book); maintenance.chain_query = Some(Arc::clone(&query)); @@ -3784,7 +3878,7 @@ mod tests { Ordering::Release, ); } else { - book.set_services(address, 1); + book.set_services(address.into(), 1); } let mut shared = crate::listener::ConnectionShared::new( Arc::clone(&maintenance.peer_table), @@ -3820,11 +3914,11 @@ mod tests { "stale parked metadata never starts a worker" ); assert!( - !book.is_pending(address), + !book.is_pending(address.into()), "rejected ordinary claim is released" ); let result = crate::listener::spawn_outbound_connection( - address, + address.into(), shared, crate::PeerRole::FullRelay, ) @@ -3857,8 +3951,8 @@ mod tests { None, ); maintenance.address_book = Arc::clone(&book); - let bad: SocketAddr = "8.8.8.8:8333".parse().expect("incomplete"); - let healthy: SocketAddr = "9.9.9.9:8333".parse().expect("healthy"); + let bad: PeerEndpoint = "8.8.8.8:8333".parse().expect("incomplete"); + let healthy: PeerEndpoint = "9.9.9.9:8333".parse().expect("healthy"); book.learn_dns("seed", &[bad, healthy], 10_000); book.succeeded(bad, 9, 10_000); book.succeeded(healthy, 9, 10_000); @@ -3905,8 +3999,10 @@ mod tests { let server = TcpListener::bind("127.0.0.1:0").expect("listener"); let address = server.local_addr().expect("address"); let now = crate::addrman::now(); - service.address_book.learn_dns("loopback", &[address], now); - service.address_book.set_services(address, 9); + service + .address_book + .learn_dns("loopback", &[address.into()], now); + service.address_book.set_services(address.into(), 9); let (entered, entered_rx) = crossbeam_channel::bounded(1); let (release, release_rx) = crossbeam_channel::bounded(1); let query: Arc = Arc::new(ServiceTip { @@ -3933,8 +4029,11 @@ mod tests { crate::listener::ListenerExtras::default(), ); shared.address_book = Some(Arc::clone(&service.address_book)); - let worker = - crate::listener::spawn_outbound_connection(address, shared, crate::PeerRole::FullRelay); + let worker = crate::listener::spawn_outbound_connection( + address.into(), + shared, + crate::PeerRole::FullRelay, + ); entered_rx .recv_timeout(Duration::from_secs(5)) .expect("passed early activity check, paused before TCP"); diff --git a/crates/p2p/src/sync/receive.rs b/crates/p2p/src/sync/receive.rs index 5eb9ec3d3..a9342c5ef 100644 --- a/crates/p2p/src/sync/receive.rs +++ b/crates/p2p/src/sync/receive.rs @@ -187,7 +187,7 @@ impl BlockSync { /// operations precede the scheduler lock to preserve the `PeerTable` -> /// scheduler ordering used elsewhere. fn discard_inadmissible_header_bodies(&self, invalid: &[(Hash256, Option)]) { - let blamed: Vec = invalid + let blamed: Vec = invalid .iter() .filter_map(|(_, source)| *source) .filter(|source| { diff --git a/crates/p2p/src/sync/tests.rs b/crates/p2p/src/sync/tests.rs index 240775724..6193c7578 100644 --- a/crates/p2p/src/sync/tests.rs +++ b/crates/p2p/src/sync/tests.rs @@ -1,7 +1,7 @@ pub(crate) use crate::test_support::{OrFail, TestResult}; +use bitcoin_rs_primitives::PeerEndpoint; use std::net::IpAddr; use std::net::Ipv4Addr; -use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -558,7 +558,7 @@ fn tick_allows_demoted_peer_when_it_is_the_only_eligible_peer() -> TestResult { } .with_pending_timeout_override(Duration::ZERO), ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -653,7 +653,7 @@ fn getdata_uses_compact_flavor_only_for_relaying_peers_near_tip() -> TestResult let (sync, peers, _block_tree, _applied, _expected) = sync_with_header_chain(4)?; install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_101); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_101); let mut relaying = synthetic_peer(addr, 100); relaying.compact_block_relay = true; let rx = connect_peer(&peers, relaying); @@ -662,7 +662,7 @@ fn getdata_uses_compact_flavor_only_for_relaying_peers_near_tip() -> TestResult let (sync, peers, _block_tree, _applied, _expected) = sync_with_header_chain(4)?; install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_102); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_102); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); assert_flavor(&first_getdata(&rx), false); @@ -677,7 +677,7 @@ fn getdata_uses_compact_flavor_only_for_relaying_peers_near_tip() -> TestResult ..super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_103); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_103); let mut relaying = synthetic_peer(addr, 100); relaying.compact_block_relay = true; let rx = connect_peer(&peers, relaying); @@ -723,7 +723,7 @@ fn unsolicited_stale_block_retries_from_resolved_header_height() -> TestResult { ..super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -787,7 +787,7 @@ fn inv_delivered_block_admits_carried_header_and_applies() -> TestResult { inbound_headers_tx: _inbound_headers_tx, } = SyncHarness::new(tree); install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 0)); sync.tick(); @@ -856,7 +856,7 @@ fn out_of_order_delivered_blocks_admit_and_apply() -> TestResult { inbound_headers_tx: _inbound_headers_tx, } = SyncHarness::new(tree); install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 0)); let source = current_source(&peers, addr); @@ -916,7 +916,7 @@ fn missing_parent_block_delivery_recovers_with_getheaders() -> TestResult { inbound_headers_tx, } = SyncHarness::new(tree); install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 0)); let source = current_source(&peers, addr); @@ -1180,7 +1180,7 @@ fn clean_fast_path_caps_request_at_peer_height() -> TestResult { ..super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 2)); sync.tick(); @@ -1253,8 +1253,8 @@ fn staging_exhaustion_fixture() -> Result Result, addr: SocketAddr) -> PeerSource { +pub(crate) fn current_source(peer_table: &Arc, addr: PeerEndpoint) -> PeerSource { peer_table.lease(addr).map_or_else( || panic!("test peer {addr} must be connected"), |lease| lease.source(addr), @@ -2398,7 +2398,7 @@ fn register_info(peer_table: &Arc, info: PeerInfo) { } /// The canonical sync-test peer: outbound, and advertising `NODE_NETWORK` plus /// `NODE_WITNESS`, so it may serve block bodies at any height. -pub(crate) fn synthetic_peer(addr: SocketAddr, start_height: i32) -> PeerInfo { +pub(crate) fn synthetic_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo { PeerInfo { addr, version: 70_016, @@ -2413,7 +2413,9 @@ pub(crate) fn synthetic_peer(addr: SocketAddr, start_height: i32) -> PeerInfo { best_known_height: start_height, conn_time: 0, inbound: false, - addr_bind: addr, + addr_bind: addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(crate::PeerCounters::default()), transport: crate::TransportProtocol::V1, @@ -2422,7 +2424,7 @@ pub(crate) fn synthetic_peer(addr: SocketAddr, start_height: i32) -> PeerInfo { /// A peer that may not serve block bodies at all: inbound (attacker-chosen) /// and advertising no services. -pub(crate) fn ineligible_peer(addr: SocketAddr, start_height: i32) -> PeerInfo { +pub(crate) fn ineligible_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo { PeerInfo { services: 0, inbound: true, @@ -2433,8 +2435,8 @@ pub(crate) fn ineligible_peer(addr: SocketAddr, start_height: i32) -> PeerInfo { pub(crate) fn test_addr( base_port: usize, idx: usize, -) -> Result> { - Ok(SocketAddr::new( +) -> Result> { + Ok(PeerEndpoint::new( IpAddr::V4(Ipv4Addr::LOCALHOST), u16::try_from(base_port + idx)?, )) diff --git a/crates/p2p/src/sync/tests/chain_sync.rs b/crates/p2p/src/sync/tests/chain_sync.rs index d29cae267..3d31484a8 100644 --- a/crates/p2p/src/sync/tests/chain_sync.rs +++ b/crates/p2p/src/sync/tests/chain_sync.rs @@ -15,7 +15,7 @@ use crate::sync::peers::{ /// An outbound connection that demonstrated `height`. fn outbound(port: u16, height: u32, role: PeerRole, at: Instant) -> UsablePeer { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); UsablePeer { source: PeerSource::for_test(addr), info: synthetic_peer(addr, i32::try_from(height).unwrap_or(i32::MAX)), diff --git a/crates/p2p/src/sync/tests/download_batches.rs b/crates/p2p/src/sync/tests/download_batches.rs index 9571eac3d..c0472b59e 100644 --- a/crates/p2p/src/sync/tests/download_batches.rs +++ b/crates/p2p/src/sync/tests/download_batches.rs @@ -34,7 +34,7 @@ fn tick_sends_getdata_from_next_applied_height_when_gap_exceeds_batch() -> TestR ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -79,7 +79,7 @@ fn second_tick_does_not_re_request_already_pending_blocks() -> TestResult { inbound_headers_tx: _inbound_headers_tx, inbound_blocks_tx: _inbound_blocks_tx, } = SyncHarness::new(tree); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -114,7 +114,7 @@ fn second_tick_does_not_re_request_already_pending_blocks() -> TestResult { #[test] fn successful_getdata_send_marks_requested_blocks_pending() -> TestResult { let (sync, peers, block_tree, applied_tip, expected) = sync_with_header_chain(3)?; - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -174,7 +174,7 @@ fn tick_respects_pending_byte_budget() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -198,7 +198,7 @@ fn tick_limits_inflight_per_peer() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/download_frontier.rs b/crates/p2p/src/sync/tests/download_frontier.rs index 09fd83944..b37563e6e 100644 --- a/crates/p2p/src/sync/tests/download_frontier.rs +++ b/crates/p2p/src/sync/tests/download_frontier.rs @@ -115,7 +115,7 @@ fn fork_getdata_starts_at_common_ancestor_child() -> TestResult { inbound_blocks_tx: _inbound_blocks_tx, } = SyncHarness::new(tree); applied_tip.store(Some(Arc::new(applied))); - let peer = SocketAddr::from(([127, 0, 0, 1], 18_460)); + let peer = PeerEndpoint::from(([127, 0, 0, 1], 18_460)); let rx = connect_peer(&peers, synthetic_peer(peer, 100)); assert!( @@ -262,7 +262,7 @@ fn tick_does_not_resend_same_getheaders_while_pending() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 8)); sync.tick(); @@ -297,7 +297,7 @@ fn inbound_headers_response_releases_getheaders_gate() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 8)); sync.tick(); @@ -349,7 +349,7 @@ fn rejected_matching_peer_headers_release_gate_and_retry_immediately() -> TestRe ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 8)); sync.tick(); @@ -386,7 +386,7 @@ fn orphan_headers_keep_source_peer_connected() -> TestResult { peers, .. } = header_sync_with_genesis()?; - let peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let _rx = connect_peer(&peers, synthetic_peer(peer_addr, 8)); inbound_headers_tx.send(InboundHeaders { headers: vec![ @@ -427,7 +427,7 @@ fn tick_bounded_request_peer_selection_skips_inflight_saturated_prefix() -> Test ..super::super::default_sync_budget(Network::Regtest) }, ); - let first_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let first_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let first_rx = connect_peer(&peers, synthetic_peer(first_addr, 100)); sync.tick(); @@ -442,7 +442,7 @@ fn tick_bounded_request_peer_selection_skips_inflight_saturated_prefix() -> Test return Err(std::io::Error::other("expected first peer getheaders").into()); } - let second_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let second_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); let second_rx = connect_peer(&peers, synthetic_peer(second_addr, 100)); sync.tick(); @@ -476,8 +476,8 @@ fn tick_demotes_peer_after_expired_pending_and_retries_on_alternate_peer() -> Te } .with_pending_timeout_override(Duration::ZERO), ); - let stale_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); - let healthy_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let stale_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let healthy_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); let stale_rx = connect_peer(&peers, synthetic_peer(stale_addr, 100)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/fork_retargeting.rs b/crates/p2p/src/sync/tests/fork_retargeting.rs index 41061c599..dbabae370 100644 --- a/crates/p2p/src/sync/tests/fork_retargeting.rs +++ b/crates/p2p/src/sync/tests/fork_retargeting.rs @@ -90,7 +90,7 @@ fn two_branches() -> Result> { inbound_blocks_rx, crate::sync::syncing_ibd_latch(), ); - let peer = SocketAddr::from(([127, 0, 0, 1], 18_461)); + let peer = PeerEndpoint::from(([127, 0, 0, 1], 18_461)); let rx = connect_peer(&peers, synthetic_peer(peer, 100)); let source = current_source(&sync.peer_table, peer); @@ -299,7 +299,7 @@ fn retarget_runs_before_the_peer_budget_truncates() -> TestResult { max_pending_blocks: LOSING_LEN, ..crate::sync::default_sync_budget(Network::Regtest) }); - let peer = SocketAddr::from(([127, 0, 0, 1], 18_462)); + let peer = PeerEndpoint::from(([127, 0, 0, 1], 18_462)); let _rx = connect_peer(&peers, synthetic_peer(peer, 100)); let source = current_source(&sync.peer_table, peer); assert!( diff --git a/crates/p2p/src/sync/tests/frontier_model.rs b/crates/p2p/src/sync/tests/frontier_model.rs index 0bc55a310..4875e09e6 100644 --- a/crates/p2p/src/sync/tests/frontier_model.rs +++ b/crates/p2p/src/sync/tests/frontier_model.rs @@ -56,8 +56,8 @@ fn hash(byte: u8) -> Hash256 { Hash256::from_le_bytes(&[byte; 32]) } -fn addr_of(port: u16) -> SocketAddr { - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) +fn addr_of(port: u16) -> PeerEndpoint { + PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) } fn usable(port: u16, best_known: i32, active_height: Option) -> UsablePeer { diff --git a/crates/p2p/src/sync/tests/header_progress.rs b/crates/p2p/src/sync/tests/header_progress.rs index d73438130..552a74bd2 100644 --- a/crates/p2p/src/sync/tests/header_progress.rs +++ b/crates/p2p/src/sync/tests/header_progress.rs @@ -24,7 +24,7 @@ fn tick_sends_getdata_for_headers_above_applied_tip() -> TestResult { inbound_headers_tx: _inbound_headers_tx, inbound_blocks_tx: _inbound_blocks_tx, } = SyncHarness::new(tree); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -100,7 +100,7 @@ fn tick_fetches_new_tip_headers_from_at_tip_peers() -> TestResult { applied_tip.store(Some(Arc::new(applied))); // The peer's handshake height equals the applied height: it connected // while the node was at the tip, before the new block existed. - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 2)); // The peer announces the new tip header; drain accepts it and must @@ -189,7 +189,7 @@ fn tick_fetches_reorg_fork_announced_by_at_tip_peer() -> TestResult { // The peer's handshake height equals the applied height: it // connected at the losing tip, and only the fork announcement it // delivers demonstrates anything beyond that. - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 2)); inbound_headers_tx.send(InboundHeaders { diff --git a/crates/p2p/src/sync/tests/header_rotation.rs b/crates/p2p/src/sync/tests/header_rotation.rs index d675f7813..152737f2a 100644 --- a/crates/p2p/src/sync/tests/header_rotation.rs +++ b/crates/p2p/src/sync/tests/header_rotation.rs @@ -8,8 +8,8 @@ fn invalid_nbits_headers_disconnect_source_and_rotate_getheaders() -> TestResult inbound_headers_tx, peers, } = header_sync_with_genesis()?; - let invalid_peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); - let other_peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let invalid_peer = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let other_peer = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); let (invalid_tx, invalid_rx) = crate::test_support::message_channel(); let (other_tx, other_rx) = crate::test_support::message_channel(); @@ -94,7 +94,7 @@ fn unattributed_invalid_headers_do_not_disconnect_any_peer() -> TestResult { inbound_headers_tx, peers, } = header_sync_with_genesis()?; - let peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let _rx = connect_peer(&peers, synthetic_peer(peer_addr, 8)); inbound_headers_tx.send(InboundHeaders { headers: vec![nbits_mismatch_header(genesis.compute_hash(), 1)], @@ -120,7 +120,7 @@ fn unattributed_invalid_headers_do_not_disconnect_any_peer() -> TestResult { #[test] fn disconnected_outbound_channel_does_not_mark_blocks_pending() -> TestResult { let (sync, peers, block_tree, applied_tip, _expected) = sync_with_header_chain(3)?; - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); register_info(&peers, synthetic_peer(addr, 100)); let (tx, rx) = crate::test_support::message_channel(); drop(rx); diff --git a/crates/p2p/src/sync/tests/headers_presync.rs b/crates/p2p/src/sync/tests/headers_presync.rs index 9c986a5a1..7ffaac840 100644 --- a/crates/p2p/src/sync/tests/headers_presync.rs +++ b/crates/p2p/src/sync/tests/headers_presync.rs @@ -1,8 +1,8 @@ //! Header-presync admission gating over the real headers drain. +use bitcoin_rs_primitives::PeerEndpoint; use std::net::IpAddr; use std::net::Ipv4Addr; -use std::net::SocketAddr; use std::time::Instant; use super::settlement_faults::deliver_headers; @@ -105,8 +105,12 @@ fn connect( peers: &Arc, port: u16, start_height: i32, -) -> (SocketAddr, PeerLease, crate::test_support::MessageReceiver) { - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); +) -> ( + PeerEndpoint, + PeerLease, + crate::test_support::MessageReceiver, +) { + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let (tx, rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(addr, lease.clone()); diff --git a/crates/p2p/src/sync/tests/limited_peers.rs b/crates/p2p/src/sync/tests/limited_peers.rs index 2574bdda7..b52a15fb8 100644 --- a/crates/p2p/src/sync/tests/limited_peers.rs +++ b/crates/p2p/src/sync/tests/limited_peers.rs @@ -11,7 +11,7 @@ use crate::download_window::{ }; /// A pruned peer: witness relay plus `NODE_NETWORK_LIMITED`, no `NODE_NETWORK`. -fn limited_peer(addr: SocketAddr, height: i32) -> PeerInfo { +fn limited_peer(addr: PeerEndpoint, height: i32) -> PeerInfo { PeerInfo { services: ServiceFlags::WITNESS.to_u64() | ServiceFlags::NETWORK_LIMITED.to_u64(), ..synthetic_peer(addr, height) @@ -20,7 +20,7 @@ fn limited_peer(addr: SocketAddr, height: i32) -> PeerInfo { /// A full-history peer: `NODE_NETWORK | NODE_WITNESS`, the [`synthetic_peer`] /// default. -fn full_peer(addr: SocketAddr, height: i32) -> PeerInfo { +fn full_peer(addr: PeerEndpoint, height: i32) -> PeerInfo { synthetic_peer(addr, height) } diff --git a/crates/p2p/src/sync/tests/peer_qualification.rs b/crates/p2p/src/sync/tests/peer_qualification.rs index c35c9268e..bb3fd4254 100644 --- a/crates/p2p/src/sync/tests/peer_qualification.rs +++ b/crates/p2p/src/sync/tests/peer_qualification.rs @@ -54,7 +54,7 @@ fn received_only_state_uses_scan_path_without_duplicate_request() -> TestResult let (sync, peers, block_tree, applied_tip, expected) = sync_with_header_chain(3)?; let (_, blocks) = mined_chain(3, 0)?; stage_body(&sync, &blocks[1]); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 3)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/pending_retries.rs b/crates/p2p/src/sync/tests/pending_retries.rs index 95cf9e506..b90f8e328 100644 --- a/crates/p2p/src/sync/tests/pending_retries.rs +++ b/crates/p2p/src/sync/tests/pending_retries.rs @@ -12,7 +12,7 @@ fn tick_retries_expired_pending_before_new_heights() -> TestResult { } .with_pending_timeout_override(Duration::ZERO), ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -47,7 +47,7 @@ fn tick_fills_mixed_retry_and_new_height_batch() -> TestResult { } .with_pending_timeout_override(Duration::ZERO), ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -90,7 +90,7 @@ fn tick_applies_contiguous_blocks_before_requesting_more() -> TestResult { inbound_headers_tx: _inbound_headers_tx, inbound_blocks_tx, } = SyncHarness::new(tree); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); inbound_blocks_tx.send(crate::InboundBlock::from_decoded(genesis))?; @@ -135,7 +135,7 @@ fn oversized_received_block_releases_pending_budget_for_retry() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -217,7 +217,7 @@ fn staging_byte_exhaustion_backpressures_requests_then_recovers() -> TestResult ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/settlement_faults.rs b/crates/p2p/src/sync/tests/settlement_faults.rs index 64df440bf..7350ba1c9 100644 --- a/crates/p2p/src/sync/tests/settlement_faults.rs +++ b/crates/p2p/src/sync/tests/settlement_faults.rs @@ -188,7 +188,7 @@ fn apply_cache_horizon_capped_by_pending_budget() -> TestResult { #[test] fn on_peer_ready_sweeps_dead_predecessor_header_request() -> TestResult { let HeaderSyncFixture { sync, .. } = header_sync_with_genesis()?; - let peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let source = crate::PeerSource::for_test(peer_addr); { let mut scheduler = sync.scheduler.lock(); @@ -217,7 +217,7 @@ fn on_peer_ready_sweeps_dead_predecessor_header_request() -> TestResult { #[test] fn on_peer_ready_sweep_releases_dead_owned_requests_only() -> TestResult { let HeaderSyncFixture { sync, .. } = header_sync_with_genesis()?; - let peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); let (stale_tx, _stale_rx) = crate::test_support::message_channel(); let stale = PeerLease::new(stale_tx); sync.peer_table.register(peer_addr, stale.clone()); @@ -259,7 +259,7 @@ fn far_future_matching_peer_retries_without_peer_blame() -> TestResult { inbound_headers_tx, peers, } = header_sync_with_genesis()?; - let peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let (tx, rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(peer_addr, lease.clone()); diff --git a/crates/p2p/src/sync/tests/stale_tip.rs b/crates/p2p/src/sync/tests/stale_tip.rs index 17052d433..8b877b603 100644 --- a/crates/p2p/src/sync/tests/stale_tip.rs +++ b/crates/p2p/src/sync/tests/stale_tip.rs @@ -83,8 +83,12 @@ fn the_stale_tip_allowance_dials_past_the_slot_cap() { let listeners: Vec = (0..3) .map(|_| { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) - .expect("bind a fake peer listener"); + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) + .expect("bind a fake peer listener"); listener .set_nonblocking(true) .expect("nonblocking fake peer listener"); @@ -94,7 +98,7 @@ fn the_stale_tip_allowance_dials_past_the_slot_cap() { for listener in &listeners { let addr = listener.local_addr().expect("fake peer address"); service - .test_queue_automatic_dial(addr) + .test_queue_automatic_dial(addr.into()) .expect("queue the automatic dial"); } diff --git a/crates/p2p/src/sync/tests/tip_alignment.rs b/crates/p2p/src/sync/tests/tip_alignment.rs index 836d5d422..ac3c25b7b 100644 --- a/crates/p2p/src/sync/tests/tip_alignment.rs +++ b/crates/p2p/src/sync/tests/tip_alignment.rs @@ -22,7 +22,7 @@ fn tick_skips_getheaders_when_header_tip_matches_peer_height() -> TestResult { } }; applied_tip.store(Some(Arc::new(applied_snapshot))); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 3)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/trickle_peers.rs b/crates/p2p/src/sync/tests/trickle_peers.rs index e315f3fd9..e803d7ef1 100644 --- a/crates/p2p/src/sync/tests/trickle_peers.rs +++ b/crates/p2p/src/sync/tests/trickle_peers.rs @@ -205,7 +205,7 @@ fn uniform_slow_saturated_fanout_disconnects_no_peer_and_completes() -> TestResu fn single_peer_can_fill_default_pending_window() -> TestResult { let (sync, peers, block_tree, applied_tip, expected) = sync_with_header_chain(u32::try_from(super::super::PENDING_BUDGET)?)?; - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 300)); let mut requested = Vec::new(); @@ -247,7 +247,7 @@ fn tick_preserves_partial_window_order_across_pending_gap() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); diff --git a/crates/p2p/src/tx_policy.rs b/crates/p2p/src/tx_policy.rs index 7fb68f51a..19446d1ee 100644 --- a/crates/p2p/src/tx_policy.rs +++ b/crates/p2p/src/tx_policy.rs @@ -1030,7 +1030,8 @@ mod tests { &lease, crate::PeerInfo::outbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, @@ -1071,7 +1072,9 @@ mod tests { &new_lease, crate::PeerInfo::outbound_from_version( new_addr, - new_addr, + new_addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, @@ -1120,7 +1123,8 @@ mod tests { lease, crate::PeerInfo::outbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, diff --git a/crates/p2p/src/tx_policy/preferred_tests.rs b/crates/p2p/src/tx_policy/preferred_tests.rs index 9cbc5a89f..23da73426 100644 --- a/crates/p2p/src/tx_policy/preferred_tests.rs +++ b/crates/p2p/src/tx_policy/preferred_tests.rs @@ -464,7 +464,8 @@ fn registered_inbound( &lease, crate::PeerInfo::inbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, diff --git a/crates/p2p/src/tx_policy/relay.rs b/crates/p2p/src/tx_policy/relay.rs index c8a8f53ca..daac4039d 100644 --- a/crates/p2p/src/tx_policy/relay.rs +++ b/crates/p2p/src/tx_policy/relay.rs @@ -1,7 +1,9 @@ //! Fee-aware queued inventory, contained in the transaction policy owner. +#[cfg(test)] +use bitcoin_rs_primitives::PeerEndpoint; use std::cmp::Reverse; use std::collections::{BinaryHeap, VecDeque}; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::time::{Duration, Instant}; use bitcoin::hashes::Hash as _; @@ -32,7 +34,7 @@ pub(super) struct InboundClockKey { } impl InboundClockKey { - fn new(remote: IpAddr, bind: SocketAddr) -> Self { + fn new(remote: crate::PeerAddress, bind: std::net::SocketAddr) -> Self { Self { network: network_class(remote), bind_ip: canonical_ip(bind.ip()), @@ -201,7 +203,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); let TxPolicy { relay, inbound_inv, .. } = &mut *policy; @@ -258,7 +260,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); { let mut policy = self.tx_policy.lock(); if let Some(peer) = policy.relay.get_mut(&source) { @@ -303,7 +305,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); { let mut policy = self.tx_policy.lock(); if let Some(peer) = policy.relay.get_mut(&source) { @@ -362,7 +364,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); let requests = { let mut policy = self.tx_policy.lock(); let TxPolicy { @@ -654,8 +656,8 @@ mod tests { fn connect_at( table: &PeerTable, - addr: SocketAddr, - bind: SocketAddr, + addr: PeerEndpoint, + bind: PeerEndpoint, inbound: bool, wtxid: bool, role: crate::PeerRole, @@ -680,7 +682,8 @@ mod tests { ); let mut info = crate::PeerInfo::outbound_from_version( addr, - bind, + bind.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, @@ -817,10 +820,10 @@ mod tests { let plain: std::net::Ipv6Addr = "fe80::1" .parse() .unwrap_or_else(|error| panic!("IPv6: {error}")); - let bind = SocketAddr::V6(std::net::SocketAddrV6::new(plain, 8333, 123, 456)); + let bind = std::net::SocketAddr::V6(std::net::SocketAddrV6::new(plain, 8333, 123, 456)); assert_eq!( key("8.8.8.8", "[fe80::1]:8333"), - InboundClockKey::new(([8, 8, 8, 8]).into(), bind), + InboundClockKey::new(std::net::IpAddr::from([8, 8, 8, 8]).into(), bind), ); } @@ -1057,7 +1060,8 @@ mod tests { ); let mut info = crate::PeerInfo::outbound_from_version( addr, - addr, + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, 0, diff --git a/crates/p2p/src/tx_relay.rs b/crates/p2p/src/tx_relay.rs index bca1bf47a..4b334959b 100644 --- a/crates/p2p/src/tx_relay.rs +++ b/crates/p2p/src/tx_relay.rs @@ -450,9 +450,9 @@ mod tests { use super::*; use bitcoin_rs_consensus::ValidationEngine; use bitcoin_rs_primitives::Hash256; + use bitcoin_rs_primitives::PeerEndpoint; use crossbeam_channel::bounded; use parking_lot::Mutex; - use std::net::SocketAddr; use std::sync::atomic::AtomicBool; /// A peer in the fake sink: a node id and a remaining send budget. @@ -969,7 +969,7 @@ mod tests { ( 3, AdmissionOrigin::Peer(PeerToken { - addr: SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id: 7, }), false, @@ -1094,7 +1094,7 @@ mod tests { } fn relay_identity_peer() -> AdmissionOrigin { AdmissionOrigin::Peer(bitcoin_rs_mempool::PeerToken { - addr: SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: PeerEndpoint::from(([127, 0, 0, 1], 8333)), connection_id: 7, }) } diff --git a/crates/p2p/src/wire.rs b/crates/p2p/src/wire.rs index 4a38f50ce..271c79367 100644 --- a/crates/p2p/src/wire.rs +++ b/crates/p2p/src/wire.rs @@ -287,10 +287,10 @@ pub enum PeerError { Incoming(#[source] Box), /// Automatic dialing is temporarily avoided after a prior remote violation. #[error("discouraged destination {0}")] - DiscouragedDestination(std::net::IpAddr), + DiscouragedDestination(bitcoin_rs_primitives::PeerAddress), /// Attempted destination is currently banned. #[error("banned destination {0}")] - BannedDestination(std::net::IpAddr), + BannedDestination(bitcoin_rs_primitives::PeerAddress), /// The BIP324 transport failed: a malformed handshake, or a packet that /// is oversized or fails authentication. BIP324 does not authenticate /// peers, so this ends the connection without implicating the peer. diff --git a/crates/p2p/tests/core_compat.rs b/crates/p2p/tests/core_compat.rs index f425316be..994a50bac 100644 --- a/crates/p2p/tests/core_compat.rs +++ b/crates/p2p/tests/core_compat.rs @@ -1,8 +1,9 @@ //! Deterministic Bitcoin Core 31.1 P2P compatibility contract tests. +use bitcoin_rs_primitives::PeerEndpoint; use std::error::Error; use std::io::Cursor; -use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; @@ -763,7 +764,11 @@ fn inbound_block_and_tx_messages_decode_and_leave_no_response() -> Result<(), Bo #[test] fn block_relay_only_dial_is_prohibited_from_transaction_relay() -> Result<(), Box> { let magic = Magic::BITCOIN; - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let (headers_tx, headers_rx) = crossbeam_channel::unbounded::(); let (blocks_tx, _blocks_rx) = crossbeam_channel::unbounded::(); @@ -782,7 +787,7 @@ fn block_relay_only_dial_is_prohibited_from_transaction_relay() -> Result<(), Bo None, ListenerExtras::default(), ); - let dial = spawn_outbound_connection(addr, shared, PeerRole::BlockRelayOnly); + let dial = spawn_outbound_connection(addr.into(), shared, PeerRole::BlockRelayOnly); let (mut server, _) = listener.accept()?; server.set_read_timeout(Some(Duration::from_secs(5)))?; @@ -1131,7 +1136,11 @@ fn handshake_completes_over_tcp_for_every_network() -> Result<(), Box } fn serve_and_handshake_over_tcp(network: Network, magic: Magic) -> Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); @@ -1156,7 +1165,7 @@ fn serve_and_handshake_over_tcp(network: Network, magic: Magic) -> Result<(), Bo let handle = thread::spawn(move || serve(listener, listener_shutdown, shared)); - let client = match connect_with_retry(addr) { + let client = match connect_with_retry(addr.into()) { Ok(client) => client, Err(error) => { shutdown.store(true, Ordering::Relaxed); @@ -1171,10 +1180,15 @@ fn serve_and_handshake_over_tcp(network: Network, magic: Magic) -> Result<(), Bo outcome } -fn connect_with_retry(addr: SocketAddr) -> Result> { +fn connect_with_retry(addr: PeerEndpoint) -> Result> { let deadline = Instant::now() + Duration::from_secs(2); loop { - match TcpStream::connect_timeout(&addr, Duration::from_millis(200)) { + match TcpStream::connect_timeout( + &addr + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + Duration::from_millis(200), + ) { Ok(stream) => return Ok(stream), Err(error) if Instant::now() < deadline => { let _ = error; @@ -1344,9 +1358,13 @@ fn outbound_peer_without_network_flag_disconnected() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { ..genesis_block()?.header }; shared.chain_query = Some(Arc::new(FakeChain::new(vec![recent], HashMap::new()))); - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; - let dial = spawn_outbound_connection(addr, shared, PeerRole::FullRelay); + let dial = spawn_outbound_connection(addr.into(), shared, PeerRole::FullRelay); let (mut server, _) = listener.accept()?; server.set_read_timeout(Some(Duration::from_secs(5)))?; @@ -1447,11 +1473,11 @@ fn outbound_near_tip_limited_peer_is_accepted() -> Result<(), Box> { write_message(&mut server, magic, &Message::Verack)?; let deadline = Instant::now() + Duration::from_secs(5); - while peer_table.ready_source(addr).is_none() && Instant::now() < deadline { + while peer_table.ready_source(addr.into()).is_none() && Instant::now() < deadline { thread::sleep(Duration::from_millis(10)); } assert!( - peer_table.ready_source(addr).is_some(), + peer_table.ready_source(addr.into()).is_some(), "a LIMITED peer near the local tip is desirable" ); drop(server); diff --git a/crates/p2p/tests/data/addrman-historical-v8-endpoints.dat b/crates/p2p/tests/data/addrman-historical-v8-endpoints.dat new file mode 100644 index 000000000..a5a683612 --- /dev/null +++ b/crates/p2p/tests/data/addrman-historical-v8-endpoints.dat @@ -0,0 +1 @@ +{"version":8,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":null,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[145]},{"addr":"8.8.4.4:8333","services":0,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[145]},{"addr":"9.9.9.9:8333","services":9,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":1700000001,"failures":0,"tried":true,"new_buckets":[]},{"addr":"[2001:4860:4860::8888]:8333","services":9,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":1700000001,"failures":0,"tried":true,"new_buckets":[]},{"addr":"11.12.13.14:8333","services":9,"source":{"ip":"1.1.1.1"},"last_seen":1699992800,"last_success":0,"failures":1,"tried":false,"new_buckets":[955,369,341,601,401,948,8,797]}],"asmap_id":null,"anchors":[{"addr":"9.9.9.9:8333","confirmed_at":1700000002},{"addr":"[2001:4860:4860::8888]:8333","confirmed_at":1700000002}]}*u©Ž‰Ú}°¤%seà•sK—ýÓ™(j­Ê^G-艴 \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical.README.md b/crates/p2p/tests/data/addrman-historical.README.md index 7b3dac3e7..8049d7908 100644 --- a/crates/p2p/tests/data/addrman-historical.README.md +++ b/crates/p2p/tests/data/addrman-historical.README.md @@ -77,3 +77,20 @@ SHA-256 values are `b91d1cfd3add210e4cf2e20307894088e99206916d1bda9f1e525c65bb36 The two ambiguous old DNS rows deliberately migrate to unknown: their bytes cannot recover whether zero was observed. Schema8 tests then observe known zero without Good and verify that future DNS refresh and restart retain it. + +Overlay endpoint migration adds an original v8 writer file: + +| File | Original writer commit | Bytes | SHA-256 | +| --- | --- | ---: | --- | +| addrman-historical-v8-endpoints.dat | 653c150836ce3d7de684609c9bc72b3d432d24de | 1245 | 434f6b21032e4da8d3d6b28856fdfcd5d7e5e2a75fffa7cb0e533cca63aa3c17 | + +The immutable AddrMan module (blob `7e76785854e78d4eeea7a1a8269e61364cf8170a`) and its original +netgroup module were compiled unchanged in a scratch producer; only a local +fixture entry point was appended. The exact original desirable-services +predicate supplied its listener linkage. The producer uses secret `[7;32]`, +StdRng seed17, magic `[1;4]` and time1700000000. It retains unknown DNS +8.8.8.8, observed-zero DNS8.8.4.4, Good IPv4 9.9.9.9 and Good IPv6 +2001:4860:4860::8888, with both Good records as anchors. A New record for +11.12.13.14 has eight references and one attempt. No schema was relabelled. +The v9 test compares every persisted field except the version and validates +exact backup bytes before publication and preservation when backup fails. diff --git a/crates/p2p/tests/listener_ban.rs b/crates/p2p/tests/listener_ban.rs index 86dd8adf1..86c851850 100644 --- a/crates/p2p/tests/listener_ban.rs +++ b/crates/p2p/tests/listener_ban.rs @@ -1,7 +1,8 @@ //! P2P listener manual-ban and network-control enforcement coverage. +use bitcoin_rs_primitives::PeerEndpoint; use std::error::Error; use std::io::{self, Read}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, Ipv4Addr, TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; @@ -17,7 +18,11 @@ use parking_lot::RwLock; #[test] fn outbound_ban_short_circuits_before_connect_with_typed_error() -> Result<(), Box> { - let helper = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let helper = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; helper.set_nonblocking(true)?; let addr = helper.local_addr()?; let accept_helper = helper.try_clone()?; @@ -36,7 +41,7 @@ fn outbound_ban_short_circuits_before_connect_with_typed_error() -> Result<(), B Arc::new(AtomicBool::new(false)), ); - let handle = spawn_outbound_connection(addr, shared, PeerRole::FullRelay); + let handle = spawn_outbound_connection(addr.into(), shared, PeerRole::FullRelay); let result = match handle.join() { Ok(result) => result, Err(error) => std::panic::resume_unwind(error), @@ -49,7 +54,7 @@ fn outbound_ban_short_circuits_before_connect_with_typed_error() -> Result<(), B ); match result { - Err(PeerError::BannedDestination(ip)) => assert_eq!(ip, addr.ip()), + Err(PeerError::BannedDestination(ip)) => assert_eq!(ip.ip(), Some(addr.ip())), other => { return Err(io::Error::other(format!( "expected banned destination error, got {other:?}" @@ -63,7 +68,11 @@ fn outbound_ban_short_circuits_before_connect_with_typed_error() -> Result<(), B #[test] fn inbound_ban_drops_connection_pre_handshake() -> Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); @@ -82,7 +91,7 @@ fn inbound_ban_drops_connection_pre_handshake() -> Result<(), Box> { let listener_shutdown = Arc::clone(&shutdown); let handle = thread::spawn(move || serve(listener, listener_shutdown, shared)); - let mut client = match connect_with_retry(addr, Duration::from_secs(1)) { + let mut client = match connect_with_retry(addr.into(), Duration::from_secs(1)) { Ok(client) => client, Err(error) => { shutdown.store(true, Ordering::Relaxed); @@ -102,7 +111,11 @@ fn inbound_ban_drops_connection_pre_handshake() -> Result<(), Box> { #[test] fn network_inactive_drops_inbound_pre_handshake() -> Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); @@ -117,7 +130,7 @@ fn network_inactive_drops_inbound_pre_handshake() -> Result<(), Box> let listener_shutdown = Arc::clone(&shutdown); let handle = thread::spawn(move || serve(listener, listener_shutdown, shared)); - let mut client = connect_with_retry(addr, Duration::from_secs(1))?; + let mut client = connect_with_retry(addr.into(), Duration::from_secs(1))?; wait_for_disconnect(&mut client, Duration::from_secs(1))?; assert!(peer_table.is_empty()); @@ -128,7 +141,11 @@ fn network_inactive_drops_inbound_pre_handshake() -> Result<(), Box> #[test] fn network_active_blocks_outbound_until_reenabled() -> Result<(), Box> { - let helper = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let helper = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; helper.set_nonblocking(true)?; let addr = helper.local_addr()?; let accept_helper = helper.try_clone()?; @@ -150,7 +167,7 @@ fn network_active_blocks_outbound_until_reenabled() -> Result<(), Box Arc::new(AtomicBool::new(false)), ); - let inactive = spawn_outbound_connection(addr, shared.clone(), PeerRole::FullRelay); + let inactive = spawn_outbound_connection(addr.into(), shared.clone(), PeerRole::FullRelay); let inactive = inactive .join() .map_err(|_| io::Error::other("inactive outbound thread panicked"))?; @@ -165,7 +182,7 @@ fn network_active_blocks_outbound_until_reenabled() -> Result<(), Box ); network_active.store(true, Ordering::Release); - let active = spawn_outbound_connection(addr, shared, PeerRole::FullRelay); + let active = spawn_outbound_connection(addr.into(), shared, PeerRole::FullRelay); assert!(join_accept(accept_handle)?); let _ = active .join() @@ -176,7 +193,11 @@ fn network_active_blocks_outbound_until_reenabled() -> Result<(), Box #[test] fn cancelled_start_refuses_outbound_before_connect() -> Result<(), Box> { - let helper = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let helper = TcpListener::bind( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; helper.set_nonblocking(true)?; let addr = helper.local_addr()?; let accept_helper = helper.try_clone()?; @@ -198,7 +219,7 @@ fn cancelled_start_refuses_outbound_before_connect() -> Result<(), Box Result<(), Box Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); @@ -233,7 +258,7 @@ fn live_handshake_traffic_reaches_the_aggregate_ledger() -> Result<(), Box BannedSubnet { } } -fn connect_with_retry(addr: SocketAddr, timeout: Duration) -> io::Result { +fn connect_with_retry(addr: PeerEndpoint, timeout: Duration) -> io::Result { let deadline = Instant::now() + timeout; loop { - match TcpStream::connect(addr) { + match TcpStream::connect( + addr.socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + ) { Ok(stream) => return Ok(stream), Err(error) => { if Instant::now() >= deadline { diff --git a/crates/p2p/tests/listener_shutdown.rs b/crates/p2p/tests/listener_shutdown.rs index 8675d7645..07a7eefec 100644 --- a/crates/p2p/tests/listener_shutdown.rs +++ b/crates/p2p/tests/listener_shutdown.rs @@ -2,9 +2,10 @@ use bitcoin::p2p::Magic; use bitcoin_rs_p2p::listener::{ConnectionShared, bind_listener, serve}; use bitcoin_rs_p2p::{ListenerExtras, NetworkActivity, PeerTable}; +use bitcoin_rs_primitives::PeerEndpoint; use std::error::Error; use std::io; -use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::net::{Ipv4Addr, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; @@ -49,7 +50,11 @@ impl Drop for ServeGuard { #[test] fn serve_exits_when_flag_set() -> Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); @@ -100,7 +105,11 @@ fn serve_exits_when_flag_set() -> Result<(), Box> { #[test] fn serve_returns_without_accepting_when_flag_preset() -> Result<(), Box> { - let listener = bind_listener(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + let listener = bind_listener( + PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) + .socket_addr() + .unwrap_or_else(|| panic!("expected IP fixture")), + )?; let shutdown = Arc::new(AtomicBool::new(true)); let listener_shutdown = Arc::clone(&shutdown); diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 2ab3e09b6..71338f18c 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -15,6 +15,8 @@ workspace = true bytemuck.workspace = true ruint.workspace = true sha2.workspace = true +sha3.workspace = true +serde.workspace = true thiserror.workspace = true tinyvec.workspace = true zerocopy.workspace = true diff --git a/crates/primitives/src/endpoint.rs b/crates/primitives/src/endpoint.rs new file mode 100644 index 000000000..96d76b7d5 --- /dev/null +++ b/crates/primitives/src/endpoint.rs @@ -0,0 +1,358 @@ +//! Logical Bitcoin peer destinations, independent of the socket carrying them. +use std::{ + fmt, + net::{IpAddr, SocketAddr}, + str::FromStr, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha3::{Digest, Sha3_256}; + +/// Network used by a logical peer destination. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PeerNetwork { + /// IPv4 TCP. + Ipv4, + /// IPv6 TCP. + Ipv6, + /// Tor v3. + Onion, + /// I2P streaming. + I2p, +} + +impl PeerNetwork { + /// RPC/configuration spelling. + pub const fn name(self) -> &'static str { + match self { + Self::Ipv4 => "ipv4", + Self::Ipv6 => "ipv6", + Self::Onion => "onion", + Self::I2p => "i2p", + } + } +} + +/// Remote identity. An accepted Tor circuit does not reveal a remote onion key. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub enum PeerAddress { + /// Ordinary IP address. + Ip(IpAddr), + /// Tor v3 service public key. + Onion([u8; 32]), + /// SHA256 of an I2P public Destination. + I2p([u8; 32]), + /// Opaque process-unique accepted Tor connection, never persisted or relayed. + AnonymousTor(u64), +} + +impl PeerAddress { + /// Canonicalize mapped IPs without changing overlay identities. + #[must_use] + pub fn to_canonical(self) -> Self { + match self { + Self::Ip(ip) => Self::Ip(ip.to_canonical()), + other => other, + } + } + /// IP when this is an IP identity. + pub const fn ip(self) -> Option { + if let Self::Ip(ip) = self { + Some(ip) + } else { + None + } + } + /// Transport network. + pub const fn network(self) -> PeerNetwork { + match self { + Self::Ip(IpAddr::V4(_)) => PeerNetwork::Ipv4, + Self::Ip(IpAddr::V6(ip)) => { + if ip.to_ipv4_mapped().is_some() { + PeerNetwork::Ipv4 + } else { + PeerNetwork::Ipv6 + } + } + Self::Onion(_) | Self::AnonymousTor(_) => PeerNetwork::Onion, + Self::I2p(_) => PeerNetwork::I2p, + } + } + /// Whether an IP identity is loopback. Overlay identities never inherit proxy exemptions. + pub fn is_loopback(self) -> bool { + self.ip().is_some_and(|ip| ip.is_loopback()) + } + /// Whether an IP identity is unspecified. + pub fn is_unspecified(self) -> bool { + self.ip().is_some_and(|ip| ip.is_unspecified()) + } + /// Whether an IP identity is multicast. + pub fn is_multicast(self) -> bool { + self.ip().is_some_and(|ip| ip.is_multicast()) + } +} +impl From for PeerAddress { + fn from(ip: IpAddr) -> Self { + Self::Ip(ip) + } +} + +/// Bitcoin service endpoint. Local proxy/router sockets are stored separately. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct PeerEndpoint { + address: PeerAddress, + port: u16, + flowinfo: u32, + scope_id: u32, +} +impl PeerEndpoint { + /// Construct an endpoint from its logical identity and port. + pub fn new(address: impl Into, port: u16) -> Self { + Self { + address: address.into(), + port, + flowinfo: 0, + scope_id: 0, + } + } + /// Logical remote identity, without port. + pub const fn address(self) -> PeerAddress { + self.address + } + /// Service port. + pub const fn port(self) -> u16 { + self.port + } + /// Direct TCP address, only for IP destinations. + pub fn socket_addr(self) -> Option { + self.address.ip().map(|ip| match ip { + IpAddr::V4(ip) => SocketAddr::new(ip.into(), self.port), + IpAddr::V6(ip) => SocketAddr::V6(std::net::SocketAddrV6::new( + ip, + self.port, + self.flowinfo, + self.scope_id, + )), + }) + } + /// Canonical IP identity with native IPv6 socket scope retained. + #[must_use] + pub fn to_canonical(self) -> Self { + if self.address.to_canonical() == self.address { + self + } else { + Self::new(self.address.to_canonical(), self.port) + } + } + /// Whether this is an IPv4 destination. + pub const fn is_ipv4(&self) -> bool { + matches!(self.address, PeerAddress::Ip(IpAddr::V4(_))) + } + /// Whether this is an IPv6 destination. + pub const fn is_ipv6(&self) -> bool { + matches!(self.address, PeerAddress::Ip(IpAddr::V6(_))) + } +} +impl From for PeerEndpoint { + fn from(addr: SocketAddr) -> Self { + let mut endpoint = Self::new(addr.ip(), addr.port()); + if let SocketAddr::V6(addr) = addr { + endpoint.flowinfo = addr.flowinfo(); + endpoint.scope_id = addr.scope_id(); + } + endpoint + } +} +impl From<(T, u16)> for PeerEndpoint +where + SocketAddr: From<(T, u16)>, +{ + fn from(value: (T, u16)) -> Self { + Self::from(SocketAddr::from(value)) + } +} + +/// Lowercase RFC4648 base32 without padding, shared by overlay address encodings. +pub fn base32_encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut output = String::new(); + let mut bits = 0_u32; + let mut count = 0; + for &byte in bytes { + bits = (bits << 8) | u32::from(byte); + count += 8; + while count >= 5 { + count -= 5; + output.push(char::from( + ALPHABET[usize::try_from((bits >> count) & 31).unwrap_or_default()], + )); + } + } + if count > 0 { + output.push(char::from( + ALPHABET[usize::try_from((bits << (5 - count)) & 31).unwrap_or_default()], + )); + } + output +} +fn base32_decode(text: &str) -> Result<[u8; N], &'static str> { + let mut output = [0; N]; + let mut index = 0; + let mut bits = 0_u32; + let mut count = 0; + for ch in text.bytes() { + let value = match ch { + b'a'..=b'z' => ch - b'a', + b'2'..=b'7' => ch - b'2' + 26, + _ => return Err("invalid base32 destination"), + }; + bits = (bits << 5) | u32::from(value); + count += 5; + if count >= 8 { + count -= 8; + *output.get_mut(index).ok_or("oversized destination")? = + u8::try_from((bits >> count) & 255).unwrap_or_default(); + index += 1; + } + } + if index != N || bits & ((1 << count) - 1) != 0 { + return Err("invalid destination length or padding"); + } + Ok(output) +} +fn onion_bytes(key: &[u8; 32]) -> [u8; 35] { + let mut bytes = [0; 35]; + bytes[..32].copy_from_slice(key); + bytes[34] = 3; + let mut digest = Sha3_256::new(); + digest.update(b".onion checksum"); + digest.update(key); + digest.update([3]); + bytes[32..34].copy_from_slice(&digest.finalize()[..2]); + bytes +} +impl fmt::Display for PeerAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ip(ip) => ip.fmt(f), + Self::Onion(key) => write!(f, "{}.onion", base32_encode(&onion_bytes(key))), + Self::I2p(hash) => write!(f, "{}.b32.i2p", base32_encode(hash)), + Self::AnonymousTor(id) => write!(f, "tor-inbound-{id}"), + } + } +} +impl fmt::Display for PeerEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(addr) = self.socket_addr() { + addr.fmt(f) + } else { + write!(f, "{}:{}", self.address, self.port) + } + } +} +impl FromStr for PeerAddress { + type Err = &'static str; + fn from_str(value: &str) -> Result { + if let Ok(ip) = value.parse::() { + return Ok(Self::Ip(ip)); + } + let text = value.to_ascii_lowercase(); + if let Some(name) = text.strip_suffix(".onion") { + if name.len() != 56 { + return Err("only Tor v3 destinations are supported"); + } + let bytes = base32_decode::<35>(name)?; + let mut key = [0; 32]; + key.copy_from_slice(&bytes[..32]); + if bytes != onion_bytes(&key) { + return Err("invalid Tor v3 checksum or version"); + } + return Ok(Self::Onion(key)); + } + if let Some(name) = text.strip_suffix(".b32.i2p") { + if name.len() != 52 { + return Err("invalid I2P base32 destination length"); + } + return Ok(Self::I2p(base32_decode(name)?)); + } + if let Some(id) = text.strip_prefix("tor-inbound-") { + return Ok(Self::AnonymousTor( + id.parse() + .map_err(|_| "invalid anonymous Tor connection handle")?, + )); + } + Err("expected IP, Tor v3, or I2P base32 destination") + } +} +impl FromStr for PeerEndpoint { + type Err = &'static str; + fn from_str(value: &str) -> Result { + if let Ok(socket) = value.parse::() { + return Ok(Self::from(socket)); + } + let (host, port) = value + .rsplit_once(':') + .ok_or("destination requires a port")?; + if host.contains(':') { + return Err("IPv6 endpoints require brackets"); + } + Ok(Self::new( + host.parse::()?, + port.parse().map_err(|_| "invalid destination port")?, + )) + } +} +impl Serialize for PeerEndpoint { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} +impl<'de> Deserialize<'de> for PeerEndpoint { + fn deserialize>(deserializer: D) -> Result { + let endpoint: Self = String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom)?; + if matches!(endpoint.address(), PeerAddress::AnonymousTor(_)) { + return Err(serde::de::Error::custom( + "anonymous Tor connection handles are not durable destinations", + )); + } + Ok(endpoint) + } +} +impl Serialize for PeerAddress { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} +impl<'de> Deserialize<'de> for PeerAddress { + fn deserialize>(deserializer: D) -> Result { + let address: Self = String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom)?; + if matches!(address, Self::AnonymousTor(_)) { + return Err(serde::de::Error::custom( + "anonymous Tor connection handles are not durable identities", + )); + } + Ok(address) + } +} + +impl FromStr for PeerNetwork { + type Err = &'static str; + fn from_str(value: &str) -> Result { + match value { + "ipv4" => Ok(Self::Ipv4), + "ipv6" => Ok(Self::Ipv6), + "onion" => Ok(Self::Onion), + "i2p" => Ok(Self::I2p), + _ => Err("expected ipv4, ipv6, onion, or i2p"), + } + } +} + +#[cfg(test)] +#[expect(clippy::expect_used)] +mod tests; diff --git a/crates/primitives/src/endpoint/tests.rs b/crates/primitives/src/endpoint/tests.rs new file mode 100644 index 000000000..4c8693a48 --- /dev/null +++ b/crates/primitives/src/endpoint/tests.rs @@ -0,0 +1,55 @@ +use super::*; + +#[test] +fn overlay_destinations_have_canonical_checked_encodings() { + // RFC4648 encoding plus Python hashlib.sha3_256, independent of this codec. + let onion = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion:8333"; + let endpoint = onion.parse::().expect("Tor v3 vector"); + assert_eq!(endpoint.address(), PeerAddress::Onion([0; 32])); + assert_eq!(endpoint.to_string(), onion); + assert_eq!( + onion + .to_ascii_uppercase() + .parse::() + .expect("case"), + endpoint + ); + assert!(endpoint.socket_addr().is_none()); + assert!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaad.onion:8333" + .parse::() + .is_err() + ); + assert!( + "expyuzz4wqqyqhjn.onion:8333" + .parse::() + .is_err() + ); + let i2p = PeerEndpoint::new(PeerAddress::I2p([255; 32]), 18444); + assert_eq!(i2p.to_string().parse::().expect("I2P"), i2p); + assert!(i2p.socket_addr().is_none()); + let json = serde_json::to_string(&i2p).expect("encoding"); + assert_eq!( + serde_json::from_str::(&json).expect("decoding"), + i2p + ); +} + +#[test] +fn ip_sockets_keep_scope_and_anonymous_circuits_are_not_durable_destinations() { + let socket = SocketAddr::V6(std::net::SocketAddrV6::new( + "fe80::1".parse().expect("IP"), + 18444, + 123, + 7, + )); + let endpoint = PeerEndpoint::from(socket); + assert_eq!(endpoint.socket_addr(), Some(socket)); + assert_eq!(endpoint.to_canonical(), endpoint); + let handle = "tor-inbound-7:0" + .parse::() + .expect("RPC handle"); + assert_eq!(handle.address(), PeerAddress::AnonymousTor(7)); + assert!(!handle.address().is_loopback()); + assert!(serde_json::from_str::("\"tor-inbound-7:0\"").is_err()); +} diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 16d821d23..0e0d175a1 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -12,6 +12,9 @@ pub mod chain_constants { /// Native consensus encoding and decoding for protocol types. pub mod encode; +/// Logical peer network destinations. +pub mod endpoint; +pub use endpoint::{PeerAddress, PeerEndpoint, PeerNetwork}; /// Fixed-width 256-bit hash type. pub mod hash; /// Native block header type and header hash computation. diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index 7fd30d05c..ce24bc42d 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -2366,7 +2366,7 @@ mod admission_chain_tests { let result = ctx.mempool.gateway.submit_transaction( Arc::new(tx), AdmissionOrigin::Peer(PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)), + addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into(), connection_id: 1, }), None, diff --git a/crates/rpc/src/handlers/network.rs b/crates/rpc/src/handlers/network.rs index 904d50754..c445026ee 100644 --- a/crates/rpc/src/handlers/network.rs +++ b/crates/rpc/src/handlers/network.rs @@ -97,14 +97,6 @@ fn optional_u64(params: &Value, index: usize, default: u64) -> Result &'static str { - match ip { - IpAddr::V4(_) => "ipv4", - IpAddr::V6(_) => "ipv6", - } -} - pub(crate) fn getnetworkinfo(ctx: &Arc, params: &Value) -> Result { ensure_no_params(params)?; let peers = ctx.network.peer_table.infos(); @@ -112,20 +104,28 @@ pub(crate) fn getnetworkinfo(ctx: &Arc, params: &Value) -> Result>(); + let states = ctx.network.p2p.network_states(); + let networks = states + .iter() + .map(|state| GetNetworkInfoNetwork { + name: state.network.name().to_owned(), + limited: !state.allowed, + reachable: state.allowed + && network_active + && match state.network { + bitcoin_rs_p2p::PeerNetwork::Ipv4 | bitcoin_rs_p2p::PeerNetwork::Ipv6 => { + state.proxy.is_none() || state.operational + } + _ => state.operational, + }, + proxy: state + .proxy + .map_or_else(String::new, |socket| socket.to_string()), + proxy_randomize_credentials: state.proxy.is_some() + && state.network != bitcoin_rs_p2p::PeerNetwork::I2p + && ctx.network.p2p.proxy_randomize_credentials(), + }) + .collect::>(); let mut local_services = ctx.network.local_services & !bitcoin::p2p::ServiceFlags::COMPACT_FILTERS.to_u64(); if ctx @@ -154,7 +154,15 @@ pub(crate) fn getnetworkinfo(ctx: &Arc, params: &Value) -> Result, params: &Value) -> Result, params: &Value) -> Result, params: &Value) -> Result { let node = required_str(params, 0, "node is required")?; let command = required_str(params, 1, "command is required")?; - let addr = SocketAddr::from_str(node) - .map_err(|_| RpcError::InvalidParams("node must be a valid host:port address"))?; + if !matches!(command, "add" | "onetry" | "remove") { + return Err(RpcError::InvalidParams( + "command must be one of: add, remove, onetry", + )); + } + let addr = if let Ok(endpoint) = bitcoin_rs_p2p::PeerEndpoint::from_str(node) { + endpoint + } else if command != "remove" + && node + .to_ascii_lowercase() + .rsplit_once(':') + .is_some_and(|(host, _)| host.ends_with(".i2p")) + { + ctx.network + .p2p + .resolve_peer(node) + .map_err(|_| RpcError::InvalidParams("I2P name resolution failed"))? + .into_iter() + .next() + .ok_or(RpcError::InvalidParams("I2P name has no destination"))? + } else { + return Err(RpcError::InvalidParams( + "node must be a valid host:port address", + )); + }; match command { "add" => { ctx.network @@ -430,6 +465,9 @@ pub(crate) fn addnode(ctx: &Arc, params: &Value) -> Result RpcError { match error { + bitcoin_rs_p2p::P2pControlError::NetworkDisabled => { + RpcError::InvalidParams("destination network disabled") + } bitcoin_rs_p2p::P2pControlError::Banned => RpcError::InvalidParams("node is banned"), bitcoin_rs_p2p::P2pControlError::QueueFull => { RpcError::Internal("p2p outbound queue full".to_owned()) @@ -443,7 +481,7 @@ fn control_error_to_rpc_error(error: bitcoin_rs_p2p::P2pControlError) -> RpcErro pub(crate) fn disconnectnode(ctx: &Arc, params: &Value) -> Result { let array = params_array(params)?; let address = required_str(params, 0, "address is required")?; - let addr = SocketAddr::from_str(address) + let addr = bitcoin_rs_p2p::PeerEndpoint::from_str(address) .map_err(|_| RpcError::InvalidParams("address must be a valid host:port"))?; // Optional nodeid — the index reported by `getpeerinfo`, used to @@ -511,20 +549,24 @@ pub(crate) fn getnodeaddresses(ctx: &Arc, params: &Value) -> Result = HashSet::new(); + let mut seen: HashSet = HashSet::new(); let mut entries: Vec = Vec::new(); // Live peers carry real service flags and handshake times. for peer in ctx.network.peer_table.infos() { - if !seen.insert(peer.addr) { + if matches!( + peer.addr.address(), + bitcoin_rs_p2p::PeerAddress::AnonymousTor(_) + ) || !seen.insert(peer.addr) + { continue; } entries.push(v31::NodeAddress { time: peer.conn_time, services: peer.services, - address: peer.addr.ip().to_string(), + address: peer.addr.address().to_string(), port: peer.addr.port(), - network: network_name(peer.addr.ip()).to_owned(), + network: peer.addr.address().network().name().to_owned(), }); } @@ -536,9 +578,9 @@ pub(crate) fn getnodeaddresses(ctx: &Arc, params: &Value) -> Result Result<(), Box/overlay-/i2p.key` and `onion.key`, with create-once +publication, checksums and private Unix permissions. Back up those files to +retain inbound addresses. An existing unreadable/damaged key fails startup; +it is never silently regenerated. SAM control loss invalidates its stream +epoch and reconnects with backoff capped at 60 seconds, reusing a stable key +when inbound I2P is enabled. Persistently added overlay peers use the existing +outbound drain for bounded retries. + +`getpeerinfo` reports logical network identities and separate local socket +bindings. `getnetworkinfo` reports configured routes, effective restrictions, +live reachability observations and active inbound service addresses. A live +control service is not evidence of end-to-end Bitcoin interoperability, and +fresh SOCKS credentials alone do not establish anonymity. The manually run +[overlay interoperability workflow](../.github/workflows/overlay-interop.yml) +records external-backend Bitcoin exchange and restart evidence separately +from deterministic local protocol fixtures. + ## Storage `fjall` is the default backend; `redb` is also compiled into the default binary. diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 802f5abcc..46e0d3ca3 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -77,8 +77,8 @@ The decoder types exactly the commands in `crates/p2p/src/compat.rs::COMMANDS` ( | `block` | sink | Forwarded to the node's block pipeline with the original wire bytes preserved. The shared inbound channel is bounded once for the node, and each connection's unsolicited share of it is bounded separately; a body the download window asked that connection for is always admitted (`docs/contracts/p2p-wire.md` `P2P-07`). | | `tx` | sink | Forwarded from a Ready peer into the node's bounded ingress channel, except while the node is in initial block download: unsolicited transaction bodies are then dropped before ingress, without a misbehavior score or disconnect (Core 31.1 `net_processing.cpp:4713-4716`). Mempool prepares and retries admission through its one gateway; node connects committed peer accepts to P2P's relay queue, which announces the negotiated inventory type excluding the exact delivering connection. P2P requests missing parents from that live connection using txid-typed `getdata`; mempool owns orphan retention and retry. A full ingress channel drops the body so the peer read loop can still service ping, headers, and blocks. No protocol response, no disconnect. | | `mempool` | ignored | BIP35 mempool snapshot request; Core answers with an `inv` of relay-pool transactions. Deviation: silent. | -| `getaddr` | served | At most 32 retained IPv4/IPv6 addresses, once per full-relay connection; a peer that sent `sendaddrv2` gets `addrv2` rows from the same book, any other peer gets legacy `addr`. Book-disabled test/embedding connections remain silent. Core sends `addrv2` once negotiated (`net_processing.cpp:5803-5807`); non-IP networks are not relayed. | -| `addr` / `addrv2` | consumed | Decoded with a 1,000-entry wire bound, then admitted to the shared address book behind a 32-entry connection allowance replenished one per 10 seconds. Source-group limits and routability apply before retention; unsupported non-IP transports are ignored. The same learned IPv4/IPv6 rows feed both `addr` and `addrv2` `getaddr` replies. | +| `getaddr` | served | At most 32 retained IPv4/IPv6/Tor-v3/I2P addresses, once per full-relay connection; a peer that sent `sendaddrv2` gets `addrv2` rows from the same book, any other peer gets legacy `addr`. Book-disabled test/embedding connections remain silent. Core sends `addrv2` once negotiated (`net_processing.cpp:5803-5807`); legacy `addr` replies omit overlay endpoints. | +| `addr` / `addrv2` | consumed | Decoded with a 1,000-entry wire bound, then admitted to the shared address book behind a 32-entry connection allowance replenished one per 10 seconds. Source-group limits and routability apply before retention; Tor v3 and I2P identities are retained and relayed as `addrv2`; Tor v2, CJDNS and unknown families are ignored. Legacy `addr` replies contain only the IP subset. Anonymous inbound Tor circuits do not supply a durable address-learning source. | | `feefilter` | applied | BIP133. Valid nonnegative thresholds through `MAX_MONEY` filter queued outbound transaction inventory for that connection; invalid values are ignored. The threshold never changes admission. Full-relay connections at protocol 70013+ receive rounded effective local floors on randomized refreshes. | | `sendcmpct` | negotiated | BIP152. Sent after `verack` in low-bandwidth mode (`send_compact=false`, version 2). Up to 3 peers are promoted to high-bandwidth mode (`sendcmpct(true, 2)`), demoting older peers (`sendcmpct(false, 2)`) when the cap is reached. Inbound `sendcmpct` establishes peer compact-relay version and high-bandwidth preference. Ready peers in high-bandwidth mode receive unsolicited `cmpctblock` announcements for new tips if the peer is known to hold the parent block (`prev_blockhash`); suppressed in `blocksonly` mode. A high-bandwidth `cmpctblock` may precede connect for a PoW-valid tip child (rules passed, commit intent, segwit active); a later connect failure is not retracted and sends nothing further. Inbound announcements also publish compact-fetch eligibility. | | `cmpctblock` / `blocktxn` | sink | BIP152 receive path. Like Core, a `cmpctblock` enters reconstruction only when its header earns the mempool scan. The header needs valid proof of work for the network, the block must not be marked invalid, and its parent must be a known block not marked invalid. The header must also pass the contextual checks header admission applies against that parent, including the required nBits, so a header mined at an easier target never earns the scan; as in admission, a header already in the tree is not rechecked. Finally it must either carry more work than the applied chain or answer a body fetch the same connection owns. Any other `cmpctblock` routes only its header to admission. A hash already attempted on that connection within the reconstruction deadline is not scanned again. Admitted, `cmpctblock` starts bounded per-peer reconstruction: the prefilled coinbase/transactions plus short-ID matches against the mempool and the bounded reconstruction extra pool under the identity profile we advertised (`COMPACT_BLOCK_VERSION`) — the extra pool holds at most 100 wtxid-keyed bodies (transactions removed as replaced, peer orphan-held bodies, and peer policy-only rejects below 100 000 serialized bytes; never a coinbase; hint-only, never admitted or relayed by retention; Core `DEFAULT_BLOCK_RECONSTRUCTION_EXTRA_TXN`) — BIP152 identity is directional: our advertised version fixes what a compliant peer sends us, and the peer's recorded version fixes what we serve; version 1 matches by txid and version 2 by wtxid; two witness variants of one txid stay distinct while a pool-resident identity duplicated in the extra pool is one claim, not a collision; missing transactions are requested with `getblocktxn` on the same connection; two distinct hint identities that collide on one short ID retire only that slot, which `getblocktxn` then carries; a duplicate declared short ID, a count mismatch, or a reconstruction that fails the bounds checks falls back to one full-block `getdata`; an entry whose deadline passes is dropped silently and the peer relies on the connection's separate stall/liveness handling — this module sends no deadline-driven `getdata`. `blocktxn` completes a pending reconstruction. Before delivery both completion paths re-verify the assembled body against the header's transaction-ID merkle root and reject a mutated transaction-ID tree (CVE-2012-2459 duplicate-final-transaction collision) and a body without a coinbase that holds a 64-byte transaction (CVE-2017-12842), so a short-ID misguess can never publish a wrong block; a failed check falls back the same way. A verified block enters the ordinary block pipeline like any `block` message — no validation bypass. | @@ -366,7 +366,7 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at Its shared at-most-32-entry sample stays byte-stable for 24 hours across reconnects and discoveries, then rotates and applies IsTerrible filtering. This differs from Core's randomized 21–27-hour cache and response sizing. - Non-IP addrv2 families are ignored; block-relay-only peers neither learn nor + Tor v3 and I2P addrv2 families are retained; other non-IP families are ignored; block-relay-only peers neither learn nor serve addresses. These boundaries are not a claim of complete Core peer lifecycle equivalence. @@ -392,12 +392,12 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at and migration backup creation; restoring the map recovers the untouched book. Intentionally omitting the map permits backed-up prefix migration. - Schema v8 persists the secret, original source, health, map identity, + Schema v9 persists the secret, original source, health, map identity, New membership and at most two anchors in one checksummed snapshot. Services is a required field: JSON null means unknown DNS metadata, while numeric zero means a known zero-bit claim. Null is valid only for an Internal/legacy-DNS original source with no prior Good. Missing or invalid fields fail closed. - Same-map restart restores every reference; same-classifier v5/v6/v7-to-v8 + Same-map restart restores every reference; same-classifier v5/v6/v7/v8-to-v9 upgrades preserve all references and anchors exactly. Known v1–v7 formats, including genuine v3 files carrying anchors, are strict migration sources. Their service fields must be numeric u64; old null or missing fields remain @@ -467,7 +467,7 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at - A block counts once applied. Core credits a peer whose block is newly stored (`net_processing.cpp:3429-3432`), side-branch blocks included. bitcoin-rs credits a block when it is applied to the active chain. - Wanted services are judged at eviction. Core fixes `fRelevantServices` when the peer's `version` arrives (`net_processing.cpp:3672`). bitcoin-rs evaluates `has_all_desirable_service_flags` against the tip depth at the newcomer's arrival, which differs only for a `NODE_NETWORK_LIMITED` peer whose standing crossed the 144-block threshold in between. - A newcomer at an address holding a live outbound connection is refused instead of evicting. The table keys one connection per address, so admitting it would replace the outbound connection too. Core keeps both. - - Core reserves part of the uptime protection for onion, I2P, CJDNS and localhost peers; only localhost exists as a transport here. + - Core reserves part of the uptime protection for onion, I2P, CJDNS and localhost peers; this implementation still reserves uptime protection only for localhost, even when overlay transports are enabled. Two Core steps never apply. BIP37 filters are ignored, so no peer has the bloom filter Core's transaction-time tie-break looks at. A discouraged address is refused at accept, where Core admits it while the inbound slots are not almost full and then evicts it first (`net.cpp:1811-1817`), so no candidate is discouraged. 13. **Outbound service gate**: an outbound peer that does not advertise the desirable set is disconnected right after its `version`, before it is published as usable, exactly as Core's `HasAllDesirableServiceFlags` check does (`net_processing.cpp:1857-1872`, applied to an outbound connection at `:3864-3871`). The desirable set is `NETWORK | WITNESS`, or `NETWORK_LIMITED | WITNESS` while the local tip is younger than 144 blocks (`NODE_NETWORK_LIMITED_ALLOW_CONN_BLOCKS`). Inbound peers are not service-checked, as in Core. @@ -671,3 +671,49 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at - Node-level reorg effects are coordinated in `crates/node/src/reorg.rs` (`switch_to_branch`, `invalidate_block`), which moves the applied tip off a losing branch; the node's P2P-chain adapter calls `switch_to_branch` when a higher-work header branch wins. The reorg fixture pins the peer-visible part of this at the `ChainQuery` seam — the exact surface `ActiveChainQuery` implements — via `reorg_switches_which_chain_a_peer_sees` (`crates/p2p/tests/core_compat.rs`). See also [docs/contracts/p2p-wire.md](../contracts/p2p-wire.md) for the contracts index and precedence rule. + +## Overlay transports + +`primitives::endpoint` owns logical IP, Tor v3 and I2P destination identities. +Those identities key the existing peer table, connection sources, mempool +attribution, address book, netgroups and relay. A loopback SOCKS/SAM hop is +never the remote peer's identity. The existing listener owns Bitcoin framing, +handshake, leases, counters, admission and message processing for all streams. + +`p2p::overlay` owns opt-in route selection and SOCKS5/SAM setup; its private +Tor control and IVNP SAM workers participate in `P2pService` startup/shutdown. +Allowed-network policy is checked both during selection and final admission. +The [configuration reference](../getting-started.md#tor-and-ivnp-i2p-networking) +owns option spellings and defaults. Setup uses absolute deadlines and bounded +cancellation polls. SAM session preparation has a 120-second bound; onion +circuit and I2P stream connection have a 60-second bound. Local service errors do not enter +remote protocol discouragement or count as failed overlay peer attempts. +Control-session loss revokes I2P stream epochs, including late registrations, +and the worker restores the selected stable destination with bounded backoff. + +Inbound Tor service identity is persistent but remote circuit identity is +unknown. Opaque per-connection handles are usable for disconnecting a live +connection; they are never persisted, learned as address-book sources, or +advertised as destinations. Inbound I2P uses `STREAM ACCEPT` Destination and +port metadata. Both paths use ordinary inbound capacity/eviction policy and +have no localhost exemptions. Core's special onion/I2P uptime protection in +eviction remains a deviation, as recorded above. + +Schema v9 adds overlay endpoints and source identities using the existing +backup-before-migration path. Upgrading v8 IP records preserves the secret, +all membership references, services and restart anchors; IP hash bytes and +grouping remain unchanged. Application keys have a separate versioned, +checksummed create-once format. A bad existing identity fails startup and is +never replaced. IVNP router data and application identities have separate +owners and directories. + +Local fixtures in `p2p::overlay` prove protocol transitions, refusal, +malformed input, timeout/cancellation, service loss and key preservation. +They do not prove privacy or external router interoperability. The manual +[`overlay-interoperability`](../../.github/workflows/overlay-interop.yml) +workflow runs `e2e/tests/overlay_connectivity.rs` through the existing process +harness against external Tor and the IVNP revision owned by +[`scripts/overlay-interop.py`](../../scripts/overlay-interop.py). Its artifacts +record executable hashes, Bitcoin peer identity/counters, headers/block +exchange and stable service-address restart observations. A failing or absent +campaign is unavailable interoperability evidence, not an advertised pass. diff --git a/e2e/tests/overlay_connectivity.rs b/e2e/tests/overlay_connectivity.rs new file mode 100644 index 000000000..44310c785 --- /dev/null +++ b/e2e/tests/overlay_connectivity.rs @@ -0,0 +1,135 @@ +//! External Tor / pinned IVNP interoperability. Ordinary CI uses local protocol fixtures. +#![allow(clippy::expect_used)] +use bitcoin_rs_e2e::helpers::{mine_bare_blocks, submit_genesis}; +use bitcoin_rs_e2e::{Error, Kind, ProcessNode, Result, SpawnOptions}; +use serde_json::json; +use std::{env, time::Duration}; + +fn setting(name: &str) -> Result { + env::var(name).map_err(|_| { + Error::Assertion(format!( + "required external backend setting {name} is absent" + )) + }) +} +fn service(node: &mut ProcessNode, suffix: &'static str) -> Result { + node.wait_for( + "overlay service address", + Duration::from_secs(180), + |node| { + let info = node.rpc("getnetworkinfo", &json!([]))?; + Ok(info["localaddresses"].as_array().and_then(|rows| { + rows.iter().find_map(|row| { + let address = row["address"].as_str()?; + address + .ends_with(suffix) + .then(|| format!("{address}:{}", row["port"])) + }) + })) + }, + ) +} +fn ready(node: &mut ProcessNode, network: &'static str, expected: Option<&str>) -> Result<()> { + node.wait_for( + "overlay Bitcoin handshake", + Duration::from_secs(180), + |node| { + let peers = node.rpc("getpeerinfo", &json!([]))?; + Ok(peers + .as_array() + .is_some_and(|peers| { + peers.iter().any(|peer| { + peer["network"] == network + && peer["version"] + .as_u64() + .is_some_and(|version| version >= 70016) + && expected.is_none_or(|expected| peer["addr"] == expected) + && peer["bytesrecv"].as_u64().is_some_and(|bytes| bytes > 0) + && peer["bytessent"].as_u64().is_some_and(|bytes| bytes > 0) + }) + }) + .then_some(())) + }, + ) +} +fn roundtrip( + network: &'static str, + suffix: &'static str, + server_config: &str, + client_config: &str, +) -> Result<()> { + let options = SpawnOptions { + toml_extra: server_config, + ..SpawnOptions::default() + }; + let mut server = ProcessNode::spawn_with(Kind::BitcoinRs, &options)?; + let mut client = ProcessNode::spawn_with( + Kind::BitcoinRs, + &SpawnOptions { + toml_extra: client_config, + ..SpawnOptions::default() + }, + )?; + submit_genesis(&mut server)?; + submit_genesis(&mut client)?; + let address = service(&mut server, suffix)?; + client.rpc("addnode", &json!([address, "add"]))?; + ready(&mut client, network, Some(&address))?; + ready(&mut server, network, None)?; + mine_bare_blocks(&mut server, 3)?; + let peers = server.rpc("getpeerinfo", &json!([]))?; + let remote = peers[0]["addr"].as_str().expect("remote identity"); + if network == "onion" { + assert!(remote.starts_with("tor-inbound-"), "{remote}"); + } else { + assert!(remote.contains(".b32.i2p:"), "{remote}"); + } + client.wait_block_count(3, Duration::from_secs(180))?; + assert_eq!( + client.rpc("getbestblockhash", &json!([]))?, + server.rpc("getbestblockhash", &json!([]))? + ); + let info = client.rpc("getnetworkinfo", &json!([]))?; + for row in info["networks"].as_array().expect("networks") { + if row["name"] == "ipv4" || row["name"] == "ipv6" { + assert_eq!(row["limited"], true); + assert_eq!(row["reachable"], false); + } + } + client.rpc("setnetworkactive", &json!([false]))?; + let datadir = server.take_datadir()?; + server.stop()?; + let mut server = ProcessNode::spawn_in_datadir(Kind::BitcoinRs, &options, datadir)?; + assert_eq!( + service(&mut server, suffix)?, + address, + "application identity must survive restart" + ); + client.rpc("setnetworkactive", &json!([true]))?; + ready(&mut client, network, Some(&address))?; + ready(&mut server, network, None)?; + mine_bare_blocks(&mut server, 1)?; + client.wait_block_count(4, Duration::from_secs(180))?; + client.stop()?; + server.stop() +} + +#[test] +#[ignore = "requires an external Tor SOCKS/control service; run the overlay interoperability workflow"] +fn tor_bitcoin_exchange_and_service_identity_restart() -> Result<()> { + let proxy = setting("OVERLAY_TOR_PROXY")?; + let control = setting("OVERLAY_TOR_CONTROL")?; + let cookie = setting("OVERLAY_TOR_COOKIE")?; + let client = format!("onlynet = [\"onion\"]\nonion_proxy = {proxy:?}\n"); + let server = format!("{client}tor_control = {control:?}\ntor_cookie = {cookie:?}\n"); + roundtrip("onion", ".onion", &server, &client) +} + +#[test] +#[ignore = "requires pinned ivnpd with a working SAM bridge; run the overlay interoperability workflow"] +fn ivnp_bitcoin_exchange_and_destination_identity_restart() -> Result<()> { + let sam = setting("OVERLAY_IVNP_SAM")?; + let client = format!("onlynet = [\"i2p\"]\ni2p_sam = {sam:?}\n"); + let server = format!("{client}i2p_accept = true\n"); + roundtrip("i2p", ".b32.i2p", &server, &client) +} diff --git a/scripts/overlay-interop.py b/scripts/overlay-interop.py new file mode 100644 index 000000000..634bace02 --- /dev/null +++ b/scripts/overlay-interop.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Own external overlay backend processes and run the real process-harness tests.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time + +IVNP_REVISION = "d5274b4752bd5a37bb35e6bb47796916c21e514c" + + +def port(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def wait_port(address, process): + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("overlay backend exited; inspect the backend log") + try: + with socket.create_connection(address, timeout=1): + return + except OSError: + time.sleep(0.2) + raise TimeoutError("overlay backend did not open its local control endpoint") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ivnp-revision", action="store_true") + parser.add_argument("--ivnpd", type=Path) + parser.add_argument("--tor", type=Path) + parser.add_argument("--cargo", default="cargo") + parser.add_argument("--evidence", type=Path, default=Path("target/overlay-interop")) + args = parser.parse_args() + if args.ivnp_revision: + print(IVNP_REVISION) + return 0 + if not args.ivnpd or not args.tor: + parser.error("--ivnpd and --tor are required") + evidence = args.evidence.resolve() + evidence.mkdir(parents=True, exist_ok=True) + binaries = {"tor": args.tor.resolve(), "ivnpd": args.ivnpd.resolve()} + (evidence / "backends.json").write_text(json.dumps({ + "ivnp_revision": IVNP_REVISION, + "binaries": {name: {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + for name, path in binaries.items()}, + "tor_version": subprocess.check_output([str(binaries["tor"]), "--version"], text=True).strip(), + }, indent=2) + "\n") + children = [] + logs = [] + with tempfile.TemporaryDirectory(prefix="bitcoin-rs-overlay-") as directory: + state = Path(directory) + socks, control, sam, sam_udp = port(), port(), port(), port() + cookie = state / "tor.cookie" + config = state / "ivnp.conf" + config.write_text(f"[sam]\nlisten_host = 127.0.0.1\nlisten_port = {sam}\nudp_host = 127.0.0.1\nudp_port = {sam_udp}\n") + commands = [ + ("tor", [str(binaries["tor"]), "--DataDirectory", str(state / "tor"), + "--SocksPort", f"127.0.0.1:{socks}", "--ControlPort", f"127.0.0.1:{control}", + "--CookieAuthentication", "1", "--CookieAuthFile", str(cookie)]), + ("ivnpd", [str(binaries["ivnpd"]), "-config", str(config), "-data-dir", str(state / "ivnp"), + "-webui=false", "-http-proxy=false", "-log-level", "warn"]), + ] + try: + for name, command in commands: + log = (evidence / f"{name}.log").open("wb") + logs.append(log) + children.append(subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT)) + wait_port(("127.0.0.1", control), children[0]) + wait_port(("127.0.0.1", sam), children[1]) + environment = dict(os.environ, OVERLAY_TOR_PROXY=f"127.0.0.1:{socks}", + OVERLAY_TOR_CONTROL=f"127.0.0.1:{control}", OVERLAY_TOR_COOKIE=str(cookie), + OVERLAY_IVNP_SAM=f"127.0.0.1:{sam}") + command = [args.cargo, "test", "--locked", "-p", "bitcoin-rs-e2e", "--test", "overlay_connectivity", + "--", "--ignored", "--nocapture", "--test-threads=1"] + with (evidence / "results.log").open("wb") as log: + result = subprocess.run(command, env=environment, stdout=log, stderr=subprocess.STDOUT, timeout=1500) + print((evidence / "results.log").read_text(errors="replace")) + return result.returncode + finally: + for child in children: + child.terminate() + for child in children: + try: + child.wait(timeout=20) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + for log in logs: + log.close() + + +if __name__ == "__main__": + raise SystemExit(main()) From 506cffd10812e4e9361979ed1bb18c75cd9b78ce Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 14:40:44 +0900 Subject: [PATCH 02/19] fix(ci): sync standalone lockfiles for overlay dependencies --- fuzz/Cargo.lock | 29 +++++++++++++++++++ tools/ldk/Cargo.lock | 23 +++++++++++++++ .../stratum-sv2/template-provider/Cargo.lock | 23 +++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 44a62df0c..00fb73ee9 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -66,6 +66,12 @@ dependencies = [ "bitcoin_hashes", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bech32" version = "0.11.1" @@ -229,6 +235,7 @@ name = "bitcoin-rs-p2p" version = "0.14.0" dependencies = [ "arc-swap", + "base64", "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-chainstate", @@ -259,7 +266,9 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "sha2", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -796,6 +805,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "libc" version = "0.2.191" @@ -1226,6 +1245,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" diff --git a/tools/ldk/Cargo.lock b/tools/ldk/Cargo.lock index 0e17f74b9..e3b69b326 100644 --- a/tools/ldk/Cargo.lock +++ b/tools/ldk/Cargo.lock @@ -313,6 +313,7 @@ name = "bitcoin-rs-p2p" version = "0.14.0" dependencies = [ "arc-swap", + "base64 0.22.1", "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-chainstate", @@ -343,7 +344,9 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "sha2", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -1116,6 +1119,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -1941,6 +1954,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/tools/stratum-sv2/template-provider/Cargo.lock b/tools/stratum-sv2/template-provider/Cargo.lock index 063d3fcc5..d2b54b0e6 100644 --- a/tools/stratum-sv2/template-provider/Cargo.lock +++ b/tools/stratum-sv2/template-provider/Cargo.lock @@ -370,6 +370,7 @@ name = "bitcoin-rs-p2p" version = "0.14.0" dependencies = [ "arc-swap", + "base64 0.22.1", "bitcoin", "bitcoin-rs-chain", "bitcoin-rs-chainstate", @@ -400,7 +401,9 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "sha2 0.11.1", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -1691,6 +1694,16 @@ dependencies = [ "serde", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + [[package]] name = "konst" version = "0.2.20" @@ -2780,6 +2793,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" From 3bfc44163b6d14bbcb2811c71e4c10a4f05e25b2 Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 14:50:43 +0900 Subject: [PATCH 03/19] fix(p2p): address overlay lifecycle and policy review findings --- crates/mempool/src/mutation.rs | 2 +- crates/node/src/options.rs | 17 ++++---- crates/node/tests/config_layered.rs | 20 +++++++++ crates/p2p/src/addrman.rs | 10 ++--- .../src/addrman/tests/overlay_destinations.rs | 25 +++++++++++ crates/p2p/src/discouragement.rs | 17 ++++++++ crates/p2p/src/eviction.rs | 5 ++- crates/p2p/src/listener.rs | 4 +- crates/p2p/src/netgroup.rs | 34 ++++++++++++--- crates/p2p/src/overlay/identity.rs | 18 +++++++- crates/p2p/src/overlay/sam.rs | 9 +++- crates/p2p/src/overlay/tests.rs | 19 ++++++++- crates/p2p/src/overlay/tor.rs | 9 +++- crates/p2p/src/peer_table.rs | 5 ++- crates/p2p/src/tx_policy/relay.rs | 2 + crates/primitives/README.md | 3 ++ crates/rpc/tests/core_compat.rs | 14 ++++++- docs/policies/p2p-compatibility.md | 23 +++++++--- scripts/overlay-interop.py | 42 +++++++++++++++---- 19 files changed, 235 insertions(+), 43 deletions(-) diff --git a/crates/mempool/src/mutation.rs b/crates/mempool/src/mutation.rs index 46d16f6ac..2f6262307 100644 --- a/crates/mempool/src/mutation.rs +++ b/crates/mempool/src/mutation.rs @@ -141,7 +141,7 @@ impl MutationResult { /// Identifies the network peer a transaction arrived from. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct PeerToken { - /// The peer's wire address. + /// The peer's logical Bitcoin endpoint, including anonymous inbound Tor handles. pub addr: bitcoin_rs_primitives::PeerEndpoint, /// The node-local id of the connection the peer arrived on. pub connection_id: u64, diff --git a/crates/node/src/options.rs b/crates/node/src/options.rs index 30dbde9ec..c70bf3b26 100644 --- a/crates/node/src/options.rs +++ b/crates/node/src/options.rs @@ -108,6 +108,9 @@ pub fn parse_peer_network(value: &str) -> std::result::Result Result> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } value .split(',') .map(|network| parse_peer_network(network.trim()).map_err(anyhow::Error::msg)) @@ -568,43 +571,43 @@ macro_rules! option_rows { env["BITCOIN_RS_CONNECT", parse_connect_list] toml each("connect", parse_connect_endpoint) } - /// Opt-in overlay routing: proxy. + /// SOCKS5 route for IP destinations; onion routing is configured separately. proxy as proxy: Option { cli[#[arg(long = "proxy")]] env["BITCOIN_RS_PROXY", str::parse] toml native("proxy") } - /// Opt-in overlay routing: onion-proxy. + /// SOCKS5 route for Tor v3 destinations, passed to the proxy without local DNS. onion_proxy as onion_proxy: Option { cli[#[arg(long = "onion-proxy")]] env["BITCOIN_RS_ONION_PROXY", str::parse] toml native("onion_proxy") } - /// Opt-in overlay routing: i2p-sam. + /// Loopback SAM bridge of an operator-managed IVNP router. i2p_sam as i2p_sam: Option { cli[#[arg(long = "i2p-sam")]] env["BITCOIN_RS_I2P_SAM", str::parse] toml native("i2p_sam") } - /// Opt-in overlay routing: tor-control. + /// Loopback Tor control endpoint enabling a persistent inbound onion service. tor_control as tor_control: Option { cli[#[arg(long = "tor-control")]] env["BITCOIN_RS_TOR_CONTROL", str::parse] toml native("tor_control") } - /// Opt-in overlay routing: tor-cookie. + /// Tor control authentication cookie file; otherwise use NULL authentication. tor_cookie as tor_cookie: Option { cli[#[arg(long = "tor-cookie")]] env["BITCOIN_RS_TOR_COOKIE", parse_path] toml native("tor_cookie") } - /// Opt-in overlay setting: i2p-accept. + /// Accept inbound I2P streams using a persistent application destination. i2p_accept as i2p_accept: Option { cli[#[arg(long = "i2p-accept", num_args = 0..=1, default_missing_value = "true")]] env["BITCOIN_RS_I2P_ACCEPT", parse_bool] toml native("i2p_accept") } - /// Opt-in overlay setting: proxy-randomize-credentials. + /// Require SOCKS username/password authentication with fresh credentials per connection. proxy_randomize_credentials as proxy_randomize_credentials: Option { cli[#[arg(long = "proxy-randomize-credentials", num_args = 0..=1, default_missing_value = "true")]] env["BITCOIN_RS_PROXY_RANDOMIZE_CREDENTIALS", parse_bool] diff --git a/crates/node/tests/config_layered.rs b/crates/node/tests/config_layered.rs index a94979828..4cbc7bd0a 100644 --- a/crates/node/tests/config_layered.rs +++ b/crates/node/tests/config_layered.rs @@ -61,6 +61,26 @@ fn overlay_routing_is_opt_in_and_layered_without_implicit_fallback() -> Result<( Ok(()) } +#[test] +fn blank_onlynet_environment_clears_an_inherited_network_list() -> Result<()> { + let file: UserConfig = toml::from_str("onlynet = ['ipv4']")?; + let mut environment = UserConfig::default(); + environment.apply_env("BITCOIN_RS_ONLYNET", std::ffi::OsStr::new(" "))?; + assert!( + resolve(&[&file, &environment])? + .p2p + .overlay + .onlynet + .is_empty() + ); + assert!( + environment + .apply_env("BITCOIN_RS_ONLYNET", std::ffi::OsStr::new("ipv4,")) + .is_err() + ); + Ok(()) +} + #[test] fn silent_payments_toml_overlay_preserves_explicit_disable() -> Result<()> { assert!( diff --git a/crates/p2p/src/addrman.rs b/crates/p2p/src/addrman.rs index 16078343c..228feef91 100644 --- a/crates/p2p/src/addrman.rs +++ b/crates/p2p/src/addrman.rs @@ -563,12 +563,12 @@ impl Manager { { return false; } - let time_penalty = if matches!(&source, Source::Ip(ip) if Some(crate::netgroup::canonical_ip(*ip)) == addr.address().ip()) - { - 0 - } else { - time_penalty + let self_source = match &source { + Source::Ip(ip) => Some(crate::netgroup::canonical_ip(*ip)) == addr.address().ip(), + Source::Overlay(address) => *address == addr.address(), + _ => false, }; + let time_penalty = if self_source { 0 } else { time_penalty }; let existing = self.by_addr.get(&addr).copied(); let mut refs = 0; if let Some(index) = existing { diff --git a/crates/p2p/src/addrman/tests/overlay_destinations.rs b/crates/p2p/src/addrman/tests/overlay_destinations.rs index 6abb60417..9244c525b 100644 --- a/crates/p2p/src/addrman/tests/overlay_destinations.rs +++ b/crates/p2p/src/addrman/tests/overlay_destinations.rs @@ -1,5 +1,30 @@ use super::*; +#[test] +fn overlay_self_announcements_keep_the_advertised_time() { + for address in [ + crate::PeerAddress::Onion([3; 32]), + crate::PeerAddress::I2p([4; 32]), + ] { + let book = AddressBook::open(None, [1; 4], false, None); + let own = PeerEndpoint::new(address, 8333); + let other = PeerEndpoint::new(crate::PeerAddress::Onion([5; 32]), 8333); + book.learn_peer(address, &[(own, 9, EPOCH), (other, 9, EPOCH)], EPOCH); + let state = book.state.lock(); + let seen = |endpoint| { + state + .stored + .records + .iter() + .find(|entry| entry.addr == endpoint) + .expect("learned endpoint") + .last_seen + }; + assert_eq!(seen(own), EPOCH); + assert_eq!(seen(other), EPOCH - 2 * 60 * 60); + } +} + #[test] fn overlay_endpoints_sources_and_anchors_survive_restart_and_addrv2_gossip() { let directory = tempfile::tempdir().expect("directory"); diff --git a/crates/p2p/src/discouragement.rs b/crates/p2p/src/discouragement.rs index dd2130873..2c6c62b1d 100644 --- a/crates/p2p/src/discouragement.rs +++ b/crates/p2p/src/discouragement.rs @@ -34,6 +34,9 @@ impl DiscouragedPeers { pub(crate) fn insert(&mut self, ip: impl Into, now: Instant) { self.expire(now); let ip = ip.into().to_canonical(); + if matches!(ip, PeerAddress::AnonymousTor(_)) { + return; + } if self.deadlines.contains_key(&ip) { return; } @@ -101,6 +104,20 @@ pub(crate) fn is_local(address: impl Into) -> bool { mod tests { use super::*; + #[test] + fn anonymous_circuits_cannot_displace_reconnectable_identities() { + let now = Instant::now(); + let mut entries = DiscouragedPeers::default(); + let ip = IpAddr::from([8, 8, 8, 8]); + entries.insert(ip, now); + for id in 0..=u64::try_from(MAX_DISCOURAGED_IPS).unwrap_or(u64::MAX) { + entries.insert(PeerAddress::AnonymousTor(id), now); + } + assert!(entries.contains(ip, now)); + assert_eq!(entries.deadlines.len(), 1); + assert_eq!(entries.oldest.len(), 1); + } + #[test] fn avoidance_is_bounded_expires_and_does_not_refresh_on_repetition() -> Result<(), Box> { diff --git a/crates/p2p/src/eviction.rs b/crates/p2p/src/eviction.rs index 85c89a256..8b5ccd11c 100644 --- a/crates/p2p/src/eviction.rs +++ b/crates/p2p/src/eviction.rs @@ -107,8 +107,9 @@ fn compare_block_time(a: &EvictionCandidate, b: &EvictionCandidate) -> Ordering /// Core's `ProtectEvictionCandidatesByRatio`: protects the half connected /// longest, reserving up to a quarter of the candidates for localhost peers -/// even when they are newer. Core's other reserved networks (onion, I2P, -/// CJDNS) are not transports here, so localhost is the only one. +/// even when they are newer. Overlay peers use the existing eviction policy +/// without Core's onion/I2P quotas; transport support does not add those +/// policy preferences. This deviation is recorded in the compatibility policy. fn protect_by_ratio(candidates: &mut Vec) { let initial = candidates.len(); let protect_total = initial / 2; diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index 789eb30ba..cccc6f0a5 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -891,7 +891,9 @@ fn dial( .try_register_outbound(addr, lease, &shared.activity) else { let _ = stream.shutdown(std::net::Shutdown::Both); - return Err(crate::wire::PeerError::Protocol("network inactive")); + return Err(crate::wire::PeerError::Protocol( + "transport or network unavailable", + )); }; if let Err(error) = shared.check_destination(addr, manual) { shared.peer_table.remove_current(addr, &lease); diff --git a/crates/p2p/src/netgroup.rs b/crates/p2p/src/netgroup.rs index 19bd10047..3407a72a7 100644 --- a/crates/p2p/src/netgroup.rs +++ b/crates/p2p/src/netgroup.rs @@ -169,11 +169,9 @@ impl NetGroups { PeerAddress::Ip(ip) => ip, PeerAddress::Onion(key) => return vec![4, key[0] | 15], PeerAddress::I2p(key) => return vec![5, key[0] | 15], - PeerAddress::AnonymousTor(id) => { - let mut group = vec![4]; - group.extend_from_slice(&id.to_le_bytes()); - return group; - } + // The remote identity is unknown: fresh circuit handles cannot + // establish independent network diversity. + PeerAddress::AnonymousTor(_) => return vec![4], }; // Core applies ASMap only to IPv4/IPv6 network classes; local and // unroutable IPs keep the prefix owner's unclassified group. @@ -348,6 +346,32 @@ pub(crate) const LINKED_ASMAP: &[u8] = mod tests { use super::*; + #[test] + fn overlay_groups_use_address_prefixes_and_share_unknown_tor_origins() { + let groups = NetGroups::default(); + for (address, class, group) in [ + ( + PeerAddress::Onion([0x12; 32]), + NetworkClass::Onion, + vec![4, 0x1f], + ), + ( + PeerAddress::I2p([0x23; 32]), + NetworkClass::I2p, + vec![5, 0x2f], + ), + (PeerAddress::AnonymousTor(1), NetworkClass::Onion, vec![4]), + ( + PeerAddress::AnonymousTor(u64::MAX), + NetworkClass::Onion, + vec![4], + ), + ] { + assert_eq!(network_class(address), class); + assert_eq!(groups.group(address), group); + } + } + // The bytes and expected addresses/ASNs are copied unchanged from Bitcoin // Core v31.1 src/test/netbase_tests.cpp::asmap_test_vectors (MIT). #[test] diff --git a/crates/p2p/src/overlay/identity.rs b/crates/p2p/src/overlay/identity.rs index f714444a9..d50218dc8 100644 --- a/crates/p2p/src/overlay/identity.rs +++ b/crates/p2p/src/overlay/identity.rs @@ -77,7 +77,10 @@ pub(super) fn validate(text: &str, kind: Kind) -> io::Result<()> { .decode(text) .map_err(|_| invalid("invalid I2P private destination encoding"))?; let public_length = destination_length(&bytes)?; - if bytes.len() < public_length + 32 || bytes.len() > 12_288 { + // SAM PrivateKeyFile contains a 256-byte encryption-private-key + // field (also when unused), followed by a signing private key. + // DSA's 20 bytes are the smallest supported signing-key encoding. + if bytes.len() < public_length + 256 + 20 || bytes.len() > 12_288 { return Err(invalid("invalid I2P private destination length")); } } @@ -165,6 +168,19 @@ pub(super) fn create(path: &Path, text: &str, kind: Kind) -> io::Result<()> { .parent() .ok_or_else(|| invalid("identity path has no parent"))?; ensure_directory(parent)?; + // A previous publication may have linked the key but failed directory + // sync. Retry durability for that exact identity, never replace a key. + if let Some(existing) = read(path, kind)? { + if existing != text { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "overlay identity already exists with a different key", + )); + } + return bitcoin_rs_storage::checkpoint::fs::sync_dir( + &bitcoin_rs_storage::checkpoint::fs::open_data_dir(parent)?, + ); + } let temporary = path.with_extension(format!( "tmp-{}-{:016x}", std::process::id(), diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 7ed558bc6..02eb86282 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -152,12 +152,14 @@ impl OverlayTransport { } let private = field(&line, "PRIV") .ok_or_else(|| invalid("SAM destination lacks private key"))?; + key = Some(private.to_owned()); + } + if let Some(private) = &key { identity::create( &self.config.identity_dir.join("i2p.key"), private, identity::Kind::I2p, )?; - key = Some(private.to_owned()); } let id = format!( "bitcoin-rs-{:016x}", @@ -191,7 +193,7 @@ impl OverlayTransport { if self.config.i2p_accept { *self.local_i2p.write() = Some(local); } - backoff = Duration::from_secs(1); + let healthy_since = Instant::now(); std::thread::scope(|scope| { let accept = self @@ -205,6 +207,9 @@ impl OverlayTransport { if control.line()? != "PONG bitcoin-rs" { return Err(invalid("SAM control heartbeat failed")); } + if healthy_since.elapsed() >= Duration::from_secs(60) { + backoff = Duration::from_secs(1); + } pause(&cancelled, Duration::from_secs(1)); } Ok(()) diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index 3876abd7b..58e559274 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -170,12 +170,29 @@ fn application_identity_is_create_once_and_corruption_is_explicit() { identity::read(&path, identity::Kind::Tor).expect("reuse"), Some(key.clone()) ); - assert!(identity::create(&path, &key, identity::Kind::Tor).is_err()); + identity::create(&path, &key, identity::Kind::Tor).expect("retry same identity durability"); + let other = format!( + "ED25519-V3:{}", + base64::engine::general_purpose::STANDARD.encode([8; 64]) + ); + assert!(identity::create(&path, &other, identity::Kind::Tor).is_err()); std::fs::write(&path, "damaged").expect("damage"); assert!(identity::read(&path, identity::Kind::Tor).is_err()); assert_eq!(std::fs::read_to_string(path).expect("preserved"), "damaged"); } +#[test] +fn sam_private_destination_requires_the_encryption_and_signing_fields() { + use base64::Engine as _; + let mut bytes = vec![0; 387 + 256 + 20]; + let encoded = identity::i2p_base64().encode(&bytes); + identity::validate(&encoded, identity::Kind::I2p).expect("complete legacy private key"); + bytes.pop(); + assert!( + identity::validate(&identity::i2p_base64().encode(bytes), identity::Kind::I2p).is_err() + ); +} + #[test] #[expect( clippy::too_many_lines, diff --git a/crates/p2p/src/overlay/tor.rs b/crates/p2p/src/overlay/tor.rs index 6a1d54d0b..763532bc0 100644 --- a/crates/p2p/src/overlay/tor.rs +++ b/crates/p2p/src/overlay/tor.rs @@ -78,22 +78,27 @@ impl OverlayTransport { .iter() .find_map(|line| line.strip_prefix("PrivateKey=")) .ok_or_else(|| invalid("Tor control missing private key"))?; + key = Some(private.to_owned()); + } + if let Some(private) = &key { identity::create( &self.config.identity_dir.join("onion.key"), private, identity::Kind::Tor, )?; - key = Some(private.to_owned()); } *self.local_onion.write() = Some(endpoint); tracing::info!(service = %endpoint, %forwarding, "Tor inbound service active"); - backoff = Duration::from_secs(1); + let healthy_since = Instant::now(); let mut next_heartbeat = Instant::now(); while !cancelled() { if Instant::now() >= next_heartbeat { control.deadline = Instant::now() + SETUP_TIMEOUT; control.write(b"GETINFO version\r\n")?; reply(&mut control)?; + if healthy_since.elapsed() >= Duration::from_secs(60) { + backoff = Duration::from_secs(1); + } next_heartbeat = Instant::now() + Duration::from_secs(1); } match listener.accept() { diff --git a/crates/p2p/src/peer_table.rs b/crates/p2p/src/peer_table.rs index 6b2853674..89ef40b34 100644 --- a/crates/p2p/src/peer_table.rs +++ b/crates/p2p/src/peer_table.rs @@ -192,6 +192,8 @@ impl PeerTable { /// Register an outbound socket only while the service activity flag is enabled. /// Activity admission and connection replacement share the table write lock. + /// Inactive admission or a cancelled lease (including a revoked transport + /// epoch) returns `None` without changing the table. pub(crate) fn try_register_outbound( &self, addr: PeerEndpoint, @@ -236,7 +238,8 @@ impl PeerTable { /// [`crate::netgroup::NetGroups::group`], `max_inbound` is the resolved /// automatic-connection remainder, and `best_block_depth` is /// [`crate::listener::approximate_best_block_depth`]. - /// POST: inactive admission returns `None` without mutation, even for an + /// POST: inactive admission or a cancelled lease (including a revoked + /// transport epoch) returns `None` without mutation, even for an /// already registered lease. A lease at an address already holding a /// LIVE inbound connection replaces and cancels it exactly as /// [`Self::register`] does, which never grows the count. Any other diff --git a/crates/p2p/src/tx_policy/relay.rs b/crates/p2p/src/tx_policy/relay.rs index daac4039d..40d64052b 100644 --- a/crates/p2p/src/tx_policy/relay.rs +++ b/crates/p2p/src/tx_policy/relay.rs @@ -26,6 +26,8 @@ const INVENTORY_TARGET: usize = 70; /// Core v31.1 net.cpp builds `m_network_key` from remote `GetNetClass` plus /// bound address bytes and port. Socket scope/flow and the remote address/port /// are not part of this identity. +/// Accepted I2P SAM streams use their ephemeral local bind endpoint, so their +/// clocks are per stream; they do not share a destination-wide inbound clock. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(super) struct InboundClockKey { network: NetworkClass, diff --git a/crates/primitives/README.md b/crates/primitives/README.md index df95cea83..f6e6fb98f 100644 --- a/crates/primitives/README.md +++ b/crates/primitives/README.md @@ -24,5 +24,8 @@ the Bitcoin network constants re-exported as `Network`. The `version` module pub `PKG_VERSION` and `USER_AGENT`, the workspace release constants carried in wire and RPC user-agent strings. +`endpoint` defines logical IP, Tor v3 and I2P destinations (`PeerAddress`, +`PeerEndpoint`, `PeerNetwork`), independent of the sockets carrying peer traffic. + Part of [`bitcoin-rs`](../../README.md); see [`CONCEPTS.md`](../../CONCEPTS.md) for the project vocabulary. diff --git a/crates/rpc/tests/core_compat.rs b/crates/rpc/tests/core_compat.rs index a78673b62..d51d6fc0d 100644 --- a/crates/rpc/tests/core_compat.rs +++ b/crates/rpc/tests/core_compat.rs @@ -191,7 +191,19 @@ fn network_responses_deserialize_into_pinned_types() -> Result<(), Box>(), + ["ipv4", "ipv6", "onion", "i2p"] + ); + for row in &network.networks[2..] { + assert!(row.limited); + assert!(!row.reachable); + assert!(row.proxy.is_empty()); + } assert_eq!(network.warnings, Vec::::new()); let peers: corepc_types::v31::GetPeerInfo = diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 46e0d3ca3..7eec928df 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -355,7 +355,7 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at timestamps before Add. Existing native port/routability admission is retained (`addrman.rs::routable`), including its narrower accepted IPv6 ranges and reserved IPv4 exclusions; exact group/hash vectors do not claim that every - Core-routable address is admitted. Only existing IP transports are supported. + Core-routable address is admitted. IP, Tor v3 and I2P destinations are supported. DNS remains bounded bootstrap input (64 results per seed pass), not a second address owner. Books with at least 64 records get 60 seconds to connect before a @@ -398,10 +398,11 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at means a known zero-bit claim. Null is valid only for an Internal/legacy-DNS original source with no prior Good. Missing or invalid fields fail closed. Same-map restart restores every reference; same-classifier v5/v6/v7/v8-to-v9 - upgrades preserve all references and anchors exactly. Known v1–v7 formats, + upgrades preserve all references and anchors exactly. Known v1–v8 formats, including genuine v3 files carrying anchors, are strict migration sources. - Their service fields must be numeric u64; old null or missing fields remain - invalid. Historical DNS zero/no-Good records cannot reveal whether zero was + In v1–v7, service fields must be numeric u64; old null or missing fields remain + invalid. V8 retains its explicit nullable DNS-service representation. + Historical DNS zero/no-Good records cannot reveal whether zero was actually observed. Decoding infers only that ambiguous case as unknown in memory, where it may retry; its next accepted VERSION records the observation precisely. Exact-byte backup gates publication of the upgraded file, not @@ -426,7 +427,8 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at Actual Core group/placement evidence includes 224 ASMap rows and official Tried236/New795 anchors. Migration fixtures were emitted by the original - v1/v2/v3/v4/v5/v6/v7 writers; v5/v6/v7 fixtures pin all eight retained references. These + v1/v2/v3/v4/v5/v6/v7/v8 writers; v5/v6/v7 fixtures pin all eight retained references. + The v8 endpoint fixture pins exact IP membership and anchor preservation. These are specific algorithm/persistence checks, not full peer-lifecycle parity. Filenames remain scoped to actual P2P magic: `peers.dat` becomes @@ -685,7 +687,10 @@ Tor control and IVNP SAM workers participate in `P2pService` startup/shutdown. Allowed-network policy is checked both during selection and final admission. The [configuration reference](../getting-started.md#tor-and-ivnp-i2p-networking) owns option spellings and defaults. Setup uses absolute deadlines and bounded -cancellation polls. SAM session preparation has a 120-second bound; onion +cancellation polls for local overlay-service I/O. Direct IP dialing retains +the existing single 10-second TCP connect timeout and does not poll cancellation +during that call; the 100ms local-service reconnect helper is unsuitable for +high-latency remote TCP establishment. SAM session preparation has a 120-second bound; onion circuit and I2P stream connection have a 60-second bound. Local service errors do not enter remote protocol discouragement or count as failed overlay peer attempts. Control-session loss revokes I2P stream epochs, including late registrations, @@ -699,6 +704,12 @@ port metadata. Both paths use ordinary inbound capacity/eviction policy and have no localhost exemptions. Core's special onion/I2P uptime protection in eviction remains a deviation, as recorded above. +Inbound transaction-inventory clocks use the remote network class and local +bind endpoint. Tor circuits forwarded to one service share its bind clock. +Accepted I2P SAM streams have ephemeral local bind ports, so their clocks are +per stream rather than shared by application destination. This remains a +deviation from Core's shared inbound clock policy. + Schema v9 adds overlay endpoints and source identities using the existing backup-before-migration path. Upgrading v8 IP records preserves the secret, all membership references, services and restart anchors; IP hash bytes and diff --git a/scripts/overlay-interop.py b/scripts/overlay-interop.py index 634bace02..90dbace4f 100644 --- a/scripts/overlay-interop.py +++ b/scripts/overlay-interop.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """Own external overlay backend processes and run the real process-harness tests.""" import argparse +from contextlib import ExitStack import hashlib import json import os from pathlib import Path import socket +import signal import subprocess import tempfile import time @@ -13,10 +15,14 @@ IVNP_REVISION = "d5274b4752bd5a37bb35e6bb47796916c21e514c" -def port(): - with socket.socket() as listener: +def reserve_port(kind=socket.SOCK_STREAM): + listener = socket.socket(type=kind) + try: listener.bind(("127.0.0.1", 0)) - return listener.getsockname()[1] + return listener + except BaseException: + listener.close() + raise def wait_port(address, process): @@ -45,6 +51,8 @@ def main(): return 0 if not args.ivnpd or not args.tor: parser.error("--ivnpd and --tor are required") + if os.name != "posix": + parser.error("external overlay campaigns require POSIX process-group cleanup") evidence = args.evidence.resolve() evidence.mkdir(parents=True, exist_ok=True) binaries = {"tor": args.tor.resolve(), "ivnpd": args.ivnpd.resolve()} @@ -56,9 +64,11 @@ def main(): }, indent=2) + "\n") children = [] logs = [] - with tempfile.TemporaryDirectory(prefix="bitcoin-rs-overlay-") as directory: + with tempfile.TemporaryDirectory(prefix="bitcoin-rs-overlay-") as directory, ExitStack() as reservations: state = Path(directory) - socks, control, sam, sam_udp = port(), port(), port(), port() + held = [reservations.enter_context(reserve_port(kind)) for kind in + (socket.SOCK_STREAM, socket.SOCK_STREAM, socket.SOCK_STREAM, socket.SOCK_DGRAM)] + socks, control, sam, sam_udp = [listener.getsockname()[1] for listener in held] cookie = state / "tor.cookie" config = state / "ivnp.conf" config.write_text(f"[sam]\nlisten_host = 127.0.0.1\nlisten_port = {sam}\nudp_host = 127.0.0.1\nudp_port = {sam_udp}\n") @@ -70,9 +80,14 @@ def main(): "-webui=false", "-http-proxy=false", "-log-level", "warn"]), ] try: - for name, command in commands: + for (name, command), ports in zip(commands, (held[:2], held[2:])): log = (evidence / f"{name}.log").open("wb") logs.append(log) + # Keep each reservation until immediately before its backend starts. + # The backend does not accept inherited sockets, so a short handoff + # race remains; a bind failure is reported by wait_port. + for listener in ports: + listener.close() children.append(subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT)) wait_port(("127.0.0.1", control), children[0]) wait_port(("127.0.0.1", sam), children[1]) @@ -82,9 +97,20 @@ def main(): command = [args.cargo, "test", "--locked", "-p", "bitcoin-rs-e2e", "--test", "overlay_connectivity", "--", "--ignored", "--nocapture", "--test-threads=1"] with (evidence / "results.log").open("wb") as log: - result = subprocess.run(command, env=environment, stdout=log, stderr=subprocess.STDOUT, timeout=1500) + with subprocess.Popen(command, env=environment, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) as campaign: + try: + returncode = campaign.wait(timeout=1500) + finally: + # Cargo, its test binary and the nodes inherit this group. + # Also reap surviving descendants after a failed test run. + try: + os.killpg(campaign.pid, signal.SIGKILL) + except ProcessLookupError: + pass + campaign.wait() print((evidence / "results.log").read_text(errors="replace")) - return result.returncode + return returncode finally: for child in children: child.terminate() From 93dc7025461c63583ae0debed76ed982e6677351 Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 14:56:13 +0900 Subject: [PATCH 04/19] fix(p2p): attribute routed destination failures to address retries --- crates/p2p/src/listener.rs | 7 +++-- crates/p2p/src/overlay.rs | 26 ++++++++++++++++++ crates/p2p/src/overlay/sam.rs | 35 ++++++++++++++++++++++--- crates/p2p/src/overlay/tests.rs | 42 ++++++++++++++++++++++++++++++ docs/policies/p2p-compatibility.md | 4 +++ 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index cccc6f0a5..8efb797e3 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -850,10 +850,13 @@ fn dial( shared.is_session_cancelled() || !shared.activity.is_active() }); if let Some(book) = &shared.address_book { - // Like Core ConnectNode, record actual TCP attempts on success/failure, - // after local admission guards. Spawn failures/cancellation are not peer evidence. + // Record direct attempts, routed success and explicit remote refusals. + // Local proxy/router setup failures are not evidence against the peer. if addr.address().ip().is_some() && shared.overlay.config.proxy.is_none() || connection.is_ok() + || connection + .as_ref() + .is_err_and(crate::overlay::remote_connect_failed) { book.attempted(addr, count_failure && !manual, crate::addrman::now()); } diff --git a/crates/p2p/src/overlay.rs b/crates/p2p/src/overlay.rs index 197a2275d..78aa49f7d 100644 --- a/crates/p2p/src/overlay.rs +++ b/crates/p2p/src/overlay.rs @@ -351,6 +351,25 @@ impl OverlayTransport { pub(super) fn invalid(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } + +// Only explicit destination failures reported by a live proxy/router carry +// this attribution. I/O, authentication and control-session errors stay local. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct RemoteConnectFailure(String); + +fn remote_failure(message: impl Into) -> io::Error { + io::Error::new( + io::ErrorKind::ConnectionRefused, + RemoteConnectFailure(message.into()), + ) +} + +pub(crate) fn remote_connect_failed(error: &io::Error) -> bool { + error + .get_ref() + .is_some_and(|inner| inner.is::()) +} fn cancelled_error() -> io::Error { io::Error::new(io::ErrorKind::Interrupted, "overlay operation cancelled") } @@ -524,6 +543,13 @@ fn socks( let mut reply = [0; 4]; setup.read(&mut reply)?; if reply[..3] != [5, 0, 0] { + if reply[0] == 5 + && reply[2] == 0 + && matches!(reply[3], 1 | 3 | 4) + && matches!(reply[1], 3..=6) + { + return Err(remote_failure("SOCKS destination unreachable or refused")); + } return Err(io::Error::new( io::ErrorKind::ConnectionRefused, "SOCKS connect refused", diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 02eb86282..991cf50e7 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -46,10 +46,14 @@ fn status(setup: &mut Setup<'_>, prefix: &str) -> io::Result { Some("KEY_NOT_FOUND") => "KEY_NOT_FOUND", _ => "malformed reply", }; - return Err(io::Error::new( - io::ErrorKind::ConnectionRefused, - format!("SAM {prefix}{result}"), - )); + let message = format!("SAM {prefix}{result}"); + if prefix == "STREAM STATUS " + && line.starts_with(prefix) + && matches!(result, "CANT_REACH_PEER" | "TIMEOUT") + { + return Err(super::remote_failure(message)); + } + return Err(io::Error::new(io::ErrorKind::ConnectionRefused, message)); } Ok(line) } @@ -292,6 +296,29 @@ mod tests { use std::io::{BufRead as _, BufReader, Write as _}; use std::net::TcpListener; + #[test] + fn only_explicit_stream_destination_failures_are_remote_attempts() { + for (reply, remote) in [ + ("STREAM STATUS RESULT=CANT_REACH_PEER\n", true), + ("STREAM STATUS RESULT=TIMEOUT\n", true), + ("STREAM STATUS RESULT=I2P_ERROR\n", false), + ("STREAM STATUS RESULT=INVALID_ID\n", false), + ("SESSION STATUS RESULT=TIMEOUT\n", false), + ("STREAM STATUS RESULT=TIMEOUT RESULT=TIMEOUT\n", false), + ] { + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + stream.write_all(reply.as_bytes()).expect("status"); + }); + let mut setup = Setup::new(address, &|| false).expect("setup"); + let error = status(&mut setup, "STREAM STATUS ").expect_err("refused stream"); + assert_eq!(super::super::remote_connect_failed(&error), remote); + fixture.join().expect("fixture"); + } + } + #[test] fn negotiation_refuses_unsupported_duplicate_and_malformed_replies() { for reply in [ diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index 58e559274..8fa6c3a44 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -86,6 +86,48 @@ fn socks_requires_the_selected_auth_policy_and_refuses_bad_replies() { } } +#[test] +fn socks_destination_refusals_are_distinct_from_local_and_malformed_failures() { + for reply in [ + [5, 1, 0, 1], + [5, 2, 0, 1], + [5, 3, 0, 1], + [5, 4, 0, 1], + [5, 5, 0, 1], + [5, 6, 0, 1], + [5, 7, 0, 1], + [5, 8, 0, 1], + [4, 5, 0, 1], + [5, 5, 1, 1], + [5, 5, 0, 9], + ] { + let listener = TcpListener::bind("127.0.0.1:0").expect("proxy"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + stream.read_exact(&mut [0; 3]).expect("hello"); + stream.write_all(&[5, 0]).expect("method"); + stream.read_exact(&mut [0; 10]).expect("IP request"); + stream.write_all(&reply).expect("refusal"); + }); + let error = socks( + address, + PeerEndpoint::from(([8, 8, 8, 8], 8333)), + false, + &|| false, + ) + .expect_err("refused connection"); + assert_eq!( + remote_connect_failed(&error), + matches!(reply, [5, 3..=6, 0, 1]) + ); + fixture.join().expect("fixture"); + } + assert!(!remote_connect_failed(&io::Error::from( + io::ErrorKind::ConnectionRefused + ))); +} + #[test] fn setup_retains_partial_reads_and_obeys_deadline_and_cancellation() { let listener = TcpListener::bind("127.0.0.1:0").expect("proxy"); diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 7eec928df..b97d36945 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -693,6 +693,10 @@ during that call; the 100ms local-service reconnect helper is unsuitable for high-latency remote TCP establishment. SAM session preparation has a 120-second bound; onion circuit and I2P stream connection have a 60-second bound. Local service errors do not enter remote protocol discouragement or count as failed overlay peer attempts. +Explicit SOCKS destination-unreachable/refused replies and SAM stream +`CANT_REACH_PEER`/`TIMEOUT` replies do update the existing address-book attempt +and retry policy. Authentication, malformed replies, local I/O and control +session failures remain local errors. Control-session loss revokes I2P stream epochs, including late registrations, and the worker restores the selected stable destination with bounded backoff. From bc1d8a88efd9dad65f5a1ee92787821ee348c823 Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 15:04:43 +0900 Subject: [PATCH 05/19] refactor(p2p): consolidate overlay configuration and session ownership --- crates/node/src/state/open.rs | 3 +- crates/p2p/src/addrman.rs | 2 + .../src/addrman/tests/overlay_destinations.rs | 27 ++++++++++++ crates/p2p/src/listener.rs | 4 +- crates/p2p/src/overlay.rs | 19 ++++---- crates/p2p/src/overlay/sam.rs | 43 ++++++++++++++++--- crates/p2p/src/overlay/tests.rs | 6 +-- crates/p2p/src/overlay/tor.rs | 4 ++ crates/p2p/src/service.rs | 10 ++--- crates/rpc/src/handlers/network.rs | 3 +- docs/policies/p2p-compatibility.md | 9 +++- 11 files changed, 103 insertions(+), 27 deletions(-) diff --git a/crates/node/src/state/open.rs b/crates/node/src/state/open.rs index dee33c5cd..7304e382e 100644 --- a/crates/node/src/state/open.rs +++ b/crates/node/src/state/open.rs @@ -376,7 +376,8 @@ impl NodeState { overlay: bitcoin_rs_p2p::OverlayConfig { identity_dir: config.data_dir.join(&config.p2p.overlay.identity_dir), ..config.p2p.overlay.clone() - }, + } + .into(), listen_addrs: config.p2p.listen.clone(), address_book_path: Some(config.data_dir.join("peers.dat")), asmap_path: config.p2p.asmap.clone(), diff --git a/crates/p2p/src/addrman.rs b/crates/p2p/src/addrman.rs index 228feef91..ed5d8b85c 100644 --- a/crates/p2p/src/addrman.rs +++ b/crates/p2p/src/addrman.rs @@ -1726,6 +1726,8 @@ fn validate_current( for entry in &stored.records { if !routable(entry.addr, allow_local) || canonical(entry.addr) != entry.addr + || matches!(&entry.source, Source::Overlay(address) + if !matches!(address, crate::PeerAddress::Onion(_) | crate::PeerAddress::I2p(_))) || !endpoints.insert(entry.addr) || entry.services.is_none() && !entry.allows_unknown_services() || entry.new_buckets.len() > MAX_NEW_REFS diff --git a/crates/p2p/src/addrman/tests/overlay_destinations.rs b/crates/p2p/src/addrman/tests/overlay_destinations.rs index 9244c525b..133c43e3c 100644 --- a/crates/p2p/src/addrman/tests/overlay_destinations.rs +++ b/crates/p2p/src/addrman/tests/overlay_destinations.rs @@ -1,5 +1,32 @@ use super::*; +#[test] +fn ip_source_disguised_as_overlay_is_rejected_without_rewriting_the_book() { + let directory = tempfile::tempdir().expect("directory"); + let base = directory.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); + book.learn_peer(addr(2).address(), &[(addr(1), 9, EPOCH)], EPOCH); + book.save(); + let original = fs::read(&path).expect("valid book"); + let mut value = serde_json::to_value(&book.state.lock().stored).expect("state"); + // This alias has the same source group as the valid IP source, so only + // the source representation invariant rejects the otherwise valid file. + value["records"][0]["source"] = serde_json::json!({"overlay": addr(2).address().to_string()}); + let mut bytes = serde_json::to_vec(&value).expect("invalid source payload"); + let checksum = Sha256::digest(&bytes); + bytes.extend_from_slice(&checksum); + fs::write(&path, &bytes).expect("write alias"); + let rejected = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert!(!rejected.state.lock().writable); + rejected.save(); + assert_eq!(fs::read(&path).expect("preserved bytes"), bytes); + fs::write(&path, original).expect("restore valid book"); + let restored = AddressBook::open(Some(base), [1; 4], false, None); + assert!(restored.state.lock().writable); + assert_eq!(restored.len(), 1); +} + #[test] fn overlay_self_announcements_keep_the_advertised_time() { for address in [ diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index 8efb797e3..7846de9db 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -231,9 +231,7 @@ impl ConnectionShared { max_inbound: crate::service::P2pServiceConfig::default().max_inbound(), local_services: ServiceFlags::NETWORK | ServiceFlags::WITNESS, v2_transport: false, - overlay: Arc::new(crate::overlay::OverlayTransport::new( - crate::OverlayConfig::default(), - )), + overlay: Arc::new(crate::overlay::OverlayTransport::new(Arc::default())), shared_outbound: Arc::new(crate::connection::SharedOutboundBudget::new()), } } diff --git a/crates/p2p/src/overlay.rs b/crates/p2p/src/overlay.rs index 78aa49f7d..baa9fc257 100644 --- a/crates/p2p/src/overlay.rs +++ b/crates/p2p/src/overlay.rs @@ -141,15 +141,13 @@ pub(crate) struct OverlayTransport { pub(crate) config: Arc, sam: RwLock>, local_onion: RwLock>, - local_i2p: RwLock>, } impl OverlayTransport { - pub(crate) fn new(config: OverlayConfig) -> Self { + pub(crate) fn new(config: Arc) -> Self { Self { - config: Arc::new(config), + config, sam: RwLock::new(None), local_onion: RwLock::new(None), - local_i2p: RwLock::new(None), } } @@ -164,7 +162,14 @@ impl OverlayTransport { .map(|network| { let local_address = match network { PeerNetwork::Onion => *self.local_onion.read(), - PeerNetwork::I2p => *self.local_i2p.read(), + PeerNetwork::I2p => self + .sam + .read() + .as_ref() + .filter(|session| { + self.config.i2p_accept && session.alive.load(Ordering::Acquire) + }) + .map(|session| session.local_address), _ => None, }; OverlayNetworkState { @@ -366,9 +371,7 @@ fn remote_failure(message: impl Into) -> io::Error { } pub(crate) fn remote_connect_failed(error: &io::Error) -> bool { - error - .get_ref() - .is_some_and(|inner| inner.is::()) + matches!(error.get_ref(), Some(inner) if inner.is::()) } fn cancelled_error() -> io::Error { io::Error::new(io::ErrorKind::Interrupted, "overlay operation cancelled") diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 991cf50e7..197753abc 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -7,7 +7,8 @@ use super::{ use sha2::{Digest, Sha256}; pub(super) struct Session { id: String, - alive: Arc, + pub(super) alive: Arc, + pub(super) local_address: PeerEndpoint, } fn hello(setup: &mut Setup<'_>) -> io::Result<()> { setup.write(b"HELLO VERSION MIN=3.1 MAX=3.3\n")?; @@ -193,10 +194,8 @@ impl OverlayTransport { *self.sam.write() = Some(Session { id: id.clone(), alive: Arc::clone(&alive), + local_address: local, }); - if self.config.i2p_accept { - *self.local_i2p.write() = Some(local); - } let healthy_since = Instant::now(); std::thread::scope(|scope| { @@ -229,7 +228,6 @@ impl OverlayTransport { if let Some(session) = retired { session.alive.store(false, Ordering::Release); } - *self.local_i2p.write() = None; retire(&shared.peer_table, PeerNetwork::I2p); if let Err(error) = result { if !cancelled() { @@ -296,6 +294,41 @@ mod tests { use std::io::{BufRead as _, BufReader, Write as _}; use std::net::TcpListener; + #[test] + fn advertised_i2p_address_belongs_to_a_live_accepting_session() { + let local = PeerEndpoint::new(PeerAddress::I2p([7; 32]), 8333); + for accept in [false, true] { + let transport = OverlayTransport::new(Arc::new(crate::OverlayConfig { + i2p_accept: accept, + i2p_sam: Some("127.0.0.1:7656".parse().expect("SAM socket")), + ..crate::OverlayConfig::default() + })); + let table = crate::PeerTable::new(); + let state = || { + transport + .states(&table) + .into_iter() + .find(|state| state.network == PeerNetwork::I2p) + .expect("I2P state") + }; + let alive = Arc::new(AtomicBool::new(true)); + assert!(state().local_address.is_none()); + *transport.sam.write() = Some(Session { + id: "fixture".to_owned(), + alive: Arc::clone(&alive), + local_address: local, + }); + assert_eq!(state().local_address, accept.then_some(local)); + assert_eq!(state().operational, accept); + alive.store(false, Ordering::Release); + assert!(state().local_address.is_none()); + assert!(!state().operational); + assert!(transport.sam_session().is_err()); + transport.sam.write().take(); + assert!(state().local_address.is_none()); + } + } + #[test] fn only_explicit_stream_destination_failures_are_remote_attempts() { for (reply, remote) in [ diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index 8fa6c3a44..0f5152d46 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -167,7 +167,7 @@ fn overlay_only_resolution_and_admission_never_fall_back_to_ip_or_dns() { ..OverlayConfig::default() }; config.validate().expect("config"); - let transport = OverlayTransport::new(config); + let transport = OverlayTransport::new(config.into()); for text in [ "127.0.0.1:8333", "localhost:8333", @@ -248,11 +248,11 @@ fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback( let listener = TcpListener::bind("127.0.0.1:0").expect("SAM fixture"); listener.set_nonblocking(true).expect("nonblocking"); let address = listener.local_addr().expect("address"); - let owner = Arc::new(OverlayTransport::new(OverlayConfig { + let owner = Arc::new(OverlayTransport::new(Arc::new(OverlayConfig { i2p_sam: Some(address), onlynet: vec![PeerNetwork::I2p], ..OverlayConfig::default() - })); + }))); let shared = shared(&owner); let shutdown = Arc::new(AtomicBool::new(false)); let controls = Arc::new(AtomicUsize::new(0)); diff --git a/crates/p2p/src/overlay/tor.rs b/crates/p2p/src/overlay/tor.rs index 763532bc0..6788beede 100644 --- a/crates/p2p/src/overlay/tor.rs +++ b/crates/p2p/src/overlay/tor.rs @@ -26,6 +26,10 @@ fn reply(setup: &mut Setup<'_>) -> io::Result> { Err(invalid("oversized Tor control reply")) } impl OverlayTransport { + #[expect( + clippy::too_many_lines, + reason = "One service owner keeps key publication, control lifetime and inbound teardown ordered" + )] pub(super) fn run_tor( &self, address: SocketAddr, diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index 35bec42fe..235f874b7 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -69,7 +69,7 @@ pub struct P2pServiceConfig { /// Addresses on which inbound P2P connections are accepted. pub listen_addrs: Vec, /// Opt-in overlay routing and service configuration. - pub overlay: crate::OverlayConfig, + pub overlay: Arc, /// Network message-start bytes. pub magic: Magic, /// Auxiliary peer-book base/legacy path. The owner adds the P2P magic to @@ -118,7 +118,7 @@ impl Default for P2pServiceConfig { fn default() -> Self { Self { listen_addrs: Vec::new(), - overlay: crate::OverlayConfig::default(), + overlay: Arc::default(), magic: Magic::from_bytes([0; 4]), address_book_path: None, asmap_path: None, @@ -436,9 +436,9 @@ impl P2pService { config.allow_local_addresses, config.asmap_path.as_deref(), ); - let overlay = Arc::new(crate::overlay::OverlayTransport::new( - config.overlay.clone(), - )); + let overlay = Arc::new(crate::overlay::OverlayTransport::new(Arc::clone( + &config.overlay, + ))); let mut banned = BannedReader::with_protection( Arc::new(RwLock::new(Vec::new())), config.noban_subnets.clone(), diff --git a/crates/rpc/src/handlers/network.rs b/crates/rpc/src/handlers/network.rs index c445026ee..4d3969684 100644 --- a/crates/rpc/src/handlers/network.rs +++ b/crates/rpc/src/handlers/network.rs @@ -615,7 +615,8 @@ mod tests { ), onlynet: vec![bitcoin_rs_p2p::PeerNetwork::Onion], ..bitcoin_rs_p2p::OverlayConfig::default() - }, + } + .into(), ..bitcoin_rs_p2p::P2pServiceConfig::default() }, bitcoin_rs_chain::LatchReader::fixture_never(), diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index b97d36945..22670372b 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -684,6 +684,9 @@ handshake, leases, counters, admission and message processing for all streams. `p2p::overlay` owns opt-in route selection and SOCKS5/SAM setup; its private Tor control and IVNP SAM workers participate in `P2pService` startup/shutdown. +The service, transport and admission policy share one immutable routing +configuration. The SAM session owns its local destination; RPC advertises it +only while that session is live and inbound acceptance is enabled. Allowed-network policy is checked both during selection and final admission. The [configuration reference](../getting-started.md#tor-and-ivnp-i2p-networking) owns option spellings and defaults. Setup uses absolute deadlines and bounded @@ -717,7 +720,11 @@ deviation from Core's shared inbound clock policy. Schema v9 adds overlay endpoints and source identities using the existing backup-before-migration path. Upgrading v8 IP records preserves the secret, all membership references, services and restart anchors; IP hash bytes and -grouping remain unchanged. Application keys have a separate versioned, +grouping remain unchanged. +Overlay source tags admit only Tor v3 and I2P identities; IP sources retain +their existing IP tag. Invalid source representations keep the original file +untouched with persistence disabled. +Application keys have a separate versioned, checksummed create-once format. A bad existing identity fails startup and is never replaced. IVNP router data and application identities have separate owners and directories. From 86775d4df4a20c28a4c4134d6e603ca08cb0f0df Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 15:18:29 +0900 Subject: [PATCH 06/19] fix(ci): use diagnostic equality assertions for empty values --- crates/node/tests/config_layered.rs | 9 +++------ crates/rpc/tests/core_compat.rs | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/node/tests/config_layered.rs b/crates/node/tests/config_layered.rs index 4cbc7bd0a..ac80d35e6 100644 --- a/crates/node/tests/config_layered.rs +++ b/crates/node/tests/config_layered.rs @@ -66,12 +66,9 @@ fn blank_onlynet_environment_clears_an_inherited_network_list() -> Result<()> { let file: UserConfig = toml::from_str("onlynet = ['ipv4']")?; let mut environment = UserConfig::default(); environment.apply_env("BITCOIN_RS_ONLYNET", std::ffi::OsStr::new(" "))?; - assert!( - resolve(&[&file, &environment])? - .p2p - .overlay - .onlynet - .is_empty() + assert_eq!( + resolve(&[&file, &environment])?.p2p.overlay.onlynet, + Vec::::new() ); assert!( environment diff --git a/crates/rpc/tests/core_compat.rs b/crates/rpc/tests/core_compat.rs index d51d6fc0d..e4ff7f3ca 100644 --- a/crates/rpc/tests/core_compat.rs +++ b/crates/rpc/tests/core_compat.rs @@ -202,7 +202,7 @@ fn network_responses_deserialize_into_pinned_types() -> Result<(), Box::new()); From 7bbf58d33219cbfdd76018e43d5428f0e31985db Mon Sep 17 00:00:00 2001 From: Kim Date: Mon, 26 Oct 2026 15:38:25 +0900 Subject: [PATCH 07/19] test(p2p): align inactive admission diagnostic expectation --- crates/p2p/src/service.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index 235f874b7..2b1c2611a 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -4060,10 +4060,15 @@ mod tests { let result = worker.join().expect("outbound worker"); disabled.expect("disable does not wait for socket I/O or metadata query"); assert_eq!(received.expect("closed without handshake"), 0); - assert!(matches!( - result, - Err(crate::PeerError::Protocol("network inactive")) - )); + assert!( + matches!( + result, + Err(crate::PeerError::Protocol( + "transport or network unavailable" + )) + ), + "unexpected outbound result: {result:?}" + ); assert_eq!(service.peer_table.sessions().len(), 0); assert_eq!(ready.load(Ordering::Relaxed), 0); service.address_book.save(); From e003ccc0c6f5ea23426a098258c3531b7b4f6462 Mon Sep 17 00:00:00 2001 From: metaphorics Date: Tue, 27 Oct 2026 00:47:03 +0000 Subject: [PATCH 08/19] test(rpc): pin getnetworkinfo networks in batch differential fixture --- .../rpc/tests/corpus/core-31.1/20_batch_two_requests.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/rpc/tests/corpus/core-31.1/20_batch_two_requests.json b/crates/rpc/tests/corpus/core-31.1/20_batch_two_requests.json index 9c17a490b..06581ffd1 100644 --- a/crates/rpc/tests/corpus/core-31.1/20_batch_two_requests.json +++ b/crates/rpc/tests/corpus/core-31.1/20_batch_two_requests.json @@ -190,6 +190,13 @@ "reachable": false, "proxy": "", "proxy_randomize_credentials": false + }, + { + "name": "i2p", + "limited": true, + "reachable": false, + "proxy": "", + "proxy_randomize_credentials": false } ], "relayfee": 1e-05, From 34b0ee81256d787b103eee1119ed096f5744834d Mon Sep 17 00:00:00 2001 From: Kim Date: Tue, 27 Oct 2026 19:00:12 +0900 Subject: [PATCH 09/19] fix(p2p): bind SAM streams to requested identities --- crates/p2p/src/discouragement.rs | 16 ++-- crates/p2p/src/listener.rs | 7 +- crates/p2p/src/overlay/sam.rs | 90 ++++++++++++++++---- crates/p2p/src/overlay/tests.rs | 138 +++++++++++++++---------------- crates/p2p/src/service.rs | 4 +- 5 files changed, 159 insertions(+), 96 deletions(-) diff --git a/crates/p2p/src/discouragement.rs b/crates/p2p/src/discouragement.rs index 2c6c62b1d..253e29e10 100644 --- a/crates/p2p/src/discouragement.rs +++ b/crates/p2p/src/discouragement.rs @@ -31,17 +31,19 @@ impl DiscouragedPeers { } } - pub(crate) fn insert(&mut self, ip: impl Into, now: Instant) { + /// Record a reconnectable address and report whether it is retained. + /// Anonymous Tor circuit identities are ephemeral and are never recorded. + pub(crate) fn insert(&mut self, ip: impl Into, now: Instant) -> bool { self.expire(now); let ip = ip.into().to_canonical(); if matches!(ip, PeerAddress::AnonymousTor(_)) { - return; + return false; } if self.deadlines.contains_key(&ip) { - return; + return true; } let Some(until) = now.checked_add(DISCOURAGEMENT_LIFETIME) else { - return; + return false; }; if self.deadlines.len() == MAX_DISCOURAGED_IPS && let Some(oldest) = self.oldest.pop_front() @@ -50,6 +52,7 @@ impl DiscouragedPeers { } self.deadlines.insert(ip, until); self.oldest.push_back(ip); + true } pub(crate) fn contains(&mut self, ip: impl Into, now: Instant) -> bool { @@ -109,9 +112,10 @@ mod tests { let now = Instant::now(); let mut entries = DiscouragedPeers::default(); let ip = IpAddr::from([8, 8, 8, 8]); - entries.insert(ip, now); + assert!(entries.insert(ip, now)); + assert!(entries.insert(ip, now)); for id in 0..=u64::try_from(MAX_DISCOURAGED_IPS).unwrap_or(u64::MAX) { - entries.insert(PeerAddress::AnonymousTor(id), now); + assert!(!entries.insert(PeerAddress::AnonymousTor(id), now)); } assert!(entries.contains(ip, now)); assert_eq!(entries.deadlines.len(), 1); diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index 7846de9db..dbd2356c9 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -275,18 +275,19 @@ impl ConnectionShared { } let protected = lease.is_protected(); let local = crate::discouragement::is_local(addr.address()); + let mut discouraged = false; let current = self.peer_table.with_current(lease.source(addr), || { if !protected && !local { - self.banned.discourage(addr.address()); + discouraged = self.banned.discourage(addr.address()); } }); if current { if lease.ignores_protocol_error(error) { tracing::debug!(peer_addr = %addr, node_id = lease.node_id(), %error, protected, local, - discouraged = !protected && !local, "peer protocol violation"); + discouraged, "peer protocol violation"); } else { tracing::warn!(peer_addr = %addr, node_id = lease.node_id(), %error, protected, local, - discouraged = !protected && !local, "peer protocol violation"); + discouraged, "peer protocol violation"); } } } diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 197753abc..7f5ea281b 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -64,6 +64,13 @@ fn destination(text: &str) -> io::Result { )) } +fn lookup(setup: &mut Setup<'_>, name: &str) -> io::Result<(String, PeerAddress)> { + setup.write(format!("NAMING LOOKUP NAME={name}\n").as_bytes())?; + let line = status(setup, "NAMING REPLY ")?; + let public = field(&line, "VALUE").ok_or_else(|| invalid("SAM naming lacks destination"))?; + Ok((public.to_owned(), destination(public)?)) +} + impl OverlayTransport { fn sam_session(&self) -> io::Result<(String, Arc)> { let (id, alive) = self @@ -92,17 +99,11 @@ impl OverlayTransport { &stop, )?; hello(&mut setup)?; - setup.write(format!("NAMING LOOKUP NAME={name}\n").as_bytes())?; - let line = status(&mut setup, "NAMING REPLY ")?; + let (_, address) = lookup(&mut setup, name)?; if !alive.load(Ordering::Acquire) { return Err(cancelled_error()); } - Ok(PeerEndpoint::new( - destination( - field(&line, "VALUE").ok_or_else(|| invalid("SAM naming lacks destination"))?, - )?, - port, - )) + Ok(PeerEndpoint::new(address, port)) } pub(super) fn sam_connect( &self, @@ -111,17 +112,24 @@ impl OverlayTransport { ) -> io::Result { let (id, alive) = self.sam_session()?; let stop = || cancelled() || !alive.load(Ordering::Acquire); - let mut setup = Setup::new( - self.config.i2p_sam.ok_or_else(|| invalid("SAM disabled"))?, - &stop, - )?; + let address = self.config.i2p_sam.ok_or_else(|| invalid("SAM disabled"))?; + let mut naming = Setup::new(address, &stop)?; + hello(&mut naming)?; + let (public, resolved) = lookup(&mut naming, &endpoint.address().to_string())?; + if resolved != endpoint.address() { + return Err(invalid( + "SAM naming destination does not match requested identity", + )); + } + drop(naming); + let mut setup = Setup::new(address, &stop)?; hello(&mut setup)?; setup.deadline = Instant::now() + Duration::from_secs(60); - // IVNP resolves base32 names inside the router; no system DNS is involved. + // Connect to the validated public destination, not a second name lookup. setup.write( format!( "STREAM CONNECT ID={id} DESTINATION={} TO_PORT={} SILENT=false\n", - endpoint.address(), + public, endpoint.port() ) .as_bytes(), @@ -291,9 +299,63 @@ impl OverlayTransport { #[expect(clippy::expect_used)] mod tests { use super::*; + use base64::Engine as _; use std::io::{BufRead as _, BufReader, Write as _}; use std::net::TcpListener; + #[test] + fn naming_mismatch_or_invalid_destination_never_opens_a_stream() { + for public in [ + identity::i2p_base64().encode([0; 387]), + identity::i2p_base64().encode([0; 388]), + ] { + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("naming connection"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("hello"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + line.clear(); + reader.read_line(&mut line).expect("lookup"); + assert!(line.starts_with("NAMING LOOKUP NAME=")); + stream + .write_all(format!("NAMING REPLY RESULT=OK VALUE={public}\n").as_bytes()) + .expect("naming reply"); + line.clear(); + assert_eq!(reader.read_line(&mut line).expect("closed"), 0); + listener.set_nonblocking(true).expect("nonblocking"); + assert_eq!( + listener.accept().expect_err("no stream connection").kind(), + io::ErrorKind::WouldBlock + ); + }); + let transport = OverlayTransport::new(Arc::new(crate::OverlayConfig { + i2p_sam: Some(address), + ..crate::OverlayConfig::default() + })); + let endpoint = PeerEndpoint::new(PeerAddress::I2p([1; 32]), 8333); + *transport.sam.write() = Some(Session { + id: "fixture".to_owned(), + alive: Arc::new(AtomicBool::new(true)), + local_address: endpoint, + }); + let error = match transport.sam_connect(endpoint, &|| false) { + Ok(_) => panic!("untrusted destination accepted"), + Err(error) => error, + }; + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(!super::super::remote_connect_failed(&error)); + fixture.join().expect("fixture"); + } + } + #[test] fn advertised_i2p_address_belongs_to_a_live_accepting_session() { let local = PeerEndpoint::new(PeerAddress::I2p([7; 32]), 8333); diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index 0f5152d46..d8b230add 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -259,50 +259,49 @@ fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback( let break_control = Arc::new(AtomicBool::new(false)); let public = identity::i2p_base64().encode([0; 387]); let endpoint = PeerEndpoint::new(PeerAddress::I2p(Sha256::digest([0; 387]).into()), 18444); - let fixture = - { - let stop = Arc::clone(&shutdown); - let controls = Arc::clone(&controls); - let break_control = Arc::clone(&break_control); - std::thread::spawn(move || { - let mut connections = Vec::new(); - while !stop.load(Ordering::Acquire) { - match listener.accept() { - Ok((mut stream, _)) => { + let fixture = { + let stop = Arc::clone(&shutdown); + let controls = Arc::clone(&controls); + let break_control = Arc::clone(&break_control); + std::thread::spawn(move || { + let mut connections = Vec::new(); + while !stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_nonblocking(false) + .expect("blocking fixture stream"); + let stop = Arc::clone(&stop); + let controls = Arc::clone(&controls); + let break_control = Arc::clone(&break_control); + let public = public.clone(); + connections.push(std::thread::spawn(move || { stream - .set_nonblocking(false) - .expect("blocking fixture stream"); - let stop = Arc::clone(&stop); - let controls = Arc::clone(&controls); - let break_control = Arc::clone(&break_control); - let public = public.clone(); - connections.push(std::thread::spawn(move || { - stream - .set_read_timeout(Some(Duration::from_secs(3))) - .expect("timeout"); - let mut reader = BufReader::new(stream.try_clone().expect("clone")); - let mut line = String::new(); - reader.read_line(&mut line).expect("hello"); + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("hello"); - assert_eq!(line, "HELLO VERSION MIN=3.1 MAX=3.3\n"); - stream - .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") - .expect("hello reply"); - let mut epoch = 0; - while !stop.load(Ordering::Acquire) { - line.clear(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - break; - } + assert_eq!(line, "HELLO VERSION MIN=3.1 MAX=3.3\n"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + let mut epoch = 0; + while !stop.load(Ordering::Acquire) { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } - if line.starts_with("SESSION CREATE STYLE=STREAM ") { - assert!(line.contains("DESTINATION=TRANSIENT")); - epoch = controls.fetch_add(1, Ordering::AcqRel) + 1; - stream - .write_all(b"SESSION STATUS RESULT=OK\n") - .expect("session"); - } else if line == "NAMING LOOKUP NAME=ME\n" { - stream + if line.starts_with("SESSION CREATE STYLE=STREAM ") { + assert!(line.contains("DESTINATION=TRANSIENT")); + epoch = controls.fetch_add(1, Ordering::AcqRel) + 1; + stream + .write_all(b"SESSION STATUS RESULT=OK\n") + .expect("session"); + } else if line.starts_with("NAMING LOOKUP NAME=") { + stream .write_all( format!( "NAMING REPLY RESULT=OK NAME=ME VALUE={public}\n" @@ -310,41 +309,38 @@ fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback( .as_bytes(), ) .expect("local destination"); - } else if line.starts_with("STREAM CONNECT ") { - assert!(line.contains(&format!( - "DESTINATION={}", - endpoint.address() - ))); - assert!(line.contains("TO_PORT=18444 SILENT=false")); - stream - .write_all(b"STREAM STATUS RESULT=OK\nbitcoin") - .expect("stream"); - while !stop.load(Ordering::Acquire) { - std::thread::sleep(POLL); - } + } else if line.starts_with("STREAM CONNECT ") { + assert!(line.contains(&format!("DESTINATION={public}"))); + assert!(line.contains("TO_PORT=18444 SILENT=false")); + stream + .write_all(b"STREAM STATUS RESULT=OK\nbitcoin") + .expect("stream"); + while !stop.load(Ordering::Acquire) { + std::thread::sleep(POLL); + } + break; + } else if line == "PING bitcoin-rs\n" { + if epoch == 1 && break_control.load(Ordering::Acquire) { break; - } else if line == "PING bitcoin-rs\n" { - if epoch == 1 && break_control.load(Ordering::Acquire) { - break; - } - stream.write_all(b"PONG bitcoin-rs\n").expect("heartbeat"); - } else { - panic!("unexpected SAM command"); } + stream.write_all(b"PONG bitcoin-rs\n").expect("heartbeat"); + } else { + panic!("unexpected SAM command"); } - })); - } - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - } - Err(error) => panic!("accept: {error}"), + } + })); } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept: {error}"), } - for connection in connections { - connection.join().expect("connection fixture"); - } - }) - }; + } + for connection in connections { + connection.join().expect("connection fixture"); + } + }) + }; let workers = owner.start(&shared, &shutdown).expect("backend worker"); until(|| owner.sam.read().is_some()); let mut connected = owner.connect(endpoint, &|| false).expect("SAM stream"); diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index 2b1c2611a..fbc02594c 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -350,8 +350,8 @@ impl BannedReader { } // Only current, typed remote violations at the connection boundary call this. - pub(crate) fn discourage(&self, ip: impl Into) { - self.discouraged.lock().insert(ip, Instant::now()); + pub(crate) fn discourage(&self, ip: impl Into) -> bool { + self.discouraged.lock().insert(ip, Instant::now()) } /// Acquires a shared read lock on the ban list. From d97afa0c136d926f33231d53ddb939bb4bf032c5 Mon Sep 17 00:00:00 2001 From: Kim Date: Tue, 27 Oct 2026 19:14:44 +0900 Subject: [PATCH 10/19] fix(p2p): harden overlay authentication and recovery --- Cargo.lock | 1 + crates/node/src/options.rs | 2 +- crates/p2p/Cargo.toml | 3 + crates/p2p/src/overlay.rs | 19 ++- crates/p2p/src/overlay/identity.rs | 65 +++++++- crates/p2p/src/overlay/sam.rs | 206 +++++++++++++++++++++++- crates/p2p/src/overlay/tests.rs | 61 +++++++- crates/p2p/src/overlay/tor.rs | 244 ++++++++++++++++++++++++++--- docs/getting-started.md | 2 +- 9 files changed, 562 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca385be76..3f6404245 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,6 +467,7 @@ dependencies = [ "metrics", "parking_lot", "proptest", + "rustix", "serde", "serde_json", "sha2", diff --git a/crates/node/src/options.rs b/crates/node/src/options.rs index c70bf3b26..882b15feb 100644 --- a/crates/node/src/options.rs +++ b/crates/node/src/options.rs @@ -595,7 +595,7 @@ macro_rules! option_rows { env["BITCOIN_RS_TOR_CONTROL", str::parse] toml native("tor_control") } - /// Tor control authentication cookie file; otherwise use NULL authentication. + /// Tor SAFECOOKIE file; otherwise use NULL authentication. tor_cookie as tor_cookie: Option { cli[#[arg(long = "tor-cookie")]] env["BITCOIN_RS_TOR_COOKIE", parse_path] diff --git a/crates/p2p/Cargo.toml b/crates/p2p/Cargo.toml index 18841e7d6..940352ec0 100644 --- a/crates/p2p/Cargo.toml +++ b/crates/p2p/Cargo.toml @@ -44,6 +44,9 @@ chacha20.workspace = true chacha20poly1305.workspace = true hkdf.workspace = true +[target.'cfg(unix)'.dependencies] +rustix.workspace = true + [dev-dependencies] bitcoin-rs-chain = { workspace = true, features = ["test-seam"] } bitcoin-rs-mempool = { workspace = true, features = ["test-seam"] } diff --git a/crates/p2p/src/overlay.rs b/crates/p2p/src/overlay.rs index baa9fc257..0f570be11 100644 --- a/crates/p2p/src/overlay.rs +++ b/crates/p2p/src/overlay.rs @@ -36,7 +36,7 @@ pub struct OverlayConfig { pub i2p_accept: bool, /// External Tor control socket, enabling a persistent inbound onion service. pub tor_control: Option, - /// Tor control cookie. Absence selects NULL authentication on a local control socket. + /// Tor SAFECOOKIE file. Absence selects NULL authentication on a local control socket. pub tor_cookie: Option, /// Application identity directory, separate from IVNP router keys. pub identity_dir: PathBuf, @@ -386,8 +386,14 @@ struct Setup<'a> { } impl<'a> Setup<'a> { fn new(address: SocketAddr, cancelled: &'a dyn Fn() -> bool) -> io::Result { - let deadline = Instant::now() + SETUP_TIMEOUT; - let stream = connect(address, cancelled)?; + Self::new_until(address, cancelled, Instant::now() + SETUP_TIMEOUT) + } + fn new_until( + address: SocketAddr, + cancelled: &'a dyn Fn() -> bool, + deadline: Instant, + ) -> io::Result { + let stream = connect(address, cancelled, deadline)?; stream.set_read_timeout(Some(POLL))?; stream.set_write_timeout(Some(POLL))?; Ok(Self { @@ -470,8 +476,11 @@ fn poll_error(error: &io::Error) -> bool { io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted ) } -fn connect(address: SocketAddr, cancelled: &dyn Fn() -> bool) -> io::Result { - let deadline = Instant::now() + SETUP_TIMEOUT; +fn connect( + address: SocketAddr, + cancelled: &dyn Fn() -> bool, + deadline: Instant, +) -> io::Result { loop { if cancelled() { return Err(cancelled_error()); diff --git a/crates/p2p/src/overlay/identity.rs b/crates/p2p/src/overlay/identity.rs index d50218dc8..9da1be855 100644 --- a/crates/p2p/src/overlay/identity.rs +++ b/crates/p2p/src/overlay/identity.rs @@ -45,6 +45,40 @@ pub(super) fn public_destination(text: &str) -> io::Result> { if bytes.len() != length { return Err(invalid("invalid I2P public destination length")); } + let payload = &bytes[387..]; + let valid = match bytes[384] { + 0 | 2 => payload.is_empty(), + 1 | 4 => true, + 3 => matches!(payload.len(), 40 | 72), + 5 if payload.len() >= 4 => { + let signing = u16::from_be_bytes([payload[0], payload[1]]); + let crypto = u16::from_be_bytes([payload[2], payload[3]]); + let signing_len: usize = match signing { + 0 | 10 => 128, + 1 | 9 => 64, + 2 => 96, + 3 => 132, + 4 => 256, + 5 => 384, + 6 => 512, + 7 | 8 | 11 => 32, + _ => return Err(invalid("unsupported I2P signing key type")), + }; + let crypto_len: usize = match crypto { + 0 => 256, + 1 => 64, + 2 => 96, + 3 => 132, + 4 | 6 | 7 => 32, + _ => return Err(invalid("unsupported I2P encryption key type")), + }; + payload.len() == 4 + signing_len.saturating_sub(128) + crypto_len.saturating_sub(256) + } + _ => false, + }; + if !valid { + return Err(invalid("invalid I2P destination certificate")); + } Ok(bytes) } fn destination_length(bytes: &[u8]) -> io::Result { @@ -88,11 +122,31 @@ pub(super) fn validate(text: &str, kind: Kind) -> io::Result<()> { Ok(()) } pub(super) fn read(path: &Path, kind: Kind) -> io::Result> { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, + #[cfg(unix)] + let opened = { + use rustix::fs::{Mode, OFlags}; + rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map(File::from) + .map_err(io::Error::from) + }; + #[cfg(not(unix))] + let opened = (|| { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() { + return Err(invalid("invalid overlay identity file type or size")); + } + File::open(path) + })(); + let file = match opened { + Ok(file) => file, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error), }; + let metadata = file.metadata()?; if !metadata.is_file() || metadata.len() > LIMIT { return Err(invalid("invalid overlay identity file type or size")); } @@ -104,9 +158,10 @@ pub(super) fn read(path: &Path, kind: Kind) -> io::Result> { } } let mut text = String::new(); - File::open(path)? - .take(LIMIT + 1) - .read_to_string(&mut text)?; + file.take(LIMIT + 1).read_to_string(&mut text)?; + if u64::try_from(text.len()).unwrap_or(u64::MAX) > LIMIT { + return Err(invalid("overlay identity file too large")); + } let stored: Stored = serde_json::from_str(&text).map_err(|_| invalid("damaged overlay identity file"))?; if stored.version != 1 diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 7f5ea281b..4a370d974 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -33,6 +33,17 @@ fn field<'a>(line: &'a str, name: &str) -> Option<&'a str> { Some(value) } } +#[derive(Debug, thiserror::Error)] +#[error("SAM stream session no longer exists")] +struct InvalidSession; + +fn retire_invalid_session(error: &io::Error, alive: &AtomicBool) { + if matches!(error.get_ref(), Some(cause) if cause.is::()) { + // The flag belongs to the session used by this request. A late reply + // cannot revoke a replacement session's distinct flag. + alive.store(false, Ordering::Release); + } +} fn status(setup: &mut Setup<'_>, prefix: &str) -> io::Result { let line = setup.line()?; if !line.starts_with(prefix) || field(&line, "RESULT") != Some("OK") { @@ -48,6 +59,9 @@ fn status(setup: &mut Setup<'_>, prefix: &str) -> io::Result { _ => "malformed reply", }; let message = format!("SAM {prefix}{result}"); + if prefix == "STREAM STATUS " && line.starts_with(prefix) && result == "INVALID_ID" { + return Err(io::Error::new(io::ErrorKind::NotConnected, InvalidSession)); + } if prefix == "STREAM STATUS " && line.starts_with(prefix) && matches!(result, "CANT_REACH_PEER" | "TIMEOUT") @@ -109,11 +123,24 @@ impl OverlayTransport { &self, endpoint: PeerEndpoint, cancelled: &dyn Fn() -> bool, + ) -> io::Result { + self.sam_connect_until( + endpoint, + cancelled, + Instant::now() + Duration::from_secs(80), + ) + } + fn sam_connect_until( + &self, + endpoint: PeerEndpoint, + cancelled: &dyn Fn() -> bool, + deadline: Instant, ) -> io::Result { let (id, alive) = self.sam_session()?; let stop = || cancelled() || !alive.load(Ordering::Acquire); let address = self.config.i2p_sam.ok_or_else(|| invalid("SAM disabled"))?; - let mut naming = Setup::new(address, &stop)?; + let mut naming = + Setup::new_until(address, &stop, deadline.min(Instant::now() + SETUP_TIMEOUT))?; hello(&mut naming)?; let (public, resolved) = lookup(&mut naming, &endpoint.address().to_string())?; if resolved != endpoint.address() { @@ -122,9 +149,10 @@ impl OverlayTransport { )); } drop(naming); - let mut setup = Setup::new(address, &stop)?; + let mut setup = + Setup::new_until(address, &stop, deadline.min(Instant::now() + SETUP_TIMEOUT))?; hello(&mut setup)?; - setup.deadline = Instant::now() + Duration::from_secs(60); + setup.deadline = deadline.min(Instant::now() + Duration::from_secs(60)); // Connect to the validated public destination, not a second name lookup. setup.write( format!( @@ -134,7 +162,11 @@ impl OverlayTransport { ) .as_bytes(), )?; - status(&mut setup, "STREAM STATUS ")?; + if let Err(error) = status(&mut setup, "STREAM STATUS ") { + retire_invalid_session(&error, &alive); + return Err(error); + } + setup.check()?; if !alive.load(Ordering::Acquire) { return Err(cancelled_error()); } @@ -212,7 +244,8 @@ impl OverlayTransport { .i2p_accept .then(|| scope.spawn(|| self.accept_sam(&id, &alive, shared, &cancelled))); let monitored = (|| -> io::Result<()> { - while !cancelled() { + let stop = || cancelled() || !alive.load(Ordering::Acquire); + while !stop() { control.deadline = Instant::now() + SETUP_TIMEOUT; control.write(b"PING bitcoin-rs\n")?; if control.line()? != "PONG bitcoin-rs" { @@ -221,9 +254,13 @@ impl OverlayTransport { if healthy_since.elapsed() >= Duration::from_secs(60) { backoff = Duration::from_secs(1); } - pause(&cancelled, Duration::from_secs(1)); + pause(&stop, Duration::from_secs(1)); + } + if !alive.load(Ordering::Acquire) && !cancelled() { + Err(io::Error::new(io::ErrorKind::NotConnected, InvalidSession)) + } else { + Ok(()) } - Ok(()) })(); alive.store(false, Ordering::Release); if let Some(accept) = accept { @@ -265,7 +302,10 @@ impl OverlayTransport { // IVNP sends STATUS only after accepting a circuit. This is a listener wait, // not an unbounded setup operation; cancellation still polls every 100ms. setup.deadline = Instant::now() + Duration::from_hours(24); - status(&mut setup, "STREAM STATUS ")?; + if let Err(error) = status(&mut setup, "STREAM STATUS ") { + retire_invalid_session(&error, alive); + return Err(error); + } setup.deadline = Instant::now() + SETUP_TIMEOUT; let metadata = setup.line()?; let public = metadata @@ -391,6 +431,156 @@ mod tests { } } + #[test] + fn late_invalid_session_retires_only_its_observed_epoch() { + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM"); + let address = listener.local_addr().expect("address"); + let public = [0u8; 387]; + let endpoint = PeerEndpoint::new(PeerAddress::I2p(Sha256::digest(public).into()), 8333); + let (sent, received) = std::sync::mpsc::channel(); + let (release, gate) = std::sync::mpsc::channel(); + let fixture = std::thread::spawn(move || { + for naming in [true, false] { + let (mut stream, _) = listener.accept().expect("connection"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("hello"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + line.clear(); + reader.read_line(&mut line).expect("request"); + if naming { + stream + .write_all( + format!( + "NAMING REPLY RESULT=OK VALUE={}\n", + identity::i2p_base64().encode(public) + ) + .as_bytes(), + ) + .expect("naming reply"); + } else { + assert!(line.starts_with("STREAM CONNECT ID=old ")); + sent.send(()).expect("request observed"); + gate.recv_timeout(Duration::from_secs(3)).expect("release"); + stream + .write_all(b"STREAM STATUS RESULT=INVALID_ID\n") + .expect("late reply"); + } + } + }); + let transport = Arc::new(OverlayTransport::new(Arc::new(crate::OverlayConfig { + i2p_sam: Some(address), + ..crate::OverlayConfig::default() + }))); + let old = Arc::new(AtomicBool::new(true)); + *transport.sam.write() = Some(Session { + id: "old".into(), + alive: Arc::clone(&old), + local_address: endpoint, + }); + let caller = Arc::clone(&transport); + let dial = std::thread::spawn(move || caller.sam_connect(endpoint, &|| false).is_err()); + received + .recv_timeout(Duration::from_secs(3)) + .expect("stream request"); + let replacement = Arc::new(AtomicBool::new(true)); + *transport.sam.write() = Some(Session { + id: "new".into(), + alive: Arc::clone(&replacement), + local_address: endpoint, + }); + release.send(()).expect("release"); + assert!(dial.join().expect("dial")); + fixture.join().expect("fixture"); + assert!(!old.load(Ordering::Acquire)); + assert!(replacement.load(Ordering::Acquire)); + assert_eq!(transport.sam_session().expect("replacement").0, "new"); + } + + #[test] + fn naming_and_stream_share_the_dial_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM"); + let address = listener.local_addr().expect("address"); + let public = [0u8; 387]; + let endpoint = PeerEndpoint::new(PeerAddress::I2p(Sha256::digest(public).into()), 8333); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("naming"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("hello"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + line.clear(); + reader.read_line(&mut line).expect("lookup"); + std::thread::sleep(Duration::from_millis(200)); + stream + .write_all( + format!( + "NAMING REPLY RESULT=OK VALUE={}\n", + identity::i2p_base64().encode(public) + ) + .as_bytes(), + ) + .expect("naming reply"); + listener.set_nonblocking(true).expect("nonblocking"); + let end = Instant::now() + Duration::from_secs(2); + let (mut stream, _) = loop { + match listener.accept() { + Ok(stream) => break stream, + Err(error) + if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < end => + { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("stream accept: {error}"), + } + }; + stream.set_nonblocking(false).expect("blocking stream"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + line.clear(); + reader.read_line(&mut line).expect("hello"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + line.clear(); + reader.read_line(&mut line).expect("stream request"); + assert!(line.starts_with("STREAM CONNECT ")); + line.clear(); + assert_eq!( + reader.read_line(&mut line).expect("deadline closes stream"), + 0 + ); + }); + let transport = OverlayTransport::new(Arc::new(crate::OverlayConfig { + i2p_sam: Some(address), + ..crate::OverlayConfig::default() + })); + *transport.sam.write() = Some(Session { + id: "fixture".to_owned(), + alive: Arc::new(AtomicBool::new(true)), + local_address: endpoint, + }); + let started = Instant::now(); + let error = match transport.sam_connect_until( + endpoint, + &|| false, + started + Duration::from_millis(500), + ) { + Ok(_) => panic!("stalled stream accepted"), + Err(error) => error, + }; + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(!super::super::remote_connect_failed(&error)); + assert!(started.elapsed() < Duration::from_secs(2)); + fixture.join().expect("fixture"); + } + #[test] fn only_explicit_stream_destination_failures_are_remote_attempts() { for (reply, remote) in [ diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index d8b230add..f2924fa3d 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -223,6 +223,22 @@ fn application_identity_is_create_once_and_corruption_is_explicit() { assert_eq!(std::fs::read_to_string(path).expect("preserved"), "damaged"); } +#[cfg(unix)] +#[test] +fn identity_reader_refuses_existing_and_dangling_symlinks() { + use std::os::unix::fs::symlink; + let directory = tempfile::tempdir().expect("directory"); + let target = directory.path().join("target"); + let link = directory.path().join("identity"); + std::fs::write(&target, b"operator bytes").expect("target"); + symlink(&target, &link).expect("symlink"); + assert!(identity::read(&link, identity::Kind::Tor).is_err()); + assert_eq!(std::fs::read(&target).expect("target"), b"operator bytes"); + std::fs::remove_file(&target).expect("remove target"); + assert!(identity::read(&link, identity::Kind::Tor).is_err()); + assert!(link.is_symlink()); +} + #[test] fn sam_private_destination_requires_the_encryption_and_signing_fields() { use base64::Engine as _; @@ -236,11 +252,40 @@ fn sam_private_destination_requires_the_encryption_and_signing_fields() { } #[test] +fn sam_public_destination_validates_certificate_structure() { + use base64::Engine as _; + let encode = |bytes: &[u8]| identity::i2p_base64().encode(bytes); + let mut public = vec![0; 387]; + assert!(identity::public_destination(&encode(&public)).is_ok()); + public[384] = 255; + assert!(identity::public_destination(&encode(&public)).is_err()); + public[384] = 5; + assert!(identity::public_destination(&encode(&public)).is_err()); + public[386] = 4; + public.extend_from_slice(&[0, 7, 0, 0]); + assert!(identity::public_destination(&encode(&public)).is_ok()); + public[390] = 255; + assert!(identity::public_destination(&encode(&public)).is_err()); + public[390] = 0; + public.push(0); + assert!(identity::public_destination(&encode(&public)).is_err()); +} + +#[test] +fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback() { + sam_session_recovery(false); +} + +#[test] +fn sam_invalid_id_revokes_stream_epoch_and_reconnects_while_control_responds() { + sam_session_recovery(true); +} + #[expect( clippy::too_many_lines, reason = "One fixture follows control setup, loss, epoch revocation, reconnect, and shutdown" )] -fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback() { +fn sam_session_recovery(invalid_id: bool) { use base64::Engine as _; use sha2::{Digest as _, Sha256}; use std::io::{BufRead as _, BufReader}; @@ -310,6 +355,12 @@ fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback( ) .expect("local destination"); } else if line.starts_with("STREAM CONNECT ") { + if invalid_id && break_control.load(Ordering::Acquire) { + stream + .write_all(b"STREAM STATUS RESULT=INVALID_ID\n") + .expect("invalid session"); + break; + } assert!(line.contains(&format!("DESTINATION={public}"))); assert!(line.contains("TO_PORT=18444 SILENT=false")); stream @@ -320,7 +371,10 @@ fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback( } break; } else if line == "PING bitcoin-rs\n" { - if epoch == 1 && break_control.load(Ordering::Acquire) { + if !invalid_id + && epoch == 1 + && break_control.load(Ordering::Acquire) + { break; } stream.write_all(b"PONG bitcoin-rs\n").expect("heartbeat"); @@ -352,6 +406,9 @@ fn sam_control_loss_revokes_stream_epoch_and_reconnects_without_direct_fallback( shared.peer_table.register(endpoint, lease.clone()); assert!(!lease.is_cancelled()); break_control.store(true, Ordering::Release); + if invalid_id { + assert!(owner.connect(endpoint, &|| false).is_err()); + } until(|| lease.is_cancelled()); until(|| controls.load(Ordering::Acquire) >= 2 && owner.sam.read().is_some()); assert!( diff --git a/crates/p2p/src/overlay/tor.rs b/crates/p2p/src/overlay/tor.rs index 6788beede..90307d7f3 100644 --- a/crates/p2p/src/overlay/tor.rs +++ b/crates/p2p/src/overlay/tor.rs @@ -3,6 +3,9 @@ use super::{ AtomicBool, Duration, Instant, Ordering, OverlayTransport, POLL, PeerAddress, PeerEndpoint, SETUP_TIMEOUT, Setup, SocketAddr, identity, invalid, io, pause, }; +use bitcoin::hashes::{Hash as _, HashEngine as _, hmac, sha256}; +use bitcoin::hex::{DisplayHex as _, FromHex as _}; +use bitcoin::secp256k1::rand::RngCore as _; use std::io::Read as _; use std::net::TcpListener; use std::sync::atomic::AtomicU64; @@ -25,11 +28,98 @@ fn reply(setup: &mut Setup<'_>) -> io::Result> { } Err(invalid("oversized Tor control reply")) } + +fn safecookie_hash(key: &[u8], cookie: &[u8], client: &[u8], server: &[u8]) -> [u8; 32] { + let mut engine = hmac::HmacEngine::::new(key); + engine.input(cookie); + engine.input(client); + engine.input(server); + hmac::Hmac::::from_engine(engine).to_byte_array() +} + +fn authenticate(setup: &mut Setup<'_>, cookie_path: Option<&std::path::Path>) -> io::Result<()> { + let Some(path) = cookie_path else { + setup.write(b"AUTHENTICATE\r\n")?; + reply(setup)?; + return Ok(()); + }; + let mut cookie = Vec::with_capacity(33); + std::fs::File::open(path)? + .take(33) + .read_to_end(&mut cookie)?; + if cookie.len() != 32 { + return Err(invalid("Tor cookie must contain 32 bytes")); + } + setup.write(b"PROTOCOLINFO 1\r\n")?; + let info = reply(setup)?; + let mut methods = info + .iter() + .filter_map(|line| line.strip_prefix("AUTH ")) + .flat_map(|line| line.split_ascii_whitespace()) + .filter_map(|field| field.strip_prefix("METHODS=")); + let offered = methods + .next() + .ok_or_else(|| invalid("Tor control authentication methods missing"))?; + if methods.next().is_some() { + return Err(invalid("duplicate Tor control authentication methods")); + } + if !offered.split(',').any(|method| method == "SAFECOOKIE") { + return Err(invalid("Tor control does not offer SAFECOOKIE")); + } + let mut nonce = [0u8; 32]; + bitcoin::secp256k1::rand::thread_rng().fill_bytes(&mut nonce); + setup.write( + format!( + "AUTHCHALLENGE SAFECOOKIE {}\r\n", + nonce.to_lower_hex_string() + ) + .as_bytes(), + )?; + let lines = reply(setup)?; + let line = lines + .iter() + .find_map(|line| line.strip_prefix("AUTHCHALLENGE ")) + .ok_or_else(|| invalid("Tor control challenge missing"))?; + let parse_field = |name: &str| -> io::Result<[u8; 32]> { + let mut values = line + .split_ascii_whitespace() + .filter_map(|token| token.split_once('=')) + .filter(|(key, _)| *key == name) + .map(|(_, value)| value); + let value = values + .next() + .ok_or_else(|| invalid("Tor control challenge field missing"))?; + if values.next().is_some() { + return Err(invalid("duplicate Tor control challenge field")); + } + <[u8; 32]>::from_hex(value).map_err(|_| invalid("invalid Tor control challenge field")) + }; + let server_hash = parse_field("SERVERHASH")?; + let server_nonce = parse_field("SERVERNONCE")?; + let expected = safecookie_hash( + b"Tor safe cookie authentication server-to-controller hash", + &cookie, + &nonce, + &server_nonce, + ); + if server_hash != expected { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Tor SAFECOOKIE server hash mismatch", + )); + } + let client_hash = safecookie_hash( + b"Tor safe cookie authentication controller-to-server hash", + &cookie, + &nonce, + &server_nonce, + ); + setup.write(format!("AUTHENTICATE {}\r\n", client_hash.to_lower_hex_string()).as_bytes())?; + reply(setup)?; + Ok(()) +} + impl OverlayTransport { - #[expect( - clippy::too_many_lines, - reason = "One service owner keeps key publication, control lifetime and inbound teardown ordered" - )] pub(super) fn run_tor( &self, address: SocketAddr, @@ -37,7 +127,6 @@ impl OverlayTransport { shared: &crate::listener::ConnectionShared, shutdown: &AtomicBool, ) { - use bitcoin::hex::DisplayHex as _; let cancelled = || shutdown.load(Ordering::Acquire) || shared.is_session_cancelled(); let mut backoff = Duration::from_secs(1); while !cancelled() { @@ -46,20 +135,7 @@ impl OverlayTransport { listener.set_nonblocking(true)?; let forwarding = listener.local_addr()?; let mut control = Setup::new(address, &cancelled)?; - let authentication = if let Some(path) = &self.config.tor_cookie { - let mut bytes = Vec::with_capacity(33); - std::fs::File::open(path)? - .take(33) - .read_to_end(&mut bytes)?; - if bytes.len() != 32 { - return Err(invalid("Tor cookie must contain 32 bytes")); - } - format!("AUTHENTICATE {}\r\n", bytes.to_lower_hex_string()) - } else { - "AUTHENTICATE\r\n".to_owned() - }; - control.write(authentication.as_bytes())?; - reply(&mut control)?; + authenticate(&mut control, self.config.tor_cookie.as_deref())?; control.write( format!( "ADD_ONION {} Port={},{}\r\n", @@ -145,3 +221,133 @@ impl OverlayTransport { } } } + +#[cfg(test)] +#[expect(clippy::expect_used)] +mod tests { + use super::*; + use std::io::{BufRead as _, BufReader, Write as _}; + + #[test] + #[ignore = "requires an explicitly provisioned Tor SAFECOOKIE control endpoint"] + fn external_tor_safecookie_authentication() { + let address = std::env::var("BITCOIN_RS_TEST_TOR_CONTROL") + .expect("control endpoint") + .parse() + .expect("control socket"); + let cookie = std::env::var_os("BITCOIN_RS_TEST_TOR_COOKIE").expect("cookie path"); + let mut setup = Setup::new(address, &|| false).expect("connect"); + authenticate(&mut setup, Some(std::path::Path::new(&cookie))).expect("authenticate"); + setup.write(b"GETINFO version\r\n").expect("GETINFO"); + assert!( + reply(&mut setup) + .expect("version") + .iter() + .any(|line| line.starts_with("version=")) + ); + } + + #[test] + fn safecookie_hash_matches_independent_vectors() { + let cookie = [7; 32]; + let client = [8; 32]; + let server = [9; 32]; + assert_eq!( + safecookie_hash( + b"Tor safe cookie authentication server-to-controller hash", + &cookie, + &client, + &server + ) + .to_lower_hex_string(), + "ccc316d685a291115dfae269666c7b18bdb14aa6bffa43453a35a84f5b556475" + ); + assert_eq!( + safecookie_hash( + b"Tor safe cookie authentication controller-to-server hash", + &cookie, + &client, + &server + ) + .to_lower_hex_string(), + "d960215167ca3d7d91b2f68132429c4039829f4125d8372bf57268137ce66cbe" + ); + } + + #[test] + fn safecookie_authenticates_only_after_valid_server_challenge() { + for mismatch in [false, true] { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("cookie"); + std::fs::write(&path, [7; 32]).expect("cookie"); + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("protocolinfo"); + assert_eq!(line, "PROTOCOLINFO 1\r\n"); + stream + .write_all( + b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=COOKIE,SAFECOOKIE\r\n250 OK\r\n", + ) + .expect("info"); + line.clear(); + reader.read_line(&mut line).expect("challenge"); + let client = <[u8; 32]>::from_hex( + line.trim() + .strip_prefix("AUTHCHALLENGE SAFECOOKIE ") + .expect("challenge command"), + ) + .expect("client nonce"); + let server = [9; 32]; + let mut hash = safecookie_hash( + b"Tor safe cookie authentication server-to-controller hash", + &[7; 32], + &client, + &server, + ); + if mismatch { + hash[0] ^= 1; + } + stream + .write_all( + format!( + "250 AUTHCHALLENGE SERVERHASH={} SERVERNONCE={}\r\n", + hash.to_lower_hex_string(), + server.to_lower_hex_string() + ) + .as_bytes(), + ) + .expect("challenge response"); + line.clear(); + let count = reader + .read_line(&mut line) + .expect("authentication or close"); + if mismatch { + assert_eq!(count, 0); + } else { + let expected = safecookie_hash( + b"Tor safe cookie authentication controller-to-server hash", + &[7; 32], + &client, + &server, + ); + assert_eq!( + line, + format!("AUTHENTICATE {}\r\n", expected.to_lower_hex_string()) + ); + stream.write_all(b"250 OK\r\n").expect("authenticated"); + } + }); + let mut setup = Setup::new(address, &|| false).expect("setup"); + assert_eq!(authenticate(&mut setup, Some(&path)).is_ok(), !mismatch); + drop(setup); + fixture.join().expect("fixture"); + } + } +} diff --git a/docs/getting-started.md b/docs/getting-started.md index e44d58db3..cd03739e9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -54,7 +54,7 @@ describes routing, persistence, and the evidence boundary. | `--i2p-sam IP:port` | `i2p_sam` | Unset. SAM v3.1–3.3 on an operator-managed `ivnpd`; its usual bridge is `127.0.0.1:7656`. | | `--i2p-accept` | `i2p_accept` | `false`. Persistent I2P application destination and `STREAM ACCEPT`; requires `i2p_sam`. | | `--tor-control IP:port` | `tor_control` | Unset. Enables a persistent v3 onion service; requires `onion_proxy`. | -| `--tor-cookie PATH` | `tor_cookie` | Unset selects Tor control NULL authentication; set this to external Tor's 32-byte authentication cookie file. | +| `--tor-cookie PATH` | `tor_cookie` | Unset selects Tor control NULL authentication; set this to external Tor's 32-byte authentication cookie file and enable SAFECOOKIE on its control endpoint. | | `--overlay-port PORT` | `overlay_port` | The selected Bitcoin network's default P2P port. Public service port, independent of local forwarding sockets. | Each option also has a `BITCOIN_RS_` environment spelling using the uppercase From 9c8e93dfecf141143868c506ab0f5b80172bf1f6 Mon Sep 17 00:00:00 2001 From: AgentDawn <186692589+AgentDawn@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:03:40 +0900 Subject: [PATCH 11/19] Synchronize standalone lockfiles with overlay dependencies Record the p2p rustix dependency in all three standalone workspaces and the existing RPC metrics dependency in LDK. Keep resolved package versions unchanged. --- fuzz/Cargo.lock | 1 + tools/ldk/Cargo.lock | 2 ++ tools/stratum-sv2/template-provider/Cargo.lock | 1 + 3 files changed, 4 insertions(+) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 00fb73ee9..b4ad38ec5 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -252,6 +252,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2", diff --git a/tools/ldk/Cargo.lock b/tools/ldk/Cargo.lock index e3b69b326..64bfb2890 100644 --- a/tools/ldk/Cargo.lock +++ b/tools/ldk/Cargo.lock @@ -330,6 +330,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2", @@ -372,6 +373,7 @@ dependencies = [ "corepc-types", "crossbeam-channel", "hashbrown 0.17.1", + "metrics", "miniscript", "parking_lot", "serde", diff --git a/tools/stratum-sv2/template-provider/Cargo.lock b/tools/stratum-sv2/template-provider/Cargo.lock index 0cbf2b6b4..3f466ffcb 100644 --- a/tools/stratum-sv2/template-provider/Cargo.lock +++ b/tools/stratum-sv2/template-provider/Cargo.lock @@ -387,6 +387,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2 0.11.1", From d97de4c3862b42d62a384060a5d6c9d9380c4a7f Mon Sep 17 00:00:00 2001 From: AgentDawn <186692589+AgentDawn@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:07:27 +0900 Subject: [PATCH 12/19] Account for unresolved I2P destinations when retrying peers Classify a valid SAM naming KEY_NOT_FOUND reply as a remote dial failure so the existing AddressBook attempt owner applies retry policy. Keep malformed replies, router errors, identity mismatch and undocumented naming timeouts local. Add a socket regression covering attribution, no stream creation and the retained session epoch. Sync impact: transport discovery/retry can affect SYNC-02, SYNC-04 and SYNC-06; authoritative header/body validation, frontier scheduling and chainstate commits are unchanged. Overlay (25 passed, 1 external ignored), frontier recovery (12), request tick (1), expiry handover (1), and node config (27) tests passed on Windows/Rust 1.99 with test opt-level=0. P2P all-target Clippy -D warnings, formatting and diff checks passed. Real Tor/IVNP convergence remains unqualified. --- crates/p2p/src/overlay/sam.rs | 73 ++++++++++++++++++++++++++++-- docs/policies/p2p-compatibility.md | 6 ++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 4a370d974..a8f0768ee 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -62,9 +62,16 @@ fn status(setup: &mut Setup<'_>, prefix: &str) -> io::Result { if prefix == "STREAM STATUS " && line.starts_with(prefix) && result == "INVALID_ID" { return Err(io::Error::new(io::ErrorKind::NotConnected, InvalidSession)); } - if prefix == "STREAM STATUS " - && line.starts_with(prefix) - && matches!(result, "CANT_REACH_PEER" | "TIMEOUT") + // Naming is part of dialing: an unresolved destination must reach the + // same AddrMan attempt owner as an explicit stream refusal. Only the + // documented naming miss qualifies; router errors and malformed replies + // are still local failures, not evidence against the requested peer. + if line.starts_with(prefix) + && matches!( + (prefix, result), + ("STREAM STATUS ", "CANT_REACH_PEER" | "TIMEOUT") + | ("NAMING REPLY ", "KEY_NOT_FOUND") + ) { return Err(super::remote_failure(message)); } @@ -343,6 +350,66 @@ mod tests { use std::io::{BufRead as _, BufReader, Write as _}; use std::net::TcpListener; + #[test] + fn naming_failure_attribution_preserves_peer_retry_accounting() { + for (reply, remote) in [ + ("NAMING REPLY RESULT=KEY_NOT_FOUND\n", true), + ("NAMING REPLY RESULT=INVALID_KEY\n", false), + ("NAMING REPLY RESULT=I2P_ERROR\n", false), + ("NAMING REPLY RESULT=TIMEOUT\n", false), + ("STREAM STATUS RESULT=KEY_NOT_FOUND\n", false), + ("NAMING REPLY RESULT=KEY_NOT_FOUND RESULT=OK\n", false), + ] { + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM"); + let address = listener.local_addr().expect("address"); + let endpoint = PeerEndpoint::new(PeerAddress::I2p([1; 32]), 8333); + let fixture = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("naming connection"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("timeout"); + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + reader.read_line(&mut line).expect("hello"); + stream + .write_all(b"HELLO REPLY RESULT=OK VERSION=3.3\n") + .expect("hello reply"); + line.clear(); + reader.read_line(&mut line).expect("lookup"); + assert_eq!(line, format!("NAMING LOOKUP NAME={}\n", endpoint.address())); + stream.write_all(reply.as_bytes()).expect("naming reply"); + line.clear(); + assert_eq!(reader.read_line(&mut line).expect("closed"), 0); + listener.set_nonblocking(true).expect("nonblocking"); + assert_eq!( + listener.accept().expect_err("no stream connection").kind(), + io::ErrorKind::WouldBlock + ); + }); + let transport = OverlayTransport::new(Arc::new(crate::OverlayConfig { + i2p_sam: Some(address), + ..crate::OverlayConfig::default() + })); + let alive = Arc::new(AtomicBool::new(true)); + *transport.sam.write() = Some(Session { + id: "fixture".to_owned(), + alive: Arc::clone(&alive), + local_address: endpoint, + }); + let error = match transport.sam_connect(endpoint, &|| false) { + Ok(_) => panic!("failed naming opened a stream"), + Err(error) => error, + }; + fixture.join().expect("fixture"); + assert_eq!( + super::super::remote_connect_failed(&error), + remote, + "{reply}" + ); + assert!(alive.load(Ordering::Acquire)); + } + } + #[test] fn naming_mismatch_or_invalid_destination_never_opens_a_stream() { for public in [ diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 22670372b..78d2303e0 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -697,8 +697,10 @@ high-latency remote TCP establishment. SAM session preparation has a 120-second circuit and I2P stream connection have a 60-second bound. Local service errors do not enter remote protocol discouragement or count as failed overlay peer attempts. Explicit SOCKS destination-unreachable/refused replies and SAM stream -`CANT_REACH_PEER`/`TIMEOUT` replies do update the existing address-book attempt -and retry policy. Authentication, malformed replies, local I/O and control +`CANT_REACH_PEER`/`TIMEOUT` replies, plus a SAM naming `KEY_NOT_FOUND` reply +while dialing a destination, do update the existing address-book attempt +and retry policy. Undocumented naming results (including `TIMEOUT`), +authentication, malformed replies, local I/O and control session failures remain local errors. Control-session loss revokes I2P stream epochs, including late registrations, and the worker restores the selected stable destination with bounded backoff. From 0c7614c16a9587909acc5faf515340efad059039 Mon Sep 17 00:00:00 2001 From: AgentDawn <186692589+AgentDawn@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:11:44 +0900 Subject: [PATCH 13/19] Keep the combined startup validation gate Clippy-clean Merging main adds public-listener constraints alongside overlay validation, taking the ordered gate just over the line-count lint. Document the reason to retain one fail-early validation gate; runtime behavior is unchanged. Node all-target Clippy with fjall,zmq,silent-payments and config_layered (27 tests) pass on Rust 1.99. --- crates/node/src/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 23393f303..26c779633 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -362,6 +362,10 @@ impl NodeConfig { /// This is the fail-early gate for engine selection: an engine this build /// cannot execute is rejected here — before the data directory opens, /// chainstate exists, or any worker starts. + #[expect( + clippy::too_many_lines, + reason = "Keep resolved cross-field checks in one ordered fail-early gate before startup" + )] pub fn validate(&self) -> Result<()> { anyhow::ensure!( self.mempool_snapshot_max_bytes >= 1024 * 1024 From 6ee8a5a4751d9a2ac2e03308a989a3610656b02c Mon Sep 17 00:00:00 2001 From: AgentDawn <186692589+AgentDawn@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:07:52 +0900 Subject: [PATCH 14/19] Strengthen overlay exchange and publication recovery tests Exercise both block-transfer directions before and after application identity restart in the ignored Tor/IVNP process test, checking admitted headers, complete block bodies, and the receiving tip for every transfer. Add local Tor/SAM fixtures proving failed identity publication neither advertises a service nor discards its generated key. Retrying must persist the same key. Keep backend sockets open to prove shutdown interrupts a blocked Tor heartbeat and an unanswered SAM accept. Sync impact: test-only changes; authoritative scheduling, validation and commit paths are unchanged. The process assertions cover SYNC-02, SYNC-04 and SYNC-06 but remain unexecuted external evidence, not qualification. Validation: offline Windows overlay suite 27 passed, 1 external test ignored; strict P2P library/test and overlay E2E Clippy; formatting and diff checks. No external Tor/IVNP campaign, installation, or network policy change. The 60-second healthy-backoff reset and real backend recovery remain gaps. Refs #1601 --- crates/p2p/src/overlay/tests.rs | 2 + crates/p2p/src/overlay/tests/recovery.rs | 194 +++++++++++++++++++++++ e2e/tests/overlay_connectivity.rs | 35 +++- 3 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 crates/p2p/src/overlay/tests/recovery.rs diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index f2924fa3d..e3f8a3bec 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -1,6 +1,8 @@ use super::*; use std::net::TcpListener; +mod recovery; + fn shared(owner: &Arc) -> crate::listener::ConnectionShared { let mut shared = crate::listener::ConnectionShared::new( Arc::new(crate::PeerTable::new()), diff --git a/crates/p2p/src/overlay/tests/recovery.rs b/crates/p2p/src/overlay/tests/recovery.rs new file mode 100644 index 000000000..36c036408 --- /dev/null +++ b/crates/p2p/src/overlay/tests/recovery.rs @@ -0,0 +1,194 @@ +//! Publication failures must not rotate identities or leave blocked workers behind. +use super::*; +use base64::Engine as _; +use std::io::{BufRead as _, BufReader}; + +fn accept(listener: &TcpListener) -> BufReader { + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).expect("blocking stream"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("read deadline"); + return BufReader::new(stream); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "backend retry deadline"); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept: {error}"), + } + } +} + +fn line(stream: &mut BufReader) -> String { + let mut line = String::new(); + stream.read_line(&mut line).expect("backend command or EOF"); + line +} + +fn reply(stream: &mut BufReader, response: &str) { + stream + .get_mut() + .write_all(response.as_bytes()) + .expect("reply"); +} + +fn with_owner(config: OverlayConfig, fixture: impl FnOnce(&Arc) -> T) { + // Signal shutdown even if a fixture assertion unwinds; the scoped worker + // must finish before the caller drops its temporary identity directory. + struct StopOnDrop(Arc); + impl Drop for StopOnDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + let owner = Arc::new(OverlayTransport::new(Arc::new(config))); + let shared = shared(&owner); + let shutdown = Arc::new(AtomicBool::new(false)); + std::thread::scope(|scope| { + let worker = scope.spawn(|| { + if let Some(address) = owner.config.tor_control { + owner.run_tor(address, None, &shared, &shutdown); + } else { + owner.run_sam(owner.config.i2p_sam.expect("SAM"), None, &shared, &shutdown); + } + }); + let _stop = StopOnDrop(Arc::clone(&shutdown)); + let connections = fixture(&owner); + shutdown.store(true, Ordering::Release); + until(|| worker.is_finished()); + worker.join().expect("overlay owner"); + assert!(owner.local_onion.read().is_none()); + assert!(owner.sam.read().is_none()); + drop(connections); + }); +} + +#[test] +fn tor_publication_failure_reuses_key_and_shutdown_cancels_blocked_heartbeat() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("onion.key"); + // A directory at the key path causes publication to fail without relying + // on permission bits (which privileged CI users can bypass). + std::fs::create_dir(&path).expect("publication obstruction"); + let listener = TcpListener::bind("127.0.0.1:0").expect("control"); + let address = listener.local_addr().expect("address"); + let key = format!( + "ED25519-V3:{}", + base64::engine::general_purpose::STANDARD.encode([7; 64]) + ); + let service = PeerAddress::Onion([7; 32]).to_string(); + let service_id = service.strip_suffix(".onion").expect("onion address"); + with_owner( + OverlayConfig { + tor_control: Some(address), + onion_proxy: Some(address), + identity_dir: directory.path().to_owned(), + ..OverlayConfig::default() + }, + |owner| { + let mut control = accept(&listener); + assert_eq!(line(&mut control), "AUTHENTICATE\r\n"); + reply(&mut control, "250 OK\r\n"); + assert!(line(&mut control).starts_with("ADD_ONION NEW:ED25519-V3 ")); + reply( + &mut control, + &format!("250-ServiceID={service_id}\r\n250-PrivateKey={key}\r\n250 OK\r\n"), + ); + assert_eq!( + line(&mut control), + "", + "failed publication closes the service" + ); + assert!(owner.local_onion.read().is_none()); + std::fs::rename(&path, directory.path().join("obstruction")) + .expect("unblock publication"); + + let mut control = accept(&listener); + assert_eq!(line(&mut control), "AUTHENTICATE\r\n"); + reply(&mut control, "250 OK\r\n"); + assert!(line(&mut control).starts_with(&format!("ADD_ONION {key} "))); + reply( + &mut control, + &format!("250-ServiceID={service_id}\r\n250 OK\r\n"), + ); + assert_eq!(line(&mut control), "GETINFO version\r\n"); + assert_eq!( + identity::read(&path, identity::Kind::Tor).expect("durable key"), + Some(key.clone()) + ); + assert!(owner.local_onion.read().is_some()); + // Leave GETINFO unanswered. Shutdown must interrupt its blocking read. + // Keep the socket open until with_owner signals cancellation. + control + }, + ); +} + +#[test] +fn sam_publication_failure_reuses_key_and_shutdown_cancels_blocked_accept() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("i2p.key"); + std::fs::create_dir(&path).expect("publication obstruction"); + let listener = TcpListener::bind("127.0.0.1:0").expect("SAM"); + let public = identity::i2p_base64().encode([0; 387]); + let private = identity::i2p_base64().encode([0; 387 + 256 + 20]); + with_owner( + OverlayConfig { + i2p_sam: Some(listener.local_addr().expect("address")), + i2p_accept: true, + identity_dir: directory.path().to_owned(), + ..OverlayConfig::default() + }, + |owner| { + let mut control = accept(&listener); + assert_eq!(line(&mut control), "HELLO VERSION MIN=3.1 MAX=3.3\n"); + reply(&mut control, "HELLO REPLY RESULT=OK VERSION=3.3\n"); + assert_eq!(line(&mut control), "DEST GENERATE SIGNATURE_TYPE=7\n"); + reply( + &mut control, + &format!("DEST REPLY PUB={public} PRIV={private}\n"), + ); + assert_eq!( + line(&mut control), + "", + "no session before durable publication" + ); + assert!(owner.sam.read().is_none()); + std::fs::rename(&path, directory.path().join("obstruction")) + .expect("unblock publication"); + + let mut control = accept(&listener); + assert_eq!(line(&mut control), "HELLO VERSION MIN=3.1 MAX=3.3\n"); + reply(&mut control, "HELLO REPLY RESULT=OK VERSION=3.3\n"); + let create = line(&mut control); + assert!(create.starts_with("SESSION CREATE STYLE=STREAM ")); + assert!(create.contains(&format!(" DESTINATION={private} "))); + reply(&mut control, "SESSION STATUS RESULT=OK\n"); + assert_eq!(line(&mut control), "NAMING LOOKUP NAME=ME\n"); + reply( + &mut control, + &format!("NAMING REPLY RESULT=OK NAME=ME VALUE={public}\n"), + ); + assert_eq!(line(&mut control), "PING bitcoin-rs\n"); + reply(&mut control, "PONG bitcoin-rs\n"); + assert_eq!( + identity::read(&path, identity::Kind::I2p).expect("durable key"), + Some(private.clone()) + ); + let mut inbound = accept(&listener); + assert_eq!(line(&mut inbound), "HELLO VERSION MIN=3.1 MAX=3.3\n"); + reply(&mut inbound, "HELLO REPLY RESULT=OK VERSION=3.3\n"); + assert!(line(&mut inbound).starts_with("STREAM ACCEPT ID=")); + assert!(owner.sam.read().is_some()); + // Neither a remote circuit nor a STATUS arrives before cancellation. + (control, inbound) + }, + ); +} diff --git a/e2e/tests/overlay_connectivity.rs b/e2e/tests/overlay_connectivity.rs index 44310c785..ebf29c125 100644 --- a/e2e/tests/overlay_connectivity.rs +++ b/e2e/tests/overlay_connectivity.rs @@ -52,6 +52,29 @@ fn ready(node: &mut ProcessNode, network: &'static str, expected: Option<&str>) }, ) } +fn exchange( + sender: &mut ProcessNode, + receiver: &mut ProcessNode, + count: u32, + height: u64, +) -> Result<()> { + let hashes = mine_bare_blocks(sender, count)?; + let tip = hashes.last().expect("nonempty mined chain"); + receiver.wait_block_count(height, Duration::from_secs(180))?; + assert_eq!(receiver.rpc("getbestblockhash", &json!([]))?, json!(tip)); + // Check admitted headers and complete bodies, not just peer byte counters. + for hash in hashes { + assert_eq!( + receiver.rpc("getblockheader", &json!([hash, false]))?, + sender.rpc("getblockheader", &json!([hash, false]))? + ); + assert_eq!( + receiver.rpc("getblock", &json!([hash, 0]))?, + sender.rpc("getblock", &json!([hash, 0]))? + ); + } + Ok(()) +} fn roundtrip( network: &'static str, suffix: &'static str, @@ -76,7 +99,7 @@ fn roundtrip( client.rpc("addnode", &json!([address, "add"]))?; ready(&mut client, network, Some(&address))?; ready(&mut server, network, None)?; - mine_bare_blocks(&mut server, 3)?; + exchange(&mut server, &mut client, 3, 3)?; let peers = server.rpc("getpeerinfo", &json!([]))?; let remote = peers[0]["addr"].as_str().expect("remote identity"); if network == "onion" { @@ -84,11 +107,7 @@ fn roundtrip( } else { assert!(remote.contains(".b32.i2p:"), "{remote}"); } - client.wait_block_count(3, Duration::from_secs(180))?; - assert_eq!( - client.rpc("getbestblockhash", &json!([]))?, - server.rpc("getbestblockhash", &json!([]))? - ); + exchange(&mut client, &mut server, 2, 5)?; let info = client.rpc("getnetworkinfo", &json!([]))?; for row in info["networks"].as_array().expect("networks") { if row["name"] == "ipv4" || row["name"] == "ipv6" { @@ -108,8 +127,8 @@ fn roundtrip( client.rpc("setnetworkactive", &json!([true]))?; ready(&mut client, network, Some(&address))?; ready(&mut server, network, None)?; - mine_bare_blocks(&mut server, 1)?; - client.wait_block_count(4, Duration::from_secs(180))?; + exchange(&mut server, &mut client, 1, 6)?; + exchange(&mut client, &mut server, 1, 7)?; client.stop()?; server.stop() } From bd5ac737c42effd0a02ba23c1c05864f615509cc Mon Sep 17 00:00:00 2001 From: rabbitprincess Date: Tue, 27 Oct 2026 22:31:55 +0900 Subject: [PATCH 15/19] refactor(addrman): reset pre-release storage to canonical v1 Read and write one explicitly identified v1 format. Preserve incompatible development snapshots before fresh discovery, remove historical conversions and upgrade fixtures, and retain current validation and atomic publication. Refs #1688 --- bin/bitcoin-rs/tests/addrman_restart_e2e.rs | 3 +- crates/p2p/src/addrman.rs | 519 ++++-------------- crates/p2p/src/addrman/tests.rs | 94 +--- crates/p2p/src/addrman/tests/asmap_tests.rs | 146 +---- .../src/addrman/tests/eligibility_tests.rs | 3 +- .../src/addrman/tests/overlay_destinations.rs | 39 -- crates/p2p/src/addrman/tests/persistence.rs | 235 ++++++++ .../tests/persistence/canonical-v1.json | 43 ++ crates/p2p/src/addrman/tests/probe_tests.rs | 94 ---- .../p2p/src/addrman/tests/services_tests.rs | 224 +------- .../tests/data/addrman-core-v5-eight-refs.dat | 1 - .../tests/data/addrman-core-v6-eight-refs.dat | 1 - .../data/addrman-historical-v1-services.dat | Bin 863 -> 0 bytes .../data/addrman-historical-v2-asmap.dat | 1 - .../p2p/tests/data/addrman-historical-v2.dat | 2 - .../data/addrman-historical-v3-anchors.dat | 1 - .../data/addrman-historical-v3-asmap.dat | Bin 644 -> 0 bytes .../p2p/tests/data/addrman-historical-v3.dat | 1 - .../data/addrman-historical-v4-anchors.dat | 2 - .../addrman-historical-v7-services-asmap.dat | 1 - .../data/addrman-historical-v7-services.dat | 1 - .../data/addrman-historical-v8-endpoints.dat | 1 - .../tests/data/addrman-historical.README.md | 96 ---- docs/policies/db-migration.md | 4 +- docs/policies/p2p-compatibility.md | 115 ++-- docs/validation-tooling.md | 2 +- 26 files changed, 457 insertions(+), 1172 deletions(-) create mode 100644 crates/p2p/src/addrman/tests/persistence.rs create mode 100644 crates/p2p/src/addrman/tests/persistence/canonical-v1.json delete mode 100644 crates/p2p/tests/data/addrman-core-v5-eight-refs.dat delete mode 100644 crates/p2p/tests/data/addrman-core-v6-eight-refs.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v1-services.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v2-asmap.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v2.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v3-anchors.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v3-asmap.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v3.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v4-anchors.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v7-services-asmap.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v7-services.dat delete mode 100644 crates/p2p/tests/data/addrman-historical-v8-endpoints.dat delete mode 100644 crates/p2p/tests/data/addrman-historical.README.md diff --git a/bin/bitcoin-rs/tests/addrman_restart_e2e.rs b/bin/bitcoin-rs/tests/addrman_restart_e2e.rs index 39f9649bc..77343ef0f 100644 --- a/bin/bitcoin-rs/tests/addrman_restart_e2e.rs +++ b/bin/bitcoin-rs/tests/addrman_restart_e2e.rs @@ -97,7 +97,8 @@ fn restart_reconnects( let datadir = learner.stop_keep_datadir()?; let bytes = std::fs::read(datadir.path().join("node/peers-fabfb5da.dat"))?; let book: serde_json::Value = serde_json::from_slice(&bytes[..bytes.len() - 32])?; - assert_eq!(book["version"], 8); + assert_eq!(book["format"], "bitcoin-rs-addrman"); + assert_eq!(book["version"], 1); assert_eq!( book["asmap_id"], json!(expected_map), diff --git a/crates/p2p/src/addrman.rs b/crates/p2p/src/addrman.rs index ed5d8b85c..c35ae9511 100644 --- a/crates/p2p/src/addrman.rs +++ b/crates/p2p/src/addrman.rs @@ -28,7 +28,9 @@ const MAX_DNS_RESULTS: usize = 64; const MAX_GOSSIP: usize = 32; const GOSSIP_CACHE_TTL: Duration = Duration::from_hours(24); const MAX_TEMP_ATTEMPTS: usize = 8; -const VERSION: u32 = 9; +// Frozen until the first official release; pre-release layouts are not migrated. +const SCHEMA_VERSION: u32 = 1; +const FORMAT: &str = "bitcoin-rs-addrman"; const MAX_COLLISIONS: usize = 10; const REPLACEMENT_WINDOW: u64 = 4 * 60 * 60; const COLLISION_TEST_WINDOW: u64 = 40 * 60; @@ -43,8 +45,6 @@ enum Source { Ip(IpAddr), Overlay(crate::PeerAddress), Internal([u8; 10]), - // Historical formats did not retain the seed name or full Core internal hash. - LegacyDns(u64), } impl Source { fn dns(seed: &str) -> Self { @@ -62,15 +62,6 @@ impl Source { group.extend_from_slice(bytes); group } - Self::LegacyDns(id) => { - let mut hasher = Sha256::new(); - hasher.update(b"bitcoin-rs v1 DNS source"); - hasher.update(id.to_le_bytes()); - let hash = hasher.finalize(); - let mut group = vec![6]; - group.extend_from_slice(&hash[..10]); - group - } } } } @@ -97,7 +88,7 @@ struct Candidate { } impl Candidate { fn allows_unknown_services(&self) -> bool { - self.last_success == 0 && matches!(self.source, Source::Internal(_) | Source::LegacyDns(_)) + self.last_success == 0 && matches!(self.source, Source::Internal(_)) } fn ordinary_services_eligible(&self, best_block_depth: u64) -> bool { self.services.map_or_else( @@ -110,14 +101,6 @@ impl Candidate { }, ) } - fn infer_historical_services(mut self) -> Self { - // Old schemas cannot distinguish an unobserved DNS record from a - // rejected VERSION0 before Good. Preserve their exact source backup. - if self.services == Some(0) && self.allows_unknown_services() { - self.services = None; - } - self - } fn terrible(&self, now: u64) -> bool { if now.saturating_sub(self.last_attempt) <= 60 { return false; @@ -155,6 +138,7 @@ enum PendingClaim { #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Stored { + format: String, version: u32, magic: [u8; 4], secret: [u8; 32], @@ -265,7 +249,8 @@ impl Manager { rng.fill_bytes(&mut secret); Self { stored: Stored { - version: VERSION, + format: FORMAT.to_owned(), + version: SCHEMA_VERSION, magic, secret, records: Vec::new(), @@ -566,7 +551,7 @@ impl Manager { let self_source = match &source { Source::Ip(ip) => Some(crate::netgroup::canonical_ip(*ip)) == addr.address().ip(), Source::Overlay(address) => *address == addr.address(), - _ => false, + Source::Internal(_) => false, }; let time_penalty = if self_source { 0 } else { time_penalty }; let existing = self.by_addr.get(&addr).copied(); @@ -770,22 +755,6 @@ fn routable(addr: PeerEndpoint, allow_local: bool) -> bool { } } -fn legacy_prefix(ip: IpAddr) -> u64 { - match ip { - IpAddr::V4(ip) => { - let bytes = ip.octets(); - (1_u64 << 48) | u64::from(u16::from_be_bytes([bytes[0], bytes[1]])) - } - IpAddr::V6(ip) => { - if let Some(ip) = ip.to_ipv4_mapped() { - return legacy_prefix(ip.into()); - } - let bytes = ip.octets(); - (2_u64 << 48) | u64::from(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) - } - } -} - impl AddressBook { pub(crate) fn open( path: Option, @@ -802,7 +771,7 @@ impl AddressBook { let loaded = match read_book(&scoped, Some(magic), allow_local, &manager.groups) { Ok(Some(loaded)) => Some((scoped, true, loaded)), Ok(None) => path.and_then(|legacy| match read_book(&legacy, None, true, &manager.groups) { - Ok(Some(loaded)) if loaded.stored.magic == magic => Some((legacy, false, loaded)), + Ok(Some(loaded)) if loaded.magic == magic => Some((legacy, false, loaded)), Ok(_) => None, Err(error) => { tracing::warn!(path=%legacy.display(), %error, "legacy address book unavailable; preserving file and disabling writes"); @@ -815,48 +784,48 @@ impl AddressBook { } }; if let Some((source_path, scoped_source, loaded)) = loaded { - if loaded - .stored - .records - .iter() - .all(|entry| routable(entry.addr, allow_local)) - { - manager.stored = loaded.stored; - manager.published = scoped_source; - let rebucket = - loaded.schema <= 4 || manager.stored.asmap_id != manager.groups.identity(); - let migrate = loaded.schema != VERSION || rebucket; - if migrate && manager.writable { - if let Err(error) = - backup_before_migration(&source_path, &loaded.bytes, loaded.schema) - { - tracing::warn!(path=%source_path.display(), %error, "address book migration backup failed; preserving source and disabling writes"); - manager.writable = false; + if let Some(stored) = loaded.stored { + if stored + .records + .iter() + .all(|entry| routable(entry.addr, allow_local)) + { + manager.stored = stored; + manager.published = scoped_source; + let rebucket = manager.stored.asmap_id != manager.groups.identity(); + if rebucket { + manager.rebucket(); + manager.stored.asmap_id = manager.groups.identity(); + manager.revision = 1; + } else { + manager.install_indexes(); + } + // Collision state is empty on restart; IDs remain runtime-only. + for (index, entry) in manager.stored.records.iter_mut().enumerate() { + entry.creation_id = u64::try_from(index).unwrap_or(u64::MAX); + } + manager.next_creation_id = + u64::try_from(manager.stored.records.len()).unwrap_or(u64::MAX); + if !scoped_source { + manager.revision = manager.revision.wrapping_add(1); } - } - if rebucket { - manager.rebucket(); } else { - manager.install_indexes(); + tracing::warn!(path=%source_path.display(), "address book contains inadmissible addresses; preserving file and disabling writes"); + manager.writable = false; } - // Collision state is empty on restart, so persisted record - // order establishes fresh runtime IDs without a disk field. - for (index, entry) in manager.stored.records.iter_mut().enumerate() { - entry.creation_id = u64::try_from(index).unwrap_or(u64::MAX); - } - manager.next_creation_id = - u64::try_from(manager.stored.records.len()).unwrap_or(u64::MAX); - if migrate { - manager.stored.version = VERSION; - manager.stored.asmap_id = manager.groups.identity(); - manager.revision = 1; - } - if !scoped_source { - manager.revision = manager.revision.wrapping_add(1); + } else if manager.writable { + match preserve_incompatible(&source_path, &loaded.bytes) { + Ok(backup) => { + tracing::warn!(path=%source_path.display(), backup=%backup.display(), "pre-release address book preserved; starting fresh discovery without migration"); + // Keep the old scoped file until atomic publication succeeds. + manager.published = scoped_source; + manager.revision = 1; + } + Err(error) => { + tracing::warn!(path=%source_path.display(), %error, "address book preservation failed; disabling writes"); + manager.writable = false; + } } - } else { - tracing::warn!(path=%source_path.display(), "legacy address book contains inadmissible addresses; preserving file and disabling writes"); - manager.writable = false; } } } @@ -1494,8 +1463,8 @@ fn invalid(message: &'static str) -> io::Error { } struct Loaded { - stored: Stored, - schema: u32, + stored: Option, + magic: [u8; 4], bytes: Vec, } @@ -1584,115 +1553,6 @@ fn read_anchors<'de, D: serde::Deserializer<'de>>( deserializer.deserialize_seq(Anchors) } -// Historical v1–v4 shapes are read only for validated, backed-up migration. -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct LegacyCandidate { - addr: PeerEndpoint, - services: u64, - source_group: u64, - source_ip: Option, - last_seen: u64, - last_attempt: u64, - last_success: u64, - failures: u8, - tried: bool, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct LegacyStored { - version: u32, - magic: [u8; 4], - secret: [u8; 32], - records: Vec, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct GroupedLegacyStored { - version: u32, - magic: [u8; 4], - secret: [u8; 32], - records: Vec, - asmap_id: Option<[u8; 32]>, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct ReferencedCandidate { - addr: PeerEndpoint, - services: u64, - source: Source, - last_seen: u64, - last_success: u64, - failures: u32, - tried: bool, - #[serde(deserialize_with = "read_new_buckets")] - new_buckets: Vec, -} -impl From for Candidate { - fn from(old: ReferencedCandidate) -> Self { - Self { - creation_id: 0, - addr: old.addr, - services: Some(old.services), - source: old.source, - last_seen: old.last_seen, - last_success: old.last_success, - failures: old.failures, - tried: old.tried, - new_buckets: old.new_buckets, - last_attempt: 0, - last_count_attempt: 0, - } - .infer_historical_services() - } -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct PrefixStored { - version: u32, - magic: [u8; 4], - secret: [u8; 32], - #[serde(deserialize_with = "read_records")] - records: Vec, -} - -// These separate structs admit only fields present in the published schemas. -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AnchoredLegacyStored { - version: u32, - magic: [u8; 4], - secret: [u8; 32], - records: Vec, - asmap_id: Option<[u8; 32]>, - #[serde(default, deserialize_with = "read_anchors")] - anchors: Vec, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct GroupedStored { - version: u32, - magic: [u8; 4], - secret: [u8; 32], - #[serde(deserialize_with = "read_records")] - records: Vec, - asmap_id: Option<[u8; 32]>, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct AnchoredReferencedStored { - version: u32, - magic: [u8; 4], - secret: [u8; 32], - #[serde(deserialize_with = "read_records")] - records: Vec, - asmap_id: Option<[u8; 32]>, - #[serde(deserialize_with = "read_anchors")] - anchors: Vec, -} fn validate_anchors(stored: &Stored) -> io::Result<()> { let mut anchors = HashSet::new(); if stored.anchors.len() > MAX_ANCHORS { @@ -1717,7 +1577,10 @@ fn validate_current( allow_local: bool, groups: Option<&NetGroups>, ) -> io::Result<()> { - if stored.version != VERSION || stored.records.len() > MAX_RECORDS { + if stored.format != FORMAT + || stored.version != SCHEMA_VERSION + || stored.records.len() > MAX_RECORDS + { return Err(invalid("address book version/count")); } let mut endpoints = HashSet::new(); @@ -1742,7 +1605,7 @@ fn validate_current( return Err(invalid("Tried count exceeds table")); } // A changed map cannot reproduce the old Tried slots. Validate all - // map-independent structure, then back up and rebucket at open. + // map-independent structure, then rebucket at open. if let Some(groups) = groups { let bucket = tried_bucket(&stored.secret, entry.addr, groups); let position = bucket_position(&stored.secret, entry.addr, false, bucket); @@ -1769,133 +1632,6 @@ fn validate_current( } Ok(()) } -fn legacy_slot(secret: &[u8; 32], entry: &LegacyCandidate, ip: IpAddr) -> u64 { - let mut hash = Sha256::new(); - hash.update(secret); - hash.update(legacy_prefix(ip).to_le_bytes()); - if !entry.tried { - hash.update(entry.source_group.to_le_bytes()); - } - let mut first = [0; 8]; - first.copy_from_slice(&hash.finalize()[..8]); - let bucket = u64::from_le_bytes(first); - let endpoint = Sha256::digest(entry.addr.to_string().as_bytes()); - let offset = u64::from(endpoint[0] & 15); - if entry.tried { - 3072 + bucket % 64 * 16 + offset - } else { - bucket % 192 * 16 + offset - } -} -fn convert_legacy(old: LegacyStored, allow_local: bool) -> io::Result { - if !(1..=4).contains(&old.version) || old.records.len() > 4096 { - return Err(invalid("legacy address book version/count")); - } - let mut addresses = HashSet::new(); - let mut sources = HashMap::new(); - let mut slots = HashSet::new(); - for entry in &old.records { - let ip = entry - .addr - .address() - .ip() - .ok_or_else(|| invalid("overlay in legacy IP schema"))?; - if entry.addr.address().ip().is_none() - || !routable(entry.addr, allow_local) - || canonical(entry.addr) != entry.addr - || !addresses.insert(entry.addr) - || entry - .source_ip - .is_some_and(|ip| legacy_prefix(ip) != entry.source_group) - || entry.tried && entry.last_success == 0 - || old.version == 1 && !slots.insert(legacy_slot(&old.secret, entry, ip)) - { - return Err(invalid("invalid legacy address record")); - } - let count = sources.entry(entry.source_group).or_insert(0_usize); - *count += 1; - if old.version == 1 && *count > 64 { - return Err(invalid("legacy source limit")); - } - } - let records = old - .records - .into_iter() - .map(|entry| { - // Core's local attempt timestamps are deliberately not restored. - let _ = entry.last_attempt; - Candidate { - creation_id: 0, - addr: entry.addr, - services: Some(entry.services), - source: entry - .source_ip - .map_or(Source::LegacyDns(entry.source_group), Source::Ip), - last_seen: entry.last_seen, - last_success: entry.last_success, - failures: u32::from(entry.failures), - tried: entry.tried, - new_buckets: Vec::new(), - last_attempt: 0, - last_count_attempt: 0, - } - .infer_historical_services() - }) - .collect(); - Ok(Stored { - version: VERSION, - magic: old.magic, - secret: old.secret, - records, - asmap_id: None, - anchors: Vec::new(), - }) -} -// Historical shapes are decoded explicitly before conversion; current fields -// cannot be smuggled into older formats through defaults or version relabeling. -fn decode_legacy_book(payload: &[u8], version: u32, allow_local: bool) -> io::Result { - if version == 1 { - let old = serde_json::from_slice(payload).map_err(io::Error::other)?; - convert_legacy(old, allow_local) - } else { - let (old, asmap_id, anchors) = if version == 2 { - let old: GroupedLegacyStored = - serde_json::from_slice(payload).map_err(io::Error::other)?; - ( - LegacyStored { - version: old.version, - magic: old.magic, - secret: old.secret, - records: old.records, - }, - old.asmap_id, - Vec::new(), - ) - } else { - let old: AnchoredLegacyStored = - serde_json::from_slice(payload).map_err(io::Error::other)?; - ( - LegacyStored { - version: old.version, - magic: old.magic, - secret: old.secret, - records: old.records, - }, - old.asmap_id, - old.anchors, - ) - }; - let mut stored = convert_legacy(old, allow_local)?; - stored.asmap_id = asmap_id; - stored.anchors = anchors; - validate_anchors(&stored)?; - Ok(stored) - } -} -#[expect( - clippy::too_many_lines, - reason = "Historical schema admission and validation stay ordered before backed-up publication" -)] fn read_book( path: &Path, magic: Option<[u8; 4]>, @@ -1904,7 +1640,15 @@ fn read_book( ) -> io::Result> { #[derive(Deserialize)] struct Header { + #[serde(default, deserialize_with = "present_format")] + format: Option, version: u32, + magic: [u8; 4], + } + fn present_format<'de, D: serde::Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + String::deserialize(deserializer).map(Some) } let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, @@ -1926,107 +1670,47 @@ fn read_book( return Err(invalid("address book checksum")); } let header: Header = serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; - let stored = match header.version { - 1..=4 => { - if bytes.len() > 2 * 1024 * 1024 { - return Err(invalid("legacy address book size")); - } - decode_legacy_book(&bytes[..payload_len], header.version, allow_local)? - } - 5 => { - let old: PrefixStored = - serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; - if old.version != 5 { - return Err(invalid("prefix address book version")); - } - Stored { - version: VERSION, - magic: old.magic, - secret: old.secret, - records: old.records.into_iter().map(Candidate::from).collect(), - asmap_id: None, - anchors: Vec::new(), - } - } - 6 => { - let old: GroupedStored = - serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; - if old.version != 6 { - return Err(invalid("grouped address book version")); - } - Stored { - version: VERSION, - magic: old.magic, - secret: old.secret, - records: old.records.into_iter().map(Candidate::from).collect(), - asmap_id: old.asmap_id, - anchors: Vec::new(), - } - } - 7 => { - let old: AnchoredReferencedStored = - serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; - if old.version != 7 { - return Err(invalid("anchored address book version")); - } - Stored { - version: VERSION, - magic: old.magic, - secret: old.secret, - records: old.records.into_iter().map(Candidate::from).collect(), - asmap_id: old.asmap_id, - anchors: old.anchors, - } - } - 8 | VERSION => { - let mut stored: Stored = - serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; - if header.version == 8 - && stored.records.iter().any(|entry| { - entry.addr.address().ip().is_none() - || matches!(entry.source, Source::Overlay(_)) - }) - { - return Err(invalid("overlay in historical IP schema")); - } - stored.version = VERSION; - stored - } - _ => return Err(invalid("unsupported address book schema")), - }; - if header.version < VERSION - && stored.records.iter().any(|entry| { - entry.addr.address().ip().is_none() || matches!(entry.source, Source::Overlay(_)) - }) - { - return Err(invalid("overlay identity in historical IP schema")); - } - if header.version >= 5 { - let prefix = NetGroups::default(); - let classifier = if stored.asmap_id.is_none() { - Some(&prefix) - } else if stored.asmap_id == groups.identity() { - Some(groups) - } else { - None - }; - validate_current(&stored, allow_local, classifier)?; - } - validate_anchors(&stored)?; - if magic.is_some_and(|magic| magic != stored.magic) { + if magic.is_some_and(|magic| magic != header.magic) { return Err(invalid("address book network")); } + // Only the checked envelope identifies disposable development snapshots. + // Their records are never decoded or converted into the current schema. + if header.format.is_none() && (1..=9).contains(&header.version) { + return Ok(Some(Loaded { + stored: None, + magic: header.magic, + bytes, + })); + } + if header.format.as_deref() != Some(FORMAT) || header.version != SCHEMA_VERSION { + return Err(invalid("unsupported address book format/version")); + } + let stored: Stored = serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?; + let prefix = NetGroups::default(); + let classifier = if stored.asmap_id.is_none() { + Some(&prefix) + } else if stored.asmap_id == groups.identity() { + Some(groups) + } else { + None + }; + validate_current(&stored, allow_local, classifier)?; + validate_anchors(&stored)?; Ok(Some(Loaded { - stored, - schema: header.version, + stored: Some(stored), + magic: header.magic, bytes, })) } -fn backup_before_migration(path: &Path, bytes: &[u8], schema: u32) -> io::Result<()> { + +// Preserve rejected bytes before allowing replacement. Exclusive atomic +// publication cannot clobber an existing backup, including an unsafe file type. +// An interrupted first save can reuse only a byte-identical, flushed backup. +fn preserve_incompatible(path: &Path, bytes: &[u8]) -> io::Result { use bitcoin::hex::DisplayHex as _; let digest = Sha256::digest(bytes); let backup = path.with_extension(format!( - "v{schema}-{}.bak", + "pre-release-{}.bak", digest[..].to_lower_hex_string() )); match fs::symlink_metadata(&backup) { @@ -2034,17 +1718,15 @@ fn backup_before_migration(path: &Path, bytes: &[u8], schema: u32) -> io::Result if !metadata.is_file() || metadata.len() != u64::try_from(bytes.len()).unwrap_or(u64::MAX) { - return Err(invalid("migration backup size/type")); + return Err(invalid("address book backup size/type")); } - // Read, revalidate and flush the same handle. Windows requires - // GENERIC_WRITE for FlushFileBuffers even when bytes are unchanged. let mut file = OpenOptions::new().read(true).write(true).open(&backup)?; let mut existing = Vec::new(); (&mut file) .take(MAX_FILE_BYTES + 1) .read_to_end(&mut existing)?; if existing != bytes { - return Err(invalid("migration backup differs")); + return Err(invalid("address book backup differs")); } file.sync_all()?; let parent = backup @@ -2053,15 +1735,16 @@ fn backup_before_migration(path: &Path, bytes: &[u8], schema: u32) -> io::Result .unwrap_or_else(|| Path::new(".")); bitcoin_rs_storage::checkpoint::fs::sync_dir( &bitcoin_rs_storage::checkpoint::fs::open_data_dir(parent)?, - ) + )?; } Err(error) if error.kind() == io::ErrorKind::NotFound => { publish_bytes_with_nonce(&backup, bytes, &mut false, || { bitcoin::secp256k1::rand::thread_rng().next_u64() - }) + })?; } - Err(error) => Err(error), + Err(error) => return Err(error), } + Ok(backup) } impl Manager { fn rebucket(&mut self) { @@ -2169,7 +1852,7 @@ fn publish_bytes_with_nonce( file.write_all(bytes)?; file.sync_all()?; // Close before publication for Windows. Initial publication must not - // clobber an operator file created since open/migration observed absence. + // clobber an operator file created since open observed absence. drop(file); if *replace { fs::rename(&tmp, path)?; diff --git a/crates/p2p/src/addrman/tests.rs b/crates/p2p/src/addrman/tests.rs index 007d49a5f..aeb86183e 100644 --- a/crates/p2p/src/addrman/tests.rs +++ b/crates/p2p/src/addrman/tests.rs @@ -160,7 +160,7 @@ fn scoped_network_books_and_stale_temporary_files_are_independent() { } #[test] -fn legacy_import_retains_original_and_scoped_state_wins() { +fn unscoped_current_import_retains_original_and_scoped_state_wins() { let dir = tempfile::tempdir().expect("dir"); let base = dir.path().join("peers.dat"); let source = book(); @@ -253,6 +253,7 @@ fn publication_retries_collisions_with_a_bound_and_preserves_unowned_temps() { .expect("published") .expect("exists") .stored + .expect("current schema") .records .len(), 1 @@ -754,95 +755,6 @@ fn fixture_bytes(value: &serde_json::Value) -> Vec { bytes.extend_from_slice(&hash); bytes } -fn old_fixture() -> serde_json::Value { - serde_json::json!({"version":1,"magic":[1,1,1,1],"secret":vec![1;32],"records":[{ - "addr":"8.8.8.8:8333","services":9,"source_group":(1_u64<<48)|0x0101,"source_ip":"1.1.1.1", - "last_seen":EPOCH,"last_attempt":EPOCH-1,"last_success":0,"failures":3,"tried":false - }]}) -} - -#[test] -fn v1_migration_backs_up_exact_scoped_or_legacy_bytes_and_preserves_recovery() { - for scoped in [false, true] { - let dir = tempfile::tempdir().expect("dir"); - let base = dir.path().join("peers.dat"); - let source = if scoped { - network_path(&base, [1; 4]) - } else { - base.clone() - }; - let bytes = fixture_bytes(&old_fixture()); - fs::write(&source, &bytes).expect("old operator book"); - let restored = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert_eq!(restored.len(), 1); - assert_eq!( - restored.select(&[], &[], EPOCH, u64::MAX, |_| true), - Some(target()), - "failed knowledge still selectable with DNS off" - ); - let backups: Vec<_> = fs::read_dir(dir.path()) - .expect("entries") - .map(|entry| entry.expect("entry").path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "bak")) - .collect(); - assert_eq!(backups.len(), 1); - assert_eq!(fs::read(&backups[0]).expect("backup"), bytes); - { - let manager = restored.state.lock(); - let entry = &manager.stored.records[0]; - assert_eq!(entry.failures, 3); - assert_eq!(entry.last_attempt, 0); - assert_eq!(entry.last_count_attempt, 0); - assert_eq!(manager.last_good, 1); - assert_indexes(&manager); - } - let retry = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert!( - retry.state.lock().writable, - "matching durable backup is reusable before first migration publication" - ); - assert_eq!(retry.len(), 1); - restored.save(); - if !scoped { - assert_eq!(fs::read(&source).expect("legacy retained"), bytes); - } - let again = AddressBook::open(Some(base), [1; 4], false, None); - assert_eq!(again.len(), 1); - assert_eq!(again.state.lock().stored.version, VERSION); - } -} - -#[test] -fn migration_backup_failure_and_unknown_child_schemas_preserve_operator_files() { - use bitcoin::hex::DisplayHex as _; - let dir = tempfile::tempdir().expect("dir"); - let base = dir.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - let bytes = fixture_bytes(&old_fixture()); - fs::write(&path, &bytes).expect("old"); - let digest = Sha256::digest(&bytes); - let backup = path.with_extension(format!("v1-{}.bak", digest[..].to_lower_hex_string())); - fs::create_dir(&backup).expect("unusable backup"); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert_eq!(book.len(), 1); - assert!(!book.state.lock().writable); - book.learn_dns("seed", &[addr(8)], EPOCH); - book.save(); - assert_eq!(fs::read(&path).expect("preserved"), bytes); - for version in [2, 3, 4, 7, 99] { - let mut value = old_fixture(); - value["version"] = version.into(); - value["anchors"] = serde_json::json!([{"addr":"8.8.8.8:8333","confirmed_at":EPOCH}]); - let bytes = fixture_bytes(&value); - fs::write(&path, &bytes).expect("unknown"); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert!(!book.state.lock().writable); - book.learn_dns("seed", &[addr(9)], EPOCH); - book.save(); - assert_eq!(fs::read(&path).expect("preserved"), bytes); - } -} - #[test] fn same_layout_restart_retains_all_refs_and_resets_only_runtime_attempt_times() { let dir = tempfile::tempdir().expect("dir"); @@ -1449,3 +1361,5 @@ mod eligibility_tests; mod services_tests; mod overlay_destinations; + +mod persistence; diff --git a/crates/p2p/src/addrman/tests/asmap_tests.rs b/crates/p2p/src/addrman/tests/asmap_tests.rs index 1f8e91e60..5b5b64611 100644 --- a/crates/p2p/src/addrman/tests/asmap_tests.rs +++ b/crates/p2p/src/addrman/tests/asmap_tests.rs @@ -1,11 +1,6 @@ use super::*; const QUOTA_MAP: &[u8] = include_bytes!("../../../tests/data/asmap-source-quota-core-v31.1.raw"); -const V2: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v2.dat"); -const V3: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v3.dat"); -const V2_ASMAP: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v2-asmap.dat"); -const V3_ASMAP: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v3-asmap.dat"); -const V5: &[u8] = include_bytes!("../../../tests/data/addrman-core-v5-eight-refs.dat"); fn map_file(directory: &Path, bytes: &[u8]) -> PathBuf { let path = directory.join("asmap.raw"); @@ -140,136 +135,7 @@ fn incoming_source_asn_controls_corroboration_without_rewriting_primary_source() } #[test] -fn historical_v2_v3_writers_migrate_with_exact_backups_and_health() { - for (version, bytes) in [(2, V2), (3, V3), (2, V2_ASMAP), (3, V3_ASMAP)] { - for scoped in [false, true] { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = if scoped { - network_path(&base, [1; 4]) - } else { - base.clone() - }; - fs::write(&path, bytes).expect("actual historical writer bytes"); - let map = map_file(directory.path(), crate::netgroup::LINKED_ASMAP); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - { - let manager = book.state.lock(); - assert!(manager.writable, "version {version}"); - assert_eq!(manager.stored.version, VERSION); - assert_eq!(manager.stored.secret, [7; 32]); - assert_eq!(manager.stored.records.len(), 2); - let successful = &manager.stored.records - [manager.by_addr[&"8.8.8.8:8333".parse::().expect("peer")]]; - assert_eq!(successful.last_success, 1_700_000_001); - assert_eq!(successful.last_seen, 1_700_000_001); - let failed = &manager.stored.records - [manager.by_addr[&"9.9.9.9:8333".parse::().expect("peer")]]; - assert_eq!(failed.failures, 3); - assert_eq!(failed.source, Source::LegacyDns(14_378_457_816_419_903_907)); - assert!( - manager - .stored - .records - .iter() - .all(|entry| entry.last_attempt == 0 && entry.last_count_attempt == 0) - ); - assert_indexes(&manager); - } - assert_eq!(backups(directory.path()).len(), 1); - assert_eq!( - fs::read(&backups(directory.path())[0]).expect("backup"), - bytes - ); - book.save(); - if !scoped { - assert_eq!(fs::read(path).expect("legacy retained"), bytes); - } - let again = AddressBook::open(Some(base), [1; 4], false, Some(&map)); - assert_eq!(again.len(), 2); - assert_indexes(&again.state.lock()); - } - } -} - -#[test] -fn unchanged_v5_prefix_upgrade_preserves_all_eight_references() { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - fs::write(&path, V5).expect("actual immutable base writer bytes"); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); - let expected = vec![634, 268, 292, 948, 782, 987, 905, 711]; - { - let manager = book.state.lock(); - assert!(manager.writable); - assert_eq!(manager.stored.records[0].new_buckets, expected); - assert_eq!(manager.stored.records[0].failures, 1); - assert_eq!(manager.stored.records[0].last_seen, 1_699_992_800); - assert_eq!( - manager.stored.records[0].source, - Source::Ip("1.1.1.1".parse::().expect("source")) - ); - assert_eq!(manager.stored.secret, [7; 32]); - assert_indexes(&manager); - } - assert_eq!(fs::read(&backups(directory.path())[0]).expect("backup"), V5); - book.save(); - let again = AddressBook::open(Some(base), [1; 4], false, None); - assert_eq!(again.state.lock().stored.records[0].new_buckets, expected); - assert_eq!( - backups(directory.path()).len(), - 1, - "same v6 layout does not migrate again" - ); -} - -#[test] -fn failed_configured_map_preserves_original_without_creating_migration_backup() { - for malformed in [false, true] { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - fs::write(&path, V5).expect("actual v5"); - let map = directory.path().join("asmap.raw"); - if malformed { - fs::write(&map, b"invalid").expect("malformed map"); - } - let fallback = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(!fallback.state.lock().writable); - assert_eq!(refs(&fallback.state.lock(), target()), 8); - fallback.learn_dns("unpersisted", &[addr(13)], EPOCH); - fallback.save(); - assert_eq!(fs::read(&path).expect("preserved"), V5); - assert_eq!(backups(directory.path()), Vec::::new()); - fs::write(&map, QUOTA_MAP).expect("restored map"); - let restored = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(restored.state.lock().writable); - assert_eq!(restored.len(), 1); - assert_eq!( - refs(&restored.state.lock(), target()), - 1, - "changed classifier uses primary-source fallback" - ); - assert_eq!(fs::read(&backups(directory.path())[0]).expect("backup"), V5); - restored.save(); - let bytes = fs::read(&path).expect("v6"); - fs::remove_file(&map).expect("map unavailable again"); - let fallback = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(!fallback.state.lock().writable); - fallback.save(); - assert_eq!(fs::read(&path).expect("v6 retained"), bytes); - assert_eq!(backups(directory.path()).len(), 1); - fs::write(&map, QUOTA_MAP).expect("restore configured map"); - let again = AddressBook::open(Some(base), [1; 4], false, Some(&map)); - assert!(again.state.lock().writable); - assert_indexes(&again.state.lock()); - assert_eq!(backups(directory.path()).len(), 1); - } -} - -#[test] -fn asmap_change_is_backed_up_and_current_map_restart_keeps_all_refs() { +fn asmap_change_rebuckets_and_current_map_restart_keeps_all_refs() { let directory = tempfile::tempdir().expect("directory"); let base = directory.path().join("peers.dat"); let map = map_file(directory.path(), QUOTA_MAP); @@ -306,23 +172,19 @@ fn asmap_change_is_backed_up_and_current_map_restart_keeps_all_refs() { assert_eq!(refs(&manager, target()), 8); } book.save(); - let path = network_path(&base, [1; 4]); - let original = fs::read(&path).expect("v6 original"); + let restored = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); assert_eq!(refs(&restored.state.lock(), target()), 8); assert_eq!(backups(directory.path()), Vec::::new()); fs::write(&map, crate::netgroup::LINKED_ASMAP).expect("changed valid map"); let changed = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); assert_eq!(refs(&changed.state.lock(), target()), 1); - assert_eq!( - fs::read(&backups(directory.path())[0]).expect("backup"), - original - ); + assert_eq!(backups(directory.path()), Vec::::new()); changed.save(); let prefix = AddressBook::open(Some(base), [1; 4], false, None); assert!( prefix.state.lock().writable, - "intentional map omission permits backed-up migration" + "intentional map omission permits current-format rebucketing" ); assert_eq!(prefix.state.lock().stored.asmap_id, None); assert_indexes(&prefix.state.lock()); diff --git a/crates/p2p/src/addrman/tests/eligibility_tests.rs b/crates/p2p/src/addrman/tests/eligibility_tests.rs index c14fc243f..24fdea5ef 100644 --- a/crates/p2p/src/addrman/tests/eligibility_tests.rs +++ b/crates/p2p/src/addrman/tests/eligibility_tests.rs @@ -117,7 +117,6 @@ fn accepted_version_service_update_is_metadata_only_canonical_and_idempotent() { fn only_unknown_dns_services_keep_native_bootstrap_exception() { for source in [ Source::dns("seed"), - Source::LegacyDns(77), Source::Ip( addr(2) .address() @@ -128,7 +127,7 @@ fn only_unknown_dns_services_keep_native_bootstrap_exception() { let directory = tempfile::tempdir().expect("directory"); let path = directory.path().join("peers.dat"); let book = AddressBook::open(Some(path.clone()), [1; 4], false, None); - let dns = matches!(source, Source::Internal(_) | Source::LegacyDns(_)); + let dns = matches!(source, Source::Internal(_)); let services = if dns { None } else { Some(0) }; book.state .lock() diff --git a/crates/p2p/src/addrman/tests/overlay_destinations.rs b/crates/p2p/src/addrman/tests/overlay_destinations.rs index 133c43e3c..324408ccc 100644 --- a/crates/p2p/src/addrman/tests/overlay_destinations.rs +++ b/crates/p2p/src/addrman/tests/overlay_destinations.rs @@ -103,42 +103,3 @@ fn overlay_endpoints_sources_and_anchors_survive_restart_and_addrv2_gossip() { "anonymous circuits are not durable learning sources" ); } - -#[test] -fn original_v8_writer_migrates_with_exact_backup_and_no_ip_membership_changes() { - let bytes = include_bytes!("../../../tests/data/addrman-historical-v8-endpoints.dat"); - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - fs::write(&path, bytes).expect("original writer bytes"); - let original: serde_json::Value = - serde_json::from_slice(&bytes[..bytes.len() - 32]).expect("v8 JSON"); - assert_eq!(original["version"], 8); - let mut expected = original; - expected["version"] = VERSION.into(); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert!(book.state.lock().writable); - assert_eq!( - serde_json::to_value(&book.state.lock().stored).expect("migrated"), - expected - ); - assert_eq!(fs::read(&path).expect("source before save"), bytes); - let backup = path.with_extension(format!( - "v8-{}.bak", - Sha256::digest(bytes)[..].to_lower_hex_string() - )); - assert_eq!(fs::read(&backup).expect("backup before publication"), bytes); - book.save(); - let restored = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert_eq!( - serde_json::to_value(&restored.state.lock().stored).expect("reopened"), - expected - ); - // A conflicting backup must preserve the old file and make writes unavailable. - fs::write(&path, bytes).expect("restore v8 fixture"); - fs::write(&backup, b"damaged backup").expect("conflict"); - let blocked = AddressBook::open(Some(base), [1; 4], false, None); - assert!(!blocked.state.lock().writable); - blocked.save(); - assert_eq!(fs::read(path).expect("preserved v8 source"), bytes); -} diff --git a/crates/p2p/src/addrman/tests/persistence.rs b/crates/p2p/src/addrman/tests/persistence.rs new file mode 100644 index 000000000..8b9c1c179 --- /dev/null +++ b/crates/p2p/src/addrman/tests/persistence.rs @@ -0,0 +1,235 @@ +use super::*; + +fn rejected_backup(path: &Path, bytes: &[u8]) -> PathBuf { + path.with_extension(format!( + "pre-release-{}.bak", + Sha256::digest(bytes)[..].to_lower_hex_string() + )) +} + +#[test] +fn pre_release_version_is_frozen_and_the_format_marker_is_required() { + // #1688: edit the canonical v1 directly until the first official release. + assert_eq!(SCHEMA_VERSION, 1); + let value = serde_json::to_value(&book().state.lock().stored).expect("schema"); + assert_eq!(value["format"], "bitcoin-rs-addrman"); + assert_eq!(value["version"], 1); + assert_eq!(value["magic"], serde_json::json!([1, 1, 1, 1])); + assert_eq!(value["records"], serde_json::json!([])); + assert_eq!(value["anchors"], serde_json::json!([])); + assert!(value["asmap_id"].is_null()); +} + +#[test] +fn canonical_fixture_pins_endpoint_source_anchor_and_netgroup_semantics() { + // Shared adoption fixture for #1684/#1687; no historical reader is needed. + let payload = include_str!("persistence/canonical-v1.json"); + let expected: serde_json::Value = serde_json::from_str(payload).expect("canonical fixture"); + let mut bytes = payload.as_bytes().to_vec(); + bytes.extend_from_slice(&Sha256::digest(payload.as_bytes())); + let dir = tempfile::tempdir().expect("directory"); + let base = dir.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + fs::write(&path, bytes).expect("fixture"); + let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert!(book.state.lock().writable); + assert_eq!(book.len(), 3); + assert_eq!( + serde_json::to_value(&book.state.lock().stored).expect("state"), + expected + ); + let onion = PeerEndpoint::new(crate::PeerAddress::Onion([0; 32]), 8333); + let i2p = PeerEndpoint::new(crate::PeerAddress::I2p([0; 32]), 8333); + assert_eq!(book.netgroup(onion.address()), vec![4, 15]); + assert_eq!(book.netgroup(i2p.address()), vec![5, 15]); + assert_eq!( + Source::Internal([0; 10]).group(&NetGroups::default()), + vec![6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + ); + for (endpoint, tag) in [(onion, 4), (i2p, 5)] { + let mut key = vec![tag]; + key.extend_from_slice(&[0; 32]); + key.extend_from_slice(&8333_u16.to_be_bytes()); + assert_eq!(endpoint_key(endpoint), key); + } + let mut installed = true; + publish_book(&path, &book.state.lock().stored, &mut installed).expect("canonical write"); + let restored = AddressBook::open(Some(base), [1; 4], false, None); + assert_eq!( + serde_json::to_value(&restored.state.lock().stored).expect("round trip"), + expected + ); + assert_indexes(&restored.state.lock()); +} + +#[test] +fn incompatible_snapshots_are_preserved_without_decoding_or_migration() { + for scoped in [false, true] { + for version in 1..=9 { + let dir = tempfile::tempdir().expect("directory"); + let base = dir.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + let source = if scoped { &path } else { &base }; + // Only the old envelope matters. In particular, the divergent v9 + // record/source/netgroup layouts never pass through a decoder. + let bytes = fixture_bytes(&serde_json::json!({ + "version": version, "magic": [1, 1, 1, 1], + "records": [{"obsolete_layout": "not a canonical candidate"}] + })); + fs::write(source, &bytes).expect("old snapshot"); + let fresh = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert!(fresh.state.lock().writable); + assert_eq!(fresh.len(), 0, "no historical records are imported"); + assert_eq!(fs::read(source).expect("source before save"), bytes); + let backup = rejected_backup(source, &bytes); + assert_eq!(fs::read(&backup).expect("durable preservation"), bytes); + // A restart before the first fresh save must reuse the backup. + assert!( + AddressBook::open(Some(base.clone()), [1; 4], false, None) + .state + .lock() + .writable + ); + fresh.learn_dns("seed", &[target()], EPOCH); + fresh.save(); + assert_eq!(fs::read(&backup).expect("preserved after save"), bytes); + let restored = AddressBook::open(Some(base), [1; 4], false, None); + assert!(restored.state.lock().writable); + assert_eq!(restored.len(), 1); + assert_indexes(&restored.state.lock()); + } + } +} + +#[test] +fn missing_marker_never_aliases_canonical_v1() { + let dir = tempfile::tempdir().expect("directory"); + let base = dir.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + let known = book(); + known.learn_dns("seed", &[target()], EPOCH); + let mut value = serde_json::to_value(&known.state.lock().stored).expect("schema"); + value.as_object_mut().expect("object").remove("format"); + let bytes = fixture_bytes(&value); + fs::write(&path, &bytes).expect("unmarked v1"); + let fresh = AddressBook::open(Some(base), [1; 4], false, None); + assert_eq!(fresh.len(), 0); + assert!(fresh.state.lock().writable); + fresh.save(); + assert_eq!( + fs::read(rejected_backup(&path, &bytes)).expect("backup"), + bytes + ); + assert_ne!(fs::read(path).expect("fresh canonical snapshot"), bytes); +} + +#[test] +fn failed_preservation_never_replaces_the_source_and_can_be_retried() { + for directory_obstruction in [false, true] { + let dir = tempfile::tempdir().expect("directory"); + let base = dir.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + let bytes = fixture_bytes(&serde_json::json!({"version": 9, "magic": [1, 1, 1, 1]})); + fs::write(&path, &bytes).expect("old snapshot"); + let backup = rejected_backup(&path, &bytes); + if directory_obstruction { + fs::create_dir(&backup).expect("unsafe backup type"); + } else { + fs::write(&backup, vec![b'x'; bytes.len()]).expect("conflicting backup"); + } + let rejected = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert!(!rejected.state.lock().writable); + rejected.learn_dns("seed", &[target()], EPOCH); + rejected.save(); + assert_eq!(fs::read(&path).expect("source intact"), bytes); + if directory_obstruction { + fs::remove_dir(&backup).expect("remove obstruction"); + } else { + fs::remove_file(&backup).expect("remove obstruction"); + } + let fresh = AddressBook::open(Some(base), [1; 4], false, None); + assert!(fresh.state.lock().writable); + fresh.save(); + assert_ne!(fs::read(path).expect("fresh snapshot"), bytes); + assert_eq!(fs::read(backup).expect("backup"), bytes); + } +} + +#[test] +fn corrupt_unknown_and_wrong_network_files_are_not_disposable_snapshots() { + let dir = tempfile::tempdir().expect("directory"); + let base = dir.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + let current = serde_json::to_value(&book().state.lock().stored).expect("schema"); + let mut cases = vec![b"corrupt bytes".to_vec()]; + let mut bad_checksum = fixture_bytes(¤t); + bad_checksum[0] ^= 1; + cases.push(bad_checksum); + let mut wrong_network = current.clone(); + wrong_network["magic"] = serde_json::json!([2, 2, 2, 2]); + cases.push(fixture_bytes(&wrong_network)); + let mut wrong_format = current.clone(); + wrong_format["format"] = "another-format".into(); + cases.push(fixture_bytes(&wrong_format)); + for marker in [serde_json::Value::Null, "".into()] { + let mut malformed_marker = current.clone(); + malformed_marker["format"] = marker; + cases.push(fixture_bytes(&malformed_marker)); + } + let mut wrong_version = current.clone(); + wrong_version["version"] = 2.into(); + cases.push(fixture_bytes(&wrong_version)); + let mut orphan_anchor = current.clone(); + orphan_anchor["anchors"] = serde_json::json!([ + {"addr": "8.8.8.8:8333", "confirmed_at": EPOCH} + ]); + cases.push(fixture_bytes(&orphan_anchor)); + let mut invalid_record = current; + invalid_record["records"] = serde_json::json!([{"broken": true}]); + cases.push(fixture_bytes(&invalid_record)); + cases.push(fixture_bytes( + &serde_json::json!({"version": 8, "magic": [2, 2, 2, 2]}), + )); + cases.push(fixture_bytes( + &serde_json::json!({"version": 99, "magic": [1, 1, 1, 1]}), + )); + for bytes in cases { + fs::write(&path, &bytes).expect("rejected input"); + let rejected = AddressBook::open(Some(base.clone()), [1; 4], false, None); + assert!(!rejected.state.lock().writable); + rejected.learn_dns("seed", &[target()], EPOCH); + rejected.save(); + assert_eq!(fs::read(&path).expect("source intact"), bytes); + assert!(!rejected_backup(&path, &bytes).exists()); + } +} + +#[test] +fn invalid_configured_map_preserves_current_and_pre_release_files_until_repaired() { + for stale in [false, true] { + let dir = tempfile::tempdir().expect("directory"); + let base = dir.path().join("peers.dat"); + let path = network_path(&base, [1; 4]); + let map = dir.path().join("map.raw"); + let current = book(); + current.learn_dns("seed", &[target()], EPOCH); + let mut value = serde_json::to_value(¤t.state.lock().stored).expect("schema"); + if stale { + value.as_object_mut().expect("object").remove("format"); + value["version"] = 9.into(); + } + let bytes = fixture_bytes(&value); + fs::write(&path, &bytes).expect("snapshot"); + fs::write(&map, b"invalid map").expect("invalid map"); + let rejected = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); + assert!(!rejected.state.lock().writable); + rejected.save(); + assert_eq!(fs::read(&path).expect("source intact"), bytes); + assert!(!rejected_backup(&path, &bytes).exists()); + fs::write(&map, crate::netgroup::LINKED_ASMAP).expect("valid map"); + let restored = AddressBook::open(Some(base), [1; 4], false, Some(&map)); + assert!(restored.state.lock().writable); + assert_eq!(restored.len(), usize::from(!stale)); + assert_indexes(&restored.state.lock()); + } +} diff --git a/crates/p2p/src/addrman/tests/persistence/canonical-v1.json b/crates/p2p/src/addrman/tests/persistence/canonical-v1.json new file mode 100644 index 000000000..73d078c15 --- /dev/null +++ b/crates/p2p/src/addrman/tests/persistence/canonical-v1.json @@ -0,0 +1,43 @@ +{ + "format": "bitcoin-rs-addrman", + "version": 1, + "magic": [1, 1, 1, 1], + "secret": [7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7], + "records": [ + { + "addr": "8.8.8.8:8333", + "services": 9, + "source": {"ip": "1.1.1.1"}, + "last_seen": 1700000000, + "last_success": 1700000001, + "failures": 0, + "tried": true, + "new_buckets": [] + }, + { + "addr": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion:8333", + "services": 9, + "source": {"overlay": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion"}, + "last_seen": 1700000000, + "last_success": 1700000001, + "failures": 0, + "tried": true, + "new_buckets": [] + }, + { + "addr": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p:8333", + "services": 9, + "source": {"internal": [0,0,0,0,0,0,0,0,0,0]}, + "last_seen": 1700000000, + "last_success": 1700000001, + "failures": 0, + "tried": true, + "new_buckets": [] + } + ], + "asmap_id": null, + "anchors": [ + {"addr": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion:8333", "confirmed_at": 1700000002}, + {"addr": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.b32.i2p:8333", "confirmed_at": 1700000002} + ] +} diff --git a/crates/p2p/src/addrman/tests/probe_tests.rs b/crates/p2p/src/addrman/tests/probe_tests.rs index 3306e8ea9..ddd5700fc 100644 --- a/crates/p2p/src/addrman/tests/probe_tests.rs +++ b/crates/p2p/src/addrman/tests/probe_tests.rs @@ -165,49 +165,6 @@ fn connected_collision_good_uses_ip_not_port_but_not_linked_ipv6_group() { assert_eq!(state(&book, target()).last_success, EPOCH - 20_000); } -const V3_ANCHORS: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v3-anchors.dat"); -const V4_ANCHORS: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v4-anchors.dat"); -const V6_REFS: &[u8] = include_bytes!("../../../tests/data/addrman-core-v6-eight-refs.dat"); - -#[test] -fn actual_historical_anchor_and_v6_files_migrate_with_exact_backup() { - for (schema, bytes) in [(3, V3_ANCHORS), (4, V4_ANCHORS), (6, V6_REFS)] { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - fs::write(&path, bytes).expect("historic bytes"); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); - { - let manager = book.state.lock(); - assert!(manager.writable); - assert_eq!(manager.stored.version, VERSION); - assert_eq!(manager.collisions, Vec::::new()); - if schema == 6 { - assert_eq!(refs(&manager, target()), 8); - } else { - assert_eq!(manager.stored.anchors.len(), 2); - assert!( - manager - .stored - .anchors - .iter() - .all(|anchor| anchor.confirmed_at == EPOCH + 6) - ); - } - assert_indexes(&manager); - } - let backup = path.with_extension(format!( - "v{schema}-{}.bak", - Sha256::digest(bytes)[..].to_lower_hex_string() - )); - assert_eq!(fs::read(backup).expect("backup"), bytes); - book.save(); - let restored = AddressBook::open(Some(base), [1; 4], false, None); - assert_eq!(restored.len(), book.len()); - assert_indexes(&restored.state.lock()); - } -} - #[test] fn anchors_consume_durably_and_return_original_metadata_only_before_dispatch() { let directory = tempfile::tempdir().expect("directory"); @@ -570,57 +527,6 @@ fn successful_connected_challenger_can_promote_without_losing_pending_identity() assert_indexes(&book.state.lock()); } -#[test] -fn historical_anchor_migration_obeys_configured_map_and_unknown_shape_barriers() { - for bytes in [V3_ANCHORS, V4_ANCHORS] { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - let map = directory.path().join("map.raw"); - fs::write(&path, bytes).expect("old bytes"); - fs::write(&map, b"invalid map").expect("invalid map"); - let invalid = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(!invalid.state.lock().writable); - assert_eq!( - invalid.take_restart_anchors(EPOCH + 10), - Vec::::new() - ); - assert_eq!(invalid.state.lock().stored.anchors.len(), 2); - invalid.save(); - assert_eq!(fs::read(&path).expect("preserved"), bytes); - assert_eq!( - fs::read_dir(directory.path()).expect("directory").count(), - 2, - "invalid map creates no migration backup" - ); - fs::write( - &map, - include_bytes!("../../../tests/data/asmap-linked-ipv4-core-v31.1.raw"), - ) - .expect("restore map"); - let restored = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(restored.state.lock().writable); - assert_eq!(restored.state.lock().stored.anchors.len(), 2); - assert_indexes(&restored.state.lock()); - restored.save(); - let prefix = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert!(prefix.state.lock().writable); - assert_eq!(prefix.state.lock().stored.anchors.len(), 2); - assert_indexes(&prefix.state.lock()); - prefix.save(); - let current = fs::read(&path).expect("current"); - let mut value: serde_json::Value = - serde_json::from_slice(¤t[..current.len() - 32]).expect("JSON"); - value["anchors"] = serde_json::json!([{"addr":"9.9.9.9:8333","confirmed_at":EPOCH}]); - let malformed = fixture_bytes(&value); - fs::write(&path, &malformed).expect("orphan anchor"); - let rejected = AddressBook::open(Some(base), [1; 4], false, None); - assert!(!rejected.state.lock().writable); - rejected.save(); - assert_eq!(fs::read(path).expect("rejected preserved"), malformed); - } -} - #[test] fn anchor_reservation_precedes_publication_and_failed_commit_rolls_it_back() { let directory = tempfile::tempdir().expect("directory"); diff --git a/crates/p2p/src/addrman/tests/services_tests.rs b/crates/p2p/src/addrman/tests/services_tests.rs index 881c2846d..36539fac6 100644 --- a/crates/p2p/src/addrman/tests/services_tests.rs +++ b/crates/p2p/src/addrman/tests/services_tests.rs @@ -70,7 +70,7 @@ fn unknown_dns_zero_version_is_durable_metadata_without_good() { book.learn_dns("another-seed", &[target()], EPOCH + 7200); assert_eq!(book.state.lock().stored.records[0].services, Some(0)); book.save(); - assert_eq!(stored_value(&path)["version"], VERSION); + assert_eq!(stored_value(&path)["version"], SCHEMA_VERSION); assert_eq!(stored_value(&path)["records"][0]["services"], 0); let restored = AddressBook::open(Some(base), [1; 4], false, None); assert_eq!(restored.state.lock().stored.records[0].services, Some(0)); @@ -188,228 +188,6 @@ fn assert_preserved_invalid(base: &Path, path: &Path, bytes: &[u8]) { assert_eq!(fs::read(path).expect("untouched"), bytes); } -const V1_SERVICES: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v1-services.dat"); -const V7_SERVICES: &[u8] = include_bytes!("../../../tests/data/addrman-historical-v7-services.dat"); -const V7_SERVICES_ASMAP: &[u8] = - include_bytes!("../../../tests/data/addrman-historical-v7-services-asmap.dat"); -const SERVICE_MAP: &[u8] = include_bytes!("../../../tests/data/asmap-linked-ipv4-core-v31.1.raw"); - -#[test] -fn every_historical_schema_requires_numeric_services_before_conversion() { - let fixtures: [&[u8]; 7] = [ - V1_SERVICES, - include_bytes!("../../../tests/data/addrman-historical-v2.dat"), - include_bytes!("../../../tests/data/addrman-historical-v3.dat"), - include_bytes!("../../../tests/data/addrman-historical-v4-anchors.dat"), - include_bytes!("../../../tests/data/addrman-core-v5-eight-refs.dat"), - include_bytes!("../../../tests/data/addrman-core-v6-eight-refs.dat"), - V7_SERVICES, - ]; - for bytes in fixtures { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - let old: serde_json::Value = - serde_json::from_slice(&bytes[..bytes.len() - 32]).expect("genuine old writer"); - fs::write(&path, bytes).expect("old bytes"); - let book = AddressBook::open(Some(base.clone()), [1; 4], false, None); - assert!(book.state.lock().writable, "schema {}", old["version"]); - assert_eq!( - book.len(), - old["records"].as_array().expect("records").len() - ); - let schema = old["version"].as_u64().expect("version"); - let backup = path.with_extension(format!( - "v{schema}-{}.bak", - Sha256::digest(bytes)[..].to_lower_hex_string() - )); - assert_eq!(fs::read(backup).expect("exact backup"), bytes); - for invalid in [ - serde_json::Value::Null, - serde_json::json!("0"), - serde_json::json!(false), - serde_json::json!(-1), - serde_json::json!(0.5), - ] { - let mut changed = old.clone(); - changed["records"][0]["services"] = invalid; - assert_preserved_invalid(&base, &path, &fixture_bytes(&changed)); - } - let mut missing = old.clone(); - missing["records"][0] - .as_object_mut() - .expect("record") - .remove("services"); - assert_preserved_invalid(&base, &path, &fixture_bytes(&missing)); - let mut overflow = old.clone(); - overflow["records"][0]["services"] = serde_json::json!("OUT_OF_RANGE"); - let payload = serde_json::to_string(&overflow) - .expect("JSON") - .replace("\"OUT_OF_RANGE\"", "18446744073709551616"); - let mut overflow = payload.into_bytes(); - let checksum = Sha256::digest(&overflow); - overflow.extend_from_slice(&checksum); - assert_preserved_invalid(&base, &path, &overflow); - let mut impossible = old.clone(); - impossible["records"][0]["tried"] = true.into(); - impossible["records"][0]["last_success"] = 0.into(); - if schema >= 5 { - impossible["records"][0]["new_buckets"] = serde_json::json!([]); - } - assert_preserved_invalid(&base, &path, &fixture_bytes(&impossible)); - } -} - -#[test] -fn actual_v7_writer_migrates_ambiguous_zero_honestly_and_preserves_all_membership() { - for (bytes, configured) in [(V7_SERVICES, false), (V7_SERVICES_ASMAP, true)] { - for scoped in [false, true] { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - let source = if scoped { path.clone() } else { base.clone() }; - fs::write(&source, bytes).expect("actual v7 writer"); - let map = directory.path().join("map.raw"); - fs::write(&map, SERVICE_MAP).expect("map"); - let old: serde_json::Value = - serde_json::from_slice(&bytes[..bytes.len() - 32]).expect("old JSON"); - let book = AddressBook::open( - Some(base.clone()), - [1; 4], - false, - configured.then_some(map.as_path()), - ); - { - let manager = book.state.lock(); - assert!(manager.writable); - assert_eq!(manager.stored.records.len(), 7); - let current = serde_json::to_value(&manager.stored).expect("current"); - for field in ["magic", "secret", "asmap_id", "anchors"] { - assert_eq!(current[field], old[field], "{field}"); - } - for entry in old["records"].as_array().expect("records") { - let address: PeerEndpoint = entry["addr"] - .as_str() - .expect("address") - .parse() - .expect("endpoint"); - let record = &manager.stored.records[manager.by_addr[&address]]; - let ambiguous = matches!( - address.to_string().as_str(), - "8.8.8.8:8333" | "8.8.4.4:8333" - ); - assert_eq!( - record.services, - if ambiguous { - None - } else { - Some(entry["services"].as_u64().expect("services")) - } - ); - let value = serde_json::to_value(record).expect("record"); - for field in [ - "source", - "last_seen", - "last_success", - "failures", - "tried", - "new_buckets", - ] { - assert_eq!(value[field], entry[field], "{address} {field}"); - } - assert_eq!(record.last_attempt, 0); - assert_eq!(record.last_count_attempt, 0); - } - assert_eq!( - manager - .stored - .records - .iter() - .map(|entry| entry.new_buckets.len()) - .max(), - Some(8) - ); - assert_indexes(&manager); - } - let backup = source.with_extension(format!( - "v7-{}.bak", - Sha256::digest(bytes)[..].to_lower_hex_string() - )); - assert_eq!(fs::read(&backup).expect("backup"), bytes); - assert_eq!(fs::read(&source).expect("source before publish"), bytes); - book.save(); - if !scoped { - assert_eq!(fs::read(&source).expect("legacy source retained"), bytes); - } - let migrated = stored_value(&path); - assert_eq!(migrated["version"], VERSION); - let restored = AddressBook::open( - Some(base), - [1; 4], - false, - configured.then_some(map.as_path()), - ); - assert_eq!( - serde_json::to_value(&restored.state.lock().stored).expect("second reopen"), - migrated - ); - restored.set_services(target(), 0); - restored.save(); - assert!( - !restored.ordinary_services_eligible(target(), u64::MAX), - "the next actual observation resolves historical ambiguity without Good" - ); - } - } -} - -#[test] -fn v7_migration_failure_and_invalid_map_preserve_original_observation_bytes() { - let directory = tempfile::tempdir().expect("directory"); - let base = directory.path().join("peers.dat"); - let path = network_path(&base, [1; 4]); - let map = directory.path().join("map.raw"); - fs::write(&path, V7_SERVICES_ASMAP).expect("v7"); - fs::write(&map, b"invalid map").expect("invalid map"); - let readonly = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(!readonly.state.lock().writable); - readonly.set_services(target(), 0); - readonly.save(); - assert_eq!(fs::read(&path).expect("preserved"), V7_SERVICES_ASMAP); - assert_eq!( - fs::read_dir(directory.path()).expect("directory").count(), - 2, - "invalid map produces no backup or publication" - ); - fs::write(&map, SERVICE_MAP).expect("restored map"); - let backup = path.with_extension(format!( - "v7-{}.bak", - Sha256::digest(V7_SERVICES_ASMAP)[..].to_lower_hex_string() - )); - fs::create_dir(&backup).expect("backup obstruction"); - let blocked = AddressBook::open(Some(base.clone()), [1; 4], false, Some(&map)); - assert!(!blocked.state.lock().writable); - blocked.set_services(target(), 0); - blocked.save(); - assert_eq!(fs::read(&path).expect("still preserved"), V7_SERVICES_ASMAP); - fs::remove_dir(backup).expect("remove obstruction"); - let restored = AddressBook::open(Some(base), [1; 4], false, Some(&map)); - assert!(restored.state.lock().writable); - assert_eq!( - restored - .state - .lock() - .stored - .records - .iter() - .map(|entry| entry.new_buckets.len()) - .max(), - Some(8) - ); - restored.save(); - assert_eq!(stored_value(&path)["version"], VERSION); -} - #[test] fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { let directory = tempfile::tempdir().expect("directory"); diff --git a/crates/p2p/tests/data/addrman-core-v5-eight-refs.dat b/crates/p2p/tests/data/addrman-core-v5-eight-refs.dat deleted file mode 100644 index ad162e229..000000000 --- a/crates/p2p/tests/data/addrman-core-v5-eight-refs.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":5,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":9,"source":{"ip":"1.1.1.1"},"last_seen":1699992800,"last_success":0,"failures":1,"tried":false,"new_buckets":[634,268,292,948,782,987,905,711]}]}I¡¦§˜�0ºR@?/sƒwC¹3Z�b v±t™KÇ \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-core-v6-eight-refs.dat b/crates/p2p/tests/data/addrman-core-v6-eight-refs.dat deleted file mode 100644 index 1d7967c8d..000000000 --- a/crates/p2p/tests/data/addrman-core-v6-eight-refs.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":6,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":9,"source":{"ip":"1.1.1.1"},"last_seen":1699992800,"last_success":0,"failures":1,"tried":false,"new_buckets":[634,268,292,948,782,987,905,711]}],"asmap_id":null}G`jêÅn)¯uÇç°»[�= ðçh3Ë{�6% \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v1-services.dat b/crates/p2p/tests/data/addrman-historical-v1-services.dat deleted file mode 100644 index 245e4d6f36ddfc8068749e5b601b5f31034eb681..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 863 zcmcJN!AiqG6h-|BK{rC*dI*_G;!N`c?gZCT$}o8?18GxcCS4f1@n6KX;6nTkS9K*K z_!+*Yp;)jeNOD&hF7KXqXBt?d^R}8p&Kb<~)EdaIIcqgy;L$h?bR^loM57#<$`zhw zHK2>aK@KWvm8&F4U=!w+)*xA8`l)JXa6ENYy&yOxgiK@bqF|H&@ME5>#GfgZPO;&mbL4mgR@vbKGCJe72BgtqqJXI@hk0a zF|z)I9bEmLdKk6(12wgp%5*5tCFc~fS1*XD72Q&=9dBuQ+%GOYopy`EPhwXxSr3*x jT75jNZ$IP9M|?Scet16{9NjfHr*E%UC(+=Z6&LF-^9loL diff --git a/crates/p2p/tests/data/addrman-historical-v2-asmap.dat b/crates/p2p/tests/data/addrman-historical-v2-asmap.dat deleted file mode 100644 index 386607492..000000000 --- a/crates/p2p/tests/data/addrman-historical-v2-asmap.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":2,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":9,"source_group":281474976710913,"source_ip":"1.1.1.1","last_seen":1700000001,"last_attempt":0,"last_success":1700000001,"failures":0,"tried":true},{"addr":"9.9.9.9:8333","services":0,"source_group":14378457816419903907,"source_ip":null,"last_seen":1700000000,"last_attempt":1700000003,"last_success":0,"failures":3,"tried":false}],"asmap_id":[212,141,159,223,3,56,73,101,20,61,80,116,117,133,192,182,141,64,219,94,140,226,40,192,210,125,23,71,123,185,249,145]}Q˜·ªíŽVзv«�¥¤:ã¾U˜xà�˜wÀ E \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v2.dat b/crates/p2p/tests/data/addrman-historical-v2.dat deleted file mode 100644 index 08a70e2a6..000000000 --- a/crates/p2p/tests/data/addrman-historical-v2.dat +++ /dev/null @@ -1,2 +0,0 @@ -{"version":2,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":9,"source_group":281474976710913,"source_ip":"1.1.1.1","last_seen":1700000001,"last_attempt":0,"last_success":1700000001,"failures":0,"tried":true},{"addr":"9.9.9.9:8333","services":0,"source_group":14378457816419903907,"source_ip":null,"last_seen":1700000000,"last_attempt":1700000003,"last_success":0,"failures":3,"tried":false}],"asmap_id":null}?í‡öòÔ{9 -4(ï6ˆÕ¸"²ôò�o´WMµ–› \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v3-anchors.dat b/crates/p2p/tests/data/addrman-historical-v3-anchors.dat deleted file mode 100644 index b97944c74..000000000 --- a/crates/p2p/tests/data/addrman-historical-v3-anchors.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"[2002:808:808::1]:8333","services":9,"source_group":562950490423553,"source_ip":"2002:101:101::1","last_seen":1700000001,"last_attempt":0,"last_success":1700000001,"failures":0,"tried":true},{"addr":"8.8.4.4:8333","services":9,"source_group":281474976711170,"source_ip":"2.2.2.2","last_seen":1700000002,"last_attempt":0,"last_success":1700000002,"failures":0,"tried":true},{"addr":"9.9.9.9:8333","services":0,"source_group":11187734361404599818,"source_ip":null,"last_seen":1700000000,"last_attempt":1700000005,"last_success":0,"failures":3,"tried":false}],"asmap_id":[212,141,159,223,3,56,73,101,20,61,80,116,117,133,192,182,141,64,219,94,140,226,40,192,210,125,23,71,123,185,249,145],"anchors":[{"addr":"8.8.4.4:8333","confirmed_at":1700000006},{"addr":"[2002:808:808::1]:8333","confirmed_at":1700000006}]}à‹öo(6Õ{É4ÍwÉxkøW3òbn2QдÜsw¥¸ \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v3-asmap.dat b/crates/p2p/tests/data/addrman-historical-v3-asmap.dat deleted file mode 100644 index 15bbe03d206f3222db620ffd5a4a4b5aac2fc898..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 644 zcmbu6u};G<5QbTK4YQsqpY6nPRz?Iv*Q%2S+BFO$t6XXm7FMpdYv`%XppC!O z)UivAO6ov)IzV5cFlg z=TR|9X_1jY0jTKbdIp4$Ai?uNOF)hwIQR$w2SlG<4JfH#s3F)i`3eLYFp4ZVwfjJ+ z5eTF#JRn0&-jpz)lQJaq#_PkE*ZZTdtMk)?y^F`&r{ePL_;Y`F^Dw-7zkd5TdHx2V CWwzV^ diff --git a/crates/p2p/tests/data/addrman-historical-v3.dat b/crates/p2p/tests/data/addrman-historical-v3.dat deleted file mode 100644 index 2be58b1ad..000000000 --- a/crates/p2p/tests/data/addrman-historical-v3.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":9,"source_group":281474976710913,"source_ip":"1.1.1.1","last_seen":1700000001,"last_attempt":0,"last_success":1700000001,"failures":0,"tried":true},{"addr":"9.9.9.9:8333","services":0,"source_group":14378457816419903907,"source_ip":null,"last_seen":1700000000,"last_attempt":1700000003,"last_success":0,"failures":3,"tried":false}],"asmap_id":null}˜‹-IKÊ|­0~ß¹ËHNÚniÞ‹p—>™ëa ½ÿ \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v4-anchors.dat b/crates/p2p/tests/data/addrman-historical-v4-anchors.dat deleted file mode 100644 index 1132c7f94..000000000 --- a/crates/p2p/tests/data/addrman-historical-v4-anchors.dat +++ /dev/null @@ -1,2 +0,0 @@ -{"version":4,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"[2002:808:808::1]:8333","services":9,"source_group":562950490423553,"source_ip":"2002:101:101::1","last_seen":1700000001,"last_attempt":0,"last_success":1700000001,"failures":0,"tried":true},{"addr":"8.8.4.4:8333","services":9,"source_group":281474976711170,"source_ip":"2.2.2.2","last_seen":1700000002,"last_attempt":0,"last_success":1700000002,"failures":0,"tried":true},{"addr":"9.9.9.9:8333","services":0,"source_group":11187734361404599818,"source_ip":null,"last_seen":1700000000,"last_attempt":1700000005,"last_success":0,"failures":3,"tried":false}],"asmap_id":[212,141,159,223,3,56,73,101,20,61,80,116,117,133,192,182,141,64,219,94,140,226,40,192,210,125,23,71,123,185,249,145],"anchors":[{"addr":"8.8.4.4:8333","confirmed_at":1700000006},{"addr":"[2002:808:808::1]:8333","confirmed_at":1700000006}]}BkÅ·vö’yÂ> -“F‚ÌvAC2ÒM‡û\ŽcR™fL \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v7-services-asmap.dat b/crates/p2p/tests/data/addrman-historical-v7-services-asmap.dat deleted file mode 100644 index 5115db50b..000000000 --- a/crates/p2p/tests/data/addrman-historical-v7-services-asmap.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":7,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":0,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[739]},{"addr":"8.8.4.4:8333","services":0,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[51]},{"addr":"1.1.1.1:8333","services":0,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":1700000002,"failures":0,"tried":true,"new_buckets":[]},{"addr":"1.0.0.1:8333","services":9,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[516]},{"addr":"9.9.9.9:8333","services":0,"source":{"ip":"2.2.2.2"},"last_seen":1699992800,"last_success":0,"failures":0,"tried":false,"new_buckets":[244]},{"addr":"9.9.9.10:8333","services":0,"source":{"ip":"2.2.2.2"},"last_seen":1699992800,"last_success":1700000001,"failures":0,"tried":true,"new_buckets":[]},{"addr":"11.12.13.14:8333","services":9,"source":{"internal":[146,225,23,73,54,236,73,128,134,179]},"last_seen":1700000000,"last_success":0,"failures":1,"tried":false,"new_buckets":[816,41,43,101,299,382,37,44]}],"asmap_id":[212,141,159,223,3,56,73,101,20,61,80,116,117,133,192,182,141,64,219,94,140,226,40,192,210,125,23,71,123,185,249,145],"anchors":[{"addr":"1.1.1.1:8333","confirmed_at":1700000010},{"addr":"9.9.9.10:8333","confirmed_at":1700000010}]}– ­Ï|¸…Z¼S,ö«÷qv[ë–³BÍáÏÕ"§®p \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v7-services.dat b/crates/p2p/tests/data/addrman-historical-v7-services.dat deleted file mode 100644 index e5f87e24f..000000000 --- a/crates/p2p/tests/data/addrman-historical-v7-services.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":7,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":0,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[686]},{"addr":"8.8.4.4:8333","services":0,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[686]},{"addr":"1.1.1.1:8333","services":0,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":1700000002,"failures":0,"tried":true,"new_buckets":[]},{"addr":"1.0.0.1:8333","services":9,"source":{"internal":[234,171,207,27,108,195,82,146,144,234]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[965]},{"addr":"9.9.9.9:8333","services":0,"source":{"ip":"2.2.2.2"},"last_seen":1699992800,"last_success":0,"failures":0,"tried":false,"new_buckets":[151]},{"addr":"9.9.9.10:8333","services":0,"source":{"ip":"2.2.2.2"},"last_seen":1699992800,"last_success":1700000001,"failures":0,"tried":true,"new_buckets":[]},{"addr":"11.12.13.14:8333","services":9,"source":{"internal":[146,225,23,73,54,236,73,128,134,179]},"last_seen":1700000000,"last_success":0,"failures":1,"tried":false,"new_buckets":[738,754,161,65,299,828,793,41]}],"asmap_id":null,"anchors":[{"addr":"1.1.1.1:8333","confirmed_at":1700000010},{"addr":"9.9.9.10:8333","confirmed_at":1700000010}]}”•‘ÂSñ6+*Y÷°o–Yèñ‚3†ÀK� \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical-v8-endpoints.dat b/crates/p2p/tests/data/addrman-historical-v8-endpoints.dat deleted file mode 100644 index a5a683612..000000000 --- a/crates/p2p/tests/data/addrman-historical-v8-endpoints.dat +++ /dev/null @@ -1 +0,0 @@ -{"version":8,"magic":[1,1,1,1],"secret":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7],"records":[{"addr":"8.8.8.8:8333","services":null,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[145]},{"addr":"8.8.4.4:8333","services":0,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":0,"failures":0,"tried":false,"new_buckets":[145]},{"addr":"9.9.9.9:8333","services":9,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":1700000001,"failures":0,"tried":true,"new_buckets":[]},{"addr":"[2001:4860:4860::8888]:8333","services":9,"source":{"internal":[163,169,165,37,52,139,138,199,131,244]},"last_seen":1700000000,"last_success":1700000001,"failures":0,"tried":true,"new_buckets":[]},{"addr":"11.12.13.14:8333","services":9,"source":{"ip":"1.1.1.1"},"last_seen":1699992800,"last_success":0,"failures":1,"tried":false,"new_buckets":[955,369,341,601,401,948,8,797]}],"asmap_id":null,"anchors":[{"addr":"9.9.9.9:8333","confirmed_at":1700000002},{"addr":"[2001:4860:4860::8888]:8333","confirmed_at":1700000002}]}*u©Ž‰Ú}°¤%seà•sK—ýÓ™(j­Ê^G-艴 \ No newline at end of file diff --git a/crates/p2p/tests/data/addrman-historical.README.md b/crates/p2p/tests/data/addrman-historical.README.md deleted file mode 100644 index 8049d7908..000000000 --- a/crates/p2p/tests/data/addrman-historical.README.md +++ /dev/null @@ -1,96 +0,0 @@ -# Address-book migration bytes - -These are genuine files emitted through the original immutable AddressBook -writers in isolated scratch executables. They are synthetic regression fixtures, -not retained operator production data. The original addrman.rs/netgroup.rs files -were compiled unchanged; only a module-local fixture entry point was appended. -No current Stored value was serialized with a historical version number. - -| File | Original writer commit | Bytes | SHA-256 | -| --- | --- | ---: | --- | -| addrman-historical-v2.dat | cc6172301 | 531 | fb8061e7da32e22aa77f6f1413d9e240b96bbd529ca63c771a3fef0acc757c33 | -| addrman-historical-v3.dat | 5444581efdfcdd9bc9478a4b82ded39e452474d8 | 531 | 32a715d577da62853826e3c2cf22d8a81e58e9109111ce6ce0403bb03917cad2 | -| addrman-core-v5-eight-refs.dat | 73c6f7aec0b5bb3f5ecabffeda63a51b01a5cbfe | 328 | c588a6f8fb59cc145bdfbb9f33b47d5e28d875d6fa6daf9eeb57ea4ba9c92b1a | -| addrman-historical-v2-asmap.dat | cc6172301 | 644 | 1372f5477f9458ff9b0cd1a8d8010c20bc7616492d846eb7bbf61c5291ae6b04 | -| addrman-historical-v3-asmap.dat | 5444581efdfcdd9bc9478a4b82ded39e452474d8 | 644 | 05d821055c40415a937f09b777d4496aa33e1e4ff4e0826b72443e7733c2ead8 | - -All use magic `[1;4]` and secret `[7;32]`. For v2/v3 the original writer learns -8.8.8.8:8333 from 1.1.1.1 at 1700000000 and records Good at 1700000001, then learns -9.9.9.9:8333 from DNS `historical.seed` and records three attempts. The `-asmap` -variants repeat those calls with the unchanged linked-IPv4 map configured, -retaining its original asmap_id in the historical file. - -The v5 writer uses StdRng seed 17, learns 8.8.8.8:8333 from IP sources 1.n.1.1 -at 1700000000 (the normal gossip time penalty applies), retries reports until -eight distinct references are admitted, records one counted attempt, and saves. -Its ordered New references are 634, 268, 292, 948, 782, 987, 905, 711. The v6 no-map -migration must preserve those values exactly, along with source/health/secret. - -The trailing 32 bytes are the original writer's SHA256 checksum. Migration tests -read the files unchanged and compare preserved backup bytes, health, runtime -attempt reset and the resulting reference/index invariants. - -The anchor integration adds genuine original-writer files: - -| File | Original writer commit | Bytes | SHA-256 | -| --- | --- | ---: | --- | -| addrman-historical-v3-anchors.dat | 20e9064e5a41b64be44720bf1d816d8534d2d280 | 966 | 46c8b0e3de798d2e423224aa13b68a6841d4b15d23744e5ff1d59861f9530759 | -| addrman-historical-v4-anchors.dat | fba4ddef612cca0b7764d07646f3301ea1897674 | 966 | 9ad97094335dd17c0d4e6d2293a12c74b8c2e28667a11c29d524bbb1983b4f81 | -| addrman-core-v6-eight-refs.dat | 0597de4da9df6d20da0acf4b2c2a4ebc99a7ca3a | 344 | f0293ecbe561dadfb3780ec63fed9608ea5de13cc11565188ebe4d1182aefbce | - -The original v3/v4 writers learn and confirm `[2002:808:808::1]:8333` -from `2002:101:101::1` and `8.8.4.4:8333` from `2.2.2.2`, retain the linked -IPv4 ASMap identity, and remember both anchors at 1700000006. They also retain -three failed DNS attempts for `9.9.9.9:8333`. The v6 producer repeats the v5 -reference sequence above. All files were saved and reopened by their original -writer; their schema numbers were not relabelled. Compiled with Rust1.99.0; -source addrman blob IDs are respectively `570d5d44af8089891f0a6fdceb9ac26f2e2f3b65`, -`676273fd73a7c5daf0fef09328f44e7e7f6e89d8`, and -`883694c2c3db06273602558959efdac83e6c5d43`. - -Service observation migration adds these original-writer files: - -| File | Original writer commit | Bytes | SHA-256 | -| --- | --- | ---: | --- | -| addrman-historical-v1-services.dat | 2ae5b5d39eb3c4dceed8c9fd871ff0d6c8d2d3c6 | 863 | 77403eee0c4014c84a43fbb33542c87a235b961153a8f31d83ce13384fe5b1d9 | -| addrman-historical-v7-services.dat | d657ab17883e850452eac803ea6a9bf266f40439 | 1548 | 2bd0955ad87bf1e0b4b82455bfcce629e9b71ce815b194328f52183c2774c90f | -| addrman-historical-v7-services-asmap.dat | d657ab17883e850452eac803ea6a9bf266f40439 | 1658 | ef15044ef11854da5521015d9b7aade5f86209ab613ae3ea28d76abfd6f90102 | - -The v7 writer uses the actual d657 AddrMan module (blob -`d944f8c6f1ceef59afa9258566973618cba7f354`), secret `[7;32]`, RNG seed17 and time -1700000000. It saves and reopens seven records: never-observed DNS zero -`8.8.8.8:8333`, an actual `set_services(0)` DNS row without Good -`8.8.4.4:8333`, IP zero `9.9.9.9:8333`, IP Good zero `9.9.9.10:8333`, DNS Good -zero `1.1.1.1:8333`, known DNS9 `1.0.0.1:8333`, and an eight-reference New -record `11.12.13.14:8333` with one failure. Both Good-zero records are anchors -confirmed at 1700000010. Prefix and ASMap variants use the same calls; the latter -uses the retained linked-IPv4 map. The v1 writer is original blob -`5363b03540d83783f6a1c9bb24de8f9b6bcfa301` and emits four DNS/IP zero records, -with and without Good. Neither fixture was made by relabelling a current schema. - -Original writer modules were compiled unchanged with a module-local fixture -entry point appended, using Rust1.99.0. For v7 linkage only, its exact original -pure desirable-service predicate and constant were copied from listener.rs; -no writer or service-observation algorithm was stubbed. Producer executable -SHA-256 values are `b91d1cfd3add210e4cf2e20307894088e99206916d1bda9f1e525c65bb36860e` -(v7) and `075352b85065fa70bbe89bb50c7509631c6e1d8afe9e6d025be8ff3e8a99a8a7` (v1). -The two ambiguous old DNS rows deliberately migrate to unknown: their bytes -cannot recover whether zero was observed. Schema8 tests then observe known zero -without Good and verify that future DNS refresh and restart retain it. - -Overlay endpoint migration adds an original v8 writer file: - -| File | Original writer commit | Bytes | SHA-256 | -| --- | --- | ---: | --- | -| addrman-historical-v8-endpoints.dat | 653c150836ce3d7de684609c9bc72b3d432d24de | 1245 | 434f6b21032e4da8d3d6b28856fdfcd5d7e5e2a75fffa7cb0e533cca63aa3c17 | - -The immutable AddrMan module (blob `7e76785854e78d4eeea7a1a8269e61364cf8170a`) and its original -netgroup module were compiled unchanged in a scratch producer; only a local -fixture entry point was appended. The exact original desirable-services -predicate supplied its listener linkage. The producer uses secret `[7;32]`, -StdRng seed17, magic `[1;4]` and time1700000000. It retains unknown DNS -8.8.8.8, observed-zero DNS8.8.4.4, Good IPv4 9.9.9.9 and Good IPv6 -2001:4860:4860::8888, with both Good records as anchors. A New record for -11.12.13.14 has eight references and one attempt. No schema was relabelled. -The v9 test compares every persisted field except the version and validates -exact backup bytes before publication and preservation when backup fails. diff --git a/docs/policies/db-migration.md b/docs/policies/db-migration.md index 248812205..3e4ad78f9 100644 --- a/docs/policies/db-migration.md +++ b/docs/policies/db-migration.md @@ -30,7 +30,7 @@ The durable head is the only authoritative commit point. A writer appends body a Estimator, discovery, and index formats evolve outside `CURRENT_SCHEMA`. Each carries its own version: - Fee estimator state carries an estimator-owned version. A corrupt, missing, or unknown version degrades to insufficient-data status. The node starts. It never fabricates a rate or a default confidence. -- Peer discovery state carries a discovery-owned version. A corrupt, missing, or unknown version degrades to seeded or empty discovery with a typed reseed or rebuild status. The node starts. +- Peer discovery state uses the single canonical AddrMan v1 defined in [P2P compatibility section 7](p2p-compatibility.md#7-deviation-ledger). Its schema version remains 1 until the first official release. Unmarked development snapshots are preserved byte-for-byte before fresh discovery is published; no historical records are migrated. Corrupt or unreadable files disable replacement while in-memory discovery and node startup continue. - Mempool restart snapshots carry a mempool-owned version, genesis identity and checksum. Rejected candidates never prevent Chainstate startup. On clean save, a rejected ordinary snapshot is moved intact into a unique `mempool-snapshot-rejected-*` directory before publishing fresh candidates. Failure to read or quarantine it prevents replacement. The gateway revalidates candidates against recovered Chainstate; fee-estimator history remains independent. - Index layout carries one durable marker in `UtxoMeta`. The durability marker (`[0x00, b'V']`, currently row-format 5) is the hard gate: a mismatch refuses open and recovery full-resets for rebuild. The marker covers key layout and row-value widths alike; format 5 changed both. @@ -39,7 +39,7 @@ In every case: - Authoritative startup never fails because of an owner-local version. - Owner-local evolution keeps `CURRENT_SCHEMA` unchanged. - No translator, converter, or legacy reader exists for any owner-local format. -- A rejected owner-local file stays in place until an explicit authorized rebuild. The node never silently deletes it. Exceptions: mempool restart snapshots are quarantined intact as described above; disposable derived index namespaces recovery full-resets for rebuild from retained canonical chainstate on a version mismatch (operator cost of an index format bump: one unattended derived re-index on first start, no authoritative data touched). +- A rejected owner-local file stays in place until an explicit authorized rebuild. The node never silently deletes it. Exceptions: incompatible pre-release AddrMan files are durably preserved before replacement as described above; mempool restart snapshots are quarantined intact as described above; disposable derived index namespaces recovery full-resets for rebuild from retained canonical chainstate on a version mismatch (operator cost of an index format bump: one unattended derived re-index on first start, no authoritative data touched). - No backup or rotation framework exists. Recovery is reseed, rebuild, or re-admission from canonical data. ## Operator resync procedure diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 78d2303e0..a6ccaf668 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -372,7 +372,7 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at The fixed indexes occupy 327,680 bytes; endpoint count is bounded at 81,920, with at most eight New bucket IDs each and at most 65,536 occupied New slots. - On the tested 64-bit target Candidate is 144 bytes and Source is 24 bytes, + On the tested 64-bit target Candidate is 184 bytes and Source is 40 bytes, excluding allocator/index/vector overhead. Fixed-width serialized fields have a conservative 512-byte/record bound (315-byte max-width fixture), so record separators plus bounded header/checksum remain below the 64 MiB file @@ -386,56 +386,65 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at Core ASN groups serialize as NET_IPV6 followed by little-endian ASN bytes, grouping IPv4/IPv6 together. ASN0 uses the corrected prefix/HE fallback; public linked-IPv4 forms use their embedded IPv4 without rewriting the dial - endpoint. Internal and legacy DNS sources retain their source namespaces. + endpoint. Internal DNS sources retain their source namespace. Startup logs the raw-file SHA256 identity. Invalid or unavailable configured maps permit memory-only prefix fallback but disable all book publication - and migration backup creation; restoring the map recovers the untouched - book. Intentionally omitting the map permits backed-up prefix migration. - - Schema v9 persists the secret, original source, health, map identity, - New membership and at most two anchors in one checksummed snapshot. Services - is a required field: JSON null means unknown DNS metadata, while numeric zero - means a known zero-bit claim. Null is valid only for an Internal/legacy-DNS - original source with no prior Good. Missing or invalid fields fail closed. - Same-map restart restores every reference; same-classifier v5/v6/v7/v8-to-v9 - upgrades preserve all references and anchors exactly. Known v1–v8 formats, - including genuine v3 files carrying anchors, are strict migration sources. - In v1–v7, service fields must be numeric u64; old null or missing fields remain - invalid. V8 retains its explicit nullable DNS-service representation. - Historical DNS zero/no-Good records cannot reveal whether zero was - actually observed. Decoding infers only that ambiguous case as unknown in - memory, where it may retry; its next accepted VERSION records the observation - precisely. Exact-byte backup gates publication of the upgraded file, not - in-memory discovery. If backup fails, the original file remains untouched and - the in-memory book runs with persistence disabled. IP zero and every - prior-Good zero stay known. No source, success or failure value is rewritten - to manufacture evidence. - The v1 reader retains its original 4,096-record/64-source/slot checks; - historical ASMap layouts validate source/record shape before re-bucketing. - The actual source bytes are copied to an exclusive content-named - `.v-.bak` and file/directory-synced before replacement. - Schema-only upgrades do not discard corroboration. Changed classifiers or - old custom placement use deterministic original-source re-bucketing, - prioritizing proven/recent successes and logging retained/demoted/dropped - counts. Historic DNS u64 hashes retain a labelled legacy namespace; no - recovered seed name or Core hash is invented. Operational attempt/Good - times reset; advertised time, success and failures remain intact. A backup - failure allows memory discovery but disables writes. Unknown versions or - fields remain read-only. Retained anchors keep their original confirmation - time and remain a unique successful-record subset; regrouping prunes anchors - only when their endpoint is actually removed. - - Actual Core group/placement evidence includes 224 ASMap rows and official - Tried236/New795 anchors. Migration fixtures were emitted by the original - v1/v2/v3/v4/v5/v6/v7/v8 writers; v5/v6/v7 fixtures pin all eight retained references. - The v8 endpoint fixture pins exact IP membership and anchor preservation. These - are specific algorithm/persistence checks, not full peer-lifecycle parity. + and incompatible-file preservation; restoring the map recovers the untouched + book. Intentionally changing or omitting a map rebuckets the current format + through the existing placement owner, prioritizing proven/recent successes + and logging retained/demoted/dropped counts. Anchors are pruned only when + their endpoint is removed. Same-map restart retains every reference. + + AddrMan reads and writes one canonical checksummed JSON snapshot, identified + by `format: "bitcoin-rs-addrman"` and `version: 1`. `SCHEMA_VERSION` MUST remain + `1` until the first official bitcoin-rs release. Before that release, edit + this definition directly; do not add compatibility readers or increment the + version. Only after release may a breaking change increment the version with + an explicit compatibility decision; release itself needs no increment. + + The canonical object has `format`, `version`, `magic` (four P2P magic bytes), + `secret` (32 bytes), `records`, `asmap_id` (null or 32 bytes), and `anchors`. + The trailing 32 bytes are SHA256 of the exact UTF-8 JSON payload. Endpoint + strings represent IP sockets, Tor-v3 host:port or I2P host:port; mapped IPv4 + endpoints canonicalize to IPv4. Each record contains `addr`, `services`, + `source`, `last_seen`, `last_success`, `failures`, `tried`, `new_buckets`. + Source is exactly one of `{"ip": }`, `{"overlay": }`, or `{"internal": <10 SHA256 seed-hash bytes>}`. Overlay + sources cannot contain IP or ephemeral connection handles. Each anchor is + `{"addr": , "confirmed_at": }` and must name + a unique successful record, with at most two anchors. Runtime attempt, + count-attempt, global-Good times and creation IDs are not persisted. + + Services is required: JSON null means unknown DNS metadata, while numeric + zero means a known zero-bit claim. Null is valid only for an Internal source + with no prior Good. Unknown fields, invalid membership, unsafe endpoints, + malformed anchors, or excessive sizes fail closed. Tor/I2P endpoint hash + keys use BIP155 tags 4/5 followed by the 32 identity bytes and big-endian + port; netgroups are `[4, key[0] | 15]` / `[5, hash[0] | 15]`. Internal source + groups use tag 6 plus the ten seed-hash bytes. ASMap applies only to IP. + Both overlay branches must use these same identity, source and placement + rules when adopting this canonical definition; divergent development v9 + layouts are not supported inputs for record import. + + Unmarked pre-release v1-v9 files are never decoded or migrated. Only their + bounded JSON envelope, checksum and network identity are checked. Before + replacing one with an empty or freshly seeded canonical book, its exact bytes + are exclusively published and file/directory-synced to + `.pre-release-.bak`. A pre-existing backup is reusable only if it is a + regular file with identical bytes and is flushed successfully. Preservation + failure disables replacement. The original stays at its source path until + atomic save succeeds; a crash before save can retry preservation safely. + No old peers, secrets, health or anchors are imported. Relearning uses the + existing discovery path. This discards development cache knowledge, not + authoritative chainstate, keys or operator configuration. Filenames remain scoped to actual P2P magic: `peers.dat` becomes `peers-<8 hexadecimal magic digits>.dat`, including custom/drynet magics. - A valid scoped book wins; same-magic legacy-base import retains the base, - and a valid foreign-magic base is left untouched. Corrupt/unknown/unreadable - files disable publication while in-memory discovery continues. Publication + A valid scoped book wins. A same-magic unscoped current book can be copied + into the scoped path without altering its source; an unmarked development + book there follows the same preservation-before-fresh-start rule. A valid + foreign-magic base is left untouched. Corrupt/unknown/unreadable files + disable publication while in-memory discovery continues. Publication retains exclusive random temporary creation with bounded collisions, file sync, atomic installation and the storage owner's directory-sync policy. First installation cannot overwrite a newly appeared destination. Saves @@ -443,6 +452,12 @@ TXR-09 is the trickled inventory schedule, `m_next_inv_send_time` at lock; concurrent later changes remain dirty. Periodic saves occur every 15 minutes and shutdown/explicit barriers remain immediate. + Actual Core group/placement evidence includes 224 ASMap rows and official + Tried236/New795 anchors. Current-format tests retain IP/Tor/I2P round trips, + anchors, membership, services, map changes and atomic publication proofs. + These are specific algorithm/persistence checks, not full peer-lifecycle + parity. Historical upgrade fixtures are not a compatibility requirement. + Restart anchors come only from automatic block-relay peers that supplied accepted chain evidence, with the existing seven-day age limit. On the first active-network maintenance tick they @@ -719,12 +734,8 @@ Accepted I2P SAM streams have ephemeral local bind ports, so their clocks are per stream rather than shared by application destination. This remains a deviation from Core's shared inbound clock policy. -Schema v9 adds overlay endpoints and source identities using the existing -backup-before-migration path. Upgrading v8 IP records preserves the secret, -all membership references, services and restart anchors; IP hash bytes and -grouping remain unchanged. -Overlay source tags admit only Tor v3 and I2P identities; IP sources retain -their existing IP tag. Invalid source representations keep the original file +AddrMan endpoint/source persistence follows the canonical v1 definition in +section 7. Invalid current source representations keep the original file untouched with persistence disabled. Application keys have a separate versioned, checksummed create-once format. A bad existing identity fails startup and is diff --git a/docs/validation-tooling.md b/docs/validation-tooling.md index 7df48e5ee..d89bdc743 100644 --- a/docs/validation-tooling.md +++ b/docs/validation-tooling.md @@ -50,7 +50,7 @@ Owners, prerequisites, commands, and scope limitations are listed in each sectio - **Owner scripts & suites**: - Pinned installer: `scripts/install-bitcoind.sh` (POSIX) / `scripts/install-bitcoind.ps1` (Windows). - Test runner: `scripts/run-p2p-core-interop.sh`. - - Evidence validator: `crates/p2p/tests/core_interop_live.rs` (BIP324 vector provenance in [`crates/p2p/tests/data/bip324/README.md`](../crates/p2p/tests/data/bip324/README.md); AddrMan/ASMap reference fixtures in [`crates/p2p/tests/support/README.md`](../crates/p2p/tests/support/README.md), [`crates/p2p/tests/data/asmap-core-v31.1.README.md`](../crates/p2p/tests/data/asmap-core-v31.1.README.md), [`crates/p2p/tests/data/asmap-linked-ipv4-core-v31.1.README.md`](../crates/p2p/tests/data/asmap-linked-ipv4-core-v31.1.README.md), and [`crates/p2p/tests/data/addrman-historical.README.md`](../crates/p2p/tests/data/addrman-historical.README.md)). + - Evidence validator: `crates/p2p/tests/core_interop_live.rs` (BIP324 vector provenance in [`crates/p2p/tests/data/bip324/README.md`](../crates/p2p/tests/data/bip324/README.md); AddrMan/ASMap reference fixtures in [`crates/p2p/tests/support/README.md`](../crates/p2p/tests/support/README.md), [`crates/p2p/tests/data/asmap-core-v31.1.README.md`](../crates/p2p/tests/data/asmap-core-v31.1.README.md), [`crates/p2p/tests/data/asmap-linked-ipv4-core-v31.1.README.md`](../crates/p2p/tests/data/asmap-linked-ipv4-core-v31.1.README.md)). - **What is verified**: - Pinned Bitcoin Core 31.1 binary downloaded from bitcoincore.org and checked against the SHA-256 archive digest pinned in `crates/rpc/core-compat.toml`. From d2ff50b2091bdc19e814de63e85d800c02f974cb Mon Sep 17 00:00:00 2001 From: rabbitprincess Date: Tue, 27 Oct 2026 23:00:39 +0900 Subject: [PATCH 16/19] refactor(p2p): separate anonymous sessions from peer destinations Keep PeerAddress and PeerEndpoint destination-only. Use the lease connection id for anonymous Tor runtime identities and preserve attribution through sync, mempool and RPC without putting connection handles in AddrMan. Sync impact: runtime peer keys and attribution touch delivery and recovery boundaries for SYNC-02/04/06; header/body validation and chainstate commit owners are unchanged. Anonymous admission (2), peer table (33), parent requests (6), transaction policy (47), frontier recovery (12), tick delivery (1), endpoint (2), and RPC network (53) tests passed. P2P/node/RPC/mempool all-target checks and strict library/test Clippy passed. Real process and external overlay convergence remain unrun. --- crates/mempool/src/admission.rs | 5 +- crates/mempool/src/gateway.rs | 5 +- crates/mempool/src/mutation.rs | 4 +- crates/mempool/src/orphan.rs | 5 +- crates/node/benches/sync_pipeline.rs | 2 +- crates/node/src/chain_effects.rs | 6 +- crates/node/tests/lease_registration_215.rs | 8 +- crates/node/tests/tx_ingress_e2e.rs | 31 ++-- .../tests/unit/state/tests/construction.rs | 2 +- crates/node/tests/unit/tx_ingress/tests.rs | 2 +- crates/p2p/src/addrman.rs | 8 - .../src/addrman/tests/overlay_destinations.rs | 11 -- crates/p2p/src/block_announce.rs | 38 ++-- crates/p2p/src/compact_blocks.rs | 5 +- crates/p2p/src/connection.rs | 106 ++++++++++- crates/p2p/src/discouragement.rs | 19 -- crates/p2p/src/dispatch.rs | 5 +- crates/p2p/src/download_window.rs | 12 +- crates/p2p/src/download_window/tests.rs | 105 ++++------- crates/p2p/src/eviction.rs | 16 +- crates/p2p/src/inv.rs | 46 ++++- crates/p2p/src/lib.rs | 4 +- crates/p2p/src/listener.rs | 150 ++++++++++------ .../src/listener/anonymous_inbound_tests.rs | 79 ++++++++ .../p2p/src/listener/getdata_pause_tests.rs | 6 +- .../src/listener/loopback_serving_tests.rs | 8 +- crates/p2p/src/listener/v2_transport_tests.rs | 5 +- crates/p2p/src/net_trace.rs | 14 +- crates/p2p/src/netgroup.rs | 13 +- crates/p2p/src/overlay.rs | 7 +- crates/p2p/src/overlay/tor.rs | 19 +- crates/p2p/src/peer.rs | 4 +- crates/p2p/src/peer_info.rs | 74 +++++--- crates/p2p/src/peer_table.rs | 170 +++++++++++++----- crates/p2p/src/service.rs | 93 ++++++---- crates/p2p/src/sync/receive.rs | 2 +- crates/p2p/src/sync/tests.rs | 77 +++++--- crates/p2p/src/sync/tests/chain_sync.rs | 5 +- crates/p2p/src/sync/tests/download_batches.rs | 25 ++- .../p2p/src/sync/tests/download_frontier.rs | 42 ++++- crates/p2p/src/sync/tests/fork_retargeting.rs | 4 +- crates/p2p/src/sync/tests/frontier_model.rs | 7 +- crates/p2p/src/sync/tests/header_progress.rs | 15 +- crates/p2p/src/sync/tests/header_rotation.rs | 20 ++- crates/p2p/src/sync/tests/headers_presync.rs | 13 +- crates/p2p/src/sync/tests/limited_peers.rs | 4 +- .../p2p/src/sync/tests/peer_qualification.rs | 5 +- crates/p2p/src/sync/tests/pending_retries.rs | 25 ++- .../p2p/src/sync/tests/settlement_faults.rs | 15 +- crates/p2p/src/sync/tests/stale_tip.rs | 8 +- crates/p2p/src/sync/tests/tip_alignment.rs | 5 +- crates/p2p/src/sync/tests/trickle_peers.rs | 10 +- crates/p2p/src/tx_policy.rs | 8 +- crates/p2p/src/tx_policy/preferred_tests.rs | 2 +- crates/p2p/src/tx_policy/relay.rs | 27 +-- crates/p2p/src/tx_relay.rs | 5 +- crates/p2p/tests/core_compat.rs | 10 +- crates/primitives/src/endpoint.rs | 23 +-- crates/primitives/src/endpoint/tests.rs | 7 +- crates/rpc/src/context.rs | 2 +- crates/rpc/src/handlers/mining.rs | 2 +- crates/rpc/src/handlers/network.rs | 33 ++-- docs/contracts/p2p-wire.md | 4 + docs/policies/p2p-compatibility.md | 8 + 64 files changed, 958 insertions(+), 542 deletions(-) create mode 100644 crates/p2p/src/listener/anonymous_inbound_tests.rs diff --git a/crates/mempool/src/admission.rs b/crates/mempool/src/admission.rs index 247d76e20..abd45dbbc 100644 --- a/crates/mempool/src/admission.rs +++ b/crates/mempool/src/admission.rs @@ -891,7 +891,10 @@ mod tests { fn source() -> PeerToken { PeerToken { - addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))), connection_id: 7, } } diff --git a/crates/mempool/src/gateway.rs b/crates/mempool/src/gateway.rs index 755c359e2..1e54f9f68 100644 --- a/crates/mempool/src/gateway.rs +++ b/crates/mempool/src/gateway.rs @@ -3718,7 +3718,10 @@ mod tests { candidate.inputs.push(candidate.inputs[0].clone()); candidate.inputs[0].witness = Witness::from_stack(vec![vec![1]]); let origin = AdmissionOrigin::Peer(crate::PeerToken { - addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))), connection_id: 7, }); let request = admit_request(&gateway, &candidate, origin); diff --git a/crates/mempool/src/mutation.rs b/crates/mempool/src/mutation.rs index 2f6262307..07b0c96e2 100644 --- a/crates/mempool/src/mutation.rs +++ b/crates/mempool/src/mutation.rs @@ -141,8 +141,8 @@ impl MutationResult { /// Identifies the network peer a transaction arrived from. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct PeerToken { - /// The peer's logical Bitcoin endpoint, including anonymous inbound Tor handles. - pub addr: bitcoin_rs_primitives::PeerEndpoint, + /// The peer's destination, absent for anonymous inbound connections. + pub addr: Option, /// The node-local id of the connection the peer arrived on. pub connection_id: u64, } diff --git a/crates/mempool/src/orphan.rs b/crates/mempool/src/orphan.rs index f2252f7eb..26e4f1ba8 100644 --- a/crates/mempool/src/orphan.rs +++ b/crates/mempool/src/orphan.rs @@ -588,7 +588,10 @@ mod tests { }; fn source(id: u64) -> PeerToken { PeerToken { - addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))), connection_id: id, } } diff --git a/crates/node/benches/sync_pipeline.rs b/crates/node/benches/sync_pipeline.rs index 1df47c026..253477cb7 100644 --- a/crates/node/benches/sync_pipeline.rs +++ b/crates/node/benches/sync_pipeline.rs @@ -1230,7 +1230,7 @@ fn install_synthetic_peers( .unwrap_or_else(|error| panic!("invalid synthetic peer port: {error}")); let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let (outbound_tx, outbound_rx) = unbounded::(); - peer_table.register(addr.into(), bitcoin_rs_p2p::PeerLease::new(outbound_tx)); + peer_table.register(addr, bitcoin_rs_p2p::PeerLease::new(outbound_tx)); outbound_rxs.push(outbound_rx); } outbound_rxs diff --git a/crates/node/src/chain_effects.rs b/crates/node/src/chain_effects.rs index 915d8747c..3997e94e7 100644 --- a/crates/node/src/chain_effects.rs +++ b/crates/node/src/chain_effects.rs @@ -784,7 +784,7 @@ mod tests { let available = orphan_child(OutPoint::new(parent, 0)); let missing = orphan_child(OutPoint::new(Hash256::from_le_bytes(&[43; 32]).into(), 0)); let source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into(), + addr: Some(std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into()), connection_id: 7, }; let replacement = PeerToken { @@ -792,7 +792,7 @@ mod tests { ..source }; let missing_source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18445)).into(), + addr: Some(std::net::SocketAddr::from(([127, 0, 0, 1], 18445)).into()), connection_id: 9, }; let chain = AdmissionCoins::default(); @@ -883,7 +883,7 @@ mod tests { let outpoint = OutPoint::new(parent, 0); let child = orphan_child(outpoint); let source = PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into(), + addr: Some(std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into()), connection_id: 7, }; let chain = AdmissionCoins::default(); diff --git a/crates/node/tests/lease_registration_215.rs b/crates/node/tests/lease_registration_215.rs index f76fe0a95..36a41a920 100644 --- a/crates/node/tests/lease_registration_215.rs +++ b/crates/node/tests/lease_registration_215.rs @@ -74,18 +74,18 @@ fn peer_table_owns_handshake_publication_and_replacement() { let addr = SocketAddr::from(([127, 0, 0, 1], 18_447)); let (old_tx, _old_rx) = unbounded::(); let old = PeerLease::new(old_tx); - assert!(!table.register(addr.into(), old.clone())); - assert!(table.publish_info(addr.into(), &old, info(addr))); + assert!(!table.register(addr, old.clone())); + assert!(table.publish_info(addr, &old, info(addr))); assert!(!old.is_cancelled()); assert_eq!(table.len(), 1); assert_eq!(table.infos().len(), 1); let (new_tx, _new_rx) = unbounded::(); let new = PeerLease::new(new_tx); - assert!(table.register(addr.into(), new.clone())); + assert!(table.register(addr, new.clone())); assert!(old.is_cancelled()); assert!(!new.is_cancelled()); - assert!(table.is_current(new.source(addr.into()))); + assert!(table.is_current(new.source(addr))); let sync = make_sync(Arc::clone(&table)); sync.tick(); diff --git a/crates/node/tests/tx_ingress_e2e.rs b/crates/node/tests/tx_ingress_e2e.rs index f3bc21c55..5ff3c3270 100644 --- a/crates/node/tests/tx_ingress_e2e.rs +++ b/crates/node/tests/tx_ingress_e2e.rs @@ -253,8 +253,7 @@ impl LoopbackPeer { fn close(&mut self) -> bool { self.lease.cancel(); let _ = self.dialer.shutdown(Shutdown::Both); - self.peer_table - .remove_current(self.peer_addr.into(), &self.lease); + self.peer_table.remove_current(self.peer_addr, &self.lease); let service_ok = self .service .take() @@ -302,7 +301,7 @@ fn open_loopback_peer( pump: None, service: None, }; - wiring.peer_table.register(peer_addr.into(), lease.clone()); + wiring.peer_table.register(peer_addr, lease.clone()); let pump_lease = lease.clone(); peer.pump = Some( std::thread::Builder::new() @@ -372,7 +371,7 @@ fn serve_connection( return; }; let mut info = bitcoin_rs_p2p::PeerInfo::inbound_from_version( - peer_addr.into(), + peer_addr, addr_bind, version, 0, @@ -380,10 +379,7 @@ fn serve_connection( Arc::new(bitcoin_rs_p2p::PeerCounters::default()), ); info.wtxid_relay = peer.wtxid_relay.peer_supported(); - if !wiring - .peer_table - .publish_info(peer_addr.into(), lease, info) - { + if !wiring.peer_table.publish_info(peer_addr, lease, info) { return; } let _ = peer.stream.set_read_timeout(Some(READ_POLL)); @@ -397,7 +393,7 @@ fn serve_connection( match read_message(&mut peer.stream, magic) { Ok((message, _raw)) => { if let Message::Tx(tx) = message { - let source = lease.source(peer_addr.into()); + let source = lease.source(peer_addr); let _ = wiring.ingress_tx.try_send(InboundTx::new(tx, source)); continue; } @@ -422,7 +418,7 @@ fn serve_connection( &mut |items| { wiring .peer_table - .announce_transactions(lease.source(peer_addr.into()), &items); + .announce_transactions(lease.source(peer_addr), &items); wiring .peer_table .poll_transaction_requests(gateway.as_ref()); @@ -644,13 +640,10 @@ impl Harness { dial_handshake(&source.dialer, magic, false)?; dial_handshake(&bystander.dialer, magic, false)?; wait_until(OBSERVE_TIMEOUT, || { - wiring - .peer_table - .ready_source(source.peer_addr.into()) - .is_some() + wiring.peer_table.ready_source(source.peer_addr).is_some() && wiring .peer_table - .ready_source(bystander.peer_addr.into()) + .ready_source(bystander.peer_addr) .is_some() })?; @@ -701,10 +694,7 @@ impl Harness { let peer = open_loopback_peer(&wiring, Arc::clone(&self.gateway), name)?; dial_handshake(&peer.dialer, self.magic, wtxid_relay)?; wait_until(OBSERVE_TIMEOUT, || { - wiring - .peer_table - .ready_source(peer.peer_addr.into()) - .is_some() + wiring.peer_table.ready_source(peer.peer_addr).is_some() })?; Ok(peer) } @@ -750,8 +740,7 @@ fn full_relay_queue_does_not_block_peer_admission_or_mining_wake() -> anyhow::Re let tx = spending_tx(parent_txid(0xDD), 40_000); let txid = tx.txid(); let (outbound_tx, _outbound_rx) = crossbeam_channel::bounded(1); - let source = - PeerLease::new(outbound_tx).source(SocketAddr::from((Ipv4Addr::LOCALHOST, 8333)).into()); + let source = PeerLease::new(outbound_tx).source(SocketAddr::from((Ipv4Addr::LOCALHOST, 8333))); let ingress = spawn_tx_ingress_consumer( &state, Arc::clone(&gateway), diff --git a/crates/node/tests/unit/state/tests/construction.rs b/crates/node/tests/unit/state/tests/construction.rs index b54023847..1cc9b0ea2 100644 --- a/crates/node/tests/unit/state/tests/construction.rs +++ b/crates/node/tests/unit/state/tests/construction.rs @@ -104,7 +104,7 @@ fn inbound_tx_channel_is_bounded_against_flood() -> anyhow::Result<()> { let sender = state.inbound_tx_sender(); let (lease_tx, _lease_rx) = crossbeam_channel::unbounded(); let lease = bitcoin_rs_p2p::PeerLease::new(lease_tx); - let source = lease.source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333)).into()); + let source = lease.source(std::net::SocketAddr::from(([127, 0, 0, 1], 8333))); let tx = bitcoin_rs_primitives::Tx { version: 1, inputs: Vec::new(), diff --git a/crates/node/tests/unit/tx_ingress/tests.rs b/crates/node/tests/unit/tx_ingress/tests.rs index 8bbf57703..9bc92b1cb 100644 --- a/crates/node/tests/unit/tx_ingress/tests.rs +++ b/crates/node/tests/unit/tx_ingress/tests.rs @@ -112,7 +112,7 @@ fn test_source() -> bitcoin_rs_p2p::PeerSource { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18_333); let (queue, _frames) = crossbeam_channel::unbounded::(); let lease = bitcoin_rs_p2p::PeerLease::new(queue); - lease.source(addr.into()) + lease.source(addr) } #[test] diff --git a/crates/p2p/src/addrman.rs b/crates/p2p/src/addrman.rs index c35ae9511..02365de82 100644 --- a/crates/p2p/src/addrman.rs +++ b/crates/p2p/src/addrman.rs @@ -203,9 +203,6 @@ fn endpoint_key(addr: PeerEndpoint) -> Vec { crate::PeerAddress::Ip(IpAddr::V6(ip)) => ip.octets().to_vec(), crate::PeerAddress::Onion(key) => [vec![4], key.to_vec()].concat(), crate::PeerAddress::I2p(key) => [vec![5], key.to_vec()].concat(), - crate::PeerAddress::AnonymousTor(_) => { - unreachable!("anonymous circuits never enter AddrMan") - } }; bytes.extend_from_slice(&addr.port().to_be_bytes()); bytes @@ -721,7 +718,6 @@ fn routable(addr: PeerEndpoint, allow_local: bool) -> bool { } match addr.address() { crate::PeerAddress::Onion(_) | crate::PeerAddress::I2p(_) => true, - crate::PeerAddress::AnonymousTor(_) => false, crate::PeerAddress::Ip(IpAddr::V4(ip)) => { let octets = ip.octets(); if ip.is_broadcast() || octets[0] == 0 || octets[0] >= 240 { @@ -862,9 +858,6 @@ impl AddressBook { now: u64, ) { let source = source.into(); - if matches!(source, crate::PeerAddress::AnonymousTor(_)) { - return; - } let mut manager = self.state.lock(); for &(addr, services, seen) in addresses.iter().take(MAX_GOSSIP) { manager.learn( @@ -1396,7 +1389,6 @@ impl AddressBook { crate::PeerAddress::Ip(IpAddr::V6(ip)) => bitcoin::p2p::address::AddrV2::Ipv6(ip), crate::PeerAddress::Onion(key) => bitcoin::p2p::address::AddrV2::TorV3(key), crate::PeerAddress::I2p(hash) => bitcoin::p2p::address::AddrV2::I2p(hash), - crate::PeerAddress::AnonymousTor(_) => continue, }; gossip.push(bitcoin::p2p::address::AddrV2Message { time: u32::try_from(entry.last_seen).unwrap_or(u32::MAX), diff --git a/crates/p2p/src/addrman/tests/overlay_destinations.rs b/crates/p2p/src/addrman/tests/overlay_destinations.rs index 324408ccc..5cd5b34f6 100644 --- a/crates/p2p/src/addrman/tests/overlay_destinations.rs +++ b/crates/p2p/src/addrman/tests/overlay_destinations.rs @@ -91,15 +91,4 @@ fn overlay_endpoints_sources_and_anchors_survive_restart_and_addrv2_gossip() { restored.select(&[onion], &[], EPOCH + 10, u64::MAX, |_| true), Some(i2p) ); - let count = restored.len(); - restored.learn_peer( - crate::PeerAddress::AnonymousTor(7), - &[(addr(99), 9, EPOCH)], - EPOCH, - ); - assert_eq!( - restored.len(), - count, - "anonymous circuits are not durable learning sources" - ); } diff --git a/crates/p2p/src/block_announce.rs b/crates/p2p/src/block_announce.rs index 46e10355e..21f5a1304 100644 --- a/crates/p2p/src/block_announce.rs +++ b/crates/p2p/src/block_announce.rs @@ -19,7 +19,7 @@ //! - Coalesces wakeups under queue saturation and rereads the committed tip from chainstate. //! - Disconnect cleanly removes peer announcement state. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use std::sync::Arc; use std::time::Duration; @@ -198,7 +198,7 @@ impl BlockAnnouncer { /// Returns the current list of peers we requested high-bandwidth compact blocks from. #[cfg(test)] - pub fn high_bandwidth_requested_peers(&self) -> Vec { + pub fn high_bandwidth_requested_peers(&self) -> Vec { let state = self.state.lock(); state .high_bandwidth_requested_peers @@ -209,7 +209,7 @@ impl BlockAnnouncer { /// Returns the best block known for `addr`. #[cfg(test)] - pub fn peer_known_block(&self, addr: PeerEndpoint) -> Option { + pub fn peer_known_block(&self, addr: PeerRemote) -> Option { let source = self.peers.lease(addr)?.source(addr); self.peers .announcement_state(source) @@ -246,7 +246,7 @@ impl BlockAnnouncer { &self, state: &mut AnnounceState, eligible: &[(PeerSource, u64)], - sent: &mut Vec<(PeerEndpoint, Message)>, + sent: &mut Vec<(PeerRemote, Message)>, ) { // BIP152 asks for high-bandwidth slots to go to peers that recently // delivered blocks quickly. Filling strictly in session order @@ -279,7 +279,7 @@ impl BlockAnnouncer { /// Reconciles the selected high-bandwidth compact-relay peers against /// current ready sessions and IBD state. - pub fn reconcile_high_bandwidth_peers(&self) -> Vec<(PeerEndpoint, Message)> { + pub fn reconcile_high_bandwidth_peers(&self) -> Vec<(PeerRemote, Message)> { let eligible: Vec<_> = self .peers .sessions() @@ -497,7 +497,7 @@ impl BlockAnnouncer { block: &Block, hash: Hash256, height: u32, - ) -> Vec<(PeerEndpoint, Message)> { + ) -> Vec<(PeerRemote, Message)> { if self.in_ibd() { return Vec::new(); } @@ -642,7 +642,7 @@ impl BlockAnnouncer { } /// Rereads and processes the current committed tip. - pub fn process_tip(&self) -> Vec<(PeerEndpoint, Message)> { + pub fn process_tip(&self) -> Vec<(PeerRemote, Message)> { self.reconcile_high_bandwidth_peers(); if self.in_ibd() { return Vec::new(); @@ -719,7 +719,7 @@ impl BlockAnnouncer { /// ready peers rearms the retry. Peers that already know it are skipped /// by the planner, and an `inv` fallback may repeat. Without a pending /// retry, nothing happens. - pub fn process_tip_retry(&self) -> Vec<(PeerEndpoint, Message)> { + pub fn process_tip_retry(&self) -> Vec<(PeerRemote, Message)> { // IBD suppresses announcements entirely; consuming the intent here // would drop a disconnect-armed retry before the first poll that can // act on it. Keep it armed until `process_tip` can run. @@ -944,11 +944,11 @@ mod tests { } } - fn test_addr(port: u16) -> PeerEndpoint { - PeerEndpoint::from(([127, 0, 0, 1], port)) + fn test_addr(port: u16) -> PeerRemote { + PeerRemote::from(([127, 0, 0, 1], port)) } - fn test_peer_info(addr: PeerEndpoint, send_headers: bool) -> PeerInfo { + fn test_peer_info(addr: PeerRemote, send_headers: bool) -> PeerInfo { PeerInfo { addr, version: 70016, @@ -963,7 +963,8 @@ mod tests { conn_time: 0, inbound: false, addr_bind: addr - .socket_addr() + .endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: Arc::new(crate::counters::PeerCounters::default()), @@ -974,7 +975,7 @@ mod tests { fn setup_peer( table: &Arc, announcer: &BlockAnnouncer, - addr: PeerEndpoint, + addr: PeerRemote, send_headers: bool, ) -> (PeerLease, crate::test_support::MessageReceiver) { let (tx, rx) = crate::test_support::message_channel(); @@ -987,22 +988,17 @@ mod tests { (lease, rx) } - fn source(table: &PeerTable, addr: PeerEndpoint) -> PeerSource { + fn source(table: &PeerTable, addr: PeerRemote) -> PeerSource { table.lease(addr).expect("live test peer").source(addr) } - fn mark_known( - table: &PeerTable, - _announcer: &BlockAnnouncer, - addr: PeerEndpoint, - hash: Hash256, - ) { + fn mark_known(table: &PeerTable, _announcer: &BlockAnnouncer, addr: PeerRemote, hash: Hash256) { table.note_announced_block(source(table, addr), hash); } fn note_compact_preference( table: &PeerTable, - addr: PeerEndpoint, + addr: PeerRemote, high_bandwidth: bool, version: u64, ) { diff --git a/crates/p2p/src/compact_blocks.rs b/crates/p2p/src/compact_blocks.rs index d3617a1fb..989edef2a 100644 --- a/crates/p2p/src/compact_blocks.rs +++ b/crates/p2p/src/compact_blocks.rs @@ -1338,7 +1338,10 @@ mod tests { fn peer_source(connection_id: u64) -> bitcoin_rs_mempool::PeerToken { bitcoin_rs_mempool::PeerToken { - addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))), connection_id, } } diff --git a/crates/p2p/src/connection.rs b/crates/p2p/src/connection.rs index 00855ef2a..27e23dde6 100644 --- a/crates/p2p/src/connection.rs +++ b/crates/p2p/src/connection.rs @@ -10,7 +10,7 @@ use parking_lot::{Condvar, Mutex}; static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); /// Process-unique identity for one peer connection. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct ConnectionId(u64); impl ConnectionId { @@ -39,11 +39,86 @@ impl ConnectionId { } } +/// Remote identity of a live connection. Only destinations belong in `AddrMan`; +/// an anonymous inbound circuit is identified by its own connection lease. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum PeerRemote { + /// A routable Bitcoin peer destination. + Endpoint(PeerEndpoint), + /// An inbound Tor circuit whose remote destination is unknown. + AnonymousTor(ConnectionId), +} + +impl PeerRemote { + /// Destination, when the connection reveals one. + pub const fn endpoint(self) -> Option { + match self { + Self::Endpoint(endpoint) => Some(endpoint), + Self::AnonymousTor(_) => None, + } + } + + /// Network carrying this connection, independent of proxy sockets. + pub const fn network(self) -> crate::PeerNetwork { + match self { + Self::Endpoint(endpoint) => endpoint.address().network(), + Self::AnonymousTor(_) => crate::PeerNetwork::Onion, + } + } +} + +impl From for PeerRemote { + fn from(endpoint: PeerEndpoint) -> Self { + Self::Endpoint(endpoint) + } +} + +impl From for PeerRemote { + fn from(socket: std::net::SocketAddr) -> Self { + Self::Endpoint(socket.into()) + } +} + +impl From<(T, u16)> for PeerRemote +where + std::net::SocketAddr: From<(T, u16)>, +{ + fn from(value: (T, u16)) -> Self { + Self::from(std::net::SocketAddr::from(value)) + } +} + +impl std::fmt::Display for PeerRemote { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Endpoint(endpoint) => endpoint.fmt(f), + Self::AnonymousTor(id) => write!(f, "tor-inbound-{}:0", id.get()), + } + } +} + +impl std::str::FromStr for PeerRemote { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + if let Some(id) = value + .strip_prefix("tor-inbound-") + .and_then(|id| id.strip_suffix(":0")) + { + return id + .parse() + .map(|id| Self::AnonymousTor(ConnectionId(id))) + .map_err(|_| "invalid anonymous Tor connection handle"); + } + value.parse().map(Self::Endpoint) + } +} + /// Attribution token for an event delivered by one connection. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct PeerSource { - /// Logical remote Bitcoin endpoint at delivery time. - pub(crate) addr: PeerEndpoint, + /// Remote connection identity at delivery time. + pub(crate) addr: PeerRemote, connection_id: ConnectionId, } @@ -56,9 +131,9 @@ impl PeerSource { /// Mints a source with a fresh process-unique identity for tests. #[cfg(test)] - pub(crate) fn for_test(addr: PeerEndpoint) -> Self { + pub(crate) fn for_test(addr: impl Into) -> Self { Self { - addr, + addr: addr.into(), connection_id: ConnectionId::allocate(), } } @@ -67,12 +142,25 @@ impl PeerSource { impl From for bitcoin_rs_mempool::PeerToken { fn from(source: PeerSource) -> Self { Self { - addr: source.addr, + addr: source.addr.endpoint(), connection_id: source.connection_id.get(), } } } +impl From for PeerSource { + fn from(token: bitcoin_rs_mempool::PeerToken) -> Self { + let connection_id = ConnectionId(token.connection_id); + Self { + addr: token.addr.map_or( + PeerRemote::AnonymousTor(connection_id), + PeerRemote::Endpoint, + ), + connection_id, + } + } +} + /// Maximum queued messages for one peer connection. const OUTBOUND_QUEUE_MAX_MESSAGES: usize = 4096; @@ -1029,9 +1117,9 @@ impl PeerLease { /// Stamps an inbound event with this connection's identity and address. #[must_use] - pub fn source(&self, addr: PeerEndpoint) -> PeerSource { + pub fn source(&self, addr: impl Into) -> PeerSource { PeerSource { - addr, + addr: addr.into(), connection_id: self.id, } } @@ -1235,7 +1323,7 @@ mod tests { fn peer_info(addr: PeerEndpoint, conn_time: u64) -> crate::PeerInfo { crate::PeerInfo { - addr, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, diff --git a/crates/p2p/src/discouragement.rs b/crates/p2p/src/discouragement.rs index 253e29e10..c2ef20920 100644 --- a/crates/p2p/src/discouragement.rs +++ b/crates/p2p/src/discouragement.rs @@ -32,13 +32,9 @@ impl DiscouragedPeers { } /// Record a reconnectable address and report whether it is retained. - /// Anonymous Tor circuit identities are ephemeral and are never recorded. pub(crate) fn insert(&mut self, ip: impl Into, now: Instant) -> bool { self.expire(now); let ip = ip.into().to_canonical(); - if matches!(ip, PeerAddress::AnonymousTor(_)) { - return false; - } if self.deadlines.contains_key(&ip) { return true; } @@ -107,21 +103,6 @@ pub(crate) fn is_local(address: impl Into) -> bool { mod tests { use super::*; - #[test] - fn anonymous_circuits_cannot_displace_reconnectable_identities() { - let now = Instant::now(); - let mut entries = DiscouragedPeers::default(); - let ip = IpAddr::from([8, 8, 8, 8]); - assert!(entries.insert(ip, now)); - assert!(entries.insert(ip, now)); - for id in 0..=u64::try_from(MAX_DISCOURAGED_IPS).unwrap_or(u64::MAX) { - assert!(!entries.insert(PeerAddress::AnonymousTor(id), now)); - } - assert!(entries.contains(ip, now)); - assert_eq!(entries.deadlines.len(), 1); - assert_eq!(entries.oldest.len(), 1); - } - #[test] fn avoidance_is_bounded_expires_and_does_not_refresh_on_repetition() -> Result<(), Box> { diff --git a/crates/p2p/src/dispatch.rs b/crates/p2p/src/dispatch.rs index 88a23dd6f..9972f9606 100644 --- a/crates/p2p/src/dispatch.rs +++ b/crates/p2p/src/dispatch.rs @@ -2042,7 +2042,10 @@ mod tests { assert_ne!(txid.as_byte_array(), wtxid.as_byte_array()); if orphan { let source = PeerToken { - addr: bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(bitcoin_rs_primitives::PeerEndpoint::from(( + [127, 0, 0, 1], + 8333, + ))), connection_id: 1, }; assert!(matches!( diff --git a/crates/p2p/src/download_window.rs b/crates/p2p/src/download_window.rs index 1607f2706..777e92508 100644 --- a/crates/p2p/src/download_window.rs +++ b/crates/p2p/src/download_window.rs @@ -5,7 +5,7 @@ //! to manage the in-flight window budget. `crate::BlockStager` owns the //! matching inbound staging set. The node sync coordinator drives these //! types; it does not own the policy. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use std::time::{Duration, Instant}; use bitcoin_rs_chain::{BlockTree, TipSnapshot}; @@ -381,7 +381,7 @@ pub(crate) struct DownloadWindow { /// time. While inside `staller_cooldown` such a peer is not fan-out /// eligible and receives no block requests except as the last-resort peer. /// This prevents immediate re-acquisition of the same block stripe. - recent_stallers: HashMap, + recent_stallers: HashMap, } impl DownloadWindow { @@ -1153,7 +1153,7 @@ impl DownloadWindow { /// peer delivering each front block just under the adaptive threshold is /// never disconnected (same exposure as Core) but is visible here and on /// the `node.sync.stall_seconds` gauge. - pub(crate) fn stalling_peer(&self) -> Option<(PeerEndpoint, Instant)> { + pub(crate) fn stalling_peer(&self) -> Option<(PeerRemote, Instant)> { self.stall .map(|episode| (episode.owner.addr, episode.since)) } @@ -1342,7 +1342,7 @@ impl DownloadWindow { } /// Starts the re-acquisition cooldown for an unresponsive peer. - pub(crate) fn mark_peer_unresponsive(&mut self, peer_addr: PeerEndpoint, now: Instant) { + pub(crate) fn mark_peer_unresponsive(&mut self, peer_addr: PeerRemote, now: Instant) { let cooldown = self.budget.staller_cooldown; self.recent_stallers .retain(|_, fired_at| now.duration_since(*fired_at) < cooldown); @@ -1351,7 +1351,7 @@ impl DownloadWindow { /// Whether `peer_addr` is in cooldown; such peers are a last resort so a /// reconnecting staller cannot immediately reacquire the window front. - pub(crate) fn peer_in_staller_cooldown(&self, peer_addr: PeerEndpoint, now: Instant) -> bool { + pub(crate) fn peer_in_staller_cooldown(&self, peer_addr: PeerRemote, now: Instant) -> bool { self.recent_stallers .get(&peer_addr) .is_some_and(|fired_at| now.duration_since(*fired_at) < self.budget.staller_cooldown) @@ -2390,7 +2390,7 @@ impl DownloadWindow { /// POST: returns the number of distinct `pending.owner.addr` values. /// INVARIANT: each address counts once, however many requests it owns. fn owner_address_count(&self) -> usize { - let mut owners: SmallVec<[PeerEndpoint; 16]> = SmallVec::new(); + let mut owners: SmallVec<[PeerRemote; 16]> = SmallVec::new(); for pending in self.pending.values() { if !owners.contains(&pending.owner.addr) { owners.push(pending.owner.addr); diff --git a/crates/p2p/src/download_window/tests.rs b/crates/p2p/src/download_window/tests.rs index 5a8018299..879eddf16 100644 --- a/crates/p2p/src/download_window/tests.rs +++ b/crates/p2p/src/download_window/tests.rs @@ -124,7 +124,7 @@ fn apply_staged(stager: &mut BlockStager, hash: &Hash256) { stager.retire_applied(hash); } -fn test_source(addr: bitcoin_rs_primitives::PeerEndpoint) -> PeerSource { +fn test_source(addr: crate::PeerRemote) -> PeerSource { PeerSource::for_test(addr) } @@ -135,7 +135,7 @@ fn stall_owner( next_apply_height: u32, apply_side_busy: bool, now: Instant, -) -> Option { +) -> Option { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -149,7 +149,7 @@ fn timeout_owner( window: &mut DownloadWindow, apply_side_busy: bool, now: Instant, -) -> Option { +) -> Option { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -165,7 +165,7 @@ fn cold_front_owner( next_apply_height: u32, apply_side_busy: bool, now: Instant, -) -> Option<(bitcoin_rs_primitives::PeerEndpoint, Hash256)> { +) -> Option<(crate::PeerRemote, Hash256)> { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -214,10 +214,7 @@ fn request_peer_scan_limit_accounts_for_pending_bytes_and_inflight_peers() { }); let stager = test_stager(&window); let now = Instant::now(); - let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); + let owner = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); insert_pending(&mut window, owner, hash(1), 1, now); insert_pending(&mut window, owner, hash(2), 2, now); window.pending_bytes = 256 * 1024; @@ -239,7 +236,7 @@ fn request_peer_scan_limit_counts_expired_pending_capacity() { ); let stager = test_stager(&window); let now = Instant::now(); - let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let peer_addr = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); let owner = test_source(peer_addr); for (byte, height) in [(1, 1_u32), (2, 2)] { window.pending.insert( @@ -265,7 +262,7 @@ fn pending_timeout_waits_for_second_delivery_drain() { let mut stager = test_stager(&window); let requested_at = Instant::now(); let observed_at = requested_at + Duration::from_secs(10); - let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let peer_addr = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); let block_hash = hash(0x90); let owner = test_source(peer_addr); window.pending.insert( @@ -322,8 +319,8 @@ fn retry_delivery_resolves_original_peer_timeout_without_blame() { DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let requested_at = Instant::now(); let observed_at = requested_at + Duration::from_secs(10); - let original_peer = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); - let retry_peer = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 2], 8333)); + let original_peer = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); + let retry_peer = crate::PeerRemote::from(([127, 0, 0, 2], 8333)); let block_hash = hash(0x91); let original_owner = test_source(original_peer); window.pending.insert( @@ -358,10 +355,7 @@ fn batched_non_front_deliveries_do_not_postpone_the_owner_timeout() { let mut window = DownloadWindow::new(test_budget()); let stager = test_stager(&window); let now = Instant::now(); - let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); + let owner = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); let front = hash(0xe5); let first = super::non_empty_request( owner, @@ -418,10 +412,7 @@ fn tied_removal_never_rewinds_the_owner_queue_clock() { let mut window = DownloadWindow::new(test_budget()); let stager = test_stager(&window); let t0 = Instant::now(); - let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); + let owner = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); let batch_a = super::non_empty_request( owner, vec![ @@ -476,7 +467,7 @@ fn requeue_of_the_observed_block_clears_the_suspicion_without_blame() { DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let requested_at = Instant::now(); let observed_at = requested_at + Duration::from_secs(10); - let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let peer_addr = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); let block_hash = hash(0x93); let owner = insert_pending( &mut window, @@ -519,14 +510,8 @@ fn releasing_a_dead_owner_drops_its_queue_start() { let mut window = DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let now = Instant::now(); - let stale_owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); - let live_owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 2], - 8333, - ))); + let stale_owner = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); + let live_owner = test_source(crate::PeerRemote::from(([127, 0, 0, 2], 8333))); let stale_requested_at = now .checked_sub(Duration::from_secs(9)) .unwrap_or_else(|| panic!("test instant underflow")); @@ -568,10 +553,7 @@ fn receiving_the_oldest_pending_resets_the_owner_queue_start() { DownloadWindow::new(test_budget().with_pending_timeout_override(Duration::from_secs(10))); let mut stager = test_stager(&window); let now = Instant::now(); - let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); + let owner = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); let earliest = hash(0x91); let later = hash(0x92); let earliest_requested_at = now @@ -628,10 +610,7 @@ fn slow_peer_timeout_uses_spacing_and_other_downloaders() { pending_timeout_override: None, ..test_budget() }); - let solo = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); + let solo = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); insert_pending(&mut window, solo, hash(0xb1), 1, requested_at); assert_eq!( timeout_owner( @@ -659,10 +638,7 @@ fn slow_peer_timeout_uses_spacing_and_other_downloaders() { }); let owners: Vec = (1..=3_u8) .map(|byte| { - let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 20 + byte], - 8333, - ))); + let owner = test_source(crate::PeerRemote::from(([127, 0, 0, 20 + byte], 8333))); insert_pending( &mut window, owner, @@ -706,10 +682,7 @@ fn pending_timeout_override_wins_over_spacing_policy() { .with_pending_timeout_override(Duration::from_secs(5)), ); let requested_at = Instant::now(); - let owner = test_source(bitcoin_rs_primitives::PeerEndpoint::from(( - [127, 0, 0, 1], - 8333, - ))); + let owner = test_source(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); insert_pending(&mut window, owner, hash(0xd1), 1, requested_at); assert_eq!( timeout_owner(&mut window, false, requested_at + Duration::from_secs(5)), @@ -723,7 +696,7 @@ fn retire_applied_leaves_pending_accounting_untouched() { let mut window = DownloadWindow::new(test_budget()); let mut stager = test_stager(&window); let now = Instant::now(); - let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let peer_addr = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); let applied = hash(0xa1); let pending = hash(0xa2); let pending_bytes = 256 * 1024; @@ -883,7 +856,7 @@ fn expired_pendings_reopen_scan_limit_through_count_headroom() { ); let mut stager = test_stager(&window); let now = Instant::now(); - let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let peer_addr = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); let owner = test_source(peer_addr); for (byte, height) in [(0xe1, 1_u32), (0xe2, 2)] { window.pending.insert( @@ -978,7 +951,7 @@ fn insert_pending( /// immediately. fn seed_front_cadence( window: &mut DownloadWindow, - peer: bitcoin_rs_primitives::PeerEndpoint, + peer: crate::PeerRemote, t0: Instant, gap: Duration, ) -> Instant { @@ -1001,8 +974,8 @@ fn seed_front_cadence( /// `healthy` delivered heights 4..=6, leaving zero staged-count headroom — /// every stall-predicate term holds. fn window_blocked_on_staller( - staller: bitcoin_rs_primitives::PeerEndpoint, - healthy: bitcoin_rs_primitives::PeerEndpoint, + staller: crate::PeerRemote, + healthy: crate::PeerRemote, t0: Instant, ) -> (DownloadWindow, BlockStager, Instant) { let mut window = DownloadWindow::new(stall_budget()); @@ -1017,12 +990,12 @@ fn window_blocked_on_staller( (window, stager, t1) } -fn staller_addr() -> bitcoin_rs_primitives::PeerEndpoint { - bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)) +fn staller_addr() -> crate::PeerRemote { + crate::PeerRemote::from(([127, 0, 0, 1], 8333)) } -fn healthy_addr() -> bitcoin_rs_primitives::PeerEndpoint { - bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 2], 8333)) +fn healthy_addr() -> crate::PeerRemote { + crate::PeerRemote::from(([127, 0, 0, 2], 8333)) } #[test] @@ -1883,8 +1856,8 @@ fn at_most_one_action_per_tick_blame_outranks_hedge() { ); } -fn peer_addr(idx: u8) -> bitcoin_rs_primitives::PeerEndpoint { - bitcoin_rs_primitives::PeerEndpoint::from(([10, 0, 0, idx], 8333)) +fn peer_addr(idx: u8) -> crate::PeerRemote { + crate::PeerRemote::from(([10, 0, 0, idx], 8333)) } #[test] @@ -2213,13 +2186,7 @@ fn adaptive_floor_still_convicts_true_staller_after_limit_cycle() { /// and release, four more 3s front advances with the decay clamped at the /// adaptive floor. Returns `(window, stager, front_height, now, silent_peer)`. #[expect(clippy::too_many_lines)] -fn limit_cycle_window_state() -> ( - DownloadWindow, - BlockStager, - u8, - Instant, - bitcoin_rs_primitives::PeerEndpoint, -) { +fn limit_cycle_window_state() -> (DownloadWindow, BlockStager, u8, Instant, crate::PeerRemote) { let mut window = DownloadWindow::new(stall_budget()); let healthy = test_source(healthy_addr()); let staller = test_source(staller_addr()); @@ -2845,7 +2812,7 @@ fn stale_prefix_confirmation_after_release_does_not_install_probe_or_gate() #[test] fn release_of_a_dead_connection_clears_state_but_preserves_cooldown() { let mut window = DownloadWindow::new(test_budget()); - let peer_addr = bitcoin_rs_primitives::PeerEndpoint::from(([127, 0, 0, 1], 8333)); + let peer_addr = crate::PeerRemote::from(([127, 0, 0, 1], 8333)); let now = Instant::now(); let source = test_source(peer_addr); insert_pending(&mut window, source, hash(1), 1, now); @@ -3343,13 +3310,16 @@ fn owned_fetch_respects_window_capacity_and_frontier() { #[test] fn fanout_eligibility_requires_block_service_flags() { - use bitcoin_rs_primitives::PeerEndpoint; + use crate::PeerRemote; use std::net::{IpAddr, Ipv4Addr}; use crate::{PeerCounters, PeerInfo}; fn peer(services: u64, inbound: bool) -> PeerInfo { - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); PeerInfo { addr, version: 70_016, @@ -3364,7 +3334,8 @@ fn fanout_eligibility_requires_block_service_flags() { conn_time: 0, inbound, addr_bind: addr - .socket_addr() + .endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(PeerCounters::default()), diff --git a/crates/p2p/src/eviction.rs b/crates/p2p/src/eviction.rs index 8b5ccd11c..725769301 100644 --- a/crates/p2p/src/eviction.rs +++ b/crates/p2p/src/eviction.rs @@ -7,7 +7,7 @@ //! never loaded here. Core prefers peers it accepted while discouraged, and //! bitcoin-rs refuses a discouraged address at accept. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use std::cmp::Ordering; use std::time::Instant; @@ -17,7 +17,7 @@ use hashbrown::HashMap; /// `NodeEvictionCandidate` without the fields of the absent steps. #[derive(Clone, Copy, Debug)] pub(crate) struct EvictionCandidate { - pub(crate) addr: PeerEndpoint, + pub(crate) addr: PeerRemote, pub(crate) connected: Instant, /// Recency of the last block from this connection the node committed; /// 0 when there is none (Core `m_last_block_time`). @@ -45,7 +45,7 @@ pub(crate) struct EvictionCandidate { /// unprotected candidates. pub(crate) fn select_inbound_to_evict( mut candidates: Vec, -) -> Option { +) -> Option { // Core's `std::sort` leaves ties unordered; stable sorts from a // newest-first start make them deterministic. candidates.sort_by_key(|candidate| std::cmp::Reverse(candidate.connected)); @@ -139,7 +139,7 @@ fn protect_by_ratio(candidates: &mut Vec) { /// candidates, a size tie going to the group whose newest member is newer. /// /// PRE: `candidates` is sorted newest first. -fn newest_in_largest_netgroup(candidates: &[EvictionCandidate]) -> Option { +fn newest_in_largest_netgroup(candidates: &[EvictionCandidate]) -> Option { let mut groups: HashMap = HashMap::new(); let mut chosen: Option<(usize, &EvictionCandidate)> = None; for candidate in candidates { @@ -166,7 +166,7 @@ mod tests { /// A plain candidate: `order` sets its connection age, higher is newer. fn candidate(base: Instant, order: u16, keyed_netgroup: u64) -> EvictionCandidate { EvictionCandidate { - addr: PeerEndpoint::from(([203, 0, 113, 1], order)), + addr: PeerRemote::from(([203, 0, 113, 1], order)), connected: base + Duration::from_secs(u64::from(order)), last_block: 0, last_tx: 0, @@ -182,8 +182,10 @@ mod tests { (0..40).map(|order| candidate(base, order, 7)).collect() } - fn port(selected: Option) -> Option { - selected.map(bitcoin_rs_primitives::PeerEndpoint::port) + fn port(selected: Option) -> Option { + selected + .and_then(PeerRemote::endpoint) + .map(crate::PeerEndpoint::port) } #[test] diff --git a/crates/p2p/src/inv.rs b/crates/p2p/src/inv.rs index 8cbbec755..679ce4098 100644 --- a/crates/p2p/src/inv.rs +++ b/crates/p2p/src/inv.rs @@ -32,11 +32,12 @@ pub fn request_missing_parents( ) -> bool { use bitcoin::hashes::Hash as _; + let source = crate::PeerSource::from(source); let Some(lease) = peers.lease(source.addr) else { return false; }; let connection = lease.source(source.addr); - if PeerToken::from(connection) != source { + if connection != source { return false; } let mut witness = false; @@ -141,7 +142,10 @@ mod tests { let (sender, receiver) = crate::test_support::message_channel_bounded(2); let lease = PeerLease::new(sender); let source = source(&lease); - table.register(source.addr, lease.clone()); + table.register( + source.addr.unwrap_or_else(|| panic!("known destination")), + lease.clone(), + ); assert!(request_missing_parents( &table, @@ -169,7 +173,10 @@ mod tests { let (sender, receiver) = crate::test_support::message_channel_bounded(1); let lease = PeerLease::new(sender); let source = source(&lease); - table.register(source.addr, lease.clone()); + table.register( + source.addr.unwrap_or_else(|| panic!("known destination")), + lease.clone(), + ); let mut version = crate::handshake::version_message( 1, 0, @@ -182,9 +189,10 @@ mod tests { ServiceFlags::NETWORK }; let mut info = crate::PeerInfo::inbound_from_version( - source.addr, + source.addr.unwrap_or_else(|| panic!("known destination")), source .addr + .unwrap_or_else(|| panic!("known destination")) .socket_addr() .unwrap_or_else(|| panic!("expected IP fixture")), &version, @@ -193,7 +201,11 @@ mod tests { Arc::new(crate::PeerCounters::default()), ); info.wtxid_relay = wtxid_relay; - assert!(table.publish_info(source.addr, &lease, info)); + assert!(table.publish_info( + source.addr.unwrap_or_else(|| panic!("known destination")), + &lease, + info + )); assert!(request_missing_parents( &table, @@ -247,10 +259,20 @@ mod tests { let (old_sender, old_receiver) = crate::test_support::message_channel_bounded(1); let old = PeerLease::new(old_sender); let stale_source = source(&old); - table.register(stale_source.addr, old); + table.register( + stale_source + .addr + .unwrap_or_else(|| panic!("known destination")), + old, + ); let (new_sender, new_receiver) = crate::test_support::message_channel_bounded(1); let current = PeerLease::new(new_sender); - table.register(stale_source.addr, current.clone()); + table.register( + stale_source + .addr + .unwrap_or_else(|| panic!("known destination")), + current.clone(), + ); assert!(current.send(Message::Ping(7)).is_ok()); assert!(!request_missing_parents(&table, stale_source, &[parent(1)])); @@ -273,7 +295,10 @@ mod tests { let lease = PeerLease::new(sender); let source = source(&lease); assert!(!request_missing_parents(&table, source, &[parent(1)])); - table.register(source.addr, lease.clone()); + table.register( + source.addr.unwrap_or_else(|| panic!("known destination")), + lease.clone(), + ); assert!(lease.send(Message::Ping(1)).is_ok()); assert!(!request_missing_parents(&table, source, &[parent(1)])); @@ -286,7 +311,10 @@ mod tests { let (sender, receiver) = crate::test_support::message_channel_bounded(1); let lease = PeerLease::new(sender); let source = source(&lease); - table.register(source.addr, lease.clone()); + table.register( + source.addr.unwrap_or_else(|| panic!("known destination")), + lease.clone(), + ); lease.cancel(); assert!(!request_missing_parents(&table, source, &[parent(1)])); diff --git a/crates/p2p/src/lib.rs b/crates/p2p/src/lib.rs index 7cb279e86..231042ea0 100644 --- a/crates/p2p/src/lib.rs +++ b/crates/p2p/src/lib.rs @@ -77,7 +77,9 @@ pub(crate) use block_stager::BlockStager; pub use chain_query::{ActiveChainQuery, RecentBlockSlot}; pub use compact_blocks::{CompactBlockHints, Reconstruction}; pub use compat::{COMMANDS, CORE_UNTYPED_COMMANDS, PINNED_CORE_VERSION}; -pub use connection::{ConnectionId, OutboundRefusal, PeerLease, PeerSource, QueuedFrame}; +pub use connection::{ + ConnectionId, OutboundRefusal, PeerLease, PeerRemote, PeerSource, QueuedFrame, +}; pub use counters::{CountingStream, PeerCounters}; pub use dispatch::{ChainQuery, FilterRequest, FilterServing, InventoryServing, TxInventory}; pub use inbound::{InboundBlock, InboundHeaders, InboundTx}; diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index dbd2356c9..d999539b4 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -266,19 +266,24 @@ impl ConnectionShared { fn note_misbehavior( &self, - addr: PeerEndpoint, + addr: impl Into, lease: &crate::PeerLease, error: &crate::PeerError, ) { + let addr = addr.into(); if !error.is_misbehavior() || self.is_session_cancelled() || !self.activity.is_active() { return; } let protected = lease.is_protected(); - let local = crate::discouragement::is_local(addr.address()); + let address = addr.endpoint().map(PeerEndpoint::address); + let local = address.is_some_and(crate::discouragement::is_local); let mut discouraged = false; let current = self.peer_table.with_current(lease.source(addr), || { - if !protected && !local { - discouraged = self.banned.discourage(addr.address()); + if !protected + && !local + && let Some(address) = address + { + discouraged = self.banned.discourage(address); } }); if current { @@ -321,10 +326,11 @@ impl ConnectionShared { /// and stale-predecessor rules are owned by P2P-02. fn publish_info_and_notify_ready( &self, - peer_addr: PeerEndpoint, + peer_addr: impl Into, lease: &crate::PeerLease, info: &crate::PeerInfo, ) -> bool { + let peer_addr = peer_addr.into(); let source = lease.source(peer_addr); if self.peer_table.publish_info(peer_addr, lease, info.clone()) && self.peer_table.is_current(source) @@ -333,8 +339,10 @@ impl ConnectionShared { announcer.on_peer_ready(); } if !lease.is_inbound() { - if let Some(book) = &self.address_book { - book.succeeded(peer_addr, info.services, crate::addrman::now()); + if let Some(book) = &self.address_book + && let Some(endpoint) = peer_addr.endpoint() + { + book.succeeded(endpoint, info.services, crate::addrman::now()); } } self.notify_peer_ready(source); @@ -389,10 +397,11 @@ impl ConnectionShared { fn send_block( &self, lease: &crate::PeerLease, - peer_addr: PeerEndpoint, + peer_addr: impl Into, block: bitcoin_rs_primitives::Block, serialized: bytes::Bytes, ) { + let peer_addr = peer_addr.into(); let source = lease.source(peer_addr); // Every body carries its own header; route it through the headers // sink too so tips learned only by body delivery (`inv` getdata, @@ -666,14 +675,30 @@ pub(crate) fn admit_inbound_with_epoch( shared: &ConnectionShared, alive: Option>, ) { - if shared.check_destination(peer_addr, false).is_err() { + admit_inbound_remote(stream, Some(peer_addr), shared, alive); +} + +pub(crate) fn admit_anonymous_tor(stream: TcpStream, shared: &ConnectionShared) { + admit_inbound_remote(stream, None, shared, None); +} + +fn admit_inbound_remote( + stream: TcpStream, + endpoint: Option, + shared: &ConnectionShared, + alive: Option>, +) { + if endpoint.map_or_else( + || !shared.overlay.config.allows(crate::PeerNetwork::Onion), + |endpoint| shared.check_destination(endpoint, false).is_err(), + ) { drop(stream); - tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected by address policy"); + tracing::debug!(?endpoint, "p2p inbound rejected by address policy"); return; } if !shared.activity.is_active() { drop(stream); - tracing::debug!(peer_addr = %peer_addr, "p2p inbound rejected: network inactive"); + tracing::debug!(?endpoint, "p2p inbound rejected: network inactive"); return; } // Reserve the inbound slot atomically with the capacity @@ -685,11 +710,20 @@ pub(crate) fn admit_inbound_with_epoch( let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded::(); let lease = crate::PeerLease::new_inbound(outbound_tx) .with_shared_budget(&shared.shared_outbound) - .with_no_ban(shared.banned.is_noban(peer_addr.address())) + .with_no_ban(endpoint.is_some_and(|endpoint| shared.banned.is_noban(endpoint.address()))) .with_transport_alive(alive); - let netgroup = shared.address_book.as_ref().map_or_else( - || crate::netgroup::NetGroups::default().group(peer_addr.address()), - |book| book.netgroup(peer_addr.address()), + let peer_addr = endpoint.map_or_else( + || crate::PeerRemote::AnonymousTor(lease.connection_id()), + crate::PeerRemote::Endpoint, + ); + let netgroup = endpoint.map_or_else( + || vec![4], + |endpoint| { + shared.address_book.as_ref().map_or_else( + || crate::netgroup::NetGroups::default().group(endpoint.address()), + |book| book.netgroup(endpoint.address()), + ) + }, ); let Some(lease) = shared.peer_table.try_register_inbound( peer_addr, @@ -954,7 +988,7 @@ fn dial( let mut peer = Peer::with_transport(stream, shared.magic, negotiated); peer.attach_net_trace(crate::net_trace::NetTrace::outbound( lease.node_id(), - addr, + addr.into(), role, manual, )); @@ -1124,11 +1158,12 @@ pub fn has_all_desirable_service_flags( fn spawn_handshake_thread( stream: TcpStream, - peer_addr: PeerEndpoint, + peer_addr: impl Into, shared: ConnectionShared, lease: crate::PeerLease, outbound_rx: crossbeam_channel::Receiver, ) { + let peer_addr = peer_addr.into(); let thread_name = format!("bitcoin-rs-p2p-handshake-{peer_addr}"); let peer_table = Arc::clone(&shared.peer_table); let reservation = lease.clone(); @@ -1177,12 +1212,15 @@ fn negotiate_inbound( fn run_handshake( stream: TcpStream, - peer_addr: PeerEndpoint, + peer_addr: impl Into, shared: &ConnectionShared, lease: crate::PeerLease, outbound_rx: crossbeam_channel::Receiver, ) -> Result<(), crate::wire::PeerError> { - if let Err(error) = shared.check_destination(peer_addr, lease.is_manual()) { + let peer_addr = peer_addr.into(); + if let Some(endpoint) = peer_addr.endpoint() + && let Err(error) = shared.check_destination(endpoint, lease.is_manual()) + { shared.peer_table.remove_current(peer_addr, &lease); return Err(error); } @@ -1309,12 +1347,13 @@ fn run_handshake( /// are intentionally not joined by outer listener shutdown. fn run_connected_session( peer: &mut Peer>, - peer_addr: PeerEndpoint, + peer_addr: impl Into, shared: &ConnectionShared, lease: crate::PeerLease, outbound_rx: crossbeam_channel::Receiver, mut info: crate::PeerInfo, ) -> Result<(), crate::wire::PeerError> { + let peer_addr = peer_addr.into(); // BIP339 announcement preference is the peer's received wtxidrelay, // independently of our own advertisement. Publish it atomically with the // completed handshake so relay never chooses a type during negotiation. @@ -1388,8 +1427,10 @@ fn run_connected_session( announcer.on_peer_disconnected(source); } if ready && !lease.is_inbound() && !lease.is_manual() && lease.role().relays_transactions() { - if let Some(book) = &shared.address_book { - book.connected(peer_addr, crate::addrman::now()); + if let Some(book) = &shared.address_book + && let Some(endpoint) = peer_addr.endpoint() + { + book.connected(endpoint, crate::addrman::now()); } } lease.cancel(); @@ -2022,13 +2063,14 @@ fn retry_deferred_request( #[expect(clippy::too_many_lines)] fn run_message_loop( peer: &mut Peer, - peer_addr: PeerEndpoint, + peer_addr: impl Into, lease: &crate::PeerLease, shared: &ConnectionShared, ibd: Option<&(Arc, Network)>, ) -> Result<(), crate::wire::PeerError> { use crate::peer::PeerState; use std::time::Instant; + let peer_addr = peer_addr.into(); let tx_relay_open = || ibd.is_none_or(|(latch, network)| !latch.is_active(unix_time_secs(), *network)); @@ -2682,11 +2724,12 @@ fn addr_v1_rows(rows: &[AddrV2Message]) -> Vec<(u32, bitcoin::p2p::address::Addr fn learn_addresses( shared: &ConnectionShared, lease: &crate::PeerLease, - peer_addr: PeerEndpoint, + peer_addr: impl Into, addresses: &[(PeerEndpoint, u64, u64)], budget: &mut u64, last_refill: &mut u64, ) { + let peer_addr = peer_addr.into(); if !lease.role().relays_transactions() { return; } @@ -2696,8 +2739,10 @@ fn learn_addresses( *last_refill = last_refill.saturating_add(refill.saturating_mul(10)); let count = addresses.len().min(usize::try_from(*budget).unwrap_or(0)); *budget = budget.saturating_sub(u64::try_from(count).unwrap_or(0)); - if let Some(book) = &shared.address_book { - book.learn_peer(peer_addr.address(), &addresses[..count], now); + if let Some(book) = &shared.address_book + && let Some(endpoint) = peer_addr.endpoint() + { + book.learn_peer(endpoint.address(), &addresses[..count], now); } } @@ -2720,9 +2765,10 @@ fn process_compact_wire_message( local_compact_version: Option, compact_hints: Option<&dyn crate::compact_blocks::CompactBlockHints>, sink: &ProductionSink<'_>, - peer_addr: PeerEndpoint, + peer_addr: impl Into, shared: &ConnectionShared, ) { + let peer_addr = peer_addr.into(); let lease = sink.lease; // Reconstruction scans the whole mempool, so a compact block must first // earn it with its header; anything else is only a header announcement. @@ -2803,9 +2849,10 @@ fn process_compact_wire_message( fn handle_compact_outcome( outcome: crate::compact_blocks::Outcome, sink: &ProductionSink<'_>, - peer_addr: PeerEndpoint, + peer_addr: impl Into, shared: &ConnectionShared, ) -> bool { + let peer_addr = peer_addr.into(); let lease = sink.lease; let mut fetch_issued = false; let mut follow_up = |message: crate::Message| match sink.send(message) { @@ -2877,9 +2924,10 @@ fn spawn_connection_writer( outbound_rx: crossbeam_channel::Receiver, close_rx: crossbeam_channel::Receiver<()>, budget: Arc, - peer_addr: PeerEndpoint, + peer_addr: impl Into, net_trace: Option, ) -> std::io::Result> { + let peer_addr = peer_addr.into(); std::thread::Builder::new() .name(format!("bitcoin-rs-p2p-writer-{peer_addr}")) .spawn(move || { @@ -3038,9 +3086,10 @@ fn forward_tx_if_relay_open( shared: &ConnectionShared, source: crate::PeerSource, tx: bitcoin_rs_primitives::Tx, - peer_addr: PeerEndpoint, + peer_addr: impl Into, relay_open: bool, ) { + let peer_addr = peer_addr.into(); if relay_open { shared.send_tx(source, tx); } else { @@ -3054,9 +3103,10 @@ fn wake_sync(sync_wake_tx: Option<&Sender<()>>) { } } -fn generate_nonce(peer_addr: PeerEndpoint) -> u64 { +fn generate_nonce(peer_addr: impl Into) -> u64 { use std::hash::{BuildHasher, Hash, Hasher}; use std::time::{SystemTime, UNIX_EPOCH}; + let peer_addr = peer_addr.into(); let random_state = std::collections::hash_map::RandomState::new(); let mut hasher = random_state.build_hasher(); @@ -3120,7 +3170,7 @@ mod transaction_poll_tests { let table = Arc::new(crate::PeerTable::new()); let (sender, receiver) = crossbeam_channel::bounded(4); let lease = crate::PeerLease::new(sender); - let addr = ([127, 0, 0, 1], 8333).into(); + let addr: PeerEndpoint = ([127, 0, 0, 1], 8333).into(); table.register(addr, lease.clone()); let version = crate::handshake::version_message( 1, @@ -3488,7 +3538,7 @@ mod inbound_admission_tests { "the admitted socket keeps its reservation" ); assert!( - !peer_table.is_connected(second_addr.into()), + !peer_table.is_connected(second_addr), "the refused socket registered no lease" ); @@ -3513,7 +3563,7 @@ mod writer_setup_cleanup_tests { fn peer_info(addr: PeerEndpoint, conn_time: u64) -> crate::PeerInfo { crate::PeerInfo { - addr, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -3555,11 +3605,11 @@ mod writer_setup_cleanup_tests { let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(outbound_tx); let lease_probe = lease.clone(); - shared.peer_table.register(peer_addr.into(), lease.clone()); + shared.peer_table.register(peer_addr, lease.clone()); // Lease must be registered before the failure. assert!( - shared.peer_table.is_connected(peer_addr.into()), + shared.peer_table.is_connected(peer_addr), "lease must be registered before writer setup" ); @@ -3569,14 +3619,7 @@ mod writer_setup_cleanup_tests { let stream = crate::CountingStream::new(client, counters); let mut peer = Peer::new(stream, Magic::BITCOIN); let info = peer_info(peer_addr.into(), 0); - let result = run_connected_session( - &mut peer, - peer_addr.into(), - &shared, - lease, - outbound_rx, - info, - ); + let result = run_connected_session(&mut peer, peer_addr, &shared, lease, outbound_rx, info); assert!(result.is_err(), "writer setup failure must return Err"); assert!( @@ -3688,7 +3731,7 @@ mod writer_shutdown_tests { fn peer_info(addr: PeerEndpoint, start_height: i32) -> crate::PeerInfo { crate::PeerInfo { - addr, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -3892,7 +3935,7 @@ mod writer_shutdown_tests { addr, &lease, crate::PeerInfo { - addr, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -4337,7 +4380,7 @@ mod writer_shutdown_tests { shared.peer_table.register(peer_addr, lease.clone()); let info = crate::PeerInfo { - addr: peer_addr, + addr: crate::PeerRemote::Endpoint(peer_addr), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -4425,7 +4468,7 @@ mod writer_shutdown_tests { } let info = crate::PeerInfo { - addr: peer_addr, + addr: crate::PeerRemote::Endpoint(peer_addr), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -4774,7 +4817,7 @@ mod ready_notify_tests { fn peer_info(addr: PeerEndpoint, start_height: i32) -> crate::PeerInfo { crate::PeerInfo { - addr, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -4881,7 +4924,6 @@ mod ready_notify_tests { #[cfg(test)] #[expect(clippy::expect_used)] mod block_forward_tests { - use bitcoin_rs_primitives::PeerEndpoint; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -4988,7 +5030,7 @@ mod block_forward_tests { "a delivery the window owns is never dropped for the unsolicited bound" ); - let other: PeerEndpoint = test_addr(9781, 0)?; + let other: crate::PeerRemote = test_addr(9781, 0)?; let _other_rx = connect_peer(&peers, synthetic_peer(other, 3)); let other_lease = peers .lease(other) @@ -5429,7 +5471,7 @@ mod feeler_tests { remote_table.cancel_all(); } else if revoked == 2 { let replacement = crate::PeerLease::new(crossbeam_channel::unbounded().0); - remote_table.register(address.into(), replacement); + remote_table.register(address, replacement); } let sent = peer.send(&crate::Message::Version(crate::handshake::version_message( 123, @@ -5956,3 +5998,7 @@ mod aggregate_filter_tests; #[cfg(test)] mod loopback_serving_tests; + +#[cfg(test)] +#[expect(clippy::expect_used)] +mod anonymous_inbound_tests; diff --git a/crates/p2p/src/listener/anonymous_inbound_tests.rs b/crates/p2p/src/listener/anonymous_inbound_tests.rs new file mode 100644 index 000000000..62228fcd5 --- /dev/null +++ b/crates/p2p/src/listener/anonymous_inbound_tests.rs @@ -0,0 +1,79 @@ +use super::*; + +fn pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let client = TcpStream::connect(listener.local_addr().expect("address")).expect("connect"); + (client, listener.accept().expect("accept").0) +} + +#[test] +fn anonymous_admission_uses_capacity_without_learning_or_loopback_privilege() { + let table = Arc::new(crate::PeerTable::new()); + let mut shared = test_shared( + Arc::clone(&table), + crossbeam_channel::unbounded().0, + crossbeam_channel::unbounded().0, + ); + shared.overlay = Arc::new(crate::overlay::OverlayTransport::new(Arc::new( + crate::OverlayConfig { + onion_proxy: Some("127.0.0.1:9050".parse().expect("proxy")), + ..crate::OverlayConfig::default() + }, + ))); + shared.max_inbound = 2; + let book = crate::addrman::AddressBook::open(None, shared.magic.to_bytes(), false, None); + shared.address_book = Some(Arc::clone(&book)); + let (first_client, first) = pair(); + let (second_client, second) = pair(); + let (third_client, third) = pair(); + admit_anonymous_tor(first, &shared); + admit_anonymous_tor(second, &shared); + let sessions = table.sessions(); + assert_eq!(sessions.len(), 2); + assert_ne!(sessions[0].addr, sessions[1].addr); + for session in &sessions { + assert_eq!(session.addr.endpoint(), None); + assert_eq!(session.addr.network(), crate::PeerNetwork::Onion); + assert!(!session.lease.is_no_ban()); + assert_eq!( + session + .addr + .to_string() + .parse::() + .expect("RPC handle"), + session.addr + ); + } + // All two slots are protected by the existing eviction policy at this size. + admit_anonymous_tor(third, &shared); + assert_eq!(table.live_inbound_count(), 2); + let first = &sessions[0]; + let mut allowance = 32; + let mut refill = crate::addrman::now(); + learn_addresses( + &shared, + &first.lease, + first.addr, + &[("8.8.8.8:8333".parse().expect("destination"), 9, refill)], + &mut allowance, + &mut refill, + ); + assert_eq!(book.len(), 0); + shared.note_misbehavior( + first.addr, + &first.lease, + &crate::PeerError::Protocol("invalid peer message"), + ); + assert!( + !shared + .banned + .is_discouraged(std::net::IpAddr::from([127, 0, 0, 1])) + ); + table.cancel_all(); + drop((first_client, second_client, third_client)); + let deadline = Instant::now() + Duration::from_secs(3); + while !table.is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(table.len(), 0); +} diff --git a/crates/p2p/src/listener/getdata_pause_tests.rs b/crates/p2p/src/listener/getdata_pause_tests.rs index 4a4883528..819c63da8 100644 --- a/crates/p2p/src/listener/getdata_pause_tests.rs +++ b/crates/p2p/src/listener/getdata_pause_tests.rs @@ -321,7 +321,7 @@ impl Served { outbound_rx, close, Arc::clone(&handle), - addr.into(), + addr, None, )?; (None, thread) @@ -333,7 +333,7 @@ impl Served { let session_lease = lease.clone(); let (outcome_tx, outcome) = crossbeam_channel::bounded(1); let session = std::thread::spawn(move || { - let result = run_message_loop(&mut peer, addr.into(), &session_lease, &shared, None); + let result = run_message_loop(&mut peer, addr, &session_lease, &shared, None); let _ = outcome_tx.send(result); }); Ok(Self { @@ -755,7 +755,7 @@ fn production_writer_marks_the_budget_dead_when_a_write_fails() -> TestResult { outbound_rx, lease.close_signal(), lease.budget_handle(), - addr.into(), + addr, None, )?; diff --git a/crates/p2p/src/listener/loopback_serving_tests.rs b/crates/p2p/src/listener/loopback_serving_tests.rs index 01bcdedeb..4002b2113 100644 --- a/crates/p2p/src/listener/loopback_serving_tests.rs +++ b/crates/p2p/src/listener/loopback_serving_tests.rs @@ -326,11 +326,9 @@ fn messages_of(frames: Vec) -> Vec { fn session_of(node: &Node, client: &Client) -> Fallible { let mut found = None; wait_until("the node to publish the client's session", || { - found = node - .table - .sessions() - .into_iter() - .find(|session| session.addr == client.local && session.info.is_some()); + found = node.table.sessions().into_iter().find(|session| { + session.addr == crate::PeerRemote::Endpoint(client.local) && session.info.is_some() + }); found.is_some() })?; found.ok_or_else(|| "the client's session vanished".into()) diff --git a/crates/p2p/src/listener/v2_transport_tests.rs b/crates/p2p/src/listener/v2_transport_tests.rs index 10f411b39..854b72153 100644 --- a/crates/p2p/src/listener/v2_transport_tests.rs +++ b/crates/p2p/src/listener/v2_transport_tests.rs @@ -450,9 +450,8 @@ fn a_decoy_only_peer_yields_and_lease_cancel_ends_the_loop() -> TestResult { let cancel_handle = lease.clone(); let mut peer = Peer::with_transport(stream, MAGIC, negotiated); run_outbound_handshake(&mut peer, 7, 0, &lease, deadline(), addr.into(), &client)?; - let loop_handle = std::thread::spawn(move || { - run_message_loop(&mut peer, addr.into(), &lease, &client, None) - }); + let loop_handle = + std::thread::spawn(move || run_message_loop(&mut peer, addr, &lease, &client, None)); // Liveness proof: the loop must dispatch the writer's ping through // the production path and queue the pong on the peer's reply queue. // A decoy drop, a v1 byte-count alias, or a loop that exits early diff --git a/crates/p2p/src/net_trace.rs b/crates/p2p/src/net_trace.rs index 2b4c6d091..c9b95cae5 100644 --- a/crates/p2p/src/net_trace.rs +++ b/crates/p2p/src/net_trace.rs @@ -10,7 +10,7 @@ //! preparation runs only while a consumer (bpftrace, BCC, DTrace) holds //! the probe's semaphore, so an untraced or unattached run pays nothing. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use crate::peer_info::PeerRole; use crate::wire::Message; @@ -27,14 +27,14 @@ pub(crate) struct NetTrace { /// Process-unique connection id (Core argument 1, `peerid`). node_id: u64, /// Remote address and port (Core argument 2). - peer: PeerEndpoint, + peer: PeerRemote, /// Core `ConnectionTypeAsString` label (Core argument 3). connection_type: &'static str, } impl NetTrace { /// Context for an accepted inbound connection. - pub(crate) fn inbound(node_id: u64, peer: PeerEndpoint) -> Self { + pub(crate) fn inbound(node_id: u64, peer: PeerRemote) -> Self { Self { node_id, peer, @@ -44,7 +44,7 @@ impl NetTrace { /// Context for a dialed connection, labelled per Core's /// `ConnectionTypeAsString` for the dial kind and role. - pub(crate) fn outbound(node_id: u64, peer: PeerEndpoint, role: PeerRole, manual: bool) -> Self { + pub(crate) fn outbound(node_id: u64, peer: PeerRemote, role: PeerRole, manual: bool) -> Self { let connection_type = if manual { "manual" } else { @@ -119,8 +119,8 @@ fn payload_len_u64(len: usize) -> u64 { mod tests { use super::*; - fn loopback() -> PeerEndpoint { - PeerEndpoint::from(([127, 0, 0, 1], 8333)) + fn loopback() -> PeerRemote { + PeerRemote::from(([127, 0, 0, 1], 8333)) } #[test] @@ -136,7 +136,7 @@ mod tests { message_args(&trace, "ping", payload); assert_eq!(node_id, 7); - let peer: PeerEndpoint = addr + let peer: PeerRemote = addr .parse() .unwrap_or_else(|error| panic!("a consumer parses the addr back: {error}")); assert_eq!(peer, loopback()); diff --git a/crates/p2p/src/netgroup.rs b/crates/p2p/src/netgroup.rs index 3407a72a7..2e6b2ea28 100644 --- a/crates/p2p/src/netgroup.rs +++ b/crates/p2p/src/netgroup.rs @@ -76,7 +76,7 @@ pub(crate) enum NetworkClass { pub(crate) fn network_class(address: impl Into) -> NetworkClass { let ip = match address.into() { PeerAddress::Ip(ip) => ip, - PeerAddress::Onion(_) | PeerAddress::AnonymousTor(_) => return NetworkClass::Onion, + PeerAddress::Onion(_) => return NetworkClass::Onion, PeerAddress::I2p(_) => return NetworkClass::I2p, }; if !routable(ip) { @@ -169,9 +169,6 @@ impl NetGroups { PeerAddress::Ip(ip) => ip, PeerAddress::Onion(key) => return vec![4, key[0] | 15], PeerAddress::I2p(key) => return vec![5, key[0] | 15], - // The remote identity is unknown: fresh circuit handles cannot - // establish independent network diversity. - PeerAddress::AnonymousTor(_) => return vec![4], }; // Core applies ASMap only to IPv4/IPv6 network classes; local and // unroutable IPs keep the prefix owner's unclassified group. @@ -347,7 +344,7 @@ mod tests { use super::*; #[test] - fn overlay_groups_use_address_prefixes_and_share_unknown_tor_origins() { + fn overlay_groups_use_address_prefixes() { let groups = NetGroups::default(); for (address, class, group) in [ ( @@ -360,12 +357,6 @@ mod tests { NetworkClass::I2p, vec![5, 0x2f], ), - (PeerAddress::AnonymousTor(1), NetworkClass::Onion, vec![4]), - ( - PeerAddress::AnonymousTor(u64::MAX), - NetworkClass::Onion, - vec![4], - ), ] { assert_eq!(network_class(address), class); assert_eq!(groups.group(address), group); diff --git a/crates/p2p/src/overlay.rs b/crates/p2p/src/overlay.rs index 0f570be11..205262d53 100644 --- a/crates/p2p/src/overlay.rs +++ b/crates/p2p/src/overlay.rs @@ -179,7 +179,7 @@ impl OverlayTransport { || table .infos() .iter() - .any(|peer| peer.addr.address().network() == network), + .any(|peer| peer.addr.network() == network), proxy: match network { PeerNetwork::Onion => self.config.onion_proxy, PeerNetwork::I2p => self.config.i2p_sam, @@ -226,9 +226,6 @@ impl OverlayTransport { cancelled, ), PeerAddress::I2p(_) => return self.sam_connect(endpoint, cancelled), - PeerAddress::AnonymousTor(_) => { - Err(invalid("anonymous inbound circuit is not dialable")) - } }?; Ok(Connected { stream, @@ -589,7 +586,7 @@ fn pause(cancelled: &dyn Fn() -> bool, delay: Duration) { } fn retire(table: &crate::PeerTable, network: PeerNetwork) { for session in table.sessions() { - if session.addr.address().network() == network { + if session.addr.network() == network { session.lease.cancel(); } } diff --git a/crates/p2p/src/overlay/tor.rs b/crates/p2p/src/overlay/tor.rs index 90307d7f3..c145fa479 100644 --- a/crates/p2p/src/overlay/tor.rs +++ b/crates/p2p/src/overlay/tor.rs @@ -1,15 +1,13 @@ //! External Tor control service, with a dedicated unprivileged forwarding listener. use super::{ - AtomicBool, Duration, Instant, Ordering, OverlayTransport, POLL, PeerAddress, PeerEndpoint, - SETUP_TIMEOUT, Setup, SocketAddr, identity, invalid, io, pause, + AtomicBool, Duration, Instant, Ordering, OverlayTransport, POLL, PeerEndpoint, SETUP_TIMEOUT, + Setup, SocketAddr, identity, invalid, io, pause, }; use bitcoin::hashes::{Hash as _, HashEngine as _, hmac, sha256}; use bitcoin::hex::{DisplayHex as _, FromHex as _}; use bitcoin::secp256k1::rand::RngCore as _; use std::io::Read as _; use std::net::TcpListener; -use std::sync::atomic::AtomicU64; -static NEXT_ANONYMOUS: AtomicU64 = AtomicU64::new(1); fn reply(setup: &mut Setup<'_>) -> io::Result> { let mut lines = Vec::new(); for _ in 0..32 { @@ -183,16 +181,7 @@ impl OverlayTransport { } match listener.accept() { Ok((stream, _forward_socket)) => { - let id = NEXT_ANONYMOUS - .try_update(Ordering::AcqRel, Ordering::Acquire, |id| { - id.checked_add(1) - }) - .map_err(|_| invalid("Tor inbound identity exhausted"))?; - crate::listener::admit_inbound( - stream, - PeerEndpoint::new(PeerAddress::AnonymousTor(id), 0), - shared, - ); + crate::listener::admit_anonymous_tor(stream, shared); } Err(error) if error.kind() == io::ErrorKind::WouldBlock => { pause(&cancelled, POLL); @@ -207,7 +196,7 @@ impl OverlayTransport { // Forwarded connections belong to the lost service, but outbound Tor circuits // use the independently configured SOCKS route and need not be retired. for session in shared.peer_table.sessions() { - if matches!(session.addr.address(), PeerAddress::AnonymousTor(_)) { + if matches!(session.addr, crate::PeerRemote::AnonymousTor(_)) { session.lease.cancel(); } } diff --git a/crates/p2p/src/peer.rs b/crates/p2p/src/peer.rs index 2c4217849..de0ee150d 100644 --- a/crates/p2p/src/peer.rs +++ b/crates/p2p/src/peer.rs @@ -534,7 +534,7 @@ mod tests { let mut peer = Peer::new(Cursor::new(Vec::new()), Magic::BITCOIN); peer.attach_net_trace(crate::net_trace::NetTrace::outbound( 4, - PeerEndpoint::from(([127, 0, 0, 1], 8333)), + crate::PeerRemote::Endpoint(PeerEndpoint::from(([127, 0, 0, 1], 8333))), crate::peer_info::PeerRole::FullRelay, false, )); @@ -564,7 +564,7 @@ mod tests { let mut peer = Peer::new(Cursor::new(frame), Magic::BITCOIN); peer.attach_net_trace(crate::net_trace::NetTrace::inbound( 2, - PeerEndpoint::from(([127, 0, 0, 1], 8333)), + crate::PeerRemote::Endpoint(PeerEndpoint::from(([127, 0, 0, 1], 8333))), )); let (message, payload) = peer diff --git a/crates/p2p/src/peer_info.rs b/crates/p2p/src/peer_info.rs index 255e2fd00..2d61fd4c1 100644 --- a/crates/p2p/src/peer_info.rs +++ b/crates/p2p/src/peer_info.rs @@ -1,6 +1,6 @@ //! Public peer metadata published after a successful handshake. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use std::sync::Arc; use bitcoin::p2p::message_network::VersionMessage; @@ -40,7 +40,7 @@ impl PeerRole { #[expect(clippy::struct_excessive_bools)] pub struct PeerInfo { /// Logical remote Bitcoin endpoint. - pub addr: PeerEndpoint, + pub addr: PeerRemote, /// Protocol version advertised by the remote. pub version: u32, /// Whether this connection requested BIP339 witness-id announcements. @@ -90,7 +90,7 @@ impl PeerInfo { /// Constructs a `PeerInfo` for an inbound peer from the captured remote `VersionMessage`. #[must_use] pub fn inbound_from_version( - addr: PeerEndpoint, + addr: impl Into, addr_bind: std::net::SocketAddr, version: &VersionMessage, conn_time: u64, @@ -113,7 +113,7 @@ impl PeerInfo { /// Constructs a `PeerInfo` for an outbound peer from the captured remote `VersionMessage`. #[must_use] pub(crate) fn outbound_from_version( - addr: PeerEndpoint, + addr: impl Into, addr_bind: std::net::SocketAddr, version: &VersionMessage, conn_time: u64, @@ -121,7 +121,7 @@ impl PeerInfo { counters: Arc, ) -> Self { Self { - addr, + addr: addr.into(), version: version.version, wtxid_relay: false, relay_transactions: version.relay, @@ -171,7 +171,7 @@ pub fn service_flag_names(flags: u64) -> Vec<&'static str> { mod tests { use super::*; - use bitcoin_rs_primitives::PeerEndpoint; + use crate::PeerRemote; use std::net::{IpAddr, Ipv4Addr}; use bitcoin::p2p::ServiceFlags; @@ -199,12 +199,16 @@ mod tests { } fn peer_info_with_services(services: u64) -> PeerInfo { - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 8333, + )); PeerInfo { services, ..PeerInfo::outbound_from_version( addr, - addr.socket_addr() + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &fake_version(), 0, @@ -228,12 +232,16 @@ mod tests { #[test] fn time_offset_is_the_peers_clock_against_ours() { - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 8333, + )); let mut version = fake_version(); version.timestamp = 1_700_000_120; let info = PeerInfo::inbound_from_version( addr, - addr.socket_addr() + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &version, 1_700_000_000, @@ -245,7 +253,8 @@ mod tests { version.timestamp = 1_699_999_940; let behind = PeerInfo::inbound_from_version( addr, - addr.socket_addr() + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &version, 1_700_000_000, @@ -259,8 +268,14 @@ mod tests { fn peer_table_projection_keeps_remote_and_bind_apart() { use crate::peer_table::PeerTable; - let peer = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); - let bind = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), 51_234); + let peer = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 8333, + )); + let bind = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), + 51_234, + )); let table = PeerTable::new(); let (tx, _rx) = crossbeam_channel::unbounded(); let lease = crate::PeerLease::new(tx); @@ -271,7 +286,8 @@ mod tests { &lease, PeerInfo::inbound_from_version( peer, - bind.socket_addr() + bind.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &fake_version(), 0, @@ -287,20 +303,31 @@ mod tests { assert_eq!(infos[0].addr, peer); assert_eq!( infos[0].addr_bind, - bind.socket_addr() + bind.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")) ); - assert_ne!(infos[0].addr.socket_addr(), Some(infos[0].addr_bind)); + assert_ne!( + infos[0] + .addr + .endpoint() + .and_then(crate::PeerEndpoint::socket_addr), + Some(infos[0].addr_bind) + ); } #[test] fn constructors_preserve_direction_and_handshake_metadata() { - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 8333, + )); let version = fake_version(); let counters = counters(); let inbound = PeerInfo::inbound_from_version( addr, - addr.socket_addr() + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &version, 100, @@ -309,7 +336,8 @@ mod tests { ); let outbound = PeerInfo::outbound_from_version( addr, - addr.socket_addr() + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &version, 100, @@ -336,10 +364,14 @@ mod tests { #[test] fn services_names_decodes_inbound_peer_with_network_witness() { let version = version_with_services(ServiceFlags::NETWORK | ServiceFlags::WITNESS); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 8333, + )); let info = PeerInfo::inbound_from_version( addr, - addr.socket_addr() + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), &version, 0, diff --git a/crates/p2p/src/peer_table.rs b/crates/p2p/src/peer_table.rs index 89ef40b34..935fac5a8 100644 --- a/crates/p2p/src/peer_table.rs +++ b/crates/p2p/src/peer_table.rs @@ -8,7 +8,7 @@ //! mutate peer sessions only through this type, so the registration, //! replacement, and cancellation rules have exactly one implementation. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use std::hash::{BuildHasher, RandomState}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -29,7 +29,7 @@ use crate::peer_info::PeerRole; #[derive(Clone, Debug)] pub struct PeerSession { /// Logical remote Bitcoin endpoint. - pub addr: PeerEndpoint, + pub addr: PeerRemote, /// Control handle for the connection. pub lease: PeerLease, /// Handshake metadata, `None` while the handshake is still in progress. @@ -95,7 +95,7 @@ impl Entry { } } - fn session(&self, addr: PeerEndpoint) -> PeerSession { + fn session(&self, addr: PeerRemote) -> PeerSession { PeerSession { addr, lease: self.lease.clone(), @@ -114,7 +114,7 @@ impl Entry { /// exactly once, either in `map` or in `retired`/`settled_*`. #[derive(Debug, Default)] struct TableView { - map: HashMap, + map: HashMap, /// Counter sets of dropped connections whose teardown may still be in /// flight. retired: Vec>, @@ -124,7 +124,7 @@ struct TableView { } impl std::ops::Deref for TableView { - type Target = HashMap; + type Target = HashMap; fn deref(&self) -> &Self::Target { &self.map @@ -168,11 +168,12 @@ impl PeerTable { /// replacing any predecessor. Returns whether a different connection was /// replaced; re-registering the same connection is a no-op that keeps its /// published metadata. - pub fn register(&self, addr: PeerEndpoint, lease: PeerLease) -> bool { + pub fn register(&self, addr: impl Into, lease: PeerLease) -> bool { + let addr = addr.into(); Self::register_locked(&mut self.entries.write(), addr, Entry::fresh(lease)) } - fn register_locked(entries: &mut TableView, addr: PeerEndpoint, entry: Entry) -> bool { + fn register_locked(entries: &mut TableView, addr: PeerRemote, entry: Entry) -> bool { match entries.get(&addr) { Some(current) if current.lease.same_connection(&entry.lease) => false, Some(_) => { @@ -196,10 +197,11 @@ impl PeerTable { /// epoch) returns `None` without changing the table. pub(crate) fn try_register_outbound( &self, - addr: PeerEndpoint, + addr: impl Into, lease: PeerLease, activity: &crate::NetworkActivity, ) -> Option { + let addr = addr.into(); debug_assert!(!lease.is_inbound(), "only outbound leases register here"); let mut entries = self.entries.write(); if !activity.is_active() || lease.is_cancelled() { @@ -255,13 +257,14 @@ impl PeerTable { #[must_use] pub(crate) fn try_register_inbound( &self, - addr: PeerEndpoint, + addr: impl Into, lease: PeerLease, netgroup: &[u8], max_inbound: usize, best_block_depth: u64, activity: &crate::NetworkActivity, ) -> Option { + let addr = addr.into(); debug_assert!(lease.is_inbound(), "only inbound leases reserve here"); let mut entries = self.entries.write(); if !activity.is_active() || lease.is_cancelled() { @@ -299,7 +302,7 @@ impl PeerTable { /// Returns whether one was removed. fn evict_inbound_locked( entries: &mut TableView, - addr: PeerEndpoint, + addr: PeerRemote, best_block_depth: u64, ) -> bool { // The newcomer would also replace a live outbound connection at its @@ -328,7 +331,7 @@ impl PeerTable { /// The eviction view of a live inbound connection without `NoBan`, the set /// Core's `ProtectNoBanConnections` and `ProtectOutboundConnections` leave. fn eviction_candidate( - addr: PeerEndpoint, + addr: PeerRemote, entry: &Entry, best_block_depth: u64, ) -> Option { @@ -350,7 +353,9 @@ impl PeerTable { }), relay_txs: info.is_some_and(|info| info.relay_transactions), keyed_netgroup: entry.keyed_netgroup, - is_local: crate::discouragement::is_local(addr.address()), + is_local: addr + .endpoint() + .is_some_and(|endpoint| crate::discouragement::is_local(endpoint.address())), }) } @@ -368,7 +373,13 @@ impl PeerTable { } /// Publishes handshake metadata for the connection `lease` refers to. - pub fn publish_info(&self, addr: PeerEndpoint, lease: &PeerLease, info: PeerInfo) -> bool { + pub fn publish_info( + &self, + addr: impl Into, + lease: &PeerLease, + info: PeerInfo, + ) -> bool { + let addr = addr.into(); let mut entries = self.entries.write(); match entries.get_mut(&addr) { Some(entry) if entry.lease.same_connection(lease) => { @@ -606,7 +617,8 @@ impl PeerTable { /// Reports whether the live published connection at `addr` requested /// compact-block relay. - pub(crate) fn compact_relay_of(&self, addr: PeerEndpoint) -> bool { + pub(crate) fn compact_relay_of(&self, addr: impl Into) -> bool { + let addr = addr.into(); let entries = self.entries.read(); entries.get(&addr).is_some_and(|entry| { entry @@ -633,7 +645,8 @@ impl PeerTable { /// Removes and cancels the connection `lease` refers to. Returns `false` /// when a different connection is live at `addr`, leaving it untouched. - pub fn remove_current(&self, addr: PeerEndpoint, lease: &PeerLease) -> bool { + pub fn remove_current(&self, addr: impl Into, lease: &PeerLease) -> bool { + let addr = addr.into(); self.remove_if(addr, |current| current.same_connection(lease)) } @@ -644,16 +657,22 @@ impl PeerTable { } /// Removes and cancels whichever connection is live at `addr`. - pub fn disconnect(&self, addr: PeerEndpoint) -> bool { + pub fn disconnect(&self, addr: impl Into) -> bool { + let addr = addr.into(); self.remove_if(addr, |_| true) } /// Removes and cancels the live connection at `addr` only when its identity is `id`. - pub(crate) fn disconnect_connection(&self, addr: PeerEndpoint, id: ConnectionId) -> bool { + pub(crate) fn disconnect_connection( + &self, + addr: impl Into, + id: ConnectionId, + ) -> bool { + let addr = addr.into(); self.remove_if(addr, |current| current.connection_id() == id) } - fn remove_if(&self, addr: PeerEndpoint, matches: impl FnOnce(&PeerLease) -> bool) -> bool { + fn remove_if(&self, addr: PeerRemote, matches: impl FnOnce(&PeerLease) -> bool) -> bool { let mut entries = self.entries.write(); if !entries .get(&addr) @@ -672,10 +691,10 @@ impl PeerTable { /// the affected addresses. pub(crate) fn disconnect_matching( &self, - predicate: impl Fn(&PeerEndpoint, &PeerLease) -> bool, - ) -> Vec { + predicate: impl Fn(&PeerRemote, &PeerLease) -> bool, + ) -> Vec { let mut entries = self.entries.write(); - let targets: Vec = entries + let targets: Vec = entries .iter() .filter(|(addr, entry)| predicate(addr, &entry.lease)) .map(|(addr, _)| *addr) @@ -760,7 +779,8 @@ impl PeerTable { /// Returns whether any connection is live at `addr`. #[must_use] - pub fn is_connected(&self, addr: PeerEndpoint) -> bool { + pub fn is_connected(&self, addr: impl Into) -> bool { + let addr = addr.into(); self.entries.read().contains_key(&addr) } @@ -776,7 +796,8 @@ impl PeerTable { /// Clones the lease of the live, uncancelled connection at `addr`. #[must_use] - pub fn lease(&self, addr: PeerEndpoint) -> Option { + pub fn lease(&self, addr: impl Into) -> Option { + let addr = addr.into(); self.entries .read() .get(&addr) @@ -824,7 +845,7 @@ impl PeerTable { /// operation, so replacement cannot change the target or relay preference. pub(crate) fn for_each_ready_lease( &self, - mut f: impl FnMut(PeerEndpoint, &PeerLease, &PeerInfo), + mut f: impl FnMut(PeerRemote, &PeerLease, &PeerInfo), ) { for (addr, entry) in self.entries.read().iter() { if entry.lease.is_cancelled() { @@ -856,7 +877,8 @@ impl PeerTable { /// Metadata of the one connection at `addr`, if it completed its handshake. #[must_use] - pub(crate) fn info_of(&self, addr: PeerEndpoint) -> Option { + pub(crate) fn info_of(&self, addr: impl Into) -> Option { + let addr = addr.into(); self.entries .read() .get(&addr) @@ -920,7 +942,8 @@ impl PeerTable { /// metadata, so a handshaking replacement cannot inherit an old scheduler /// decision. #[must_use] - pub fn ready_source(&self, addr: PeerEndpoint) -> Option { + pub fn ready_source(&self, addr: impl Into) -> Option { + let addr = addr.into(); let entries = self.entries.read(); let entry = entries.get(&addr)?; entry.info.as_ref()?; @@ -1022,10 +1045,38 @@ impl PeerTable { mod tests { use super::*; + #[test] + fn anonymous_sources_keep_independent_leases_and_roundtrip_mempool_attribution() { + let table = PeerTable::new(); + let (sender, _receiver) = crossbeam_channel::unbounded(); + let first = PeerLease::new_inbound(sender.clone()); + let second = PeerLease::new_inbound(sender); + let first_remote = PeerRemote::AnonymousTor(first.connection_id()); + let second_remote = PeerRemote::AnonymousTor(second.connection_id()); + table.register(first_remote, first.clone()); + table.register(second_remote, second.clone()); + let source = first.source(first_remote); + let token = bitcoin_rs_mempool::PeerToken::from(source); + assert_eq!(token.addr, None); + assert_eq!(PeerSource::from(token), source); + assert_ne!( + token, + bitcoin_rs_mempool::PeerToken::from(second.source(second_remote)) + ); + assert_eq!(table.live_inbound_count(), 2); + assert!(table.remove_current(first_remote, &first)); + assert!(!table.is_current(PeerSource::from(token))); + assert!(table.is_current(second.source(second_remote))); + assert_eq!(table.live_inbound_count(), 1); + } + #[test] fn revoked_overlay_epoch_cannot_replace_a_successor_during_late_admission() { let table = PeerTable::new(); - let address = PeerEndpoint::new(crate::PeerAddress::I2p([7; 32]), 8333); + let address = PeerRemote::Endpoint(crate::PeerEndpoint::new( + crate::PeerAddress::I2p([7; 32]), + 8333, + )); let activity = crate::NetworkActivity::from_shared(Arc::new(AtomicBool::new(true))); let (sender, _receiver) = crossbeam_channel::unbounded(); let successor = PeerLease::new(sender.clone()); @@ -1053,8 +1104,8 @@ mod tests { } use crate::test_support::QueuedMessages as _; - fn addr(port: u16) -> PeerEndpoint { - PeerEndpoint::from(([127, 0, 0, 1], port)) + fn addr(port: u16) -> PeerRemote { + PeerRemote::from(([127, 0, 0, 1], port)) } fn lease() -> PeerLease { @@ -1062,7 +1113,7 @@ mod tests { PeerLease::new(tx) } - fn info(addr: PeerEndpoint, start_height: i32) -> PeerInfo { + fn info(addr: PeerRemote, start_height: i32) -> PeerInfo { info_with_counters( addr, start_height, @@ -1071,7 +1122,7 @@ mod tests { } fn info_with_counters( - addr: PeerEndpoint, + addr: PeerRemote, start_height: i32, counters: std::sync::Arc, ) -> PeerInfo { @@ -1089,7 +1140,8 @@ mod tests { conn_time: 0, inbound: false, addr_bind: addr - .socket_addr() + .endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters, @@ -1309,23 +1361,27 @@ mod tests { ); } - fn flooder(port: u16) -> PeerEndpoint { - PeerEndpoint::from(([1, 2, 3, 4], port)) + fn flooder(port: u16) -> PeerRemote { + PeerRemote::from(([1, 2, 3, 4], port)) } - fn newcomer() -> PeerEndpoint { - PeerEndpoint::from(([5, 6, 7, 8], 8333)) + fn newcomer() -> PeerRemote { + PeerRemote::from(([5, 6, 7, 8], 8333)) } - fn netgroup(addr: PeerEndpoint) -> Vec { - crate::netgroup::NetGroups::default().group(addr.address()) + fn netgroup(addr: PeerRemote) -> Vec { + crate::netgroup::NetGroups::default().group( + addr.endpoint() + .unwrap_or_else(|| panic!("known destination")) + .address(), + ) } /// Offers an inbound `lease` from `addr` to a table capped at /// `max_inbound`. fn offer( table: &PeerTable, - addr: PeerEndpoint, + addr: PeerRemote, lease: PeerLease, max_inbound: usize, ) -> Option { @@ -1507,9 +1563,27 @@ mod tests { table.register(addr(port), lease.clone()); table.publish_info(addr(port), lease, info(addr(port), i32::from(port))); } - let ports: Vec = table.infos().iter().map(|info| info.addr.port()).collect(); + let ports: Vec = table + .infos() + .iter() + .map(|info| { + info.addr + .endpoint() + .unwrap_or_else(|| panic!("known destination")) + .port() + }) + .collect(); assert_eq!(ports, vec![14, 13, 12, 11, 10]); - let session_ports: Vec = table.sessions().iter().map(|s| s.addr.port()).collect(); + let session_ports: Vec = table + .sessions() + .iter() + .map(|s| { + s.addr + .endpoint() + .unwrap_or_else(|| panic!("known destination")) + .port() + }) + .collect(); assert_eq!(session_ports, ports); } @@ -1559,7 +1633,12 @@ mod tests { let b = lease(); table.register(addr(1), a.clone()); table.register(addr(2), b.clone()); - let removed = table.disconnect_matching(|addr, _| addr.port() == 2); + let removed = table.disconnect_matching(|addr, _| { + addr.endpoint() + .unwrap_or_else(|| panic!("known destination")) + .port() + == 2 + }); assert_eq!(removed, vec![addr(2)]); assert!(b.is_cancelled()); assert!(!a.is_cancelled()); @@ -1771,7 +1850,12 @@ mod tests { info_with_counters(addr(2), 3, counters_third.clone()), ); assert!(counted(&counters_third).write_all(&[0_u8; 7]).is_ok()); - let removed = table.disconnect_matching(|addr, _| addr.port() == 2); + let removed = table.disconnect_matching(|addr, _| { + addr.endpoint() + .unwrap_or_else(|| panic!("known destination")) + .port() + == 2 + }); assert_eq!(removed, vec![addr(2)]); assert_eq!(table.traffic_totals(), (0, 42)); diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index fbc02594c..bd489d168 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -1022,7 +1022,7 @@ impl P2pService { } /// Disconnects any active connection with the given address. - pub fn disconnect(&self, addr: PeerEndpoint) -> bool { + pub fn disconnect(&self, addr: impl Into) -> bool { self.peer_table.disconnect(addr) } @@ -1142,7 +1142,7 @@ fn spawn_feeler( .sessions() .into_iter() .filter(|session| !session.lease.is_cancelled()) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); let mut addresses: Vec<_> = active.keys().copied().collect(); addresses.extend_from_slice(&connected); @@ -1288,9 +1288,12 @@ fn count_address_failure( .filter(|session| { !session.lease.is_inbound() && !session.lease.is_cancelled() - && !book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| book.is_feeler(addr)) }) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); book.count_failure(&outbound, maximum) } @@ -1340,7 +1343,8 @@ fn spawn_outbound_dial( .keys() .any(|addr| crate::addrman::canonical(*addr) == dial.addr) || peer_table.sessions().iter().any(|session| { - !session.lease.is_cancelled() && crate::addrman::canonical(session.addr) == dial.addr + !session.lease.is_cancelled() + && session.addr.endpoint().map(crate::addrman::canonical) == Some(dial.addr) }) { // A manual request cannot release a queued automatic request's claim. @@ -1445,7 +1449,11 @@ fn next_outbound_role( !session.lease.is_inbound() && !session.lease.is_cancelled() && !session.lease.is_manual() - && !pending.get(&session.addr).is_some_and(|entry| entry.feeler) + && !session + .addr + .endpoint() + .and_then(|addr| pending.get(&addr)) + .is_some_and(|entry| entry.feeler) }) .collect(); let connected_full = census @@ -1465,7 +1473,9 @@ fn next_outbound_role( !outbound.manual && !outbound.feeler && outbound.role == want - && !census.iter().any(|session| session.addr == **addr) + && !census + .iter() + .any(|session| session.addr.endpoint() == Some(**addr)) }) .count() }; @@ -1646,7 +1656,10 @@ fn ready_address_count(peer_table: &crate::PeerTable, book: &crate::addrman::Add && !session.lease.is_manual() && !session.lease.is_cancelled() && session.info.is_some() - && !book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| book.is_feeler(addr)) }) .count() } @@ -1662,9 +1675,12 @@ fn anchor_peers(table: &crate::PeerTable, book: &crate::addrman::AddressBook) -> && session.lease.role() == crate::PeerRole::BlockRelayOnly && session.info.is_some() && !session.demonstrated_tips.is_empty() - && !book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| book.is_feeler(addr)) }) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect() } @@ -1691,9 +1707,12 @@ fn queue_restart_anchors( && !session.lease.is_manual() && session.lease.role() == crate::PeerRole::BlockRelayOnly && session.info.is_some() - && !maintenance.address_book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| maintenance.address_book.is_feeler(addr)) }) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); if let Some(addr) = *attempting && maintenance.address_book.anchor_eligible(addr, &[], now) @@ -1777,8 +1796,7 @@ fn run_address_maintenance(maintenance: &AddressMaintenance) { let tick_time = SystemTime::now(); let now = tick_time .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + .map_or(0, |duration| duration.as_secs()); let ready_count = ready_address_count(&maintenance.peer_table, &maintenance.address_book); if maintenance.network_active.load(Ordering::Acquire) { if !anchors_taken && maintenance.block_slots > 0 { @@ -1798,7 +1816,7 @@ fn run_address_maintenance(maintenance: &AddressMaintenance) { .sessions() .into_iter() .filter(|session| !session.lease.is_cancelled()) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); let policy = maintenance.banned.automatic_snapshot(tick_time); { @@ -1837,9 +1855,12 @@ fn run_address_maintenance(maintenance: &AddressMaintenance) { .filter(|session| { !session.lease.is_cancelled() && !session.lease.is_inbound() - && !maintenance.address_book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| maintenance.address_book.is_feeler(addr)) }) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); let reserved = queue_restart_anchors( maintenance, @@ -1909,16 +1930,19 @@ fn queue_address_candidates( let mut active: Vec<_> = sessions .iter() .filter(|session| !session.lease.is_cancelled()) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); let grouped: Vec<_> = sessions .iter() .filter(|session| { !session.lease.is_cancelled() && !session.lease.is_inbound() - && !maintenance.address_book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| maintenance.address_book.is_feeler(addr)) }) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); let automatic: Vec<_> = sessions .iter() @@ -1926,9 +1950,12 @@ fn queue_address_candidates( !session.lease.is_cancelled() && !session.lease.is_inbound() && !session.lease.is_manual() - && !maintenance.address_book.is_feeler(session.addr) + && !session + .addr + .endpoint() + .is_some_and(|addr| maintenance.address_book.is_feeler(addr)) }) - .map(|session| session.addr) + .filter_map(|session| session.addr.endpoint()) .collect(); let occupied = automatic.len() + maintenance.address_book.pending_count_excluding(&automatic); let needed = (maintenance.target + extra).saturating_sub(occupied + reserved_anchors); @@ -2301,7 +2328,7 @@ mod tests { .expect("three full-relay peers are one extra"); assert_eq!( excess.addr, - addr(6), + crate::PeerRemote::Endpoint(addr(6)), "the newest aged connection is the one" ); assert_eq!(excess.lease.role(), PeerRole::FullRelay); @@ -2398,7 +2425,7 @@ mod tests { newest_excess_full_relay(&table, 2, now, |_| false) .expect("the aged excess peer is eligible") .addr, - addr(4), + crate::PeerRemote::Endpoint(addr(4)), "the same peer is the victim once its download completes" ); } @@ -2444,7 +2471,7 @@ mod tests { newest_excess_full_relay(&table, 2, now, |_| false) .expect("three automatic connections are one extra") .addr, - addr(4), + crate::PeerRemote::Endpoint(addr(4)), "a real automatic excess still retires its newest aged peer" ); } @@ -2616,7 +2643,7 @@ mod tests { }; table.register(addr, lease.clone()); let info = crate::PeerInfo { - addr, + addr: crate::PeerRemote::Endpoint(addr), version: 70016, wtxid_relay: false, relay_transactions: true, @@ -2721,11 +2748,11 @@ mod tests { let table = crate::PeerTable::new(); let (tx, _) = crossbeam_channel::bounded(1); table.register( - "8.8.0.1:8333".parse().expect("peer"), + "8.8.0.1:8333".parse::().expect("peer"), crate::PeerLease::new(tx.clone()), ); table.register( - "9.9.0.1:8333".parse().expect("same ASN"), + "9.9.0.1:8333".parse::().expect("same ASN"), crate::PeerLease::new_manual(tx.clone(), crate::PeerRole::FullRelay), ); assert!( @@ -2735,7 +2762,9 @@ mod tests { assert!(count_address_failure(&table, 2, &book)); let independent = crate::PeerLease::new_manual(tx.clone(), crate::PeerRole::FullRelay); table.register( - "8.8.1.1:8333".parse().expect("independent ASN"), + "8.8.1.1:8333" + .parse::() + .expect("independent ASN"), independent.clone(), ); assert!( @@ -2752,7 +2781,7 @@ mod tests { assert!(count_address_failure(&table, 125, &book)); independent.cancel(); table.register( - "8.8.1.2:8333".parse().expect("inbound"), + "8.8.1.2:8333".parse::().expect("inbound"), crate::PeerLease::new_inbound(tx), ); assert!(!count_address_failure(&table, 125, &book)); @@ -3722,8 +3751,8 @@ mod tests { let path = directory.path().join("peers-01010101.dat"); let book = crate::addrman::AddressBook::open(Some(base), [1; 4], false, Some(&map)); maintenance.address_book = Arc::clone(&book); - let anchor: PeerEndpoint = "8.8.0.1:8333".parse().expect("anchor"); - let same_as: PeerEndpoint = "9.9.0.1:8333".parse().expect("same ASN"); + let anchor: PeerEndpoint = "8.8.0.1:8333".parse::().expect("anchor"); + let same_as: PeerEndpoint = "9.9.0.1:8333".parse::().expect("same ASN"); book.learn_dns("seed", &[anchor], 10_000); book.succeeded(anchor, 9, 10_000); // Promotion frees New before learning the same-AS peer: two fresh diff --git a/crates/p2p/src/sync/receive.rs b/crates/p2p/src/sync/receive.rs index a9342c5ef..1e06a6596 100644 --- a/crates/p2p/src/sync/receive.rs +++ b/crates/p2p/src/sync/receive.rs @@ -187,7 +187,7 @@ impl BlockSync { /// operations precede the scheduler lock to preserve the `PeerTable` -> /// scheduler ordering used elsewhere. fn discard_inadmissible_header_bodies(&self, invalid: &[(Hash256, Option)]) { - let blamed: Vec = invalid + let blamed: Vec = invalid .iter() .filter_map(|(_, source)| *source) .filter(|source| { diff --git a/crates/p2p/src/sync/tests.rs b/crates/p2p/src/sync/tests.rs index 6193c7578..7f8519496 100644 --- a/crates/p2p/src/sync/tests.rs +++ b/crates/p2p/src/sync/tests.rs @@ -1,5 +1,5 @@ +use crate::PeerRemote; pub(crate) use crate::test_support::{OrFail, TestResult}; -use bitcoin_rs_primitives::PeerEndpoint; use std::net::IpAddr; use std::net::Ipv4Addr; use std::sync::Arc; @@ -558,7 +558,10 @@ fn tick_allows_demoted_peer_when_it_is_the_only_eligible_peer() -> TestResult { } .with_pending_timeout_override(Duration::ZERO), ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -653,7 +656,10 @@ fn getdata_uses_compact_flavor_only_for_relaying_peers_near_tip() -> TestResult let (sync, peers, _block_tree, _applied, _expected) = sync_with_header_chain(4)?; install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_101); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 9_101, + )); let mut relaying = synthetic_peer(addr, 100); relaying.compact_block_relay = true; let rx = connect_peer(&peers, relaying); @@ -662,7 +668,10 @@ fn getdata_uses_compact_flavor_only_for_relaying_peers_near_tip() -> TestResult let (sync, peers, _block_tree, _applied, _expected) = sync_with_header_chain(4)?; install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_102); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 9_102, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); assert_flavor(&first_getdata(&rx), false); @@ -677,7 +686,10 @@ fn getdata_uses_compact_flavor_only_for_relaying_peers_near_tip() -> TestResult ..super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9_103); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 9_103, + )); let mut relaying = synthetic_peer(addr, 100); relaying.compact_block_relay = true; let rx = connect_peer(&peers, relaying); @@ -723,7 +735,10 @@ fn unsolicited_stale_block_retries_from_resolved_header_height() -> TestResult { ..super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -787,7 +802,10 @@ fn inv_delivered_block_admits_carried_header_and_applies() -> TestResult { inbound_headers_tx: _inbound_headers_tx, } = SyncHarness::new(tree); install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 0)); sync.tick(); @@ -856,7 +874,10 @@ fn out_of_order_delivered_blocks_admit_and_apply() -> TestResult { inbound_headers_tx: _inbound_headers_tx, } = SyncHarness::new(tree); install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 0)); let source = current_source(&peers, addr); @@ -916,7 +937,10 @@ fn missing_parent_block_delivery_recovers_with_getheaders() -> TestResult { inbound_headers_tx, } = SyncHarness::new(tree); install_budget(&sync, super::default_sync_budget(Network::Regtest)); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 0)); let source = current_source(&peers, addr); @@ -1180,7 +1204,10 @@ fn clean_fast_path_caps_request_at_peer_height() -> TestResult { ..super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 2)); sync.tick(); @@ -1253,8 +1280,14 @@ fn staging_exhaustion_fixture() -> Result Result, addr: PeerEndpoint) -> PeerSource { +pub(crate) fn current_source(peer_table: &Arc, addr: PeerRemote) -> PeerSource { peer_table.lease(addr).map_or_else( || panic!("test peer {addr} must be connected"), |lease| lease.source(addr), @@ -2398,7 +2434,7 @@ fn register_info(peer_table: &Arc, info: PeerInfo) { } /// The canonical sync-test peer: outbound, and advertising `NODE_NETWORK` plus /// `NODE_WITNESS`, so it may serve block bodies at any height. -pub(crate) fn synthetic_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo { +pub(crate) fn synthetic_peer(addr: PeerRemote, start_height: i32) -> PeerInfo { PeerInfo { addr, version: 70_016, @@ -2414,7 +2450,8 @@ pub(crate) fn synthetic_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo conn_time: 0, inbound: false, addr_bind: addr - .socket_addr() + .endpoint() + .and_then(crate::PeerEndpoint::socket_addr) .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters: std::sync::Arc::new(crate::PeerCounters::default()), @@ -2424,7 +2461,7 @@ pub(crate) fn synthetic_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo /// A peer that may not serve block bodies at all: inbound (attacker-chosen) /// and advertising no services. -pub(crate) fn ineligible_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo { +pub(crate) fn ineligible_peer(addr: PeerRemote, start_height: i32) -> PeerInfo { PeerInfo { services: 0, inbound: true, @@ -2435,11 +2472,11 @@ pub(crate) fn ineligible_peer(addr: PeerEndpoint, start_height: i32) -> PeerInfo pub(crate) fn test_addr( base_port: usize, idx: usize, -) -> Result> { - Ok(PeerEndpoint::new( +) -> Result> { + Ok(PeerRemote::Endpoint(crate::PeerEndpoint::new( IpAddr::V4(Ipv4Addr::LOCALHOST), u16::try_from(base_port + idx)?, - )) + ))) } pub(crate) fn connect_peer( diff --git a/crates/p2p/src/sync/tests/chain_sync.rs b/crates/p2p/src/sync/tests/chain_sync.rs index 3d31484a8..e497c4722 100644 --- a/crates/p2p/src/sync/tests/chain_sync.rs +++ b/crates/p2p/src/sync/tests/chain_sync.rs @@ -15,7 +15,10 @@ use crate::sync::peers::{ /// An outbound connection that demonstrated `height`. fn outbound(port: u16, height: u32, role: PeerRole, at: Instant) -> UsablePeer { - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + )); UsablePeer { source: PeerSource::for_test(addr), info: synthetic_peer(addr, i32::try_from(height).unwrap_or(i32::MAX)), diff --git a/crates/p2p/src/sync/tests/download_batches.rs b/crates/p2p/src/sync/tests/download_batches.rs index c0472b59e..9ba6f30c4 100644 --- a/crates/p2p/src/sync/tests/download_batches.rs +++ b/crates/p2p/src/sync/tests/download_batches.rs @@ -34,7 +34,10 @@ fn tick_sends_getdata_from_next_applied_height_when_gap_exceeds_batch() -> TestR ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -79,7 +82,10 @@ fn second_tick_does_not_re_request_already_pending_blocks() -> TestResult { inbound_headers_tx: _inbound_headers_tx, inbound_blocks_tx: _inbound_blocks_tx, } = SyncHarness::new(tree); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -114,7 +120,10 @@ fn second_tick_does_not_re_request_already_pending_blocks() -> TestResult { #[test] fn successful_getdata_send_marks_requested_blocks_pending() -> TestResult { let (sync, peers, block_tree, applied_tip, expected) = sync_with_header_chain(3)?; - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -174,7 +183,10 @@ fn tick_respects_pending_byte_budget() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -198,7 +210,10 @@ fn tick_limits_inflight_per_peer() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/download_frontier.rs b/crates/p2p/src/sync/tests/download_frontier.rs index b37563e6e..6fab82aeb 100644 --- a/crates/p2p/src/sync/tests/download_frontier.rs +++ b/crates/p2p/src/sync/tests/download_frontier.rs @@ -115,7 +115,7 @@ fn fork_getdata_starts_at_common_ancestor_child() -> TestResult { inbound_blocks_tx: _inbound_blocks_tx, } = SyncHarness::new(tree); applied_tip.store(Some(Arc::new(applied))); - let peer = PeerEndpoint::from(([127, 0, 0, 1], 18_460)); + let peer = PeerRemote::from(([127, 0, 0, 1], 18_460)); let rx = connect_peer(&peers, synthetic_peer(peer, 100)); assert!( @@ -262,7 +262,10 @@ fn tick_does_not_resend_same_getheaders_while_pending() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 8)); sync.tick(); @@ -297,7 +300,10 @@ fn inbound_headers_response_releases_getheaders_gate() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 8)); sync.tick(); @@ -349,7 +355,10 @@ fn rejected_matching_peer_headers_release_gate_and_retry_immediately() -> TestRe ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 8)); sync.tick(); @@ -386,7 +395,10 @@ fn orphan_headers_keep_source_peer_connected() -> TestResult { peers, .. } = header_sync_with_genesis()?; - let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let _rx = connect_peer(&peers, synthetic_peer(peer_addr, 8)); inbound_headers_tx.send(InboundHeaders { headers: vec![ @@ -427,7 +439,10 @@ fn tick_bounded_request_peer_selection_skips_inflight_saturated_prefix() -> Test ..super::super::default_sync_budget(Network::Regtest) }, ); - let first_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let first_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let first_rx = connect_peer(&peers, synthetic_peer(first_addr, 100)); sync.tick(); @@ -442,7 +457,10 @@ fn tick_bounded_request_peer_selection_skips_inflight_saturated_prefix() -> Test return Err(std::io::Error::other("expected first peer getheaders").into()); } - let second_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let second_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8334, + )); let second_rx = connect_peer(&peers, synthetic_peer(second_addr, 100)); sync.tick(); @@ -476,8 +494,14 @@ fn tick_demotes_peer_after_expired_pending_and_retries_on_alternate_peer() -> Te } .with_pending_timeout_override(Duration::ZERO), ); - let stale_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); - let healthy_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let stale_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); + let healthy_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8334, + )); let stale_rx = connect_peer(&peers, synthetic_peer(stale_addr, 100)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/fork_retargeting.rs b/crates/p2p/src/sync/tests/fork_retargeting.rs index dbabae370..cee9b606b 100644 --- a/crates/p2p/src/sync/tests/fork_retargeting.rs +++ b/crates/p2p/src/sync/tests/fork_retargeting.rs @@ -90,7 +90,7 @@ fn two_branches() -> Result> { inbound_blocks_rx, crate::sync::syncing_ibd_latch(), ); - let peer = PeerEndpoint::from(([127, 0, 0, 1], 18_461)); + let peer = PeerRemote::from(([127, 0, 0, 1], 18_461)); let rx = connect_peer(&peers, synthetic_peer(peer, 100)); let source = current_source(&sync.peer_table, peer); @@ -299,7 +299,7 @@ fn retarget_runs_before_the_peer_budget_truncates() -> TestResult { max_pending_blocks: LOSING_LEN, ..crate::sync::default_sync_budget(Network::Regtest) }); - let peer = PeerEndpoint::from(([127, 0, 0, 1], 18_462)); + let peer = PeerRemote::from(([127, 0, 0, 1], 18_462)); let _rx = connect_peer(&peers, synthetic_peer(peer, 100)); let source = current_source(&sync.peer_table, peer); assert!( diff --git a/crates/p2p/src/sync/tests/frontier_model.rs b/crates/p2p/src/sync/tests/frontier_model.rs index 4875e09e6..1b53214d7 100644 --- a/crates/p2p/src/sync/tests/frontier_model.rs +++ b/crates/p2p/src/sync/tests/frontier_model.rs @@ -56,8 +56,11 @@ fn hash(byte: u8) -> Hash256 { Hash256::from_le_bytes(&[byte; 32]) } -fn addr_of(port: u16) -> PeerEndpoint { - PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) +fn addr_of(port: u16) -> PeerRemote { + PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + )) } fn usable(port: u16, best_known: i32, active_height: Option) -> UsablePeer { diff --git a/crates/p2p/src/sync/tests/header_progress.rs b/crates/p2p/src/sync/tests/header_progress.rs index 552a74bd2..71dc05be8 100644 --- a/crates/p2p/src/sync/tests/header_progress.rs +++ b/crates/p2p/src/sync/tests/header_progress.rs @@ -24,7 +24,10 @@ fn tick_sends_getdata_for_headers_above_applied_tip() -> TestResult { inbound_headers_tx: _inbound_headers_tx, inbound_blocks_tx: _inbound_blocks_tx, } = SyncHarness::new(tree); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -100,7 +103,10 @@ fn tick_fetches_new_tip_headers_from_at_tip_peers() -> TestResult { applied_tip.store(Some(Arc::new(applied))); // The peer's handshake height equals the applied height: it connected // while the node was at the tip, before the new block existed. - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 2)); // The peer announces the new tip header; drain accepts it and must @@ -189,7 +195,10 @@ fn tick_fetches_reorg_fork_announced_by_at_tip_peer() -> TestResult { // The peer's handshake height equals the applied height: it // connected at the losing tip, and only the fork announcement it // delivers demonstrates anything beyond that. - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 2)); inbound_headers_tx.send(InboundHeaders { diff --git a/crates/p2p/src/sync/tests/header_rotation.rs b/crates/p2p/src/sync/tests/header_rotation.rs index 152737f2a..f1c920109 100644 --- a/crates/p2p/src/sync/tests/header_rotation.rs +++ b/crates/p2p/src/sync/tests/header_rotation.rs @@ -8,8 +8,14 @@ fn invalid_nbits_headers_disconnect_source_and_rotate_getheaders() -> TestResult inbound_headers_tx, peers, } = header_sync_with_genesis()?; - let invalid_peer = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); - let other_peer = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let invalid_peer = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); + let other_peer = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8334, + )); let (invalid_tx, invalid_rx) = crate::test_support::message_channel(); let (other_tx, other_rx) = crate::test_support::message_channel(); @@ -94,7 +100,10 @@ fn unattributed_invalid_headers_do_not_disconnect_any_peer() -> TestResult { inbound_headers_tx, peers, } = header_sync_with_genesis()?; - let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let _rx = connect_peer(&peers, synthetic_peer(peer_addr, 8)); inbound_headers_tx.send(InboundHeaders { headers: vec![nbits_mismatch_header(genesis.compute_hash(), 1)], @@ -120,7 +129,10 @@ fn unattributed_invalid_headers_do_not_disconnect_any_peer() -> TestResult { #[test] fn disconnected_outbound_channel_does_not_mark_blocks_pending() -> TestResult { let (sync, peers, block_tree, applied_tip, _expected) = sync_with_header_chain(3)?; - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); register_info(&peers, synthetic_peer(addr, 100)); let (tx, rx) = crate::test_support::message_channel(); drop(rx); diff --git a/crates/p2p/src/sync/tests/headers_presync.rs b/crates/p2p/src/sync/tests/headers_presync.rs index 7ffaac840..b5b93e0fb 100644 --- a/crates/p2p/src/sync/tests/headers_presync.rs +++ b/crates/p2p/src/sync/tests/headers_presync.rs @@ -1,6 +1,6 @@ //! Header-presync admission gating over the real headers drain. -use bitcoin_rs_primitives::PeerEndpoint; +use crate::PeerRemote; use std::net::IpAddr; use std::net::Ipv4Addr; use std::time::Instant; @@ -105,12 +105,11 @@ fn connect( peers: &Arc, port: u16, start_height: i32, -) -> ( - PeerEndpoint, - PeerLease, - crate::test_support::MessageReceiver, -) { - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); +) -> (PeerRemote, PeerLease, crate::test_support::MessageReceiver) { + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + )); let (tx, rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(addr, lease.clone()); diff --git a/crates/p2p/src/sync/tests/limited_peers.rs b/crates/p2p/src/sync/tests/limited_peers.rs index b52a15fb8..3934150e4 100644 --- a/crates/p2p/src/sync/tests/limited_peers.rs +++ b/crates/p2p/src/sync/tests/limited_peers.rs @@ -11,7 +11,7 @@ use crate::download_window::{ }; /// A pruned peer: witness relay plus `NODE_NETWORK_LIMITED`, no `NODE_NETWORK`. -fn limited_peer(addr: PeerEndpoint, height: i32) -> PeerInfo { +fn limited_peer(addr: PeerRemote, height: i32) -> PeerInfo { PeerInfo { services: ServiceFlags::WITNESS.to_u64() | ServiceFlags::NETWORK_LIMITED.to_u64(), ..synthetic_peer(addr, height) @@ -20,7 +20,7 @@ fn limited_peer(addr: PeerEndpoint, height: i32) -> PeerInfo { /// A full-history peer: `NODE_NETWORK | NODE_WITNESS`, the [`synthetic_peer`] /// default. -fn full_peer(addr: PeerEndpoint, height: i32) -> PeerInfo { +fn full_peer(addr: PeerRemote, height: i32) -> PeerInfo { synthetic_peer(addr, height) } diff --git a/crates/p2p/src/sync/tests/peer_qualification.rs b/crates/p2p/src/sync/tests/peer_qualification.rs index bb3fd4254..74ee6ee3c 100644 --- a/crates/p2p/src/sync/tests/peer_qualification.rs +++ b/crates/p2p/src/sync/tests/peer_qualification.rs @@ -54,7 +54,10 @@ fn received_only_state_uses_scan_path_without_duplicate_request() -> TestResult let (sync, peers, block_tree, applied_tip, expected) = sync_with_header_chain(3)?; let (_, blocks) = mined_chain(3, 0)?; stage_body(&sync, &blocks[1]); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 3)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/pending_retries.rs b/crates/p2p/src/sync/tests/pending_retries.rs index b90f8e328..32c88bf00 100644 --- a/crates/p2p/src/sync/tests/pending_retries.rs +++ b/crates/p2p/src/sync/tests/pending_retries.rs @@ -12,7 +12,10 @@ fn tick_retries_expired_pending_before_new_heights() -> TestResult { } .with_pending_timeout_override(Duration::ZERO), ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -47,7 +50,10 @@ fn tick_fills_mixed_retry_and_new_height_batch() -> TestResult { } .with_pending_timeout_override(Duration::ZERO), ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -90,7 +96,10 @@ fn tick_applies_contiguous_blocks_before_requesting_more() -> TestResult { inbound_headers_tx: _inbound_headers_tx, inbound_blocks_tx, } = SyncHarness::new(tree); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); inbound_blocks_tx.send(crate::InboundBlock::from_decoded(genesis))?; @@ -135,7 +144,10 @@ fn oversized_received_block_releases_pending_budget_for_retry() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); @@ -217,7 +229,10 @@ fn staging_byte_exhaustion_backpressures_requests_then_recovers() -> TestResult ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/settlement_faults.rs b/crates/p2p/src/sync/tests/settlement_faults.rs index 7350ba1c9..ea931c38e 100644 --- a/crates/p2p/src/sync/tests/settlement_faults.rs +++ b/crates/p2p/src/sync/tests/settlement_faults.rs @@ -188,7 +188,10 @@ fn apply_cache_horizon_capped_by_pending_budget() -> TestResult { #[test] fn on_peer_ready_sweeps_dead_predecessor_header_request() -> TestResult { let HeaderSyncFixture { sync, .. } = header_sync_with_genesis()?; - let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let source = crate::PeerSource::for_test(peer_addr); { let mut scheduler = sync.scheduler.lock(); @@ -217,7 +220,10 @@ fn on_peer_ready_sweeps_dead_predecessor_header_request() -> TestResult { #[test] fn on_peer_ready_sweep_releases_dead_owned_requests_only() -> TestResult { let HeaderSyncFixture { sync, .. } = header_sync_with_genesis()?; - let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8334); + let peer_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8334, + )); let (stale_tx, _stale_rx) = crate::test_support::message_channel(); let stale = PeerLease::new(stale_tx); sync.peer_table.register(peer_addr, stale.clone()); @@ -259,7 +265,10 @@ fn far_future_matching_peer_retries_without_peer_blame() -> TestResult { inbound_headers_tx, peers, } = header_sync_with_genesis()?; - let peer_addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let peer_addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let (tx, rx) = crate::test_support::message_channel(); let lease = PeerLease::new(tx); peers.register(peer_addr, lease.clone()); diff --git a/crates/p2p/src/sync/tests/stale_tip.rs b/crates/p2p/src/sync/tests/stale_tip.rs index 8b877b603..1807d6648 100644 --- a/crates/p2p/src/sync/tests/stale_tip.rs +++ b/crates/p2p/src/sync/tests/stale_tip.rs @@ -83,12 +83,8 @@ fn the_stale_tip_allowance_dials_past_the_slot_cap() { let listeners: Vec = (0..3) .map(|_| { - let listener = TcpListener::bind( - PeerEndpoint::from((Ipv4Addr::LOCALHOST, 0)) - .socket_addr() - .unwrap_or_else(|| panic!("expected IP fixture")), - ) - .expect("bind a fake peer listener"); + let listener = + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind a fake peer listener"); listener .set_nonblocking(true) .expect("nonblocking fake peer listener"); diff --git a/crates/p2p/src/sync/tests/tip_alignment.rs b/crates/p2p/src/sync/tests/tip_alignment.rs index ac3c25b7b..c890616b8 100644 --- a/crates/p2p/src/sync/tests/tip_alignment.rs +++ b/crates/p2p/src/sync/tests/tip_alignment.rs @@ -22,7 +22,10 @@ fn tick_skips_getheaders_when_header_tip_matches_peer_height() -> TestResult { } }; applied_tip.store(Some(Arc::new(applied_snapshot))); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 3)); sync.tick(); diff --git a/crates/p2p/src/sync/tests/trickle_peers.rs b/crates/p2p/src/sync/tests/trickle_peers.rs index e803d7ef1..897d23e95 100644 --- a/crates/p2p/src/sync/tests/trickle_peers.rs +++ b/crates/p2p/src/sync/tests/trickle_peers.rs @@ -205,7 +205,10 @@ fn uniform_slow_saturated_fanout_disconnects_no_peer_and_completes() -> TestResu fn single_peer_can_fill_default_pending_window() -> TestResult { let (sync, peers, block_tree, applied_tip, expected) = sync_with_header_chain(u32::try_from(super::super::PENDING_BUDGET)?)?; - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 300)); let mut requested = Vec::new(); @@ -247,7 +250,10 @@ fn tick_preserves_partial_window_order_across_pending_gap() -> TestResult { ..super::super::default_sync_budget(Network::Regtest) }, ); - let addr = PeerEndpoint::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); + let addr = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); let rx = connect_peer(&peers, synthetic_peer(addr, 100)); sync.tick(); diff --git a/crates/p2p/src/tx_policy.rs b/crates/p2p/src/tx_policy.rs index 19446d1ee..222eac49e 100644 --- a/crates/p2p/src/tx_policy.rs +++ b/crates/p2p/src/tx_policy.rs @@ -782,7 +782,7 @@ mod tests { } pub(super) fn source(port: u16) -> PeerSource { - PeerSource::for_test(([127, 0, 0, 1], port).into()) + PeerSource::for_test(([127, 0, 0, 1], port)) } pub(super) fn item(n: u32) -> Inventory { let mut hash = [0; 32]; @@ -1017,7 +1017,7 @@ mod tests { let table = Arc::new(PeerTable::new()); let (sender, receiver) = crate::test_support::message_channel_bounded(4); let lease = crate::PeerLease::new(sender); - let addr = ([127, 0, 0, 1], 8333).into(); + let addr: crate::PeerEndpoint = ([127, 0, 0, 1], 8333).into(); table.register(addr, lease.clone()); let version = crate::handshake::version_message( 1, @@ -1061,7 +1061,7 @@ mod tests { let pending_table = Arc::clone(&table); let (new_sender, new_receiver) = crate::test_support::message_channel_bounded(4); let new_lease = crate::PeerLease::new(new_sender); - let new_addr = ([127, 0, 0, 1], 8334).into(); + let new_addr: crate::PeerEndpoint = ([127, 0, 0, 1], 8334).into(); POLL_HOOK.with(|hook| { *hook.borrow_mut() = Some(Box::new(move || { // A new ready connection and its announcement both appear @@ -1110,7 +1110,7 @@ mod tests { port: u16, lease: &crate::PeerLease, ) -> PeerSource { - let addr = ([127, 0, 0, 1], port).into(); + let addr: crate::PeerEndpoint = ([127, 0, 0, 1], port).into(); table.register(addr, lease.clone()); let version = crate::handshake::version_message( 1, diff --git a/crates/p2p/src/tx_policy/preferred_tests.rs b/crates/p2p/src/tx_policy/preferred_tests.rs index 23da73426..1e87676b8 100644 --- a/crates/p2p/src/tx_policy/preferred_tests.rs +++ b/crates/p2p/src/tx_policy/preferred_tests.rs @@ -451,7 +451,7 @@ fn registered_inbound( ) -> (PeerSource, crate::test_support::MessageReceiver) { let (sender, receiver) = crate::test_support::message_channel_bounded(32); let lease = crate::PeerLease::new_inbound(sender); - let addr = ([127, 0, 0, 1], port).into(); + let addr: crate::PeerEndpoint = ([127, 0, 0, 1], port).into(); table.register(addr, lease.clone()); let version = crate::handshake::version_message( 1, diff --git a/crates/p2p/src/tx_policy/relay.rs b/crates/p2p/src/tx_policy/relay.rs index 40d64052b..e10ebb8e2 100644 --- a/crates/p2p/src/tx_policy/relay.rs +++ b/crates/p2p/src/tx_policy/relay.rs @@ -36,9 +36,11 @@ pub(super) struct InboundClockKey { } impl InboundClockKey { - fn new(remote: crate::PeerAddress, bind: std::net::SocketAddr) -> Self { + fn new(remote: crate::PeerRemote, bind: std::net::SocketAddr) -> Self { Self { - network: network_class(remote), + network: remote.endpoint().map_or(NetworkClass::Onion, |endpoint| { + network_class(endpoint.address()) + }), bind_ip: canonical_ip(bind.ip()), bind_port: bind.port(), } @@ -205,7 +207,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr, info.addr_bind)); let TxPolicy { relay, inbound_inv, .. } = &mut *policy; @@ -262,7 +264,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr, info.addr_bind)); { let mut policy = self.tx_policy.lock(); if let Some(peer) = policy.relay.get_mut(&source) { @@ -307,7 +309,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr, info.addr_bind)); { let mut policy = self.tx_policy.lock(); if let Some(peer) = policy.relay.get_mut(&source) { @@ -366,7 +368,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.address(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr, info.addr_bind)); let requests = { let mut policy = self.tx_policy.lock(); let TxPolicy { @@ -652,7 +654,7 @@ mod tests { role: crate::PeerRole, ready: bool, ) -> (PeerSource, MessageReceiver) { - let addr = ([127, 0, 0, 1], port).into(); + let addr: crate::PeerEndpoint = ([127, 0, 0, 1], port).into(); connect_at(table, addr, addr, inbound, wtxid, role, ready) } @@ -805,9 +807,10 @@ mod tests { // GetPort. IPv4 mapped bind bytes are identical; scope/flow are absent. let key = |remote: &str, bind: &str| { InboundClockKey::new( - remote - .parse() - .unwrap_or_else(|error| panic!("remote: {error}")), + remote.parse::().map_or_else( + |error| panic!("remote: {error}"), + |address| crate::PeerEndpoint::new(address, 0).into(), + ), bind.parse().unwrap_or_else(|error| panic!("bind: {error}")), ) }; @@ -825,7 +828,7 @@ mod tests { let bind = std::net::SocketAddr::V6(std::net::SocketAddrV6::new(plain, 8333, 123, 456)); assert_eq!( key("8.8.8.8", "[fe80::1]:8333"), - InboundClockKey::new(std::net::IpAddr::from([8, 8, 8, 8]).into(), bind), + InboundClockKey::new(crate::PeerRemote::from(([8, 8, 8, 8], 0)), bind), ); } @@ -1049,7 +1052,7 @@ mod tests { port: u16, aggregate: &Arc, ) -> (PeerSource, MessageReceiver) { - let addr = ([127, 0, 0, 1], port).into(); + let addr: crate::PeerEndpoint = ([127, 0, 0, 1], port).into(); let (sender, receiver) = message_channel_bounded(1024); let lease = crate::PeerLease::new(sender).with_shared_budget(aggregate); let source = lease.source(addr); diff --git a/crates/p2p/src/tx_relay.rs b/crates/p2p/src/tx_relay.rs index 4b334959b..0b25d29fb 100644 --- a/crates/p2p/src/tx_relay.rs +++ b/crates/p2p/src/tx_relay.rs @@ -450,7 +450,6 @@ mod tests { use super::*; use bitcoin_rs_consensus::ValidationEngine; use bitcoin_rs_primitives::Hash256; - use bitcoin_rs_primitives::PeerEndpoint; use crossbeam_channel::bounded; use parking_lot::Mutex; use std::sync::atomic::AtomicBool; @@ -969,7 +968,7 @@ mod tests { ( 3, AdmissionOrigin::Peer(PeerToken { - addr: PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(crate::PeerEndpoint::from(([127, 0, 0, 1], 8333))), connection_id: 7, }), false, @@ -1094,7 +1093,7 @@ mod tests { } fn relay_identity_peer() -> AdmissionOrigin { AdmissionOrigin::Peer(bitcoin_rs_mempool::PeerToken { - addr: PeerEndpoint::from(([127, 0, 0, 1], 8333)), + addr: Some(crate::PeerEndpoint::from(([127, 0, 0, 1], 8333))), connection_id: 7, }) } diff --git a/crates/p2p/tests/core_compat.rs b/crates/p2p/tests/core_compat.rs index 994a50bac..79ed2e164 100644 --- a/crates/p2p/tests/core_compat.rs +++ b/crates/p2p/tests/core_compat.rs @@ -1394,7 +1394,7 @@ fn outbound_peer_without_network_flag_disconnected() -> Result<(), Box Result<(), Box Result<(), Box> { write_message(&mut server, magic, &Message::Verack)?; let deadline = Instant::now() + Duration::from_secs(5); - while peer_table.ready_source(addr.into()).is_none() && Instant::now() < deadline { + while peer_table.ready_source(addr).is_none() && Instant::now() < deadline { thread::sleep(Duration::from_millis(10)); } assert!( - peer_table.ready_source(addr.into()).is_some(), + peer_table.ready_source(addr).is_some(), "a LIMITED peer near the local tip is desirable" ); drop(server); diff --git a/crates/primitives/src/endpoint.rs b/crates/primitives/src/endpoint.rs index 96d76b7d5..a3e8226c2 100644 --- a/crates/primitives/src/endpoint.rs +++ b/crates/primitives/src/endpoint.rs @@ -34,7 +34,7 @@ impl PeerNetwork { } } -/// Remote identity. An accepted Tor circuit does not reveal a remote onion key. +/// Routable Bitcoin peer address, independent of a live connection. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] pub enum PeerAddress { /// Ordinary IP address. @@ -43,8 +43,6 @@ pub enum PeerAddress { Onion([u8; 32]), /// SHA256 of an I2P public Destination. I2p([u8; 32]), - /// Opaque process-unique accepted Tor connection, never persisted or relayed. - AnonymousTor(u64), } impl PeerAddress { @@ -75,7 +73,7 @@ impl PeerAddress { PeerNetwork::Ipv6 } } - Self::Onion(_) | Self::AnonymousTor(_) => PeerNetwork::Onion, + Self::Onion(_) => PeerNetwork::Onion, Self::I2p(_) => PeerNetwork::I2p, } } @@ -238,7 +236,6 @@ impl fmt::Display for PeerAddress { Self::Ip(ip) => ip.fmt(f), Self::Onion(key) => write!(f, "{}.onion", base32_encode(&onion_bytes(key))), Self::I2p(hash) => write!(f, "{}.b32.i2p", base32_encode(hash)), - Self::AnonymousTor(id) => write!(f, "tor-inbound-{id}"), } } } @@ -276,12 +273,6 @@ impl FromStr for PeerAddress { } return Ok(Self::I2p(base32_decode(name)?)); } - if let Some(id) = text.strip_prefix("tor-inbound-") { - return Ok(Self::AnonymousTor( - id.parse() - .map_err(|_| "invalid anonymous Tor connection handle")?, - )); - } Err("expected IP, Tor v3, or I2P base32 destination") } } @@ -313,11 +304,6 @@ impl<'de> Deserialize<'de> for PeerEndpoint { let endpoint: Self = String::deserialize(deserializer)? .parse() .map_err(serde::de::Error::custom)?; - if matches!(endpoint.address(), PeerAddress::AnonymousTor(_)) { - return Err(serde::de::Error::custom( - "anonymous Tor connection handles are not durable destinations", - )); - } Ok(endpoint) } } @@ -331,11 +317,6 @@ impl<'de> Deserialize<'de> for PeerAddress { let address: Self = String::deserialize(deserializer)? .parse() .map_err(serde::de::Error::custom)?; - if matches!(address, Self::AnonymousTor(_)) { - return Err(serde::de::Error::custom( - "anonymous Tor connection handles are not durable identities", - )); - } Ok(address) } } diff --git a/crates/primitives/src/endpoint/tests.rs b/crates/primitives/src/endpoint/tests.rs index 4c8693a48..f17c613d2 100644 --- a/crates/primitives/src/endpoint/tests.rs +++ b/crates/primitives/src/endpoint/tests.rs @@ -46,10 +46,7 @@ fn ip_sockets_keep_scope_and_anonymous_circuits_are_not_durable_destinations() { let endpoint = PeerEndpoint::from(socket); assert_eq!(endpoint.socket_addr(), Some(socket)); assert_eq!(endpoint.to_canonical(), endpoint); - let handle = "tor-inbound-7:0" - .parse::() - .expect("RPC handle"); - assert_eq!(handle.address(), PeerAddress::AnonymousTor(7)); - assert!(!handle.address().is_loopback()); + assert!("tor-inbound-7:0".parse::().is_err()); + assert!("tor-inbound-7".parse::().is_err()); assert!(serde_json::from_str::("\"tor-inbound-7:0\"").is_err()); } diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index ce24bc42d..443841d74 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -2366,7 +2366,7 @@ mod admission_chain_tests { let result = ctx.mempool.gateway.submit_transaction( Arc::new(tx), AdmissionOrigin::Peer(PeerToken { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into(), + addr: Some(std::net::SocketAddr::from(([127, 0, 0, 1], 18444)).into()), connection_id: 1, }), None, diff --git a/crates/rpc/src/handlers/mining.rs b/crates/rpc/src/handlers/mining.rs index c5651dd7d..abeeac4a4 100644 --- a/crates/rpc/src/handlers/mining.rs +++ b/crates/rpc/src/handlers/mining.rs @@ -901,7 +901,7 @@ mod tests { let (tx, _rx) = crossbeam_channel::bounded::(1); ctx.network.peer_table.register( "127.0.0.1:8333" - .parse() + .parse::() .unwrap_or_else(|error| panic!("dummy peer addr: {error}")), bitcoin_rs_p2p::PeerLease::new(tx), ); diff --git a/crates/rpc/src/handlers/network.rs b/crates/rpc/src/handlers/network.rs index 4d3969684..4827c6c6b 100644 --- a/crates/rpc/src/handlers/network.rs +++ b/crates/rpc/src/handlers/network.rs @@ -231,7 +231,7 @@ pub(crate) fn getpeerinfo(ctx: &Arc, params: &Value) -> Result RpcErro pub(crate) fn disconnectnode(ctx: &Arc, params: &Value) -> Result { let array = params_array(params)?; let address = required_str(params, 0, "address is required")?; - let addr = bitcoin_rs_p2p::PeerEndpoint::from_str(address) + let addr = bitcoin_rs_p2p::PeerRemote::from_str(address) .map_err(|_| RpcError::InvalidParams("address must be a valid host:port"))?; // Optional nodeid — the index reported by `getpeerinfo`, used to @@ -554,19 +554,18 @@ pub(crate) fn getnodeaddresses(ctx: &Arc, params: &Value) -> Result Date: Tue, 27 Oct 2026 23:08:00 +0900 Subject: [PATCH 17/19] refactor(p2p): unify pinned peer connection retries Make the fixed-peer worker resolution-only and let the existing outbound drain own canonical retry state for fixed peers and persistent addnode peers on every route. Completion drives bounded backoff; ready sessions reset it, removed pins drop it, and one-shot requests retain no retry state. Keep route-specific AddrMan failure attribution at the transport/listener boundary. Sync impact: outbound peer recovery and work driving affect SYNC-04/06, without editing validation, frontier or commit owners. Three pinned policy/TCP regressions, 42 existing service tests, 12 frontier recovery tests and the tick delivery proof passed. Strict P2P Clippy and node/RPC all-target checks passed; external overlays and process restart campaigns remain unrun. --- crates/p2p/src/service.rs | 79 +++++----- crates/p2p/src/service/pinned.rs | 236 +++++++++++++++++++++++++++++ docs/policies/p2p-compatibility.md | 12 ++ 3 files changed, 288 insertions(+), 39 deletions(-) create mode 100644 crates/p2p/src/service/pinned.rs diff --git a/crates/p2p/src/service.rs b/crates/p2p/src/service.rs index bd489d168..46daff935 100644 --- a/crates/p2p/src/service.rs +++ b/crates/p2p/src/service.rs @@ -20,6 +20,8 @@ use hashbrown::HashMap; use parking_lot::{Mutex, RwLock}; use thiserror::Error; +mod pinned; + use crate::listener::ListenerError; /// Core's `MAX_OUTBOUND_FULL_RELAY_CONNECTIONS` (`net.h:69`). @@ -392,6 +394,8 @@ pub struct P2pService { banned: BannedReader, address_book: Arc, added_nodes: Arc>>, + /// Current fixed-peer resolution, published only by the resolver worker. + fixed_destinations: Arc>>, outbound_tx: Sender, outbound_rx: Arc>>, inbound_headers_tx: Sender, @@ -455,6 +459,7 @@ impl P2pService { session_cancel: Mutex::new(Arc::new(AtomicBool::new(false))), banned, added_nodes: Arc::new(RwLock::new(Vec::new())), + fixed_destinations: Arc::new(RwLock::new(Vec::new())), outbound_tx, outbound_rx: Arc::new(Mutex::new(outbound_rx)), inbound_headers_tx, @@ -615,6 +620,7 @@ impl P2pService { ) -> Result, io::Error> { let address_book = Arc::clone(&self.address_book); let manual_nodes = Arc::clone(&self.added_nodes); + let fixed_destinations = Arc::clone(&self.fixed_destinations); let outbound_rx = Arc::clone(&self.outbound_rx); let peer_table = Arc::clone(&self.peer_table); let shutdown = Arc::clone(&self.worker_shutdown); @@ -632,15 +638,17 @@ impl P2pService { // full, retried in order once a slot opens. let mut parked: VecDeque = VecDeque::new(); let mut handles = Vec::new(); - let mut manual_retry: HashMap = HashMap::new(); + let mut pinned = pinned::PinnedPeers::default(); let mut next_extra_peer_check = Instant::now() + EXTRA_PEER_CHECK_INTERVAL; let mut next_feeler = Instant::now() + feeler_delay(bitcoin::secp256k1::rand::thread_rng().next_u64()); while !shutdown.load(Ordering::Acquire) && !shared.session_cancel.load() && !process_shutdown.is_triggered() { - reap_finished_outbound_connections(&mut active, &mut handles, &address_book); let now = Instant::now(); + for endpoint in reap_finished_outbound_connections(&mut active, &mut handles, &address_book) { + pinned.finished(endpoint, now); + } if now >= next_extra_peer_check { next_extra_peer_check = now + EXTRA_PEER_CHECK_INTERVAL; retire_extra_full_relay_connection( @@ -695,14 +703,13 @@ impl P2pService { } { let manual = manual_nodes.read(); - manual_retry.retain(|endpoint, _| manual.contains(endpoint)); - for &endpoint in manual.iter().filter(|endpoint| endpoint.address().ip().is_none() || shared.overlay.config.proxy.is_some()) { - if peer_table.is_connected(endpoint) { manual_retry.remove(&endpoint); continue; } - let (next, delay) = manual_retry.entry(endpoint).or_insert((Instant::now() + Duration::from_secs(1), Duration::from_secs(1))); - if Instant::now() < *next || active.contains_key(&crate::addrman::canonical(endpoint)) { continue; } + let fixed = fixed_destinations.read(); + let mut configured = manual.clone(); + configured.extend_from_slice(&fixed); + for endpoint in pinned.due(&configured, &active, &peer_table, now) { spawn_outbound_dial(&OutboundDial::pinned(endpoint), &shared, &mut active, &mut handles, full_relay_slots, block_relay_slots, extra_dial, max_peer_connections); - *delay = (*delay * 2).min(Duration::from_secs(60)); *next = Instant::now() + *delay; + if !active.contains_key(&endpoint) { pinned.finished(endpoint, now); } } } let received = outbound_rx.lock().recv_timeout(Duration::from_secs(1)); @@ -727,6 +734,8 @@ impl P2pService { continue; } let manual = manual_nodes.read(); + let fixed = fixed_destinations.read(); + if dial.manual && (is_manual_endpoint(&manual, dial.addr) || is_manual_endpoint(&fixed, dial.addr)) { continue; } if !dial.manual && is_manual_endpoint(&manual, dial.addr) { defer_rejected_dial(&dial, &address_book, &active); continue; @@ -754,18 +763,16 @@ impl P2pService { if !self.config.fixed_peers.is_empty() { let shutdown = Arc::clone(&self.worker_shutdown); let network_active = Arc::clone(&self.network_active); - let peer_table = Arc::clone(&self.peer_table); - let outbound_tx = self.outbound_tx.clone(); + let destinations = Arc::clone(&self.fixed_destinations); let endpoints = self.config.fixed_peers.clone(); let overlay = Arc::clone(&self.overlay); return thread::Builder::new() .name("bitcoin-rs-fixed-peer-bootstrap".to_owned()) .spawn(move || { - run_fixed_peer_bootstrap( + run_fixed_peer_resolution( shutdown, network_active, - peer_table, - outbound_tx, + destinations, endpoints, overlay, ); @@ -1097,7 +1104,8 @@ fn reap_finished_outbound_connections( active: &mut HashMap, handles: &mut Vec<(PeerEndpoint, JoinHandle>)>, book: &crate::addrman::AddressBook, -) { +) -> Vec { + let mut finished = Vec::new(); let mut index = 0; while index < handles.len() { if !handles[index].1.is_finished() { @@ -1105,8 +1113,12 @@ fn reap_finished_outbound_connections( continue; } let (addr, handle) = handles.swap_remove(index); - if active.remove(&addr).is_some_and(|dial| !dial.manual) { - book.unqueue(addr); + if let Some(dial) = active.remove(&addr) { + if dial.manual { + finished.push(addr); + } else { + book.unqueue(addr); + } } match handle.join() { Ok(Ok(())) => tracing::debug!(addr = %addr, "p2p outbound connection exited cleanly"), @@ -1116,6 +1128,7 @@ fn reap_finished_outbound_connections( Err(_) => tracing::warn!(addr = %addr, "p2p outbound connection panicked"), } } + finished } /// Use the existing outbound owner for one bounded probe; a feeler bypasses @@ -1193,11 +1206,10 @@ fn wait_for_shutdown(shutdown: &AtomicBool, delay: Duration) -> bool { } #[expect(clippy::needless_pass_by_value)] -fn run_fixed_peer_bootstrap( +fn run_fixed_peer_resolution( shutdown: Arc, network_active: Arc, - peer_table: Arc, - outbound_tx: Sender, + destinations: Arc>>, endpoints: Vec, overlay: Arc, ) { @@ -1208,34 +1220,23 @@ fn run_fixed_peer_bootstrap( } continue; } - 'endpoints: for endpoint in &endpoints { - if !network_active.load(Ordering::Acquire) { - break 'endpoints; - } - let addresses = match overlay.resolve(endpoint, &|| { + let mut resolved = Vec::new(); + for endpoint in &endpoints { + match overlay.resolve(endpoint, &|| { shutdown.load(Ordering::Acquire) || !network_active.load(Ordering::Acquire) }) { - Ok(addresses) => addresses, - Err(error) => { - tracing::warn!(endpoint, %error, "fixed peer resolution failed"); - continue; - } - }; - for addr in addresses { - if peer_table.is_connected(addr) || !network_active.load(Ordering::Acquire) { - continue; - } - if outbound_tx.try_send(OutboundDial::pinned(addr)).is_err() { - break 'endpoints; - } + Ok(addresses) => resolved.extend(addresses), + Err(error) => tracing::warn!(endpoint, %error, "fixed peer resolution failed"), } } + // Resolution owns desired destinations; the outbound drain owns all + // connection eligibility, active handles and retry timing. + *destinations.write() = resolved; if wait_for_shutdown(&shutdown, Duration::from_secs(2)) { break; } } } - /// Chooses the role of one dial, pinned or automatic. /// /// PRE: `dial` is a dequeued outbound request; the remaining arguments are @@ -3145,7 +3146,7 @@ mod tests { handles.push((address, thread::spawn(|| Ok(())))); let deadline = Instant::now() + Duration::from_secs(5); while !handles.is_empty() && Instant::now() < deadline { - reap_finished_outbound_connections(&mut active, &mut handles, &book); + let _ = reap_finished_outbound_connections(&mut active, &mut handles, &book); thread::sleep(Duration::from_millis(1)); } assert_eq!(handles.len(), 0); diff --git a/crates/p2p/src/service/pinned.rs b/crates/p2p/src/service/pinned.rs new file mode 100644 index 000000000..03439c73b --- /dev/null +++ b/crates/p2p/src/service/pinned.rs @@ -0,0 +1,236 @@ +//! Connection retry policy for every configured pin, independent of its route. +use super::{ActiveOutbound, HashMap, PeerEndpoint, is_manual_endpoint}; +use std::time::{Duration, Instant}; + +const INITIAL_RETRY: Duration = Duration::from_secs(1); +const MAX_RETRY: Duration = Duration::from_secs(60); + +struct Retry { + next: Instant, + delay: Duration, +} + +#[derive(Default)] +pub(super) struct PinnedPeers { + retries: HashMap, +} + +impl PinnedPeers { + // The drain owns this state and active handles. Resolution only supplies + // desired destinations; neither resolvers nor transports schedule dials. + pub(super) fn due( + &mut self, + configured: &[PeerEndpoint], + active: &HashMap, + peers: &crate::PeerTable, + now: Instant, + ) -> Vec { + self.retries + .retain(|endpoint, _| is_manual_endpoint(configured, *endpoint)); + let mut due = Vec::new(); + for &endpoint in configured { + let endpoint = crate::addrman::canonical(endpoint); + let retry = self.retries.entry(endpoint).or_insert(Retry { + next: now, + delay: INITIAL_RETRY, + }); + if peers.ready_source(endpoint).is_some() { + retry.delay = INITIAL_RETRY; + } + if now >= retry.next + && !active.contains_key(&endpoint) + && !peers.is_connected(endpoint) + && !due.contains(&endpoint) + { + due.push(endpoint); + } + } + due + } + + // Completion or local admission refusal starts the next delay. A long + // pending dial never consumes another retry or creates a second handle. + pub(super) fn finished(&mut self, endpoint: PeerEndpoint, now: Instant) { + if let Some(retry) = self.retries.get_mut(&crate::addrman::canonical(endpoint)) { + retry.next = now + retry.delay; + retry.delay = (retry.delay * 2).min(MAX_RETRY); + } + } +} + +#[cfg(test)] +#[expect(clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn every_route_shares_canonical_backoff_and_removal_without_parallel_attempts() { + let peers = crate::PeerTable::new(); + let mut pins = PinnedPeers::default(); + let mut active = HashMap::new(); + let ip: PeerEndpoint = "8.8.8.8:8333".parse().expect("IP"); + let onion = PeerEndpoint::new(crate::PeerAddress::Onion([1; 32]), 8333); + let i2p = PeerEndpoint::new(crate::PeerAddress::I2p([2; 32]), 8333); + let configured = [ + ip, + onion, + i2p, + "[::ffff:8.8.8.8]:8333".parse().expect("alias"), + ]; + let mut now = Instant::now(); + assert_eq!( + pins.due(&configured, &active, &peers, now), + vec![ip, onion, i2p] + ); + assert_eq!(pins.retries.len(), 3); + for endpoint in [ip, onion, i2p] { + active.insert( + endpoint, + ActiveOutbound { + feeler: false, + role: crate::PeerRole::FullRelay, + manual: true, + }, + ); + } + assert_eq!( + pins.due(&configured, &active, &peers, now + Duration::from_secs(80)), + [] + ); + assert!( + pins.retries + .values() + .all(|retry| retry.delay == INITIAL_RETRY) + ); + active.clear(); + for seconds in [1, 2, 4, 8, 16, 32, 60, 60] { + for endpoint in [ip, onion, i2p] { + pins.finished(endpoint, now); + } + now += Duration::from_secs(seconds); + assert_eq!( + pins.due( + &configured, + &active, + &peers, + now.checked_sub(Duration::from_millis(1)) + .expect("elapsed retry interval") + ), + [] + ); + assert_eq!( + pins.due(&configured, &active, &peers, now), + vec![ip, onion, i2p] + ); + } + assert_eq!(pins.due(&[], &active, &peers, now), []); + assert_eq!(pins.retries.len(), 0); + assert_eq!(pins.due(&[ip], &active, &peers, now), vec![ip]); + assert_eq!(pins.retries[&ip].delay, INITIAL_RETRY); + } + + #[test] + fn only_a_ready_connection_resets_backoff() { + let peers = crate::PeerTable::new(); + let mut pins = PinnedPeers::default(); + let active = HashMap::new(); + let endpoint: PeerEndpoint = "8.8.8.8:8333".parse().expect("IP"); + let now = Instant::now(); + let _ = pins.due(&[endpoint], &active, &peers, now); + pins.finished(endpoint, now); + assert_eq!(pins.retries[&endpoint].delay, Duration::from_secs(2)); + let (tx, _rx) = crossbeam_channel::unbounded(); + let lease = crate::PeerLease::new_manual(tx, crate::PeerRole::FullRelay); + peers.register(endpoint, lease.clone()); + assert_eq!(pins.due(&[endpoint], &active, &peers, now + MAX_RETRY), []); + assert_eq!(pins.retries[&endpoint].delay, Duration::from_secs(2)); + let info = crate::PeerInfo { + addr: endpoint.into(), + version: 70016, + wtxid_relay: false, + relay_transactions: true, + compact_block_relay: false, + send_headers: false, + services: 9, + user_agent: String::new(), + start_height: 0, + best_known_height: 0, + conn_time: 0, + inbound: false, + addr_bind: "127.0.0.1:1".parse().expect("bind"), + time_offset: 0, + counters: std::sync::Arc::new(crate::PeerCounters::default()), + transport: crate::TransportProtocol::V1, + }; + assert!(peers.publish_info(endpoint, &lease, info)); + assert_eq!(pins.due(&[endpoint], &active, &peers, now + MAX_RETRY), []); + assert_eq!(pins.retries[&endpoint].delay, INITIAL_RETRY); + peers.remove_current(endpoint, &lease); + pins.finished(endpoint, now + MAX_RETRY); + assert_eq!(pins.due(&[endpoint], &active, &peers, now + MAX_RETRY), []); + assert_eq!( + pins.due( + &[endpoint], + &active, + &peers, + now + MAX_RETRY + INITIAL_RETRY + ), + vec![endpoint] + ); + } + + #[test] + fn fixed_and_persistent_addnode_ip_peers_retry_through_the_same_drain() { + use super::super::{P2pService, P2pServiceConfig}; + use std::sync::{Arc, atomic::AtomicBool}; + for fixed in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let endpoint = PeerEndpoint::from(listener.local_addr().expect("endpoint")); + let service = P2pService::new( + P2pServiceConfig { + listen_addrs: Vec::new(), + fixed_peers: if fixed { + vec![endpoint.to_string()] + } else { + Vec::new() + }, + dns_seeds_enabled: false, + outbound_full_relay_slots: 0, + outbound_block_relay_slots: 0, + ..P2pServiceConfig::default() + }, + Arc::new(AtomicBool::new(false)), + ); + if !fixed { + service + .add_node(endpoint, true) + .expect("persistent addnode"); + } + let ready: Arc = Arc::new(|_| {}); + service + .start(None, None, &ready, crate::ListenerExtras::default()) + .expect("start"); + let deadline = Instant::now() + Duration::from_secs(8); + let mut attempts = 0; + while attempts < 2 && Instant::now() < deadline { + match listener.accept() { + Ok((stream, _)) => { + attempts += 1; + drop(stream); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept: {error}"), + } + } + service.shutdown(); + service.join().expect("join"); + assert_eq!( + attempts, 2, + "fixed={fixed}: a failed handshake must permit a new dial" + ); + } + } +} diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 41f278860..2108adfad 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -717,6 +717,18 @@ while dialing a destination, do update the existing address-book attempt and retry policy. Undocumented naming results (including `TIMEOUT`), authentication, malformed replies, local I/O and control session failures remain local errors. + +The existing outbound drain owns one retry policy for all `--connect` and +persistent `addnode` destinations, including direct IP, SOCKS and SAM routes. +The fixed-peer worker only resolves configured names and publishes desired +destinations. It never schedules a connection. The drain canonicalizes pins, +keeps at most one active dial per endpoint, and waits 1–60 seconds after +completion or local admission refusal before retrying. Observing a ready +connection resets its backoff; removing a pin removes its retry state. +One-shot manual requests do not create retained retry state. Remote-vs-local +failure attribution remains with the transport/listener and AddrMan owners +described above; there is no overlay-specific peer retry loop. + Control-session loss revokes I2P stream epochs, including late registrations, and the worker restores the selected stable destination with bounded backoff. From 4d7e5b85c806e082c80a802b446cb980fd497c62 Mon Sep 17 00:00:00 2001 From: AgentDawn <186692589+AgentDawn@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:12:20 +0900 Subject: [PATCH 18/19] fix(p2p): retire only leases belonging to the failed SAM epoch Let the SAM lifecycle worker unpublish the session and wake only leases carrying its exact epoch token. PeerTable remains the owner of connection identity and removal; stale cleanup cannot cancel a successor epoch. Add a recovery regression covering old leases and a replacement connection. Add RPC coverage that anonymous Tor handles can be listed/disconnected but cannot enter discovery or addnode. Set the production-writer test timeout before cloning its socket, so Windows exercises the intended write deadline. Preserve the concurrent author changes for anonymous remote identities, unified pinned retries and canonical pre-release AddrMan v1. Sync impact: lease retirement affects SYNC-04/06 recovery and delivery, with SYNC-02 frontier continuity covered by existing frontier recovery tests. No validation, authoritative frontier or durable commit path changes. Validation: offline locked P2P lib 929 passed, 1 external Tor test ignored; RPC network 54 passed; all-target strict Clippy for primitives, mempool, p2p, node and rpc; fmt and diff checks passed. Real Tor/IVNP interoperability, binary/workspace/native campaigns remain unrun. --- crates/p2p/src/connection.rs | 6 +++ .../p2p/src/listener/getdata_pause_tests.rs | 3 +- crates/p2p/src/overlay.rs | 7 ---- crates/p2p/src/overlay/sam.rs | 9 ++-- crates/p2p/src/peer_table.rs | 41 +++++++++++++++++++ crates/rpc/src/handlers/network.rs | 31 ++++++++++++++ 6 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/p2p/src/connection.rs b/crates/p2p/src/connection.rs index 27e23dde6..060d49ee9 100644 --- a/crates/p2p/src/connection.rs +++ b/crates/p2p/src/connection.rs @@ -961,6 +961,12 @@ impl PeerLease { self } + pub(crate) fn belongs_to_transport_epoch(&self, epoch: &Arc) -> bool { + self.transport_alive + .as_ref() + .is_some_and(|alive| Arc::ptr_eq(alive, epoch)) + } + pub(crate) fn with_no_ban(mut self, no_ban: bool) -> Self { self.no_ban = no_ban; self diff --git a/crates/p2p/src/listener/getdata_pause_tests.rs b/crates/p2p/src/listener/getdata_pause_tests.rs index 819c63da8..b50182238 100644 --- a/crates/p2p/src/listener/getdata_pause_tests.rs +++ b/crates/p2p/src/listener/getdata_pause_tests.rs @@ -272,10 +272,11 @@ impl Served { let (server_stream, addr) = listener.accept()?; configure_peer_stream(&client_stream)?; configure_peer_stream(&server_stream)?; - let writer_socket = server_stream.try_clone()?; if let WriterKind::Production { write_timeout } = writer { server_stream.set_write_timeout(Some(*write_timeout))?; } + // Windows clones retain the timeout present at duplication time. + let writer_socket = server_stream.try_clone()?; let (outbound_tx, outbound_rx) = crossbeam_channel::unbounded(); let mut lease = PeerLease::new_with_budget(outbound_tx, true, budget); diff --git a/crates/p2p/src/overlay.rs b/crates/p2p/src/overlay.rs index 205262d53..d6fdc3ad3 100644 --- a/crates/p2p/src/overlay.rs +++ b/crates/p2p/src/overlay.rs @@ -584,10 +584,3 @@ fn pause(cancelled: &dyn Fn() -> bool, delay: Duration) { std::thread::sleep(POLL.min(deadline.saturating_duration_since(Instant::now()))); } } -fn retire(table: &crate::PeerTable, network: PeerNetwork) { - for session in table.sessions() { - if session.addr.network() == network { - session.lease.cancel(); - } - } -} diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index a8f0768ee..398029f78 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -1,8 +1,7 @@ //! IVNP SAM v3 STREAM session lifecycle, naming, connect, and accept. use super::{ Arc, AtomicBool, Connected, Duration, Instant, Ordering, OverlayTransport, PeerAddress, - PeerEndpoint, PeerNetwork, SETUP_TIMEOUT, Setup, SocketAddr, cancelled_error, identity, - invalid, io, pause, retire, + PeerEndpoint, SETUP_TIMEOUT, Setup, SocketAddr, cancelled_error, identity, invalid, io, pause, }; use sha2::{Digest, Sha256}; pub(super) struct Session { @@ -276,11 +275,14 @@ impl OverlayTransport { monitored }) })(); + // This worker alone unpublishes the epoch and wakes its leases. + // Connect/accept failures may revoke admission through `alive`, but + // cannot remove sessions or cancel connections of another epoch. let retired = self.sam.write().take(); if let Some(session) = retired { session.alive.store(false, Ordering::Release); + shared.peer_table.cancel_transport_epoch(&session.alive); } - retire(&shared.peer_table, PeerNetwork::I2p); if let Err(error) = result { if !cancelled() { tracing::warn!(%error, "IVNP SAM unavailable; retiring streams and retrying"); @@ -346,6 +348,7 @@ impl OverlayTransport { #[expect(clippy::expect_used)] mod tests { use super::*; + use crate::PeerNetwork; use base64::Engine as _; use std::io::{BufRead as _, BufReader, Write as _}; use std::net::TcpListener; diff --git a/crates/p2p/src/peer_table.rs b/crates/p2p/src/peer_table.rs index 935fac5a8..debbdb622 100644 --- a/crates/p2p/src/peer_table.rs +++ b/crates/p2p/src/peer_table.rs @@ -164,6 +164,17 @@ impl PeerTable { Self::default() } + /// The transport owner revokes its epoch, then asks the table to wake only + /// that epoch's leases. Connection owners retain identity-checked removal; + /// neither a late teardown nor a replacement epoch can remove its successor. + pub(crate) fn cancel_transport_epoch(&self, epoch: &Arc) { + for entry in self.entries.read().values() { + if entry.lease.belongs_to_transport_epoch(epoch) { + entry.lease.cancel(); + } + } + } + /// Registers `lease` as the live connection at `addr`, cancelling and /// replacing any predecessor. Returns whether a different connection was /// replaced; re-registering the same connection is a no-op that keeps its @@ -1045,6 +1056,36 @@ impl PeerTable { mod tests { use super::*; + #[test] + fn retiring_an_epoch_wakes_its_leases_without_cancelling_a_replacement() { + let table = PeerTable::new(); + let old_epoch = Arc::new(AtomicBool::new(true)); + let new_epoch = Arc::new(AtomicBool::new(true)); + let address = crate::PeerEndpoint::new(crate::PeerAddress::I2p([7; 32]), 8333); + let other = crate::PeerEndpoint::new(crate::PeerAddress::I2p([8; 32]), 8333); + let old = lease().with_transport_alive(Some(Arc::clone(&old_epoch))); + let remaining = lease().with_transport_alive(Some(Arc::clone(&old_epoch))); + table.register(address, old.clone()); + table.register(other, remaining.clone()); + let successor = lease().with_transport_alive(Some(new_epoch)); + table.register(address, successor.clone()); + + // The SAM worker revokes admission before waking the old epoch's I/O. + old_epoch.store(false, Ordering::Release); + assert!(!remaining.cancellation().load(Ordering::Acquire)); + table.cancel_transport_epoch(&old_epoch); + assert!(remaining.cancellation().load(Ordering::Acquire)); + assert!(!successor.is_cancelled()); + assert!(!table.remove_current(address, &old)); + assert!( + table + .lease(address) + .is_some_and(|lease| lease.same_connection(&successor)) + ); + table.cancel_transport_epoch(&old_epoch); + assert!(!successor.is_cancelled()); + } + #[test] fn anonymous_sources_keep_independent_leases_and_roundtrip_mempool_attribution() { let table = PeerTable::new(); diff --git a/crates/rpc/src/handlers/network.rs b/crates/rpc/src/handlers/network.rs index 4827c6c6b..ba0058aad 100644 --- a/crates/rpc/src/handlers/network.rs +++ b/crates/rpc/src/handlers/network.rs @@ -1651,6 +1651,37 @@ mod getnodeaddresses_tests { ctx.network.peer_table.publish_info(info.addr, &lease, info); } + #[test] + fn anonymous_handles_are_visible_and_disconnectable_but_not_destinations() { + let ctx = Context::new(); + let (tx, _rx) = crossbeam_channel::unbounded(); + let lease = PeerLease::new_inbound(tx); + let remote = bitcoin_rs_p2p::PeerRemote::AnonymousTor(lease.connection_id()); + let mut info = peer("127.0.0.1:8333", 9); + info.addr = remote; + info.inbound = true; + ctx.network.peer_table.register(remote, lease.clone()); + ctx.network.peer_table.publish_info(remote, &lease, info); + let ctx = Arc::new(ctx); + let listed = getpeerinfo(&ctx, &json!([])).expect("peer info"); + let row = &listed.as_array().expect("peers")[0]; + let handle = remote.to_string(); + assert_eq!( + row.get("addr").and_then(JsonValueTrait::as_str), + Some(handle.as_str()) + ); + assert_eq!( + row.get("network").and_then(JsonValueTrait::as_str), + Some("onion") + ); + let destinations = getnodeaddresses(&ctx, &json!([0])).expect("destinations"); + assert_eq!(destinations.as_array().expect("addresses").len(), 0); + assert!(addnode(&ctx, &json!([handle.as_str(), "add"])).is_err()); + assert!(disconnectnode(&ctx, &json!([handle.as_str()])).is_ok()); + assert!(lease.is_cancelled()); + assert!(ctx.network.peer_table.is_empty()); + } + #[test] fn empty_context_returns_empty_array() { let ctx = Arc::new(Context::new()); From 8c8eac265f6d692cc57e254b71c6ec77646cf257 Mon Sep 17 00:00:00 2001 From: rabbitprincess Date: Tue, 27 Oct 2026 23:18:48 +0900 Subject: [PATCH 19/19] fix(p2p): observe lease epochs during payload reads Use PeerLease::is_cancelled for inbound and outbound payload readers instead of exposing a separate local cancellation flag. Retain SAM lifecycle ownership and exact-epoch lease wakeups. A trickling old stream cannot strand the active dial after epoch revocation; recovery tests also protect replacement and unrelated epochs. Sync impact: peer loss and transport delivery can affect SYNC-02/04/06; authoritative frontier, validation and chainstate commit implementations are unchanged. After integrating the concurrent epoch-scoped teardown change, payload cancellation, exact-epoch wakeups, mocked SAM recovery (26 passed, 1 external test ignored), anonymous RPC handling, frontier recovery (12), and tick delivery (1) passed. Strict P2P Clippy, node/RPC all-target checks and formatting passed. An AddrMan anchor-consumption test failed once in a parallel suite; isolated and full 69-test reruns passed, with cause unconfirmed. External router/process convergence remains unrun. --- crates/p2p/src/connection.rs | 8 +-- crates/p2p/src/counters.rs | 91 ++++++++++++++++-------------- crates/p2p/src/listener.rs | 3 +- crates/p2p/src/overlay/sam.rs | 5 ++ crates/p2p/src/overlay/tests.rs | 46 ++++++++++++++- crates/p2p/src/peer_table.rs | 4 +- docs/policies/p2p-compatibility.md | 12 +++- 7 files changed, 117 insertions(+), 52 deletions(-) diff --git a/crates/p2p/src/connection.rs b/crates/p2p/src/connection.rs index 060d49ee9..8544dc5ee 100644 --- a/crates/p2p/src/connection.rs +++ b/crates/p2p/src/connection.rs @@ -820,6 +820,9 @@ pub struct PeerLease { id: ConnectionId, outbound: Sender, cancel: Arc, + /// Read-only reference to the transport owner's epoch marker. Local + /// connection cancellation and provider-epoch revocation are distinct; + /// `is_cancelled` is their single predicate for all connection consumers. transport_alive: Option>, close_tx: Sender<()>, close_rx: Receiver<()>, @@ -1264,11 +1267,6 @@ impl PeerLease { .as_ref() .is_some_and(|alive| !alive.load(Ordering::Acquire)) } - - /// The flag [`Self::cancel`] sets, for a socket wrapper to observe. - pub(crate) fn cancellation(&self) -> Arc { - Arc::clone(&self.cancel) - } } #[cfg(test)] diff --git a/crates/p2p/src/counters.rs b/crates/p2p/src/counters.rs index 0adf68d02..81ae94a65 100644 --- a/crates/p2p/src/counters.rs +++ b/crates/p2p/src/counters.rs @@ -4,7 +4,7 @@ use bitcoin_rs_primitives::unix_time_secs; use std::io::{IoSlice, IoSliceMut, Read, Result as IoResult, Write}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; /// Bytes and activity times for one peer connection. /// @@ -93,8 +93,8 @@ impl Eq for PeerCounters {} pub struct CountingStream { inner: S, counters: Arc, - /// The connection's cancellation flag, when reads must stop with it. - cancelled: Option>, + /// The connection owner's cancellation view, including its transport epoch. + lease: Option, read_buf: Vec, read_pos: usize, read_end: usize, @@ -112,7 +112,7 @@ impl CountingStream { Self { inner, counters, - cancelled: None, + lease: None, read_buf: Vec::new(), read_pos: 0, read_end: 0, @@ -125,19 +125,19 @@ impl CountingStream { &self.counters } - /// Makes every read fail once `cancelled` is set, so a peer trickling a - /// payload cannot keep a cancelled connection's thread and socket alive. + /// Makes every read consult the lease, so a trickling payload cannot keep + /// a locally cancelled or transport-revoked connection alive mid-frame. #[must_use] - pub(crate) fn stop_reading_when(mut self, cancelled: Arc) -> Self { - self.cancelled = Some(cancelled); + pub(crate) fn stop_reading_when(mut self, lease: crate::PeerLease) -> Self { + self.lease = Some(lease); self } fn check_cancelled(&self) -> IoResult<()> { if self - .cancelled + .lease .as_ref() - .is_some_and(|cancelled| cancelled.load(Ordering::Acquire)) + .is_some_and(crate::PeerLease::is_cancelled) { return Err(std::io::Error::new( std::io::ErrorKind::ConnectionAborted, @@ -802,38 +802,47 @@ mod tests { #[test] fn a_cancelled_stream_stops_a_payload_that_keeps_arriving() -> IoResult<()> { - let listener = std::net::TcpListener::bind("127.0.0.1:0")?; - let mut client = std::net::TcpStream::connect(listener.local_addr()?)?; - let (server, _) = listener.accept()?; - let cancelled = Arc::new(AtomicBool::new(false)); - let mut stream = CountingStream::new(server, Arc::new(PeerCounters::default())) - .stop_reading_when(Arc::clone(&cancelled)); - // One byte every 5 ms for about a second, then EOF: without the - // cancellation check the read below ends in `UnexpectedEof`. - let trickle = std::thread::spawn(move || { - for _ in 0..200 { - if client.write_all(&[0]).is_err() { - return; + use std::sync::atomic::AtomicBool; + for transport_failure in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let mut client = std::net::TcpStream::connect(listener.local_addr()?)?; + let (server, _) = listener.accept()?; + let alive = Arc::new(AtomicBool::new(true)); + let (tx, _rx) = crossbeam_channel::unbounded(); + let lease = crate::PeerLease::new(tx).with_transport_alive(Some(Arc::clone(&alive))); + let mut stream = CountingStream::new(server, Arc::new(PeerCounters::default())) + .stop_reading_when(lease.clone()); + // One byte every 5 ms for about a second, then EOF: without the + // cancellation check the read below ends in `UnexpectedEof`. + let trickle = std::thread::spawn(move || { + for _ in 0..200 { + if client.write_all(&[0]).is_err() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - }); - let canceller = { - let cancelled = Arc::clone(&cancelled); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(50)); - cancelled.store(true, Ordering::Release); - }) - }; - let mut payload = Vec::new(); - let result = crate::recv::fill_growing(&mut stream, &mut payload, 4_000_000); - drop(stream); - let _ = trickle.join(); - let _ = canceller.join(); - assert_eq!( - result.map_err(|error| error.kind()), - Err(std::io::ErrorKind::ConnectionAborted) - ); + }); + let canceller = { + let alive = Arc::clone(&alive); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + if transport_failure { + alive.store(false, Ordering::Release); + } else { + lease.cancel(); + } + }) + }; + let mut payload = Vec::new(); + let result = crate::recv::fill_growing(&mut stream, &mut payload, 4_000_000); + drop(stream); + let _ = trickle.join(); + let _ = canceller.join(); + assert_eq!( + result.map_err(|error| error.kind()), + Err(std::io::ErrorKind::ConnectionAborted) + ); + } Ok(()) } } diff --git a/crates/p2p/src/listener.rs b/crates/p2p/src/listener.rs index d999539b4..93cc791e8 100644 --- a/crates/p2p/src/listener.rs +++ b/crates/p2p/src/listener.rs @@ -931,6 +931,7 @@ fn dial( "transport or network unavailable", )); }; + let stream = stream.stop_reading_when(lease.clone()); if let Err(error) = shared.check_destination(addr, manual) { shared.peer_table.remove_current(addr, &lease); let _ = stream.shutdown(std::net::Shutdown::Both); @@ -1236,7 +1237,7 @@ fn run_handshake( // Eviction cancels the lease from the accept loop; reads then fail even // mid-payload, as Core closes an evicted connection at once. let stream = match crate::CountingStream::from_connected(stream, counters) - .map(|stream| stream.stop_reading_when(lease.cancellation())) + .map(|stream| stream.stop_reading_when(lease.clone())) .map_err(crate::wire::PeerError::Io) { Ok(stream) => stream, diff --git a/crates/p2p/src/overlay/sam.rs b/crates/p2p/src/overlay/sam.rs index 398029f78..5c4c2394a 100644 --- a/crates/p2p/src/overlay/sam.rs +++ b/crates/p2p/src/overlay/sam.rs @@ -9,6 +9,11 @@ pub(super) struct Session { pub(super) alive: Arc, pub(super) local_address: PeerEndpoint, } +// run_sam alone publishes/removes session records and starts replacements. +// Each epoch has one irreversible alive marker, shared by streams and leases. +// An INVALID_ID only revokes the marker observed by that request; it never +// replaces a session or cancels peers by network. Ordinary lease teardown and +// the sync reconciler consume that revocation through PeerLease::is_cancelled. fn hello(setup: &mut Setup<'_>) -> io::Result<()> { setup.write(b"HELLO VERSION MIN=3.1 MAX=3.3\n")?; let line = setup.line()?; diff --git a/crates/p2p/src/overlay/tests.rs b/crates/p2p/src/overlay/tests.rs index e3f8a3bec..3d8c7b6d2 100644 --- a/crates/p2p/src/overlay/tests.rs +++ b/crates/p2p/src/overlay/tests.rs @@ -357,7 +357,10 @@ fn sam_session_recovery(invalid_id: bool) { ) .expect("local destination"); } else if line.starts_with("STREAM CONNECT ") { - if invalid_id && break_control.load(Ordering::Acquire) { + if invalid_id + && break_control.load(Ordering::Acquire) + && controls.load(Ordering::Acquire) == 1 + { stream .write_all(b"STREAM STATUS RESULT=INVALID_ID\n") .expect("invalid session"); @@ -407,6 +410,15 @@ fn sam_session_recovery(invalid_id: bool) { let lease = crate::PeerLease::new(tx).with_transport_alive(connected.alive.clone()); shared.peer_table.register(endpoint, lease.clone()); assert!(!lease.is_cancelled()); + // A shared network label is not epoch ownership. This lease carries a + // different marker and must survive both old-session recovery and shutdown. + let (other_tx, _other_rx) = crossbeam_channel::unbounded(); + let independent = + crate::PeerLease::new(other_tx).with_transport_alive(Some(Arc::new(AtomicBool::new(true)))); + let other_endpoint = PeerEndpoint::new(PeerAddress::I2p([8; 32]), 18444); + shared + .peer_table + .register(other_endpoint, independent.clone()); break_control.store(true, Ordering::Release); if invalid_id { assert!(owner.connect(endpoint, &|| false).is_err()); @@ -416,9 +428,36 @@ fn sam_session_recovery(invalid_id: bool) { assert!( connected .alive + .as_ref() .is_some_and(|alive| !alive.load(Ordering::Acquire)), "old stream never adopts new session" ); + assert!( + !independent.is_cancelled(), + "retirement is scoped to one epoch" + ); + let mut fresh = owner + .connect(endpoint, &|| false) + .expect("replacement stream"); + fresh + .stream + .read_exact(&mut bytes) + .expect("replacement P2P bytes"); + assert_eq!(&bytes, b"bitcoin"); + let (tx, _rx) = crossbeam_channel::unbounded(); + let replacement = crate::PeerLease::new(tx).with_transport_alive(fresh.alive.clone()); + shared.peer_table.register(endpoint, replacement.clone()); + assert!(!shared.peer_table.remove_current(endpoint, &lease)); + connected + .alive + .as_ref() + .expect("old epoch") + .store(false, Ordering::Release); + assert!( + !replacement.is_cancelled(), + "late old-epoch revocation cannot reach replacement" + ); + assert!(shared.peer_table.is_current(replacement.source(endpoint))); assert!( !shared.banned.is_discouraged(endpoint.address()), "local router loss is not remote misbehavior" @@ -427,5 +466,10 @@ fn sam_session_recovery(invalid_id: bool) { for worker in workers { worker.join().expect("worker").expect("backend"); } + assert!( + replacement.is_cancelled(), + "the new epoch retires at its own shutdown" + ); + assert!(!independent.is_cancelled()); fixture.join().expect("SAM fixture"); } diff --git a/crates/p2p/src/peer_table.rs b/crates/p2p/src/peer_table.rs index debbdb622..5033ed8b7 100644 --- a/crates/p2p/src/peer_table.rs +++ b/crates/p2p/src/peer_table.rs @@ -1072,9 +1072,9 @@ mod tests { // The SAM worker revokes admission before waking the old epoch's I/O. old_epoch.store(false, Ordering::Release); - assert!(!remaining.cancellation().load(Ordering::Acquire)); + assert!(remaining.close_signal().try_recv().is_err()); table.cancel_transport_epoch(&old_epoch); - assert!(remaining.cancellation().load(Ordering::Acquire)); + assert!(remaining.close_signal().try_recv().is_ok()); assert!(!successor.is_cancelled()); assert!(!table.remove_current(address, &old)); assert!( diff --git a/docs/policies/p2p-compatibility.md b/docs/policies/p2p-compatibility.md index 2108adfad..6b2cc43c8 100644 --- a/docs/policies/p2p-compatibility.md +++ b/docs/policies/p2p-compatibility.md @@ -729,8 +729,16 @@ One-shot manual requests do not create retained retry state. Remote-vs-local failure attribution remains with the transport/listener and AddrMan owners described above; there is no overlay-specific peer retry loop. -Control-session loss revokes I2P stream epochs, including late registrations, -and the worker restores the selected stable destination with bounded backoff. +`run_sam` alone publishes/removes the SAM session record and starts replacement +epochs. Each epoch has one irreversible liveness marker shared by its streams +and leases. Control-session loss or `INVALID_ID` revokes only the observed +marker; a late failure cannot revoke the replacement. Reads, including reads +in the middle of a payload, use `PeerLease::is_cancelled`, which combines local +connection cancellation with that same epoch marker. The SAM worker asks the +peer table to wake only leases carrying its exact epoch marker. Ordinary +identity-checked lease teardown and sync reconciliation handle revocation; +there is no network-wide I2P cancellation sweep. The SAM worker restores the +selected stable destination with bounded backoff, independently of remote-peer retries. Inbound Tor service identity is persistent but remote circuit identity is unknown. Opaque per-connection handles are usable for disconnecting a live