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 9970838bb..dd73cdea3 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", @@ -466,6 +467,7 @@ dependencies = [ "metrics", "parking_lot", "proptest", + "rustix", "serde", "serde_json", "sha2", @@ -481,8 +483,10 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "serde_json", "sha2", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -1553,6 +1557,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" @@ -2605,6 +2619,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/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/mempool/src/admission.rs b/crates/mempool/src/admission.rs index c60ab35eb..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: core::net::SocketAddr::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 655841d35..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: core::net::SocketAddr::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 5ca84291c..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 wire address. - pub addr: core::net::SocketAddr, + /// 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 ba5305eb4..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: core::net::SocketAddr::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/src/chain_effects.rs b/crates/node/src/chain_effects.rs index 3943a8ac0..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)), + 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)), + 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)), + addr: Some(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 5837c7b18..26c779633 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. @@ -283,6 +285,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), @@ -342,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 @@ -406,6 +430,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 24ffdc1e6..f6039e42d 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,20 @@ 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> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } + 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 +571,60 @@ macro_rules! option_rows { env["BITCOIN_RS_CONNECT", parse_connect_list] toml each("connect", parse_connect_endpoint) } + /// 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") + } + /// 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") + } + /// 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") + } + /// 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") + } + /// 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] + toml native("tor_cookie") + } + /// 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") + } + /// 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] + 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..7304e382e 100644 --- a/crates/node/src/state/open.rs +++ b/crates/node/src/state/open.rs @@ -373,6 +373,11 @@ 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() + } + .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/node/tests/config_layered.rs b/crates/node/tests/config_layered.rs index 91eec8c90..29ab77a5c 100644 --- a/crates/node/tests/config_layered.rs +++ b/crates/node/tests/config_layered.rs @@ -75,6 +75,74 @@ fn public_listener_bind_validation_handles_wildcards_and_ephemeral_ports() -> Re Ok(()) } +#[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 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_eq!( + resolve(&[&file, &environment])?.p2p.overlay.onlynet, + Vec::::new() + ); + 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/node/tests/lease_registration_215.rs b/crates/node/tests/lease_registration_215.rs index 2f0deec5a..36a41a920 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, diff --git a/crates/p2p/Cargo.toml b/crates/p2p/Cargo.toml index e9933b861..940352ec0 100644 --- a/crates/p2p/Cargo.toml +++ b/crates/p2p/Cargo.toml @@ -37,12 +37,16 @@ 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 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/addrman.rs b/crates/p2p/src/addrman.rs index 7e7678585..02365de82 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,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 = 8; +// 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; @@ -40,9 +43,8 @@ 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), } impl Source { fn dns(seed: &str) -> Self { @@ -54,20 +56,12 @@ 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); 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 - } } } } @@ -77,7 +71,7 @@ impl Source { struct Candidate { #[serde(skip)] creation_id: u64, - addr: SocketAddr, + addr: PeerEndpoint, #[serde(deserialize_with = "required_services")] services: Option, source: Source, @@ -94,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( @@ -107,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; @@ -137,7 +123,7 @@ impl Candidate { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Anchor { - addr: SocketAddr, + addr: PeerEndpoint, confirmed_at: u64, } @@ -152,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], @@ -165,7 +152,7 @@ struct Stored { struct Manager { stored: Stored, groups: NetGroups, - by_addr: HashMap, + by_addr: HashMap, new: Vec, tried: Vec, rng: StdRng, @@ -182,9 +169,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 +197,24 @@ 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(), }; 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 +222,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()); @@ -257,7 +246,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(), @@ -287,10 +277,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 +344,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 +387,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 +438,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 +446,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 +482,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 +530,7 @@ impl Manager { } fn learn( &mut self, - addr: SocketAddr, + addr: PeerEndpoint, services: Option, source: Source, seen: u64, @@ -555,12 +545,12 @@ impl Manager { { return false; } - let time_penalty = if matches!(&source, Source::Ip(ip) if crate::netgroup::canonical_ip(*ip) == addr.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(), + Source::Internal(_) => 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 { @@ -650,7 +640,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 +708,17 @@ 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::Ip(IpAddr::V4(ip)) => { let octets = ip.octets(); if ip.is_broadcast() || octets[0] == 0 || octets[0] >= 240 { return false; @@ -748,9 +734,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; @@ -765,22 +751,6 @@ fn routable(addr: SocketAddr, 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, @@ -797,7 +767,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"); @@ -810,48 +780,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(); - } - // 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; + tracing::warn!(path=%source_path.display(), "address book contains inadmissible addresses; preserving file and disabling writes"); + manager.writable = false; } - 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; } } } @@ -860,7 +830,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 +844,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 +853,20 @@ 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(); 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 +877,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 +908,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 +919,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 +928,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 +941,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 +955,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 +968,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 +978,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 +996,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 +1014,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 +1024,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 +1036,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 +1052,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 +1071,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 +1086,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 +1102,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 +1117,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 +1146,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 +1191,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 +1220,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 +1262,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 +1324,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 +1364,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 +1384,18 @@ 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), + }; + 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; } @@ -1472,8 +1455,8 @@ fn invalid(message: &'static str) -> io::Error { } struct Loaded { - stored: Stored, - schema: u32, + stored: Option, + magic: [u8; 4], bytes: Vec, } @@ -1562,115 +1545,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: SocketAddr, - 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: SocketAddr, - 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 { @@ -1695,7 +1569,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(); @@ -1704,6 +1581,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 @@ -1718,7 +1597,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); @@ -1745,123 +1624,6 @@ fn validate_current( } Ok(()) } -fn legacy_slot(secret: &[u8; 32], entry: &LegacyCandidate) -> u64 { - let mut hash = Sha256::new(); - hash.update(secret); - hash.update(legacy_prefix(entry.addr.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 { - if !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)) - { - 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) - } -} fn read_book( path: &Path, magic: Option<[u8; 4]>, @@ -1870,7 +1632,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, @@ -1892,87 +1662,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, - } - } - VERSION => serde_json::from_slice(&bytes[..payload_len]).map_err(io::Error::other)?, - _ => return Err(invalid("unsupported address book 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) { @@ -1980,17 +1710,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 @@ -1999,15 +1727,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) { @@ -2115,7 +1844,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 0250e8974..aeb86183e 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!( @@ -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 @@ -263,16 +264,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 +282,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 +290,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 +314,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 +334,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 +351,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 +394,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 +456,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 +469,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 +574,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 +646,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 +705,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 +737,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, @@ -739,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"); @@ -897,7 +824,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 +844,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 +864,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 +1050,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 +1088,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 +1106,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 +1228,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 +1247,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 +1331,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 +1342,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 +1359,7 @@ mod probe_tests; 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 f5c8abd80..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"); @@ -47,11 +42,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 +56,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 +104,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 +120,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]]; @@ -136,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); @@ -302,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 add393407..24fdea5ef 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)); { @@ -117,13 +117,17 @@ 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).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"); 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() @@ -155,10 +159,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..5cd5b34f6 --- /dev/null +++ b/crates/p2p/src/addrman/tests/overlay_destinations.rs @@ -0,0 +1,94 @@ +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 [ + 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"); + 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) + ); +} 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 a71b309f9..ddd5700fc 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()) @@ -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"); @@ -263,7 +220,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 +341,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 +449,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() @@ -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"); @@ -657,7 +563,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 +578,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 +598,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 +616,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 +627,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 +658,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..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"], 8); + 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)); @@ -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); } @@ -180,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: SocketAddr = 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"], 8); - 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"], 8); -} - #[test] fn zero_gossip_preserves_unknown_dns_bootstrap_but_version_zero_is_known() { let directory = tempfile::tempdir().expect("directory"); @@ -411,7 +197,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 +211,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 +235,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 e4d6262a8..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 std::net::SocketAddr; +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: SocketAddr) -> 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<(SocketAddr, 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<(SocketAddr, 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<(SocketAddr, 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<(SocketAddr, 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<(SocketAddr, 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) -> SocketAddr { - SocketAddr::from(([127, 0, 0, 1], port)) + fn test_addr(port: u16) -> PeerRemote { + PeerRemote::from(([127, 0, 0, 1], port)) } - fn test_peer_info(addr: SocketAddr, send_headers: bool) -> PeerInfo { + fn test_peer_info(addr: PeerRemote, send_headers: bool) -> PeerInfo { PeerInfo { addr, version: 70016, @@ -962,7 +962,10 @@ mod tests { best_known_height: 0, conn_time: 0, inbound: false, - addr_bind: addr, + addr_bind: 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()), transport: crate::TransportProtocol::V1, @@ -972,7 +975,7 @@ mod tests { fn setup_peer( table: &Arc, announcer: &BlockAnnouncer, - addr: SocketAddr, + addr: PeerRemote, send_headers: bool, ) -> (PeerLease, crate::test_support::MessageReceiver) { let (tx, rx) = crate::test_support::message_channel(); @@ -985,17 +988,17 @@ mod tests { (lease, rx) } - fn source(table: &PeerTable, addr: SocketAddr) -> 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: SocketAddr, 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: SocketAddr, + addr: PeerRemote, high_bandwidth: bool, version: u64, ) { diff --git a/crates/p2p/src/compact_blocks.rs b/crates/p2p/src/compact_blocks.rs index 9240cc105..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: std::net::SocketAddr::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 9f1e72f32..8544dc5ee 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}; @@ -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 { - /// Remote socket address at delivery time. - pub(crate) addr: SocketAddr, + /// 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: SocketAddr) -> 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; @@ -732,6 +820,10 @@ 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<()>, budget: Arc, @@ -744,7 +836,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, @@ -838,6 +930,7 @@ impl PeerLease { id: ConnectionId::allocate(), outbound, cancel: Arc::new(AtomicBool::new(false)), + transport_alive: None, close_tx, close_rx, budget: Arc::new(budget), @@ -866,6 +959,17 @@ impl PeerLease { ) } + pub(crate) fn with_transport_alive(mut self, alive: Option>) -> Self { + self.transport_alive = alive; + 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 @@ -1022,9 +1126,9 @@ 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: impl Into) -> PeerSource { PeerSource { - addr, + addr: addr.into(), connection_id: self.id, } } @@ -1158,11 +1262,10 @@ impl PeerLease { #[must_use] pub fn is_cancelled(&self) -> bool { self.cancel.load(Ordering::Acquire) - } - - /// The flag [`Self::cancel`] sets, for a socket wrapper to observe. - pub(crate) fn cancellation(&self) -> Arc { - Arc::clone(&self.cancel) + || self + .transport_alive + .as_ref() + .is_some_and(|alive| !alive.load(Ordering::Acquire)) } } @@ -1172,8 +1275,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}; @@ -1222,9 +1325,9 @@ 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, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -1236,7 +1339,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, @@ -1288,7 +1393,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())); @@ -1316,7 +1421,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()); @@ -1341,7 +1446,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/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/discouragement.rs b/crates/p2p/src/discouragement.rs index e6cf18fdf..c2ef20920 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,14 +31,15 @@ impl DiscouragedPeers { } } - pub(crate) fn insert(&mut self, ip: IpAddr, now: Instant) { + /// Record a reconnectable address and report whether it is retained. + pub(crate) fn insert(&mut self, ip: impl Into, now: Instant) -> bool { self.expire(now); - let ip = ip.to_canonical(); + let ip = ip.into().to_canonical(); 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() @@ -46,14 +48,15 @@ impl DiscouragedPeers { } self.deadlines.insert(ip, until); self.oldest.push_back(ip); + true } - 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 +66,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 +132,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 +141,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..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: std::net::SocketAddr::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 4d0622742..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 std::net::SocketAddr; +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<(SocketAddr, 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: SocketAddr, 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: SocketAddr, 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<[SocketAddr; 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 9ab09a4ed..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: std::net::SocketAddr) -> 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<(std::net::SocketAddr, Hash256)> { +) -> Option<(crate::PeerRemote, Hash256)> { if apply_side_busy { assert_eq!(observe_apply_busy(window, now), BlockedDecision::None); return None; @@ -214,7 +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(std::net::SocketAddr::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; @@ -236,7 +236,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 = 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( @@ -262,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 = std::net::SocketAddr::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( @@ -319,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 = std::net::SocketAddr::from(([127, 0, 0, 1], 8333)); - let retry_peer = std::net::SocketAddr::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( @@ -355,7 +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(std::net::SocketAddr::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, @@ -412,7 +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(std::net::SocketAddr::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![ @@ -467,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 = std::net::SocketAddr::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, @@ -510,8 +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(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(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")); @@ -553,7 +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(std::net::SocketAddr::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 @@ -610,7 +610,7 @@ 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(crate::PeerRemote::from(([127, 0, 0, 1], 8333))); insert_pending(&mut window, solo, hash(0xb1), 1, requested_at); assert_eq!( timeout_owner( @@ -638,7 +638,7 @@ 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(crate::PeerRemote::from(([127, 0, 0, 20 + byte], 8333))); insert_pending( &mut window, owner, @@ -682,7 +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(std::net::SocketAddr::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)), @@ -696,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 = std::net::SocketAddr::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; @@ -856,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 = std::net::SocketAddr::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( @@ -951,7 +951,7 @@ fn insert_pending( /// immediately. fn seed_front_cadence( window: &mut DownloadWindow, - peer: std::net::SocketAddr, + peer: crate::PeerRemote, t0: Instant, gap: Duration, ) -> Instant { @@ -974,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: std::net::SocketAddr, - healthy: std::net::SocketAddr, + staller: crate::PeerRemote, + healthy: crate::PeerRemote, t0: Instant, ) -> (DownloadWindow, BlockStager, Instant) { let mut window = DownloadWindow::new(stall_budget()); @@ -990,12 +990,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() -> crate::PeerRemote { + crate::PeerRemote::from(([127, 0, 0, 1], 8333)) } -fn healthy_addr() -> std::net::SocketAddr { - std::net::SocketAddr::from(([127, 0, 0, 2], 8333)) +fn healthy_addr() -> crate::PeerRemote { + crate::PeerRemote::from(([127, 0, 0, 2], 8333)) } #[test] @@ -1856,8 +1856,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) -> crate::PeerRemote { + crate::PeerRemote::from(([10, 0, 0, idx], 8333)) } #[test] @@ -2186,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, - std::net::SocketAddr, -) { +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()); @@ -2818,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 = std::net::SocketAddr::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); @@ -3316,12 +3310,16 @@ fn owned_fetch_respects_window_capacity_and_frontier() { #[test] fn fanout_eligibility_requires_block_service_flags() { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use crate::PeerRemote; + 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 = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 8333, + )); PeerInfo { addr, version: 70_016, @@ -3335,7 +3333,10 @@ fn fanout_eligibility_requires_block_service_flags() { best_known_height: 0, conn_time: 0, inbound, - addr_bind: addr, + addr_bind: 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()), transport: crate::TransportProtocol::V1, diff --git a/crates/p2p/src/eviction.rs b/crates/p2p/src/eviction.rs index 4e30123b9..725769301 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 crate::PeerRemote; 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: 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)); @@ -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; @@ -138,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 { @@ -165,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: SocketAddr::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, @@ -181,8 +182,10 @@ 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 + .and_then(PeerRemote::endpoint) + .map(crate::PeerEndpoint::port) } #[test] diff --git a/crates/p2p/src/inv.rs b/crates/p2p/src/inv.rs index ec8ad37f1..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; @@ -118,7 +119,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 +132,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() } @@ -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,15 +189,23 @@ mod tests { ServiceFlags::NETWORK }; let mut info = crate::PeerInfo::inbound_from_version( - source.addr, - 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, 0, 0, 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, @@ -244,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)])); @@ -270,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)])); @@ -283,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 178200a45..231042ea0 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; @@ -74,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}; @@ -101,3 +106,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 d7a954a20..93cc791e8 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,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(Arc::default())), shared_outbound: Arc::new(crate::connection::SharedOutboundBudget::new()), } } @@ -245,39 +248,51 @@ 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: 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.ip()); + 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 { - self.banned.discourage(addr.ip()); + if !protected + && !local + && let Some(address) = address + { + discouraged = self.banned.discourage(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"); } } } @@ -311,10 +326,11 @@ impl ConnectionShared { /// and stale-predecessor rules are owned by P2P-02. fn publish_info_and_notify_ready( &self, - peer_addr: SocketAddr, + 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) @@ -323,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); @@ -334,7 +352,7 @@ impl ConnectionShared { } } - fn is_session_cancelled(&self) -> bool { + pub(crate) fn is_session_cancelled(&self) -> bool { self.session_cancel.load() } @@ -379,10 +397,11 @@ impl ConnectionShared { fn send_block( &self, lease: &crate::PeerLease, - peer_addr: SocketAddr, + 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, @@ -540,7 +559,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 +576,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 +629,7 @@ pub fn serve( } fn accept_connections( - addr: SocketAddr, + addr: std::net::SocketAddr, listener: &TcpListener, shutdown: &AtomicBool, shared: &ConnectionShared, @@ -625,45 +644,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 +665,81 @@ 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>, +) { + 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!(?endpoint, "p2p inbound rejected by address policy"); + return; + } + if !shared.activity.is_active() { + drop(stream); + tracing::debug!(?endpoint, "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(endpoint.is_some_and(|endpoint| shared.banned.is_noban(endpoint.address()))) + .with_transport_alive(alive); + 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, + 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 +755,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 +773,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 +781,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 +808,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 +872,30 @@ 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()); + // 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()); + } } - 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,14 +920,18 @@ 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) 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", + )); }; + 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); @@ -917,7 +989,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, )); @@ -996,7 +1068,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,11 +1159,12 @@ pub fn has_all_desirable_service_flags( fn spawn_handshake_thread( stream: TcpStream, - peer_addr: SocketAddr, + 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(); @@ -1140,12 +1213,15 @@ fn negotiate_inbound( fn run_handshake( stream: TcpStream, - peer_addr: SocketAddr, + 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); } @@ -1161,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, @@ -1272,12 +1348,13 @@ fn run_handshake( /// are intentionally not joined by outer listener shutdown. fn run_connected_session( peer: &mut Peer>, - peer_addr: SocketAddr, + 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. @@ -1351,8 +1428,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(); @@ -1985,13 +2064,14 @@ fn retry_deferred_request( #[expect(clippy::too_many_lines)] fn run_message_loop( peer: &mut Peer, - peer_addr: SocketAddr, + 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)); @@ -2297,9 +2377,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( @@ -2315,13 +2399,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), )) @@ -2349,9 +2435,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. @@ -2614,26 +2700,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() } @@ -2643,11 +2725,12 @@ fn addr_v2_rows(rows: &[(u32, bitcoin::p2p::address::Address)]) -> Vec, + addresses: &[(PeerEndpoint, u64, u64)], budget: &mut u64, last_refill: &mut u64, ) { + let peer_addr = peer_addr.into(); if !lease.role().relays_transactions() { return; } @@ -2657,8 +2740,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.ip(), &addresses[..count], now); + if let Some(book) = &shared.address_book + && let Some(endpoint) = peer_addr.endpoint() + { + book.learn_peer(endpoint.address(), &addresses[..count], now); } } @@ -2681,9 +2766,10 @@ fn process_compact_wire_message( local_compact_version: Option, compact_hints: Option<&dyn crate::compact_blocks::CompactBlockHints>, sink: &ProductionSink<'_>, - peer_addr: SocketAddr, + 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. @@ -2764,9 +2850,10 @@ fn process_compact_wire_message( fn handle_compact_outcome( outcome: crate::compact_blocks::Outcome, sink: &ProductionSink<'_>, - peer_addr: SocketAddr, + 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) { @@ -2838,9 +2925,10 @@ fn spawn_connection_writer( outbound_rx: crossbeam_channel::Receiver, close_rx: crossbeam_channel::Receiver<()>, budget: Arc, - peer_addr: SocketAddr, + 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 || { @@ -2999,9 +3087,10 @@ fn forward_tx_if_relay_open( shared: &ConnectionShared, source: crate::PeerSource, tx: bitcoin_rs_primitives::Tx, - peer_addr: SocketAddr, + peer_addr: impl Into, relay_open: bool, ) { + let peer_addr = peer_addr.into(); if relay_open { shared.send_tx(source, tx); } else { @@ -3015,9 +3104,10 @@ fn wake_sync(sync_wake_tx: Option<&Sender<()>>) { } } -fn generate_nonce(peer_addr: SocketAddr) -> 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(); @@ -3081,7 +3171,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, @@ -3094,7 +3184,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, @@ -3138,7 +3229,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}; @@ -3148,7 +3240,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); @@ -3156,7 +3252,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), @@ -3172,7 +3268,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>; @@ -3187,8 +3283,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"); @@ -3198,13 +3298,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"); @@ -3305,13 +3405,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"); @@ -3338,7 +3444,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; @@ -3349,8 +3456,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)); @@ -3380,8 +3491,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; @@ -3391,7 +3503,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(); @@ -3436,7 +3552,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; @@ -3445,9 +3562,9 @@ 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, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -3459,7 +3576,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, @@ -3468,7 +3587,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"); @@ -3495,7 +3619,7 @@ 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 info = peer_info(peer_addr.into(), 0); let result = run_connected_session(&mut peer, peer_addr, &shared, lease, outbound_rx, info); assert!(result.is_err(), "writer setup failure must return Err"); @@ -3513,9 +3637,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}, @@ -3573,12 +3698,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 { @@ -3600,9 +3730,9 @@ 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, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -3614,7 +3744,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, @@ -3626,7 +3758,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); @@ -3655,7 +3787,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); @@ -3684,7 +3816,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); @@ -3723,7 +3855,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(); @@ -3745,7 +3877,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); @@ -3794,33 +3926,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: addr.into(), + 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( @@ -3890,7 +4026,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); @@ -3918,7 +4054,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!( @@ -3932,7 +4068,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); @@ -3949,7 +4085,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); @@ -4245,7 +4381,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, @@ -4257,7 +4393,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, @@ -4331,7 +4469,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, @@ -4343,7 +4481,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, @@ -4358,7 +4498,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, @@ -4417,7 +4557,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()); @@ -4524,7 +4664,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 @@ -4669,15 +4809,16 @@ 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, + addr: addr.into(), version: 70_016, wtxid_relay: false, relay_transactions: true, @@ -4689,7 +4830,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, @@ -4700,7 +4843,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)); @@ -4708,7 +4851,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))); @@ -4717,7 +4860,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))); @@ -4740,7 +4883,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); @@ -4765,7 +4908,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); @@ -4782,7 +4925,6 @@ mod ready_notify_tests { #[cfg(test)] #[expect(clippy::expect_used)] mod block_forward_tests { - use std::net::SocketAddr; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -4889,7 +5031,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: crate::PeerRemote = test_addr(9781, 0)?; let _other_rx = connect_peer(&peers, synthetic_peer(other, 3)); let other_lease = peers .lease(other) @@ -5087,12 +5229,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(), @@ -5116,12 +5264,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(); @@ -5148,15 +5296,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, @@ -5177,7 +5333,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] @@ -5189,15 +5350,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, @@ -5265,13 +5434,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 |_| { @@ -5322,16 +5491,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!( @@ -5394,7 +5563,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), @@ -5467,12 +5636,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!( @@ -5481,9 +5650,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" @@ -5516,14 +5686,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(), ); @@ -5547,7 +5717,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")), @@ -5556,7 +5726,7 @@ mod discouragement_tests { None, ); book.learn_peer( - address.ip(), + address.address(), &[(address, 9, unix_time_secs())], unix_time_secs(), ); @@ -5640,14 +5810,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"), @@ -5664,11 +5834,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" @@ -5685,7 +5855,7 @@ mod discouragement_tests { !shared .banned .automatic_snapshot(SystemTime::now()) - .allows(address.ip()) + .allows(address.address()) ); Ok(()) } @@ -5697,7 +5867,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()?; @@ -5758,12 +5928,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 { @@ -5829,3 +5999,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/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/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 0daf0a5ec..b50182238 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,15 +263,20 @@ 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)?; 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); @@ -734,7 +740,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); diff --git a/crates/p2p/src/listener/loopback_serving_tests.rs b/crates/p2p/src/listener/loopback_serving_tests.rs index 07865d153..4002b2113 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()?; @@ -317,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 5925deb5e..854b72153 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,7 +449,7 @@ 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)?; + 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, &lease, &client, None)); // Liveness proof: the loop must dispatch the writer's ping through diff --git a/crates/p2p/src/net_trace.rs b/crates/p2p/src/net_trace.rs index b43c666f9..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 std::net::SocketAddr; +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: SocketAddr, + 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: SocketAddr) -> 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: SocketAddr, 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() -> SocketAddr { - SocketAddr::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: SocketAddr = 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 5f99e4c81..2e6b2ea28 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(_) => return NetworkClass::Onion, + PeerAddress::I2p(_) => return NetworkClass::I2p, + }; if !routable(ip) { NetworkClass::Unroutable } else if linked_ipv4(ip).is_some() { @@ -156,7 +164,12 @@ 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], + }; // 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) { @@ -330,6 +343,26 @@ pub(crate) const LINKED_ASMAP: &[u8] = mod tests { use super::*; + #[test] + fn overlay_groups_use_address_prefixes() { + 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], + ), + ] { + 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] @@ -357,7 +390,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 +440,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 +454,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 +464,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 +508,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..d6fdc3ad3 --- /dev/null +++ b/crates/p2p/src/overlay.rs @@ -0,0 +1,586 @@ +//! 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 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, + /// 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>, +} +impl OverlayTransport { + pub(crate) fn new(config: Arc) -> Self { + Self { + config, + sam: RwLock::new(None), + local_onion: 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 + .sam + .read() + .as_ref() + .filter(|session| { + self.config.i2p_accept && session.alive.load(Ordering::Acquire) + }) + .map(|session| session.local_address), + _ => None, + }; + OverlayNetworkState { + network, + allowed: self.config.allows(network), + operational: local_address.is_some() + || table + .infos() + .iter() + .any(|peer| peer.addr.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), + }?; + 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) +} + +// 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 { + matches!(error.get_ref(), Some(inner) if inner.is::()) +} +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 { + 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 { + 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, + deadline: Instant, +) -> io::Result { + 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] { + 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", + )); + } + 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()))); + } +} diff --git a/crates/p2p/src/overlay/identity.rs b/crates/p2p/src/overlay/identity.rs new file mode 100644 index 000000000..9da1be855 --- /dev/null +++ b/crates/p2p/src/overlay/identity.rs @@ -0,0 +1,264 @@ +//! 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")); + } + 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 { + 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)?; + // 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")); + } + } + } + Ok(()) +} +pub(super) fn read(path: &Path, kind: Kind) -> io::Result> { + #[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")); + } + #[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.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 + || 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)?; + // 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(), + 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..5c4c2394a --- /dev/null +++ b/crates/p2p/src/overlay/sam.rs @@ -0,0 +1,705 @@ +//! IVNP SAM v3 STREAM session lifecycle, naming, connect, and accept. +use super::{ + Arc, AtomicBool, Connected, Duration, Instant, Ordering, OverlayTransport, PeerAddress, + PeerEndpoint, SETUP_TIMEOUT, Setup, SocketAddr, cancelled_error, identity, invalid, io, pause, +}; +use sha2::{Digest, Sha256}; +pub(super) struct Session { + id: String, + 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()?; + 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) + } +} +#[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") { + 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", + }; + 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)); + } + // 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)); + } + return Err(io::Error::new(io::ErrorKind::ConnectionRefused, message)); + } + Ok(line) +} +fn destination(text: &str) -> io::Result { + Ok(PeerAddress::I2p( + Sha256::digest(identity::public_destination(text)?).into(), + )) +} + +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 + .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)?; + let (_, address) = lookup(&mut setup, name)?; + if !alive.load(Ordering::Acquire) { + return Err(cancelled_error()); + } + Ok(PeerEndpoint::new(address, port)) + } + pub(super) fn sam_connect( + &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_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() { + return Err(invalid( + "SAM naming destination does not match requested identity", + )); + } + drop(naming); + let mut setup = + Setup::new_until(address, &stop, deadline.min(Instant::now() + SETUP_TIMEOUT))?; + hello(&mut setup)?; + setup.deadline = deadline.min(Instant::now() + Duration::from_secs(60)); + // Connect to the validated public destination, not a second name lookup. + setup.write( + format!( + "STREAM CONNECT ID={id} DESTINATION={} TO_PORT={} SILENT=false\n", + public, + endpoint.port() + ) + .as_bytes(), + )?; + 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()); + } + 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"))?; + key = Some(private.to_owned()); + } + if let Some(private) = &key { + identity::create( + &self.config.identity_dir.join("i2p.key"), + private, + identity::Kind::I2p, + )?; + } + 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), + local_address: local, + }); + let healthy_since = Instant::now(); + + std::thread::scope(|scope| { + let accept = self + .config + .i2p_accept + .then(|| scope.spawn(|| self.accept_sam(&id, &alive, shared, &cancelled))); + let monitored = (|| -> io::Result<()> { + 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" { + return Err(invalid("SAM control heartbeat failed")); + } + if healthy_since.elapsed() >= Duration::from_secs(60) { + backoff = 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(()) + } + })(); + alive.store(false, Ordering::Release); + if let Some(accept) = accept { + let _ = accept.join(); + } + 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); + } + 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); + 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 + .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 crate::PeerNetwork; + use base64::Engine as _; + 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 [ + 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); + 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 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 [ + ("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 [ + "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..3d8c7b6d2 --- /dev/null +++ b/crates/p2p/src/overlay/tests.rs @@ -0,0 +1,475 @@ +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()), + 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 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"); + 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.into()); + 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()) + ); + 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"); +} + +#[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 _; + 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] +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_session_recovery(invalid_id: bool) { + 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(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)); + 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.starts_with("NAMING LOOKUP NAME=") { + 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 ") { + 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"); + break; + } + 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 !invalid_id + && 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()); + // 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()); + } + until(|| lease.is_cancelled()); + until(|| controls.load(Ordering::Acquire) >= 2 && owner.sam.read().is_some()); + 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" + ); + shutdown.store(true, Ordering::Release); + 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/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/crates/p2p/src/overlay/tor.rs b/crates/p2p/src/overlay/tor.rs new file mode 100644 index 000000000..c145fa479 --- /dev/null +++ b/crates/p2p/src/overlay/tor.rs @@ -0,0 +1,342 @@ +//! External Tor control service, with a dedicated unprivileged forwarding listener. +use super::{ + 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; +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")) +} + +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 { + pub(super) fn run_tor( + &self, + address: SocketAddr, + mut key: Option, + shared: &crate::listener::ConnectionShared, + shutdown: &AtomicBool, + ) { + 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)?; + authenticate(&mut control, self.config.tor_cookie.as_deref())?; + 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"))?; + key = Some(private.to_owned()); + } + if let Some(private) = &key { + identity::create( + &self.config.identity_dir.join("onion.key"), + private, + identity::Kind::Tor, + )?; + } + *self.local_onion.write() = Some(endpoint); + tracing::info!(service = %endpoint, %forwarding, "Tor inbound service active"); + 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() { + Ok((stream, _forward_socket)) => { + crate::listener::admit_anonymous_tor(stream, 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, crate::PeerRemote::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)); + } + } +} + +#[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/crates/p2p/src/peer.rs b/crates/p2p/src/peer.rs index bbc2dfebb..de0ee150d 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)), + crate::PeerRemote::Endpoint(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)), + crate::PeerRemote::Endpoint(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..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 std::net::SocketAddr; +use crate::PeerRemote; 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: PeerRemote, /// 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: impl Into, + addr_bind: std::net::SocketAddr, version: &VersionMessage, conn_time: u64, version_received_time: u64, @@ -113,15 +113,15 @@ 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: impl Into, + addr_bind: std::net::SocketAddr, version: &VersionMessage, conn_time: u64, version_received_time: u64, counters: Arc, ) -> Self { Self { - addr, + addr: addr.into(), version: version.version, wtxid_relay: false, relay_transactions: version.relay, @@ -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 crate::PeerRemote; + 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,22 @@ 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 = PeerRemote::Endpoint(crate::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.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")), + &fake_version(), + 0, + 0, + counters(), + ) } } @@ -219,12 +232,17 @@ 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 = 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, + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 1_700_000_000, 1_700_000_000, @@ -235,7 +253,9 @@ mod tests { version.timestamp = 1_699_999_940; let behind = PeerInfo::inbound_from_version( addr, - addr, + addr.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")), &version, 1_700_000_000, 1_700_000_000, @@ -248,8 +268,14 @@ 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 = 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); @@ -258,7 +284,16 @@ mod tests { table.publish_info( peer, &lease, - PeerInfo::inbound_from_version(peer, bind, &fake_version(), 0, 0, counters()) + PeerInfo::inbound_from_version( + peer, + bind.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")), + &fake_version(), + 0, + 0, + counters() + ) ), "the live connection publishes" ); @@ -266,18 +301,49 @@ 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.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")) + ); + 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 = SocketAddr::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, &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.endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")), + &version, + 100, + 50, + Arc::clone(&counters), + ); + let outbound = PeerInfo::outbound_from_version( + addr, + addr.endpoint() + .and_then(crate::PeerEndpoint::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 +364,20 @@ 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 = PeerRemote::Endpoint(crate::PeerEndpoint::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + 8333, + )); + let info = PeerInfo::inbound_from_version( + addr, + addr.endpoint() + .and_then(crate::PeerEndpoint::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..5033ed8b7 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 crate::PeerRemote; 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: 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: SocketAddr) -> 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 @@ -164,15 +164,27 @@ 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 /// published metadata. - pub fn register(&self, addr: SocketAddr, 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: SocketAddr, 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(_) => { @@ -192,15 +204,18 @@ 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: SocketAddr, + 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() { + if !activity.is_active() || lease.is_cancelled() { return None; } Self::register_locked(&mut entries, addr, Entry::fresh(lease.clone())); @@ -236,7 +251,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 @@ -252,16 +268,17 @@ impl PeerTable { #[must_use] pub(crate) fn try_register_inbound( &self, - addr: SocketAddr, + 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() { + if !activity.is_active() || lease.is_cancelled() { return None; } let grows_count = match entries.get(&addr) { @@ -296,7 +313,7 @@ impl PeerTable { /// Returns whether one was removed. fn evict_inbound_locked( entries: &mut TableView, - addr: SocketAddr, + addr: PeerRemote, best_block_depth: u64, ) -> bool { // The newcomer would also replace a live outbound connection at its @@ -325,7 +342,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: PeerRemote, entry: &Entry, best_block_depth: u64, ) -> Option { @@ -347,7 +364,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.ip()), + is_local: addr + .endpoint() + .is_some_and(|endpoint| crate::discouragement::is_local(endpoint.address())), }) } @@ -365,7 +384,13 @@ 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: 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) => { @@ -603,7 +628,8 @@ 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: impl Into) -> bool { + let addr = addr.into(); let entries = self.entries.read(); entries.get(&addr).is_some_and(|entry| { entry @@ -630,7 +656,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: SocketAddr, 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)) } @@ -641,16 +668,22 @@ impl PeerTable { } /// Removes and cancels whichever connection is live at `addr`. - pub fn disconnect(&self, addr: SocketAddr) -> 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: SocketAddr, 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: SocketAddr, 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) @@ -669,10 +702,10 @@ impl PeerTable { /// the affected addresses. pub(crate) fn disconnect_matching( &self, - predicate: impl Fn(&SocketAddr, &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) @@ -757,7 +790,8 @@ 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: impl Into) -> bool { + let addr = addr.into(); self.entries.read().contains_key(&addr) } @@ -773,7 +807,8 @@ 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: impl Into) -> Option { + let addr = addr.into(); self.entries .read() .get(&addr) @@ -821,7 +856,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(PeerRemote, &PeerLease, &PeerInfo), ) { for (addr, entry) in self.entries.read().iter() { if entry.lease.is_cancelled() { @@ -853,7 +888,8 @@ 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: impl Into) -> Option { + let addr = addr.into(); self.entries .read() .get(&addr) @@ -917,7 +953,8 @@ 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: impl Into) -> Option { + let addr = addr.into(); let entries = self.entries.read(); let entry = entries.get(&addr)?; entry.info.as_ref()?; @@ -1018,10 +1055,98 @@ impl PeerTable { #[cfg(test)] 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.close_signal().try_recv().is_err()); + table.cancel_transport_epoch(&old_epoch); + assert!(remaining.close_signal().try_recv().is_ok()); + 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(); + 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 = 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()); + 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) -> PeerRemote { + PeerRemote::from(([127, 0, 0, 1], port)) } fn lease() -> PeerLease { @@ -1029,7 +1154,7 @@ mod tests { PeerLease::new(tx) } - fn info(addr: SocketAddr, start_height: i32) -> PeerInfo { + fn info(addr: PeerRemote, start_height: i32) -> PeerInfo { info_with_counters( addr, start_height, @@ -1038,7 +1163,7 @@ mod tests { } fn info_with_counters( - addr: SocketAddr, + addr: PeerRemote, start_height: i32, counters: std::sync::Arc, ) -> PeerInfo { @@ -1055,7 +1180,10 @@ mod tests { best_known_height: start_height, conn_time: 0, inbound: false, - addr_bind: addr, + addr_bind: addr + .endpoint() + .and_then(crate::PeerEndpoint::socket_addr) + .unwrap_or_else(|| panic!("expected IP fixture")), time_offset: 0, counters, transport: crate::TransportProtocol::V1, @@ -1274,23 +1402,27 @@ mod tests { ); } - fn flooder(port: u16) -> SocketAddr { - SocketAddr::from(([1, 2, 3, 4], port)) + fn flooder(port: u16) -> PeerRemote { + PeerRemote::from(([1, 2, 3, 4], port)) } - fn newcomer() -> SocketAddr { - SocketAddr::from(([5, 6, 7, 8], 8333)) + fn newcomer() -> PeerRemote { + PeerRemote::from(([5, 6, 7, 8], 8333)) } - fn netgroup(addr: SocketAddr) -> Vec { - crate::netgroup::NetGroups::default().group(addr.ip()) + 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: SocketAddr, + addr: PeerRemote, lease: PeerLease, max_inbound: usize, ) -> Option { @@ -1472,9 +1604,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); } @@ -1524,7 +1674,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()); @@ -1736,7 +1891,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 bcd10fba7..46daff935 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}; @@ -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`). @@ -67,7 +69,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: Arc, /// Network message-start bytes. pub magic: Magic, /// Auxiliary peer-book base/legacy path. The owner adds the P2P magic to @@ -116,6 +120,7 @@ impl Default for P2pServiceConfig { fn default() -> Self { Self { listen_addrs: Vec::new(), + overlay: Arc::default(), magic: Magic::from_bytes([0; 4]), address_book_path: None, asmap_path: None, @@ -202,6 +207,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 +241,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 +257,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 +267,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 +293,7 @@ pub struct BannedReader { inner: Arc>>, discouraged: Arc>, protected: Arc<[crate::IpSubnet]>, + routing: Arc, } impl BannedReader { @@ -304,6 +313,7 @@ impl BannedReader { crate::discouragement::DiscouragedPeers::default(), )), protected: protected.into(), + routing: Arc::new(crate::OverlayConfig::default()), } } @@ -320,24 +330,30 @@ 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) { - 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. @@ -347,8 +363,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 +385,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 +393,9 @@ pub struct P2pService { peer_table: Arc, banned: BannedReader, address_book: Arc, - added_nodes: 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, @@ -419,11 +440,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(Arc::clone( + &config.overlay, + ))); + 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(), @@ -433,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, @@ -444,6 +471,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 +515,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 +548,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(); @@ -568,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); @@ -580,19 +633,22 @@ 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 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( @@ -645,6 +701,17 @@ 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(); + 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); + if !active.contains_key(&endpoint) { pinned.finished(endpoint, now); } + } + } let received = outbound_rx.lock().recv_timeout(Duration::from_secs(1)); let Ok(dial) = received else { if matches!( @@ -667,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; @@ -694,18 +763,18 @@ 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, ); }) .map(Some); @@ -715,7 +784,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 +994,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 +1024,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: impl Into) -> bool { self.peer_table.disconnect(addr) } @@ -966,7 +1038,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 +1065,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 +1084,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,10 +1101,11 @@ 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, -) { +) -> Vec { + let mut finished = Vec::new(); let mut index = 0; while index < handles.len() { if !handles[index].1.is_finished() { @@ -1040,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"), @@ -1051,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 @@ -1058,9 +1136,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) @@ -1077,13 +1155,13 @@ 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); 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 +1186,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() @@ -1128,12 +1206,12 @@ 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, ) { while !shutdown.load(Ordering::Acquire) { if !network_active.load(Ordering::Acquire) { @@ -1142,32 +1220,23 @@ fn run_fixed_peer_bootstrap( } continue; } - 'endpoints: for endpoint in &endpoints { - if !network_active.load(Ordering::Acquire) { - break 'endpoints; - } - let addresses = match endpoint.as_str().to_socket_addrs() { - 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; - } + let mut resolved = Vec::new(); + for endpoint in &endpoints { + match overlay.resolve(endpoint, &|| { + shutdown.load(Ordering::Acquire) || !network_active.load(Ordering::Acquire) + }) { + 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 @@ -1183,7 +1252,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, @@ -1220,9 +1289,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) } @@ -1232,8 +1304,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 +1323,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 { @@ -1270,7 +1344,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. @@ -1357,7 +1432,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, @@ -1375,7 +1450,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 @@ -1395,7 +1474,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() }; @@ -1506,7 +1587,7 @@ fn newest_excess_full_relay( fn dial_has_capacity( dial: &OutboundDial, - active: &HashMap, + active: &HashMap, total: usize, blocks: usize, extra: bool, @@ -1523,7 +1604,7 @@ fn dial_has_capacity( } fn has_automatic_outbound_capacity( - active: &HashMap, + active: &HashMap, active_limit: usize, extra_dial: bool, ) -> bool { @@ -1547,14 +1628,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); @@ -1576,12 +1657,15 @@ 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() } -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() @@ -1592,9 +1676,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() } @@ -1602,9 +1689,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) @@ -1621,9 +1708,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) @@ -1657,7 +1747,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; @@ -1707,8 +1797,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 { @@ -1728,7 +1817,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); { @@ -1741,7 +1830,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 @@ -1767,9 +1856,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, @@ -1839,16 +1931,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() @@ -1856,9 +1951,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); @@ -1876,7 +1974,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 +1999,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 +2022,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 +2100,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 +2123,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 +2135,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 +2148,7 @@ mod tests { ); pending.insert( - SocketAddr::from(([127, 0, 0, 1], 4)), + PeerEndpoint::from(([127, 0, 0, 1], 4)), ActiveOutbound { feeler: false, @@ -2070,7 +2176,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 +2196,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 +2213,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 +2234,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 +2258,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 +2288,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(); @@ -2223,7 +2329,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); @@ -2247,7 +2353,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 +2367,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 +2385,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(); @@ -2320,7 +2426,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" ); } @@ -2331,8 +2437,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(); @@ -2366,7 +2472,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" ); } @@ -2380,8 +2486,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 +2515,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 +2534,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 +2562,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 +2625,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 @@ -2538,7 +2644,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, @@ -2550,7 +2656,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 +2678,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 +2698,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 +2717,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!( @@ -2639,11 +2749,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!( @@ -2653,7 +2763,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!( @@ -2670,7 +2782,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)); @@ -2681,7 +2793,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 +2812,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 +2837,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 +2861,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 +2889,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 +2974,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 +3030,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 +3106,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, @@ -3027,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); @@ -3041,7 +3160,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 +3175,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 +3196,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 +3253,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 +3297,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 +3371,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 +3391,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 +3407,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 +3450,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 +3469,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 +3494,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 +3582,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 +3628,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 +3647,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 +3687,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 +3752,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 +3801,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 +3888,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 +3908,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 +3944,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 +3981,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 +4029,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 +4059,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"); @@ -3961,10 +4090,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(); 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/crates/p2p/src/sync/receive.rs b/crates/p2p/src/sync/receive.rs index 5eb9ec3d3..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 240775724..7f8519496 100644 --- a/crates/p2p/src/sync/tests.rs +++ b/crates/p2p/src/sync/tests.rs @@ -1,7 +1,7 @@ +use crate::PeerRemote; pub(crate) use crate::test_support::{OrFail, TestResult}; 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,10 @@ 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 = 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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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: SocketAddr) -> 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: SocketAddr, start_height: i32) -> PeerInfo { +pub(crate) fn synthetic_peer(addr: PeerRemote, start_height: i32) -> PeerInfo { PeerInfo { addr, version: 70_016, @@ -2413,7 +2449,10 @@ 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 + .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()), transport: crate::TransportProtocol::V1, @@ -2422,7 +2461,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: PeerRemote, start_height: i32) -> PeerInfo { PeerInfo { services: 0, inbound: true, @@ -2433,11 +2472,11 @@ 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(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 d29cae267..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 = SocketAddr::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 9571eac3d..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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 09fd83944..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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); - let healthy_addr = SocketAddr::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 41061c599..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 = SocketAddr::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 = SocketAddr::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 0bc55a310..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) -> SocketAddr { - SocketAddr::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 d73438130..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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 d675f7813..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 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8333); - let other_peer = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 9c986a5a1..b5b93e0fb 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 crate::PeerRemote; 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,11 @@ fn connect( peers: &Arc, port: u16, start_height: i32, -) -> (SocketAddr, PeerLease, crate::test_support::MessageReceiver) { - let addr = SocketAddr::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 2574bdda7..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: SocketAddr, 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: 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: 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 c35c9268e..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 = SocketAddr::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 95cf9e506..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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 64df440bf..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 = SocketAddr::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 = SocketAddr::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 = SocketAddr::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 17052d433..1807d6648 100644 --- a/crates/p2p/src/sync/tests/stale_tip.rs +++ b/crates/p2p/src/sync/tests/stale_tip.rs @@ -83,8 +83,8 @@ 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((Ipv4Addr::LOCALHOST, 0)).expect("bind a fake peer listener"); listener .set_nonblocking(true) .expect("nonblocking fake peer listener"); @@ -94,7 +94,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..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 = SocketAddr::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 e315f3fd9..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 = SocketAddr::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 = SocketAddr::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 7fb68f51a..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, @@ -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, @@ -1060,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 @@ -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, @@ -1107,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, @@ -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..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, @@ -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..e10ebb8e2 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 _; @@ -24,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, @@ -32,9 +36,11 @@ pub(super) struct InboundClockKey { } impl InboundClockKey { - fn new(remote: IpAddr, bind: 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(), } @@ -201,7 +207,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr, info.addr_bind)); let TxPolicy { relay, inbound_inv, .. } = &mut *policy; @@ -258,7 +264,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), 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) { @@ -303,7 +309,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), 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) { @@ -362,7 +368,7 @@ impl PeerTable { let inbound = session .lease .is_inbound() - .then(|| InboundClockKey::new(session.addr.ip(), info.addr_bind)); + .then(|| InboundClockKey::new(session.addr, info.addr_bind)); let requests = { let mut policy = self.tx_policy.lock(); let TxPolicy { @@ -648,14 +654,14 @@ 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) } fn connect_at( table: &PeerTable, - addr: SocketAddr, - bind: SocketAddr, + addr: PeerEndpoint, + bind: PeerEndpoint, inbound: bool, wtxid: bool, role: crate::PeerRole, @@ -680,7 +686,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, @@ -800,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}")), ) }; @@ -817,10 +825,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(crate::PeerRemote::from(([8, 8, 8, 8], 0)), bind), ); } @@ -1044,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); @@ -1057,7 +1065,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..0b25d29fb 100644 --- a/crates/p2p/src/tx_relay.rs +++ b/crates/p2p/src/tx_relay.rs @@ -452,7 +452,6 @@ mod tests { use bitcoin_rs_primitives::Hash256; 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 +968,7 @@ mod tests { ( 3, AdmissionOrigin::Peer(PeerToken { - addr: SocketAddr::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: SocketAddr::from(([127, 0, 0, 1], 8333)), + addr: Some(crate::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..79ed2e164 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> { ..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)))?; 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 245e4d6f3..000000000 Binary files a/crates/p2p/tests/data/addrman-historical-v1-services.dat and /dev/null differ 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 15bbe03d2..000000000 Binary files a/crates/p2p/tests/data/addrman-historical-v3-asmap.dat and /dev/null differ 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.README.md b/crates/p2p/tests/data/addrman-historical.README.md deleted file mode 100644 index 7b3dac3e7..000000000 --- a/crates/p2p/tests/data/addrman-historical.README.md +++ /dev/null @@ -1,79 +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. 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/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/primitives/src/endpoint.rs b/crates/primitives/src/endpoint.rs new file mode 100644 index 000000000..a3e8226c2 --- /dev/null +++ b/crates/primitives/src/endpoint.rs @@ -0,0 +1,339 @@ +//! 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", + } + } +} + +/// Routable Bitcoin peer address, independent of a live connection. +#[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]), +} + +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(_) => 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)), + } + } +} +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)?)); + } + 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)?; + 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)?; + 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..f17c613d2 --- /dev/null +++ b/crates/primitives/src/endpoint/tests.rs @@ -0,0 +1,52 @@ +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); + 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/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..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)), + 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 904d50754..ba0058aad 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::PeerRemote::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,23 @@ 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) { + let Some(endpoint) = peer.addr.endpoint() else { + continue; + }; + if !seen.insert(endpoint) { continue; } entries.push(v31::NodeAddress { time: peer.conn_time, services: peer.services, - address: peer.addr.ip().to_string(), - port: peer.addr.port(), - network: network_name(peer.addr.ip()).to_owned(), + address: endpoint.address().to_string(), + port: endpoint.port(), + network: peer.addr.network().name().to_owned(), }); } @@ -536,9 +577,9 @@ pub(crate) fn getnodeaddresses(ctx: &Arc, params: &Value) -> Result Result<(), Box>(), + ["ipv4", "ipv6", "onion", "i2p"] + ); + for row in &network.networks[2..] { + assert!(row.limited); + assert!(!row.reachable); + assert_eq!(row.proxy, ""); + } assert_eq!(network.warnings, Vec::::new()); let peers: corepc_types::v31::GetPeerInfo = 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, diff --git a/crates/rpc/tests/handler_smoke.rs b/crates/rpc/tests/handler_smoke.rs index c8db6f46c..1e119a87b 100644 --- a/crates/rpc/tests/handler_smoke.rs +++ b/crates/rpc/tests/handler_smoke.rs @@ -813,7 +813,7 @@ fn network_peer_methods_read_shared_peer_table() -> 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/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 802f5abcc..6b2cc43c8 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. | @@ -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 @@ -366,13 +366,13 @@ 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. 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,54 +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 v8 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 - 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 - invalid. 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 writers; v5/v6/v7 fixtures pin all eight retained references. 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 @@ -441,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 @@ -467,7 +484,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 +688,95 @@ 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. +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 +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. +Explicit SOCKS destination-unreachable/refused replies and SAM stream +`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. + +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. + +`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 +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. + +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. + +Routable `PeerAddress`/`PeerEndpoint` values contain only IP, Tor-v3 and I2P +destinations. Anonymous Tor circuits live in the connection owner's +`PeerRemote`, keyed by the lease's process-unique connection id. They share +one unknown-origin admission group, receive no proxy-loopback protection, and +have no destination to learn, persist, discourage or advertise through AddrMan. +Their RPC connection handle remains `tor-inbound-N:0`; it is not accepted by +destination configuration or dialing APIs. + +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 +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/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`. diff --git a/e2e/tests/overlay_connectivity.rs b/e2e/tests/overlay_connectivity.rs new file mode 100644 index 000000000..ebf29c125 --- /dev/null +++ b/e2e/tests/overlay_connectivity.rs @@ -0,0 +1,154 @@ +//! 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 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, + 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)?; + 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" { + assert!(remote.starts_with("tor-inbound-"), "{remote}"); + } else { + assert!(remote.contains(".b32.i2p:"), "{remote}"); + } + 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" { + 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)?; + exchange(&mut server, &mut client, 1, 6)?; + exchange(&mut client, &mut server, 1, 7)?; + 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/fuzz/Cargo.lock b/fuzz/Cargo.lock index 44a62df0c..b4ad38ec5 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", @@ -245,6 +252,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2", @@ -259,7 +267,9 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "sha2", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -796,6 +806,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 +1246,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/scripts/overlay-interop.py b/scripts/overlay-interop.py new file mode 100644 index 000000000..90dbace4f --- /dev/null +++ b/scripts/overlay-interop.py @@ -0,0 +1,128 @@ +#!/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 + +IVNP_REVISION = "d5274b4752bd5a37bb35e6bb47796916c21e514c" + + +def reserve_port(kind=socket.SOCK_STREAM): + listener = socket.socket(type=kind) + try: + listener.bind(("127.0.0.1", 0)) + return listener + except BaseException: + listener.close() + raise + + +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") + 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()} + (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, ExitStack() as reservations: + state = Path(directory) + 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") + 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), 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]) + 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: + 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 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()) diff --git a/tools/ldk/Cargo.lock b/tools/ldk/Cargo.lock index 0e17f74b9..64bfb2890 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", @@ -329,6 +330,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2", @@ -343,7 +345,9 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "sha2", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -369,6 +373,7 @@ dependencies = [ "corepc-types", "crossbeam-channel", "hashbrown 0.17.1", + "metrics", "miniscript", "parking_lot", "serde", @@ -1116,6 +1121,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 +1956,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 1a960f55f..3f466ffcb 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", @@ -386,6 +387,7 @@ dependencies = [ "hkdf", "metrics", "parking_lot", + "rustix", "serde", "serde_json", "sha2 0.11.1", @@ -400,7 +402,9 @@ version = "0.14.0" dependencies = [ "bytemuck", "ruint", + "serde", "sha2 0.11.1", + "sha3", "thiserror", "tinyvec", "zerocopy", @@ -1692,6 +1696,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" @@ -2781,6 +2795,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"