From c02d530090ceb0884d06adb86ea1462bd350dc2d Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:20:39 -0700 Subject: [PATCH 01/13] net: parse BIP155 Tor v3 addresses as NetAddr AddrMan is still SocketAddr-keyed; onion rows need a stable pubkey+port identity with checksummed .onion display before they can be stored. Co-authored-by: Cursor --- Cargo.lock | 20 +++ crates/rbitcoin-net/Cargo.toml | 1 + crates/rbitcoin-net/src/lib.rs | 2 + crates/rbitcoin-net/src/netaddr.rs | 193 +++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 crates/rbitcoin-net/src/netaddr.rs diff --git a/Cargo.lock b/Cargo.lock index e0ef107ad..700ca890e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -532,6 +532,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "libc" version = "0.2.189" @@ -811,6 +820,7 @@ dependencies = [ "rbitcoin-primitives", "rbitcoin-query", "rbitcoin-store", + "sha3", "tokio", ] @@ -1024,6 +1034,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" diff --git a/crates/rbitcoin-net/Cargo.toml b/crates/rbitcoin-net/Cargo.toml index 5b8d6e432..d262e51fd 100644 --- a/crates/rbitcoin-net/Cargo.toml +++ b/crates/rbitcoin-net/Cargo.toml @@ -19,6 +19,7 @@ bip324 = { workspace = true } tokio = { workspace = true } arc-swap = { workspace = true } getrandom = "0.4" +sha3 = "0.10" [target.'cfg(target_os = "linux")'.dependencies] # POLLRDHUP: peer FIN with unread bytes still in the receive buffer. diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index f17c57557..e0914d94d 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -11,6 +11,7 @@ mod ibd; mod most_work; mod msg_decode; mod net_permissions; +mod netaddr; mod netgroup; mod peer; mod peer_dos; @@ -43,6 +44,7 @@ pub use net_permissions::{ apply_implicit, parse_whitebind, parse_whitelist, NetPermTable, NetPermissionFlags, WhitebindGrant, WhitelistGrant, DEFAULT_WHITELISTFORCERELAY, DEFAULT_WHITELISTRELAY, }; +pub use netaddr::NetAddr; pub use netgroup::netgroup; pub use peer::{ drain_pending_now, flush_tx_invs, force_announce_txid, local_service_flags, run_feeler_timed, diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs new file mode 100644 index 000000000..6b6255398 --- /dev/null +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -0,0 +1,193 @@ +//! BIP155-capable peer identity: clearnet [`SocketAddr`] and Tor v3 onion. + +use crate::error::NetError; +use sha3::{Digest, Sha3_256}; +use std::fmt; +use std::net::SocketAddr; +use std::str::FromStr; + +const B32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; +const ONION_VERSION: u8 = 3; +const ONION_NAME_LEN: usize = 56; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum NetAddr { + Ip(SocketAddr), + Onion { pk: [u8; 32], port: u16 }, +} + +impl fmt::Display for NetAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + NetAddr::Ip(addr) => write!(f, "{addr}"), + NetAddr::Onion { pk, port } => { + write!(f, "{}.onion:{port}", encode_onion_name(&pk)) + } + } + } +} + +impl FromStr for NetAddr { + type Err = NetError; + + fn from_str(s: &str) -> Result { + parse_net_addr(s) + } +} + +fn parse_net_addr(s: &str) -> Result { + let Some((host, port_s)) = s.rsplit_once(':') else { + return Err(NetError::Encode(format!("bad peer address {s}"))); + }; + if let Some(name) = strip_onion_suffix(host) { + let port: u16 = port_s + .parse() + .map_err(|_| NetError::Encode(format!("bad onion port {s}")))?; + let pk = decode_onion_name(name) + .ok_or_else(|| NetError::Encode(format!("bad onion address {s}")))?; + return Ok(NetAddr::Onion { pk, port }); + } + s.parse() + .map(NetAddr::Ip) + .map_err(|_| NetError::Encode(format!("bad peer address {s}"))) +} + +fn strip_onion_suffix(host: &str) -> Option<&str> { + let b = host.as_bytes(); + if b.len() < 6 { + return None; + } + if !b[b.len() - 6..].eq_ignore_ascii_case(b".onion") { + return None; + } + Some(&host[..host.len() - 6]) +} + +fn onion_checksum(pk: &[u8; 32]) -> [u8; 2] { + let mut h = Sha3_256::new(); + h.update(b".onion checksum"); + h.update(pk); + h.update([ONION_VERSION]); + let d = h.finalize(); + [d[0], d[1]] +} + +fn encode_onion_name(pk: &[u8; 32]) -> String { + let mut payload = [0u8; 35]; + payload[..32].copy_from_slice(pk); + let sum = onion_checksum(pk); + payload[32..34].copy_from_slice(&sum); + payload[34] = ONION_VERSION; + b32_encode(&payload) +} + +fn decode_onion_name(name: &str) -> Option<[u8; 32]> { + if name.len() != ONION_NAME_LEN { + return None; + } + let payload = b32_decode(name)?; + if payload[34] != ONION_VERSION { + return None; + } + let mut pk = [0u8; 32]; + pk.copy_from_slice(&payload[..32]); + if onion_checksum(&pk) != [payload[32], payload[33]] { + return None; + } + Some(pk) +} + +fn b32_encode(bytes: &[u8; 35]) -> String { + let mut out = String::with_capacity(ONION_NAME_LEN); + let mut acc = 0u32; + let mut bits = 0u32; + for &b in bytes { + acc = (acc << 8) | u32::from(b); + bits += 8; + while bits >= 5 { + bits -= 5; + out.push(B32[((acc >> bits) & 31) as usize] as char); + } + } + out +} + +fn b32_decode(s: &str) -> Option<[u8; 35]> { + let mut out = [0u8; 35]; + let mut acc = 0u32; + let mut bits = 0u32; + let mut n = 0usize; + for c in s.bytes() { + let v = match c { + b'a'..=b'z' => c - b'a', + b'A'..=b'Z' => c - b'A', + b'2'..=b'7' => 26 + (c - b'2'), + _ => return None, + }; + acc = (acc << 5) | u32::from(v); + bits += 5; + if bits >= 8 { + bits -= 8; + if n >= 35 { + return None; + } + out[n] = (acc >> bits) as u8; + n += 1; + } + } + if n != 35 || bits != 0 { + return None; + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn netaddr_onion_parse_roundtrip() { + let s = "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333"; + let a: NetAddr = s.parse().expect("valid v3 onion"); + assert_eq!(a.to_string(), s); + assert_eq!(a, s.parse().unwrap()); + match a { + NetAddr::Onion { pk, port } => { + assert_eq!( + pk, + [ + 0x79, 0xbc, 0xc6, 0x25, 0x18, 0x4b, 0x05, 0x19, 0x49, 0x75, 0xc2, 0x8b, + 0x66, 0xb6, 0x6b, 0x04, 0x69, 0xf7, 0xf6, 0x55, 0x6f, 0xb1, 0xac, 0x31, + 0x89, 0xa7, 0x9b, 0x40, 0xdd, 0xa3, 0x2f, 0x1f, + ] + ); + assert_eq!(port, 8333); + } + NetAddr::Ip(_) => panic!("expected onion"), + } + assert!( + "qg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse::() + .is_err() + ); + assert!( + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscry.onion:8333" + .parse::() + .is_err() + ); + assert!("short.onion:8333".parse::().is_err()); + assert!("not-an-addr".parse::().is_err()); + } + + #[test] + fn netaddr_ip_parse_roundtrip() { + let s = "1.2.3.4:8333"; + let a: NetAddr = s.parse().expect("ipv4"); + assert_eq!( + a, + NetAddr::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 8333)) + ); + assert_eq!(a.to_string(), s); + } +} From 801c9e326122bd7cfa8911799b039ad6f6e755b1 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:47:12 -0700 Subject: [PATCH 02/13] net: keep Tor v3 rows in AddrMan from addrv2 learn_addrv2 dropped onion because AddrMan was SocketAddr-keyed. The book now stores NetAddr; clearnet dial and peers-v1 save still use the Ip arm so existing IBD paths stay unchanged. Co-authored-by: Cursor --- crates/rbitcoin-net/src/netaddr.rs | 53 ++++++++++++++++ crates/rbitcoin-net/src/peers.rs | 64 +++++++++++++++++-- crates/rbitcoin-net/src/seeds.rs | 86 ++++++++++++++++++-------- crates/rbitcoin-rpc/src/methods/net.rs | 7 +-- 4 files changed, 173 insertions(+), 37 deletions(-) diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index 6b6255398..0077393b1 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -1,6 +1,7 @@ //! BIP155-capable peer identity: clearnet [`SocketAddr`] and Tor v3 onion. use crate::error::NetError; +use bitcoin::p2p::address::{AddrV2, AddrV2Message}; use sha3::{Digest, Sha3_256}; use std::fmt; use std::net::SocketAddr; @@ -35,6 +36,58 @@ impl FromStr for NetAddr { } } +impl NetAddr { + pub fn from_addrv2(msg: &AddrV2Message) -> Option { + if msg.port == 0 { + return None; + } + match &msg.addr { + AddrV2::Ipv4(_) | AddrV2::Ipv6(_) => msg.socket_addr().ok().map(NetAddr::Ip), + AddrV2::TorV3(pk) => Some(NetAddr::Onion { + pk: *pk, + port: msg.port, + }), + AddrV2::TorV2(_) | AddrV2::I2p(_) | AddrV2::Cjdns(_) | AddrV2::Unknown(_, _) => None, + } + } + + pub fn socket_addr(self) -> Option { + match self { + NetAddr::Ip(s) => Some(s), + NetAddr::Onion { .. } => None, + } + } + + pub fn is_ipv6(self) -> bool { + match self { + NetAddr::Ip(s) => s.is_ipv6(), + NetAddr::Onion { .. } => false, + } + } + + pub fn port(self) -> u16 { + match self { + NetAddr::Ip(s) => s.port(), + NetAddr::Onion { port, .. } => port, + } + } + + pub fn host_str(self) -> String { + match self { + NetAddr::Ip(s) => s.ip().to_string(), + NetAddr::Onion { pk, .. } => format!("{}.onion", encode_onion_name(&pk)), + } + } + + pub fn network_label(self) -> &'static str { + match self { + NetAddr::Ip(s) if s.is_ipv4() => "ipv4", + NetAddr::Ip(_) => "ipv6", + NetAddr::Onion { .. } => "onion", + } + } +} + fn parse_net_addr(s: &str) -> Result { let Some((host, port_s)) = s.rsplit_once(':') else { return Err(NetError::Encode(format!("bad peer address {s}"))); diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index d4969b125..b51069835 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -1255,7 +1255,7 @@ impl PeerHub { *self.addrman.lock().unwrap_or_else(|e| e.into_inner()) = Some(am); } - /// Learn IPv4/IPv6 rows from BIP155 `addrv2` (`p2p_addrv2_relay.py`). + /// Learn IPv4/IPv6/Tor v3 rows from BIP155 `addrv2` (`p2p_addrv2_relay.py`). pub fn learn_addrv2(&self, list: &[bitcoin::p2p::address::AddrV2Message]) { let g = self.addrman.lock().unwrap_or_else(|e| e.into_inner()); let Some(am) = g.as_ref() else { @@ -1263,8 +1263,8 @@ impl PeerHub { }; let mut book = am.lock().unwrap_or_else(|e| e.into_inner()); for a in list { - if let Ok(sock) = a.socket_addr() { - book.add_learned(sock, crate::seeds::MAX_ADDR_MAN); + if let Some(addr) = crate::NetAddr::from_addrv2(a) { + book.add_learned_addr(addr, crate::seeds::MAX_ADDR_MAN); } } } @@ -1299,7 +1299,11 @@ impl PeerHub { let g = am.lock().unwrap_or_else(|e| e.into_inner()); g.entries() }; - let n = entries.len(); + let addrs: Vec = entries + .iter() + .filter_map(|e| e.addr.socket_addr()) + .collect(); + let n = addrs.len(); let pct_cap = (n * crate::peer::MAX_PCT_ADDR_TO_SEND / 100).max(1); let cap = crate::peer::MAX_ADDR_TO_SEND.min(pct_cap).min(n); if cap == 0 { @@ -1315,7 +1319,7 @@ impl PeerHub { let services = crate::peer::local_service_flags(); let mut out = Vec::with_capacity(cap); for &i in idxs.iter().take(cap) { - let addr = entries[i].addr; + let addr = addrs[i]; out.push(( now as u32, bitcoin::p2p::address::Address::new(&addr, services), @@ -2988,4 +2992,54 @@ mod tests { assert_eq!(expired.len(), 1000); assert_ne!(a, expired); } + + #[test] + fn learn_addrv2_keeps_tor_v3() { + use bitcoin::p2p::address::{AddrV2, AddrV2Message}; + use std::net::Ipv4Addr; + + let hub = PeerHub::new(); + let am = Arc::new(Mutex::new(crate::seeds::AddrMan::new())); + hub.set_addrman(am.clone()); + let pk = [ + 0x79, 0xbc, 0xc6, 0x25, 0x18, 0x4b, 0x05, 0x19, 0x49, 0x75, 0xc2, 0x8b, 0x66, 0xb6, + 0x6b, 0x04, 0x69, 0xf7, 0xf6, 0x55, 0x6f, 0xb1, 0xac, 0x31, 0x89, 0xa7, 0x9b, 0x40, + 0xdd, 0xa3, 0x2f, 0x1f, + ]; + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + hub.learn_addrv2(&[ + AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK | ServiceFlags::WITNESS | ServiceFlags::P2P_V2, + addr: AddrV2::TorV3(pk), + port: 8333, + }, + AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK | ServiceFlags::WITNESS | ServiceFlags::P2P_V2, + addr: AddrV2::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), + port: 18444, + }, + AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::I2p([0u8; 32]), + port: 1, + }, + ]); + let book = am.lock().unwrap_or_else(|e| e.into_inner()); + let ents = book.entries(); + assert!( + ents.iter().any(|e| e.addr == onion), + "Tor v3 must stay in the book, got {ents:?}" + ); + assert!(ents + .iter() + .any(|e| e.addr + == crate::NetAddr::Ip(SocketAddr::from((Ipv4Addr::new(1, 2, 3, 4), 18444))))); + assert_eq!(ents.len(), 2, "I2P is not stored until plan 04"); + } } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index de932d014..4b2bd7927 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::asmap::AsMap; +use crate::netaddr::NetAddr; use crate::netgroup::{netgroup, select_diverse}; /// Skip a recently dialed addr while any other candidate remains. @@ -276,7 +277,7 @@ impl PeerFlags { /// One remembered peer address + flags. #[derive(Clone, Debug)] pub struct PeerEntry { - pub addr: SocketAddr, + pub addr: NetAddr, pub flags: PeerFlags, } @@ -293,10 +294,10 @@ pub const MAX_ADDR_MAN: usize = 8192; #[derive(Debug, Default, Clone)] pub struct AddrMan { /// Insertion-order keys (IPv4 preferred on inject). - order: Vec, - by_addr: HashMap, + order: Vec, + by_addr: HashMap, asmap: Option>, - last_attempt: HashMap, + last_attempt: HashMap, } impl AddrMan { @@ -327,6 +328,10 @@ impl AddrMan { } pub fn add(&mut self, addr: SocketAddr) { + self.add_addr(NetAddr::Ip(addr)); + } + + pub fn add_addr(&mut self, addr: NetAddr) { if self.by_addr.contains_key(&addr) { return; } @@ -341,6 +346,10 @@ impl AddrMan { /// /// Uncapped so `load` can keep tried-first then trim. `merge_from` trims. pub fn add_with_flags(&mut self, addr: SocketAddr, flags: PeerFlags) { + self.add_with_flags_addr(NetAddr::Ip(addr), flags); + } + + pub fn add_with_flags_addr(&mut self, addr: NetAddr, flags: PeerFlags) { if let Some(f) = self.by_addr.get_mut(&addr) { // Union: remember the best information we have. f.0 |= flags.0; @@ -356,6 +365,10 @@ impl AddrMan { /// over-cap book (`add` exceed), and a full book of only tried addrs /// return false. Never exceeds `cap`. pub fn add_learned(&mut self, addr: SocketAddr, cap: usize) -> bool { + self.add_learned_addr(NetAddr::Ip(addr), cap) + } + + pub fn add_learned_addr(&mut self, addr: NetAddr, cap: usize) -> bool { if self.by_addr.contains_key(&addr) || cap == 0 { return false; } @@ -370,7 +383,7 @@ impl AddrMan { true } - fn evict_one(&mut self, addr: SocketAddr) { + fn evict_one(&mut self, addr: NetAddr) { self.by_addr.remove(&addr); self.last_attempt.remove(&addr); self.order.retain(|a| *a != addr); @@ -381,7 +394,7 @@ impl AddrMan { .order .iter() .copied() - .find(|a| !self.flags(a).has_connected()); + .find(|a| !self.flags_of(a).has_connected()); let Some(addr) = victim else { return false; }; @@ -394,12 +407,12 @@ impl AddrMan { .order .iter() .copied() - .find(|a| self.flags(a).is_incompatible()) + .find(|a| self.flags_of(a).is_incompatible()) .or_else(|| { self.order .iter() .copied() - .find(|a| self.flags(a).failed_last_connect()) + .find(|a| self.flags_of(a).failed_last_connect()) }); if let Some(addr) = victim { self.evict_one(addr); @@ -412,20 +425,20 @@ impl AddrMan { if self.order.len() <= cap { return; } - let mut keep: Vec = self + let mut keep: Vec = self .order .iter() .copied() - .filter(|a| self.flags(a).has_connected()) + .filter(|a| self.flags_of(a).has_connected()) .collect(); keep.extend( self.order .iter() .copied() - .filter(|a| !self.flags(a).has_connected()), + .filter(|a| !self.flags_of(a).has_connected()), ); keep.truncate(cap); - let keep_set: HashSet = keep.iter().copied().collect(); + let keep_set: HashSet = keep.iter().copied().collect(); self.order = keep; self.by_addr.retain(|a, _| keep_set.contains(a)); self.last_attempt.retain(|a, _| keep_set.contains(a)); @@ -437,7 +450,7 @@ impl AddrMan { /// This path is not load: trim after the union. pub fn merge_from(&mut self, other: &AddrMan) { for e in other.entries() { - self.add_with_flags(e.addr, e.flags); + self.add_with_flags_addr(e.addr, e.flags); } self.trim_to_cap(MAX_ADDR_MAN); self.sort_order_ipv4_first(); @@ -449,11 +462,19 @@ impl AddrMan { self.order.sort_by_key(|a| a.is_ipv6()); } - pub fn peers(&self) -> &[SocketAddr] { - &self.order + pub fn peers(&self) -> Vec { + self.order + .iter() + .copied() + .filter_map(NetAddr::socket_addr) + .collect() } pub fn flags(&self, addr: &SocketAddr) -> PeerFlags { + self.flags_of(&NetAddr::Ip(*addr)) + } + + fn flags_of(&self, addr: &NetAddr) -> PeerFlags { self.by_addr .get(addr) .copied() @@ -461,6 +482,10 @@ impl AddrMan { } pub fn entry(&self, addr: &SocketAddr) -> Option { + self.entry_of(&NetAddr::Ip(*addr)) + } + + fn entry_of(&self, addr: &NetAddr) -> Option { self.by_addr .get(addr) .map(|&flags| PeerEntry { addr: *addr, flags }) @@ -477,7 +502,7 @@ impl AddrMan { /// Successful BIP324 handshake. pub fn note_connected(&mut self, addr: SocketAddr) { self.add(addr); - if let Some(f) = self.by_addr.get_mut(&addr) { + if let Some(f) = self.by_addr.get_mut(&NetAddr::Ip(addr)) { f.insert(PeerFlags::HAS_CONNECTED); f.remove(PeerFlags::FAILED_LAST_CONNECT); f.remove(PeerFlags::INCOMPATIBLE); @@ -490,19 +515,19 @@ impl AddrMan { } pub(crate) fn note_attempt_at(&mut self, addr: SocketAddr, when: Instant) { - self.last_attempt.insert(addr, when); + self.last_attempt.insert(NetAddr::Ip(addr), when); } fn recently_attempted(&self, addr: SocketAddr, now: Instant) -> bool { self.last_attempt - .get(&addr) + .get(&NetAddr::Ip(addr)) .is_some_and(|&t| now.saturating_duration_since(t) < DIAL_ATTEMPT_RECENT) } /// Dial failed. `incompatible` = no v2 / protocol reject; else network/timeout. pub fn note_connect_failed(&mut self, addr: SocketAddr, incompatible: bool) { self.add(addr); - if let Some(f) = self.by_addr.get_mut(&addr) { + if let Some(f) = self.by_addr.get_mut(&NetAddr::Ip(addr)) { if incompatible { f.insert(PeerFlags::INCOMPATIBLE); f.remove(PeerFlags::FAILED_LAST_CONNECT); @@ -515,7 +540,7 @@ impl AddrMan { /// Throughput / latency sample from an active session. pub fn note_speed(&mut self, addr: SocketAddr, latency_ms: u64, bytes_per_sec: u64) { self.add(addr); - if let Some(f) = self.by_addr.get_mut(&addr) { + if let Some(f) = self.by_addr.get_mut(&NetAddr::Ip(addr)) { f.insert(PeerFlags::HAS_CONNECTED); f.apply_speed_sample(latency_ms, bytes_per_sec); } @@ -525,7 +550,7 @@ impl AddrMan { /// would otherwise leave a prior FAST bit and keep `dial_tier` 0). pub fn note_ibd_slow(&mut self, addr: SocketAddr) { self.add(addr); - if let Some(f) = self.by_addr.get_mut(&addr) { + if let Some(f) = self.by_addr.get_mut(&NetAddr::Ip(addr)) { f.insert(PeerFlags::HAS_CONNECTED); f.insert(PeerFlags::SLOW); f.remove(PeerFlags::FAST); @@ -573,10 +598,14 @@ impl AddrMan { let mut ranked: Vec<(u8, bool, bool, SocketAddr)> = self .order .iter() - .filter(|a| !exclude.contains(*a)) - .map(|&a| { - let f = self.flags(&a); - (f.dial_tier(), a.is_ipv6(), f.is_incompatible(), a) + .copied() + .filter_map(|a| { + let sock = a.socket_addr()?; + if exclude.contains(&sock) { + return None; + } + let f = self.flags_of(&a); + Some((f.dial_tier(), sock.is_ipv6(), f.is_incompatible(), sock)) }) .collect(); ranked.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); @@ -656,7 +685,7 @@ impl AddrMan { /// Snapshot of all entries (for tests / diagnostics). pub fn entries(&self) -> Vec { - self.order.iter().filter_map(|a| self.entry(a)).collect() + self.order.iter().filter_map(|a| self.entry_of(a)).collect() } /// On-disk format magic line (text, one peer per line). @@ -758,7 +787,10 @@ impl AddrMan { "# addr flags (flags: bit0=connected bit1=fast bit2=slow bit3=incompat bit4=fail)" )?; for e in self.entries() { - writeln!(f, "{} 0x{:02x}", e.addr, e.flags.0)?; + let Some(addr) = e.addr.socket_addr() else { + continue; + }; + writeln!(f, "{} 0x{:02x}", addr, e.flags.0)?; } f.sync_all()?; } diff --git a/crates/rbitcoin-rpc/src/methods/net.rs b/crates/rbitcoin-rpc/src/methods/net.rs index 18aa8864a..e2f9e0d3c 100644 --- a/crates/rbitcoin-rpc/src/methods/net.rs +++ b/crates/rbitcoin-rpc/src/methods/net.rs @@ -290,10 +290,7 @@ pub(crate) fn getnodeaddresses(ctx: &RpcContext, params: &RpcParams) -> Result "ipv4", - std::net::IpAddr::V6(_) => "ipv6", - }; + let net = e.addr.network_label(); if let Some(want) = network { if want != net { continue; @@ -302,7 +299,7 @@ pub(crate) fn getnodeaddresses(ctx: &RpcContext, params: &RpcParams) -> Result Date: Sat, 19 Sep 2026 10:49:18 -0700 Subject: [PATCH 03/13] net: persist Tor v3 peers as rbitcoin-peers-v2 Onion rows were dropped on save because v1 tokens were SocketAddr-only. v2 writes .onion:port; v1 IPv4/IPv6 files still load. Co-authored-by: Cursor --- OPERATOR.md | 4 +- crates/rbitcoin-net/src/seeds.rs | 72 +++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/OPERATOR.md b/OPERATOR.md index 727a445ec..2e01680aa 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -609,7 +609,9 @@ warning and continues without inhibit. **Peers file:** `{datadir}/peers` stores discovered addresses and **PeerFlags** (connected / fast / slow / incompatible / last-fail) between runs. Loaded at start (before seeds), updated after IBD and on shutdown. Seeds are merged in -without clearing known flags. +without clearing known flags. New writes are `rbitcoin-peers-v2` (IPv4, IPv6, +and Tor v3 `.onion:port` tokens). `rbitcoin-peers-v1` IPv4/IPv6 files still +load. **Index modes:** Direct vs Tip: [`docs/concurrency.md`](docs/concurrency.md). IBD finishes Class A + `tx.head` + spend annotations **before** tip; tip entry diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 4b2bd7927..7e9989414 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -690,6 +690,7 @@ impl AddrMan { /// On-disk format magic line (text, one peer per line). pub const PEERS_FILE_MAGIC: &'static str = "rbitcoin-peers-v1"; + pub const PEERS_FILE_MAGIC_V2: &'static str = "rbitcoin-peers-v2"; /// Load peers + flags from `path`. Missing file → empty book (not an error). pub fn load(path: &Path) -> std::io::Result { @@ -707,14 +708,15 @@ impl AddrMan { continue; } if !saw_magic { - if line != Self::PEERS_FILE_MAGIC { + if line != Self::PEERS_FILE_MAGIC && line != Self::PEERS_FILE_MAGIC_V2 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( - "peers file {}:{}: expected magic `{}`", + "peers file {}:{}: expected magic `{}` or `{}`", path.display(), lineno + 1, - Self::PEERS_FILE_MAGIC + Self::PEERS_FILE_MAGIC, + Self::PEERS_FILE_MAGIC_V2 ), )); } @@ -726,7 +728,7 @@ impl AddrMan { continue; }; let flags_s = parts.next().unwrap_or("0"); - let addr: SocketAddr = addr_s.parse().map_err(|e| { + let addr: NetAddr = addr_s.parse().map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidData, format!( @@ -762,7 +764,7 @@ impl AddrMan { ) })? }; - am.add_with_flags(addr, PeerFlags(flags_u)); + am.add_with_flags_addr(addr, PeerFlags(flags_u)); } if !saw_magic && am.is_empty() { // Empty or comment-only without magic — treat as empty book. @@ -781,16 +783,13 @@ impl AddrMan { let tmp = path.with_extension("tmp"); { let mut f = std::fs::File::create(&tmp)?; - writeln!(f, "{}", Self::PEERS_FILE_MAGIC)?; + writeln!(f, "{}", Self::PEERS_FILE_MAGIC_V2)?; writeln!( f, "# addr flags (flags: bit0=connected bit1=fast bit2=slow bit3=incompat bit4=fail)" )?; for e in self.entries() { - let Some(addr) = e.addr.socket_addr() else { - continue; - }; - writeln!(f, "{} 0x{:02x}", addr, e.flags.0)?; + writeln!(f, "{} 0x{:02x}", e.addr, e.flags.0)?; } f.sync_all()?; } @@ -1477,4 +1476,57 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn peers_file_roundtrip_onion() { + let dir = std::env::temp_dir().join(format!( + "rbitcoin-peers-onion-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("peers"); + let onion: NetAddr = "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let mut am = AddrMan::new(); + am.add_addr(onion); + am.add(addr(1)); + am.save(&path).unwrap(); + let loaded = AddrMan::load(&path).unwrap(); + assert!( + loaded.entries().iter().any(|e| e.addr == onion), + "onion must persist, got {:?}", + loaded.entries() + ); + assert!(loaded.entry(&addr(1)).is_some()); + let body = std::fs::read_to_string(&path).unwrap(); + assert!( + body.starts_with("rbitcoin-peers-v2"), + "new writes use v2, got {body}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn peers_file_v1_ipv4_still_loads() { + let dir = std::env::temp_dir().join(format!( + "rbitcoin-peers-v1-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("peers"); + std::fs::write(&path, format!("rbitcoin-peers-v1\n{} 0x01\n", addr(4))).unwrap(); + let loaded = AddrMan::load(&path).unwrap(); + assert!(loaded.entry(&addr(4)).is_some()); + assert!(loaded.flags(&addr(4)).has_connected()); + let _ = std::fs::remove_dir_all(&dir); + } } From e32a78c67bd6b7e0b7c0ec0cf2256dc8cda8a5ac Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:57:06 -0700 Subject: [PATCH 04/13] net: SOCKS domain CONNECT for Tor v3 NetAddr Onion outbound must not TcpStream::connect or local DNS. Dialer::connect_net sends ATYP=3 through --proxy/--onion SOCKS; Direct onion is an error. Co-authored-by: Cursor --- crates/rbitcoin-net/src/ibd/peer_io.rs | 2 +- crates/rbitcoin-net/src/service.rs | 4 +-- crates/rbitcoin-net/src/socks.rs | 42 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/rbitcoin-net/src/ibd/peer_io.rs b/crates/rbitcoin-net/src/ibd/peer_io.rs index c992a1b11..3c08cead4 100644 --- a/crates/rbitcoin-net/src/ibd/peer_io.rs +++ b/crates/rbitcoin-net/src/ibd/peer_io.rs @@ -160,7 +160,7 @@ pub(crate) async fn spawn_peer( sinks: PeerEventSinks, dialer: crate::socks::Dialer, ) -> Result { - let stream = dialer.connect(addr).await?; + let stream = dialer.connect_net(crate::NetAddr::Ip(addr)).await?; let ua = rbitcoin_primitives::rbitcoin_subversion(env!("CARGO_PKG_VERSION"), &[] as &[&str]) .unwrap_or_else(|_| format!("/rbitcoin:{}/", env!("CARGO_PKG_VERSION"))); let (ver, reader, writer, _wire, _tcp_shutdown) = connect_and_handshake_timed( diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index 9c8d46d20..affa6b518 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -547,7 +547,7 @@ async fn prepare_outbound_session( ) -> Result { rbitcoin_log::debug!("{}", crate::peers::trying_connection_log(typ, &peer)); let stream = match &peer { - DialTarget::Socket(addr) => dialer.connect(*addr).await?, + DialTarget::Socket(addr) => dialer.connect_net(crate::NetAddr::Ip(*addr)).await?, DialTarget::Domain { host, port } => dialer.connect_domain(host, *port).await?, }; let peer_hint = peer.peer_hint(); @@ -654,7 +654,7 @@ async fn run_outbound_session_with_abort( return Err(NetError::Encode("feeler requires ip:port target".into())) } }; - let stream = dialer.connect(peer_addr).await?; + let stream = dialer.connect_net(crate::NetAddr::Ip(peer_addr)).await?; let height = hub.tip_height().map(|h| h as i32).unwrap_or(0); return crate::peer::run_feeler(stream, magic, local, peer_addr, height, &user_agent).await; } diff --git a/crates/rbitcoin-net/src/socks.rs b/crates/rbitcoin-net/src/socks.rs index ae06a288c..900f779ee 100644 --- a/crates/rbitcoin-net/src/socks.rs +++ b/crates/rbitcoin-net/src/socks.rs @@ -128,6 +128,20 @@ impl Dialer { Dialer::Socks { proxy, .. } => dial_isolated(*proxy, target).await, } } + + pub async fn connect_net(&self, addr: crate::NetAddr) -> Result { + match addr { + crate::NetAddr::Ip(s) => self.connect(s).await, + crate::NetAddr::Onion { port, .. } => { + if matches!(self, Dialer::Direct) { + return Err(NetError::Encode( + "onion dial requires SOCKS (--proxy or --onion)".into(), + )); + } + self.connect_domain(&addr.host_str(), port).await + } + } + } } async fn socks5_connect_dest( @@ -683,4 +697,32 @@ mod tests { .unwrap(); inbound.await.unwrap().unwrap(); } + + #[tokio::test] + async fn dial_onion_uses_socks_domain_connect() { + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = listener.local_addr().unwrap(); + let server = tokio::spawn(accept_domain_connect( + listener, + b"pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", + 8333, + )); + Dialer::Socks { + proxy, + randomize: false, + } + .connect_net(onion) + .await + .unwrap(); + server.await.unwrap(); + + assert!( + Dialer::Direct.connect_net(onion).await.is_err(), + "onion must not TcpStream::connect / local DNS" + ); + } } From d6663f9573d4d5932bfc5ad1f708f9f42b06ee2c Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:00:46 -0700 Subject: [PATCH 05/13] node: --connect and seed_node parse Tor v3 onions Operator-pinned peers are NetAddr so .onion:port is a start error when invalid, not a silent SocketAddr parse miss. Co-authored-by: Cursor --- crates/rbitcoin-node/src/cli.rs | 47 ++++++++++++++++++++++++++++++ crates/rbitcoin-node/src/config.rs | 7 ++++- crates/rbitcoin-node/src/run.rs | 20 +++++++++---- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 6f9196baa..0198f1f68 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -780,6 +780,53 @@ mod tests { ); } + #[test] + fn connect_onion_and_ipv4() { + let mut c = NodeConfig::default(); + c.apply_kv("connect", "1.2.3.4:8333").unwrap(); + c.apply_kv( + "connect", + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333", + ) + .unwrap(); + assert_eq!(c.listen.connect.len(), 2); + assert_eq!(c.listen.connect[0], "1.2.3.4:8333".parse().unwrap()); + assert!(matches!( + c.listen.connect[1], + rbitcoin_net::NetAddr::Onion { port: 8333, .. } + )); + let err = NodeConfig::default() + .apply_kv("connect", "short.onion:8333") + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("connect") || msg.contains("onion") || msg.contains("bad"), + "{msg}" + ); + let mut s = NodeConfig::default(); + s.apply_kv( + "seed_node", + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333", + ) + .unwrap(); + assert_eq!( + s.listen.seednodes, + vec!["pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333".to_string()] + ); + assert!(NodeConfig::default() + .apply_kv("seed_node", "short.onion:8333") + .is_err()); + let n = ready_config([ + "rbitcoin-node", + "--connect", + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333", + ]); + assert!(matches!( + n.listen.connect[0], + rbitcoin_net::NetAddr::Onion { port: 8333, .. } + )); + } + #[test] fn no_discover_conf() { let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 05a07e062..808b613d1 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -85,7 +85,7 @@ pub struct ListenOpts { pub p2p_extra: Vec, pub electrum: Option, pub esplora: Option, - pub connect: Vec, + pub connect: Vec, pub seednodes: Vec, pub use_seeds: bool, pub max_outbound: u32, @@ -701,6 +701,11 @@ impl NodeConfig { } "seed_node" => { if !val.is_empty() { + if val.to_ascii_lowercase().contains(".onion") { + let _: rbitcoin_net::NetAddr = val + .parse() + .map_err(|e| NodeError::Config(format!("conf seed_node: {e}")))?; + } self.listen.seednodes.push(val.to_string()); } } diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index ba1a91ccc..6812d8166 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -400,7 +400,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { addrman.set_asmap(asmap.clone()); node.peers.set_asmap(asmap); for c in &config.listen.connect { - addrman.add(*c); + addrman.add_addr(*c); } if should_resolve_default_seeds(&config) { info!( @@ -1646,13 +1646,17 @@ pub(crate) fn load_asmap(datadir: &Path, configured: Option<&Path>) -> Option Vec { if !connect.is_empty() { - connect.to_vec() + connect + .iter() + .copied() + .filter_map(rbitcoin_net::NetAddr::socket_addr) + .collect() } else { book.take_outbound_occupied(max, occupied) } @@ -1749,12 +1753,16 @@ mod tests { let mut am = AddrMan::new(); am.add(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 0, 1)), 8333)); am.add(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 0, 1)), 8333)); - let connect = vec![SocketAddr::new( + let connect = vec![rbitcoin_net::NetAddr::Ip(SocketAddr::new( IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8333, - )]; + ))]; let occupied = vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 0, 9)), 8333)]; - assert_eq!(follow_dial_targets(&connect, &am, 8, &occupied), connect); + let want = vec![SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + 8333, + )]; + assert_eq!(follow_dial_targets(&connect, &am, 8, &occupied), want); } #[test] From c7bcb68199eb6d79151d9b9c30396255ff5d3e97 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:06:14 -0700 Subject: [PATCH 06/13] net: --only-net filters dial and requires SOCKS for onion Repeatable ipv4/ipv6/onion. i2p/cjdns stay unknown until later plans. only-net=onion without --proxy/--onion is a start error. Co-authored-by: Cursor --- crates/rbitcoin-net/src/lib.rs | 2 +- crates/rbitcoin-net/src/netaddr.rs | 32 ++++++++++++++ crates/rbitcoin-net/src/seeds.rs | 68 +++++++++++++++++++++++++++++- crates/rbitcoin-node/src/cli.rs | 28 +++++++++++- crates/rbitcoin-node/src/config.rs | 21 +++++++++ crates/rbitcoin-node/src/run.rs | 1 + 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index e0914d94d..f916acfcf 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -44,7 +44,7 @@ pub use net_permissions::{ apply_implicit, parse_whitebind, parse_whitelist, NetPermTable, NetPermissionFlags, WhitebindGrant, WhitelistGrant, DEFAULT_WHITELISTFORCERELAY, DEFAULT_WHITELISTRELAY, }; -pub use netaddr::NetAddr; +pub use netaddr::{addr_allowed, NetAddr, OnlyNet}; pub use netgroup::netgroup; pub use peer::{ drain_pending_now, flush_tx_invs, force_announce_txid, local_service_flags, run_feeler_timed, diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index 0077393b1..cbdad3ab0 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -11,6 +11,38 @@ const B32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; const ONION_VERSION: u8 = 3; const ONION_NAME_LEN: usize = 56; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OnlyNet { + Ipv4, + Ipv6, + Onion, +} + +impl OnlyNet { + pub fn parse(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "ipv4" => Ok(Self::Ipv4), + "ipv6" => Ok(Self::Ipv6), + "onion" => Ok(Self::Onion), + "i2p" | "cjdns" => Err(format!("unknown network {s} (not yet implemented)")), + other => Err(format!("unknown network {other}")), + } + } + + pub fn matches_addr(self, addr: NetAddr) -> bool { + match (self, addr) { + (Self::Ipv4, NetAddr::Ip(s)) => s.is_ipv4(), + (Self::Ipv6, NetAddr::Ip(s)) => s.is_ipv6(), + (Self::Onion, NetAddr::Onion { .. }) => true, + _ => false, + } + } +} + +pub fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { + only.is_empty() || only.iter().any(|n| n.matches_addr(addr)) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum NetAddr { Ip(SocketAddr), diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index 7e9989414..cca8fe077 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use crate::asmap::AsMap; -use crate::netaddr::NetAddr; +use crate::netaddr::{addr_allowed, NetAddr, OnlyNet}; use crate::netgroup::{netgroup, select_diverse}; /// Skip a recently dialed addr while any other candidate remains. @@ -298,6 +298,7 @@ pub struct AddrMan { by_addr: HashMap, asmap: Option>, last_attempt: HashMap, + only_net: Vec, } impl AddrMan { @@ -327,11 +328,56 @@ impl AddrMan { self.sort_order_ipv4_first(); } + pub fn set_only_net(&mut self, only: Vec) { + self.only_net = only; + } + + fn allowed(&self, addr: NetAddr) -> bool { + addr_allowed(addr, &self.only_net) + } + + /// Like [`Self::take_dial_candidates`] plus onion rows when `--only-net` allows them. + pub fn take_dial_candidates_net( + &self, + max: usize, + exclude: &HashSet, + occupied: &[SocketAddr], + ) -> Vec { + let ip_ex: HashSet = exclude + .iter() + .copied() + .filter_map(NetAddr::socket_addr) + .collect(); + let mut out: Vec = self + .take_dial_candidates(max, &ip_ex, occupied) + .into_iter() + .map(NetAddr::Ip) + .collect(); + if out.len() >= max { + return out; + } + for &a in &self.order { + if out.len() >= max { + break; + } + if !matches!(a, NetAddr::Onion { .. }) || !self.allowed(a) || exclude.contains(&a) { + continue; + } + if !out.contains(&a) { + out.push(a); + } + } + out + } + pub fn add(&mut self, addr: SocketAddr) { self.add_addr(NetAddr::Ip(addr)); } pub fn add_addr(&mut self, addr: NetAddr) { + if !self.allowed(addr) { + return; + } if self.by_addr.contains_key(&addr) { return; } @@ -369,6 +415,9 @@ impl AddrMan { } pub fn add_learned_addr(&mut self, addr: NetAddr, cap: usize) -> bool { + if !self.allowed(addr) { + return false; + } if self.by_addr.contains_key(&addr) || cap == 0 { return false; } @@ -600,6 +649,9 @@ impl AddrMan { .iter() .copied() .filter_map(|a| { + if !self.allowed(a) { + return None; + } let sock = a.socket_addr()?; if exclude.contains(&sock) { return None; @@ -1529,4 +1581,18 @@ mod tests { assert!(loaded.flags(&addr(4)).has_connected()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn only_net_onion_filters_ipv4_candidates() { + let onion: NetAddr = "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let mut am = AddrMan::new(); + am.add(addr(1)); + am.add_addr(onion); + am.set_only_net(vec![OnlyNet::Onion]); + let got = am.take_dial_candidates_net(8, &HashSet::new(), &[]); + assert_eq!(got, vec![onion]); + assert!(am.take_dial_candidates(8, &HashSet::new(), &[]).is_empty()); + } } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 0198f1f68..0e99523cd 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -293,7 +293,7 @@ fn operator_usage() -> String { "rbitcoin-node {} — usage:\n\ rbitcoin-node [--conf FILE] [--datadir PATH] [--datadir-cold PATH] [--network NET] \\\n\ [--signet-challenge HEX] [--signet-block-time SECS] \\\n\ - [--listen ADDR] [--no-listen] [--connect ADDR]... [--seed-node HOST]... [--proxy HOST:PORT] [--onion HOST:PORT] [--proxy-randomize[=0|1]] \\\n\ + [--listen ADDR] [--no-listen] [--connect ADDR]... [--seed-node HOST]... [--proxy HOST:PORT] [--onion HOST:PORT] [--proxy-randomize[=0|1]] [--only-net NET]... \\\n\ [--electrum-listen ADDR] [--esplora-listen ADDR] \\\n\ [--sh-index] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--esplora-block-template] \\\n\ [--rpc] [--rpc-listen [ADDR]] [--rpc-token-file PATH] [--rpc-work-queue N] \\\n\ @@ -827,6 +827,32 @@ mod tests { )); } + #[test] + fn only_net_onion_without_proxy_is_config_error() { + let mut c = NodeConfig::default(); + c.apply_kv("only_net", "onion").unwrap(); + let err = c.validate().unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("SOCKS") && msg.contains("only-net"), "{msg}"); + c.apply_kv("proxy", "127.0.0.1:9050").unwrap(); + c.validate().unwrap(); + assert!(NodeConfig::default().apply_kv("only_net", "i2p").is_err()); + let ok = ready_config([ + "rbitcoin-node", + "--only-net", + "onion", + "--proxy", + "127.0.0.1:9050", + ]); + assert_eq!(ok.listen.only_net, vec![rbitcoin_net::OnlyNet::Onion]); + let h = operator_usage(); + assert!(h.contains("--only-net"), "help must list kebab --only-net"); + assert!( + !h.contains("--onlynet"), + "help must not advertise concatenated --onlynet" + ); + } + #[test] fn no_discover_conf() { let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 808b613d1..c9457e6ff 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -101,6 +101,8 @@ pub struct ListenOpts { pub proxy_randomize: bool, /// Core `-discover` (default on). Off: no self-announce / localaddresses. pub discover: bool, + /// Empty = all networks. Repeatable `--only-net`. + pub only_net: Vec, } impl Default for ListenOpts { @@ -122,6 +124,7 @@ impl Default for ListenOpts { onion: None, proxy_randomize: true, discover: true, + only_net: Vec::new(), } } } @@ -488,6 +491,18 @@ impl NodeConfig { "signet-block-time must be greater than zero".into(), )); } + if self + .listen + .only_net + .iter() + .any(|n| *n == rbitcoin_net::OnlyNet::Onion) + && self.listen.proxy.is_none() + && self.listen.onion.is_none() + { + return Err(NodeError::Config( + "only-net=onion requires SOCKS (--proxy or --onion)".into(), + )); + } Ok(()) } @@ -683,6 +698,12 @@ impl NodeConfig { "no_discover" => { self.listen.discover = !is_conf_true(val); } + "only_net" => { + self.listen.only_net.push( + rbitcoin_net::OnlyNet::parse(val) + .map_err(|e| NodeError::Config(format!("conf only_net: {e}")))?, + ); + } "connect" => { self.listen.connect.push( val.parse() diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 6812d8166..c22661f00 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -398,6 +398,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { }; let asmap = load_asmap(config.datadir.path(), config.asmap.as_deref()); addrman.set_asmap(asmap.clone()); + addrman.set_only_net(config.listen.only_net.clone()); node.peers.set_asmap(asmap); for c in &config.listen.connect { addrman.add_addr(*c); From d9d6909e030552026d3a6710521a691fe24bcacd Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:07:20 -0700 Subject: [PATCH 07/13] docs+nix: first-class --only-net and onion --connect NixOS onlyNet list emits --only-net per entry. OPERATOR documents peers v2, SOCKS required for onion, and kebab --only-net. Co-authored-by: Cursor --- OPERATOR.md | 7 ++++++- nix/modules/rbitcoin.nix | 11 +++++++++++ nix/tests/nixos-module-eval.nix | 3 +++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/OPERATOR.md b/OPERATOR.md index 2e01680aa..511a0c839 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -368,7 +368,8 @@ Clean smoke: | `--listen ADDR` | `listen=` | bind later default port | | `--no-listen` / `--listen=0` | `listen=0` / `no_listen=` | bind a loopback default; **off** = no P2P socket (outbound-only) | | `--no-discover` | `no_discover=` | discover **on**; flag off = no self-announce / `localaddresses` | -| `--connect ADDR` | `connect=` (repeatable) | seeds | +| `--only-net NET` | `only_net=` | all nets; repeatable `ipv4` / `ipv6` / `onion` (`i2p`/`cjdns` later) | +| `--connect ADDR` | `connect=` (repeatable) | seeds; `IP:port` or Tor v3 `.onion:port` | | `--proxy HOST:PORT` | `proxy=` | unset — SOCKS5 for all P2P outbound | | `--onion HOST:PORT` | `onion=` | unset — SOCKS5 for onion destinations | | `--proxy-randomize[=0\|1]` | `proxy_randomize=` | **on** — fresh SOCKS username per peer (Tor circuit isolation) | @@ -437,6 +438,10 @@ mempool_size_mb=100 pass `--connect ADDR` (or reuse a `peers` file). `--proxy-randomize` (default on) uses a fresh SOCKS username per peer so Tor isolates circuits. `--onion HOST:PORT` stores a separate SOCKS endpoint for onion destinations. +`--only-net onion` (repeatable with `ipv4`/`ipv6`) filters dial and learn; +onion requires `--proxy` or `--onion`. `--connect foo.onion:8333` is a start +error when the v3 checksum is invalid. The peers file is `rbitcoin-peers-v2` +(v1 IPv4/IPv6 still loads). `--listen=0` / `--no-listen` starts without a P2P TCP bind (no ISP port forward). `--max-inbound 0` refuses inbound slots. `--no-discover` does not diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 06e850b5c..ba42cd26e 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -77,6 +77,7 @@ let ++ optional (cfg.onionProxy != null) "--onion" ++ optional (cfg.onionProxy != null) cfg.onionProxy ++ optional (!cfg.proxyRandomize) "--proxy-randomize=0" + ++ lib.concatMap (n: [ "--only-net" n ]) cfg.onlyNet ++ cfg.extraArgs; in { @@ -186,6 +187,16 @@ in description = "Fresh SOCKS username per peer so Tor isolates circuits."; }; + onlyNet = mkOption { + type = types.listOf (types.enum [ + "ipv4" + "ipv6" + "onion" + ]); + default = [ ]; + description = "Restrict P2P to these networks. onion requires proxy or onionProxy."; + }; + p2p = { address = mkOption { type = types.str; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index beb8d5cea..17bc64c1a 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -31,6 +31,7 @@ let proxy = "127.0.0.1:9050"; onionProxy = "127.0.0.1:9050"; proxyRandomize = true; + onlyNet = [ "onion" ]; p2p = { address = "127.0.0.1"; openFirewall = true; @@ -89,6 +90,7 @@ assert defaultCfg.rpc.port == 8332; assert defaultCfg.proxy == null; assert defaultCfg.onionProxy == null; assert defaultCfg.proxyRandomize == true; +assert defaultCfg.onlyNet == [ ]; assert cfg.services.rbitcoin.p2p.port == 18444; assert cfg.services.rbitcoin.rpc.port == 18443; assert @@ -114,6 +116,7 @@ assert builtins.match ".*--log-level debug.*" execStart != null; assert builtins.match ".*--max-outbound 8.*" execStart != null; assert builtins.match ".*--proxy 127.0.0.1:9050.*" execStart != null; assert builtins.match ".*--onion 127.0.0.1:9050.*" execStart != null; +assert builtins.match ".*--only-net onion.*" execStart != null; assert builtins.match ".*--no-listen.*" listenOffExec != null; assert builtins.match ".*--listen .*" listenOffExec == null; assert builtins.match ".*--max-inbound 0.*" listenOffExec != null; From 0ed90d7c8709697f90ac48f7456c6a65f8c3cbc1 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:11:44 -0700 Subject: [PATCH 08/13] node: clippy contains for only-net onion SOCKS check `--all-targets` also needs the run_p2p integration connect list as NetAddr after ListenOpts.connect left SocketAddr. Co-authored-by: Cursor --- crates/rbitcoin-node/src/config.rs | 6 +----- crates/rbitcoin-test/tests/integration_multinode.rs | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index c9457e6ff..614f13a43 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -491,11 +491,7 @@ impl NodeConfig { "signet-block-time must be greater than zero".into(), )); } - if self - .listen - .only_net - .iter() - .any(|n| *n == rbitcoin_net::OnlyNet::Onion) + if self.listen.only_net.contains(&rbitcoin_net::OnlyNet::Onion) && self.listen.proxy.is_none() && self.listen.onion.is_none() { diff --git a/crates/rbitcoin-test/tests/integration_multinode.rs b/crates/rbitcoin-test/tests/integration_multinode.rs index 96f6adc24..d3acc05b0 100644 --- a/crates/rbitcoin-test/tests/integration_multinode.rs +++ b/crates/rbitcoin-test/tests/integration_multinode.rs @@ -12,7 +12,7 @@ use bitcoin::BlockHash; use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_net::{ rehydrate_block_queue_residue, run_feeler_timed, select_inbound_eviction, IbdConfig, - InboundEvictCandidate, NetError, P2PNode, + InboundEvictCandidate, NetAddr, NetError, P2PNode, }; use rbitcoin_primitives::Height; use rbitcoin_query::Query; @@ -2311,7 +2311,7 @@ async fn node_run_p2p_short() { .with_network(Network::Regtest) .with_p2p_listen("127.0.0.1:0".parse().unwrap()) .with_tiny_heads(); - cfg.listen.connect = vec![seed_addr]; + cfg.listen.connect = vec![NetAddr::Ip(seed_addr)]; cfg.listen.use_seeds = false; cfg.listen.electrum = Some(electrum_addr); cfg.listen.esplora = Some(rbitcoin_esplora::EsploraListen::Tcp(esplora_addr)); From e56eb9d0ebb55e15fd136aa947395335062aa1e0 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:51:48 -0700 Subject: [PATCH 09/13] net: track peer NetAddr identity for onion RPC and disconnect Co-authored-by: Cursor --- crates/rbitcoin-net/src/lib.rs | 4 +- crates/rbitcoin-net/src/peers.rs | 105 ++++++++++++++++++++--- crates/rbitcoin-net/src/service.rs | 6 +- crates/rbitcoin-net/src/socks.rs | 7 +- crates/rbitcoin-rpc/src/methods/net.rs | 13 +-- crates/rbitcoin-rpc/src/methods_tests.rs | 42 +++++++++ 6 files changed, 152 insertions(+), 25 deletions(-) diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index f916acfcf..d4b7fb1c0 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -52,8 +52,8 @@ pub use peer::{ }; pub use peer_dos::DEFAULT_MAX_INBOUND; pub use peers::{ - parse_peer_addr, pick_stale_follow_evict, DialRequest, DialTarget, LivePeer, PeerConnType, - PeerHub, PeerInfo, PeerOut, PingAction, + parse_peer_addr, parse_peer_net, pick_stale_follow_evict, DialRequest, DialTarget, LivePeer, + PeerConnType, PeerHub, PeerInfo, PeerOut, PingAction, }; pub use rbitcoin_mempool::AcceptError; pub(crate) use rbitcoin_mempool::MempoolGraphStats; diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index b51069835..279a84ca4 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -136,6 +136,15 @@ impl DialTarget { Self::Domain { port, .. } => SocketAddr::from(([0, 0, 0, 0], *port)), } } + + pub fn net_addr(&self) -> crate::NetAddr { + match self { + Self::Socket(addr) => crate::NetAddr::Ip(*addr), + Self::Domain { host, port } => format!("{host}:{port}") + .parse() + .unwrap_or(crate::NetAddr::Ip(self.peer_hint())), + } + } } impl std::fmt::Display for DialTarget { @@ -177,6 +186,7 @@ impl PendingSendCmpct { pub struct LivePeer { pub id: u64, pub addr: SocketAddr, + pub net: crate::NetAddr, pub addrbind: SocketAddr, pub subver: String, pub inbound: bool, @@ -911,6 +921,7 @@ impl LivePeer { PeerInfo { id: self.id, addr: self.addr, + net: self.net, addrbind: self.addrbind, subver: self.subver.clone(), inbound: self.inbound, @@ -995,6 +1006,7 @@ fn acct_bytes(cmd: &str, payload: u64) -> u64 { pub struct PeerInfo { pub id: u64, pub addr: SocketAddr, + pub net: crate::NetAddr, pub addrbind: SocketAddr, pub subver: String, pub inbound: bool, @@ -1042,7 +1054,7 @@ pub struct PeerInfo { pub struct PeerHub { next_id: AtomicU64, live: RwLock>>, - added: Mutex>, + added: Mutex>, dial_tx: Mutex>>, /// Peers we asked to send us compact (BIP152 HB, max 3, prefer outbound). hb_selected: Mutex>, @@ -1600,6 +1612,17 @@ impl PeerHub { addrbind: SocketAddr, inbound: bool, conn_type: PeerConnType, + ) -> Arc { + self.register_connecting_net(addr, crate::NetAddr::Ip(addr), addrbind, inbound, conn_type) + } + + pub fn register_connecting_net( + self: &Arc, + addr: SocketAddr, + net: crate::NetAddr, + addrbind: SocketAddr, + inbound: bool, + conn_type: PeerConnType, ) -> Arc { use bitcoin::p2p::address::Address; use bitcoin::p2p::ServiceFlags; @@ -1614,9 +1637,10 @@ impl PeerHub { start_height: -1, relay: false, }; - self.register_with_id( + self.register_with_id_net( self.next_id.fetch_add(1, Ordering::Relaxed), addr, + net, addrbind, &ver, inbound, @@ -1631,9 +1655,28 @@ impl PeerHub { ver: &VersionMessage, inbound: bool, conn_type: PeerConnType, + ) -> Arc { + self.register_net( + addr, + crate::NetAddr::Ip(addr), + addrbind, + ver, + inbound, + conn_type, + ) + } + + pub fn register_net( + self: &Arc, + addr: SocketAddr, + net: crate::NetAddr, + addrbind: SocketAddr, + ver: &VersionMessage, + inbound: bool, + conn_type: PeerConnType, ) -> Arc { let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let p = self.register_with_id(id, addr, addrbind, ver, inbound, conn_type); + let p = self.register_with_id_net(id, addr, net, addrbind, ver, inbound, conn_type); p.mark_handshake_complete(); p.note_recv("version", 100); p.note_recv("verack", 0); @@ -1648,6 +1691,27 @@ impl PeerHub { ver: &VersionMessage, inbound: bool, conn_type: PeerConnType, + ) -> Arc { + self.register_with_id_net( + id, + addr, + crate::NetAddr::Ip(addr), + addrbind, + ver, + inbound, + conn_type, + ) + } + + pub fn register_with_id_net( + self: &Arc, + id: u64, + addr: SocketAddr, + net: crate::NetAddr, + addrbind: SocketAddr, + ver: &VersionMessage, + inbound: bool, + conn_type: PeerConnType, ) -> Arc { let _ = self .next_id @@ -1657,6 +1721,7 @@ impl PeerHub { let peer = Arc::new(LivePeer { id, addr, + net, addrbind, subver: ver.user_agent.clone(), inbound, @@ -1813,14 +1878,28 @@ impl PeerHub { } pub fn addnode(&self, addr: SocketAddr, cmd: &str) -> Result<(), String> { + self.addnode_net(crate::NetAddr::Ip(addr), cmd) + } + + pub fn addnode_net(&self, addr: crate::NetAddr, cmd: &str) -> Result<(), String> { match cmd { - "onetry" => self.dial(addr, PeerConnType::Manual), + "onetry" => match addr { + crate::NetAddr::Ip(ip) => self.dial(ip, PeerConnType::Manual), + crate::NetAddr::Onion { .. } => { + self.dial_domain(addr.host_str(), addr.port(), PeerConnType::Manual) + } + }, "add" => { self.added .lock() .unwrap_or_else(|e| e.into_inner()) .insert(addr); - let _ = self.dial(addr, PeerConnType::Manual); + let _ = match addr { + crate::NetAddr::Ip(ip) => self.dial(ip, PeerConnType::Manual), + crate::NetAddr::Onion { .. } => { + self.dial_domain(addr.host_str(), addr.port(), PeerConnType::Manual) + } + }; Ok(()) } "remove" => { @@ -1828,7 +1907,7 @@ impl PeerHub { .lock() .unwrap_or_else(|e| e.into_inner()) .remove(&addr); - self.disconnect_addr(addr); + self.disconnect_net(addr); Ok(()) } other => Err(format!("unknown addnode command {other}")), @@ -2002,12 +2081,13 @@ impl PeerHub { } pub fn disconnect_addr(&self, addr: SocketAddr) -> bool { + self.disconnect_net(crate::NetAddr::Ip(addr)) + } + + pub fn disconnect_net(&self, addr: crate::NetAddr) -> bool { let ids: Vec = { let g = self.live.read().unwrap_or_else(|e| e.into_inner()); - g.values() - .filter(|p| p.addr == addr) - .map(|p| p.id) - .collect() + g.values().filter(|p| p.net == addr).map(|p| p.id).collect() }; let mut n = 0usize; for id in ids { @@ -2083,6 +2163,11 @@ pub fn parse_peer_addr(s: &str) -> Result { .map_err(|_| NetError::Encode(format!("bad peer address {s}"))) } +pub fn parse_peer_net(s: &str) -> Result { + s.parse() + .map_err(|_| NetError::Encode(format!("bad peer address {s}"))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index affa6b518..f1d046156 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -550,12 +550,13 @@ async fn prepare_outbound_session( DialTarget::Socket(addr) => dialer.connect_net(crate::NetAddr::Ip(*addr)).await?, DialTarget::Domain { host, port } => dialer.connect_domain(host, *port).await?, }; + let peer_net = peer.net_addr(); let peer_hint = peer.peer_hint(); let bind = stream.local_addr().unwrap_or(local); let height = hub.tip_height().map(|h| h as i32).unwrap_or(0); // Core adds CNode before VERSION. Provisional row so getpeerinfo is non-empty // during handshake (p2p_handshake self-connect wait_until + assert_debug_log). - let provisional = peers.register_connecting(peer_hint, bind, false, typ); + let provisional = peers.register_connecting_net(peer_hint, peer_net, bind, false, typ); let provisional_id = provisional.id; let handshake = connect_and_handshake_timed( HANDSHAKE_TIMEOUT, @@ -584,7 +585,8 @@ async fn prepare_outbound_session( let wants_addrv2 = provisional.wants_addrv2(); let wtxid_relay = provisional.wtxid_relay(); peers.unregister(provisional_id); - let sess = peers.register_with_id(provisional_id, peer_hint, bind, &ver, false, typ); + let sess = + peers.register_with_id_net(provisional_id, peer_hint, peer_net, bind, &ver, false, typ); sess.mark_handshake_complete(); if wants_addrv2 { sess.set_wants_addrv2(); diff --git a/crates/rbitcoin-net/src/socks.rs b/crates/rbitcoin-net/src/socks.rs index 900f779ee..e2fea70fb 100644 --- a/crates/rbitcoin-net/src/socks.rs +++ b/crates/rbitcoin-net/src/socks.rs @@ -711,11 +711,8 @@ mod tests { b"pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", 8333, )); - Dialer::Socks { - proxy, - randomize: false, - } - .connect_net(onion) + Dialer::socks(proxy, false) + .connect_net(onion) .await .unwrap(); server.await.unwrap(); diff --git a/crates/rbitcoin-rpc/src/methods/net.rs b/crates/rbitcoin-rpc/src/methods/net.rs index e2f9e0d3c..d0f2fbc58 100644 --- a/crates/rbitcoin-rpc/src/methods/net.rs +++ b/crates/rbitcoin-rpc/src/methods/net.rs @@ -99,7 +99,7 @@ pub(crate) fn peerinfo_json(ctx: &RpcContext, p: rbitcoin_net::PeerInfo) -> Valu }; let mut row = json!({ "id": p.id, - "addr": p.addr.to_string(), + "addr": p.net.to_string(), "addrbind": p.addrbind.to_string(), "subver": p.subver, "inbound": p.inbound, @@ -111,7 +111,7 @@ pub(crate) fn peerinfo_json(ctx: &RpcContext, p: rbitcoin_net::PeerInfo) -> Valu "connection_type": p.conn_type.as_str(), "relaytxes": p.relay && !matches!(p.conn_type, rbitcoin_net::PeerConnType::BlockRelay), "transport_protocol_type": "v2", - "network": "ipv4", + "network": p.net.network_label(), "synced_headers": synced_headers, "synced_blocks": synced_blocks, "timeoffset": p.time_offset_secs, @@ -178,9 +178,10 @@ pub(crate) fn addnode(ctx: &RpcContext, params: &RpcParams) -> Result Result Date: Sun, 20 Sep 2026 07:16:29 -0700 Subject: [PATCH 10/13] net: fix fmt and clippy on NetAddr peer register path Co-authored-by: Cursor --- crates/rbitcoin-net/src/peers.rs | 44 ++++++++++++++++++++++-------- crates/rbitcoin-net/src/service.rs | 13 +++++++-- crates/rbitcoin-net/src/socks.rs | 4 +-- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 279a84ca4..900dffbb0 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -163,6 +163,13 @@ pub struct DialRequest { pub typ: PeerConnType, } +#[derive(Clone, Debug)] +pub struct PeerEndpoint { + pub addr: SocketAddr, + pub net: crate::NetAddr, + pub addrbind: SocketAddr, +} + /// Queued `sendcmpct` to write on the next heartbeat (`AtomicU8` payload). #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] @@ -1637,11 +1644,14 @@ impl PeerHub { start_height: -1, relay: false, }; - self.register_with_id_net( - self.next_id.fetch_add(1, Ordering::Relaxed), + let endpoint = PeerEndpoint { addr, net, addrbind, + }; + self.register_with_id_net( + self.next_id.fetch_add(1, Ordering::Relaxed), + endpoint, &ver, inbound, conn_type, @@ -1676,7 +1686,17 @@ impl PeerHub { conn_type: PeerConnType, ) -> Arc { let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let p = self.register_with_id_net(id, addr, net, addrbind, ver, inbound, conn_type); + let p = self.register_with_id_net( + id, + PeerEndpoint { + addr, + net, + addrbind, + }, + ver, + inbound, + conn_type, + ); p.mark_handshake_complete(); p.note_recv("version", 100); p.note_recv("verack", 0); @@ -1694,9 +1714,11 @@ impl PeerHub { ) -> Arc { self.register_with_id_net( id, - addr, - crate::NetAddr::Ip(addr), - addrbind, + PeerEndpoint { + addr, + net: crate::NetAddr::Ip(addr), + addrbind, + }, ver, inbound, conn_type, @@ -1706,9 +1728,7 @@ impl PeerHub { pub fn register_with_id_net( self: &Arc, id: u64, - addr: SocketAddr, - net: crate::NetAddr, - addrbind: SocketAddr, + endpoint: PeerEndpoint, ver: &VersionMessage, inbound: bool, conn_type: PeerConnType, @@ -1720,9 +1740,9 @@ impl PeerHub { let connected_at = self.now_secs(); let peer = Arc::new(LivePeer { id, - addr, - net, - addrbind, + addr: endpoint.addr, + net: endpoint.net, + addrbind: endpoint.addrbind, subver: ver.user_agent.clone(), inbound, services, diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index f1d046156..0884957d2 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -585,8 +585,17 @@ async fn prepare_outbound_session( let wants_addrv2 = provisional.wants_addrv2(); let wtxid_relay = provisional.wtxid_relay(); peers.unregister(provisional_id); - let sess = - peers.register_with_id_net(provisional_id, peer_hint, peer_net, bind, &ver, false, typ); + let sess = peers.register_with_id_net( + provisional_id, + crate::peers::PeerEndpoint { + addr: peer_hint, + net: peer_net, + addrbind: bind, + }, + &ver, + false, + typ, + ); sess.mark_handshake_complete(); if wants_addrv2 { sess.set_wants_addrv2(); diff --git a/crates/rbitcoin-net/src/socks.rs b/crates/rbitcoin-net/src/socks.rs index e2fea70fb..bfbfdf954 100644 --- a/crates/rbitcoin-net/src/socks.rs +++ b/crates/rbitcoin-net/src/socks.rs @@ -713,8 +713,8 @@ mod tests { )); Dialer::socks(proxy, false) .connect_net(onion) - .await - .unwrap(); + .await + .unwrap(); server.await.unwrap(); assert!( From 48fa9971cddd252cdf9c1be0552d33df970836f7 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 07:40:28 -0700 Subject: [PATCH 11/13] net: simplify addnode_net to satisfy coverage complexity gate Co-authored-by: Cursor --- crates/rbitcoin-net/src/peers.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 900dffbb0..3baf13938 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -1901,25 +1901,22 @@ impl PeerHub { self.addnode_net(crate::NetAddr::Ip(addr), cmd) } + fn dial_manual_net(&self, addr: &crate::NetAddr) -> Result<(), String> { + match addr { + crate::NetAddr::Ip(ip) => self.dial(*ip, PeerConnType::Manual), + _ => self.dial_domain(addr.host_str(), addr.port(), PeerConnType::Manual), + } + } + pub fn addnode_net(&self, addr: crate::NetAddr, cmd: &str) -> Result<(), String> { match cmd { - "onetry" => match addr { - crate::NetAddr::Ip(ip) => self.dial(ip, PeerConnType::Manual), - crate::NetAddr::Onion { .. } => { - self.dial_domain(addr.host_str(), addr.port(), PeerConnType::Manual) - } - }, + "onetry" => self.dial_manual_net(&addr), "add" => { self.added .lock() .unwrap_or_else(|e| e.into_inner()) .insert(addr); - let _ = match addr { - crate::NetAddr::Ip(ip) => self.dial(ip, PeerConnType::Manual), - crate::NetAddr::Onion { .. } => { - self.dial_domain(addr.host_str(), addr.port(), PeerConnType::Manual) - } - }; + let _ = self.dial_manual_net(&addr); Ok(()) } "remove" => { From 830db6c1202bb4a5eff658cf022617bcce6c9d0f Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:40:22 -0700 Subject: [PATCH 12/13] net: grow the IBD dial book from addrv2 onion gossip PeerEvent::Addrs was Vec, so onion-only IBD dropped learned Tor v3 rows and could not redial from getaddr. Co-authored-by: Cursor --- .../src/ibd/events/confirm_reject_tests.rs | 25 ++++++-- crates/rbitcoin-net/src/ibd/events/mod.rs | 10 +++- crates/rbitcoin-net/src/ibd/peer_io.rs | 58 ++++++++++++++----- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs index 281021a67..e2e1533fc 100644 --- a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs +++ b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs @@ -1961,14 +1961,23 @@ fn apply_peer_event_body_and_control_surface() { inject_learned_addrs( &mut book, &[ - addr(2), - local, - SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 1), + crate::NetAddr::Ip(addr(2)), + crate::NetAddr::Ip(local), + crate::NetAddr::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 1)), ], local, 1, ); assert!(book.entry(&addr(2)).is_some()); + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + inject_learned_addrs(&mut book, &[onion], local, 1); + assert!( + book.entries().iter().any(|e| e.addr == onion), + "IBD addrv2 onion must enter the dial book" + ); // Dead releases work. st.slots[0].in_flight.insert(h(4)); @@ -2379,7 +2388,10 @@ fn apply_peer_event_block_framed_bq_horizon_and_headers_done() { let n0 = book.len(); inject_learned_addrs( &mut book, - &[SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 8333)], + &[crate::NetAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), + 8333, + ))], local, 1, ); @@ -2396,7 +2408,10 @@ fn apply_peer_event_block_framed_bq_horizon_and_headers_done() { ctrl_tx .send(PeerEvent::Addrs { peer: 1, - addrs: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 8333)], + addrs: vec![crate::NetAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), + 8333, + ))], }) .unwrap(); let stats = super::super::status::LoopStats::default(); diff --git a/crates/rbitcoin-net/src/ibd/events/mod.rs b/crates/rbitcoin-net/src/ibd/events/mod.rs index 9e14aaac3..7e6567877 100644 --- a/crates/rbitcoin-net/src/ibd/events/mod.rs +++ b/crates/rbitcoin-net/src/ibd/events/mod.rs @@ -512,7 +512,7 @@ fn apply_peer_dead(st: &mut IbdWorkState, peer_book: &mut AddrMan, peer: usize, /// Grow the IBD dial book from peer-advertised addresses (getaddr responses). pub(crate) fn inject_learned_addrs( book: &mut AddrMan, - addrs: &[SocketAddr], + addrs: &[crate::NetAddr], local_addr: SocketAddr, from_peer: usize, ) { @@ -521,10 +521,14 @@ pub(crate) fn inject_learned_addrs( } let mut added = 0usize; for &a in addrs { - if a == local_addr || a.ip().is_unspecified() || a.port() == 0 { + if a.port() == 0 { continue; } - if book.add_learned(a, MAX_PEER_POOL) { + match a { + crate::NetAddr::Ip(s) if s == local_addr || s.ip().is_unspecified() => continue, + _ => {} + } + if book.add_learned_addr(a, MAX_PEER_POOL) { added += 1; } } diff --git a/crates/rbitcoin-net/src/ibd/peer_io.rs b/crates/rbitcoin-net/src/ibd/peer_io.rs index 3c08cead4..50d3746e9 100644 --- a/crates/rbitcoin-net/src/ibd/peer_io.rs +++ b/crates/rbitcoin-net/src/ibd/peer_io.rs @@ -58,7 +58,7 @@ pub(crate) enum PeerEvent { /// Addresses learned from `addr` / `addrv2` (for IBD redial pool growth). Addrs { peer: usize, - addrs: Vec, + addrs: Vec, }, /// Peer failed or closed. Dead { @@ -277,13 +277,13 @@ pub(crate) async fn spawn_peer( } } NetworkMessage::Addr(list) => { - let addrs = socket_addrs_from_addr(&list); + let addrs = net_addrs_from_addr(&list); if !addrs.is_empty() { sinks_d.send_ctrl(PeerEvent::Addrs { peer: id, addrs }); } } NetworkMessage::AddrV2(list) => { - let addrs = socket_addrs_from_addrv2(&list); + let addrs = net_addrs_from_addrv2(&list); if !addrs.is_empty() { sinks_d.send_ctrl(PeerEvent::Addrs { peer: id, addrs }); } @@ -436,25 +436,35 @@ pub(crate) async fn spawn_peer( } /// IPv4/IPv6 sockets that advertise full/limited network **and** `P2P_V2`. -fn socket_addrs_from_addr(list: &[(u32, bitcoin::p2p::address::Address)]) -> Vec { +fn net_addrs_from_addr(list: &[(u32, bitcoin::p2p::address::Address)]) -> Vec { + socket_addrs_from_addr(list) + .into_iter() + .map(crate::NetAddr::Ip) + .collect() +} + +/// BIP155 rows that advertise full/limited network **and** `P2P_V2`, including onion. +fn net_addrs_from_addrv2(list: &[bitcoin::p2p::address::AddrV2Message]) -> Vec { let mut out = Vec::with_capacity(list.len().min(32)); - for (_ts, a) in list { + for a in list { if !services_useful_for_ibd(a.services) { continue; } - if let Ok(sa) = a.socket_addr() { - if usable_dial_addr(&sa) { - out.push(sa); - } + let Some(addr) = crate::NetAddr::from_addrv2(a) else { + continue; + }; + match addr { + crate::NetAddr::Ip(sa) if !usable_dial_addr(&sa) => {} + ok => out.push(ok), } } out } /// IPv4/IPv6 sockets that advertise full/limited network **and** `P2P_V2`. -fn socket_addrs_from_addrv2(list: &[bitcoin::p2p::address::AddrV2Message]) -> Vec { +fn socket_addrs_from_addr(list: &[(u32, bitcoin::p2p::address::Address)]) -> Vec { let mut out = Vec::with_capacity(list.len().min(32)); - for a in list { + for (_ts, a) in list { if !services_useful_for_ibd(a.services) { continue; } @@ -650,13 +660,13 @@ mod tests { addr: AddrV2::Ipv4(Ipv4Addr::new(9, 9, 9, 11)), port: 0, }; - let out2 = socket_addrs_from_addrv2(&[v2_good, v2_net_only, v2_bad_svc, v2_zero_port]); + let out2 = net_addrs_from_addrv2(&[v2_good, v2_net_only, v2_bad_svc, v2_zero_port]); assert_eq!( out2, - vec![SocketAddr::new( + vec![crate::NetAddr::Ip(SocketAddr::new( IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 18444 - )] + ))] ); let v6_multi = @@ -669,7 +679,23 @@ mod tests { addr: AddrV2::Ipv6(Ipv6Addr::LOCALHOST), port: 8333, }; - let out3 = socket_addrs_from_addrv2(&[v2_v6]); - assert_eq!(out3, vec![v6_net]); + let out3 = net_addrs_from_addrv2(&[v2_v6]); + assert_eq!(out3, vec![crate::NetAddr::Ip(v6_net)]); + + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let crate::NetAddr::Onion { pk, port } = onion else { + panic!("fixture"); + }; + let v2_onion = AddrV2Message { + time: 1, + services: v2_net, + addr: AddrV2::TorV3(pk), + port, + }; + let nets = net_addrs_from_addrv2(&[v2_onion]); + assert_eq!(nets, vec![onion]); } } From d2332e0624964da962c377ef9c05b1102031eb30 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:40:23 -0700 Subject: [PATCH 13/13] net: keep DialTarget overlay identity; 0.0.0.0 is VERSION only Domain peers parsed back through 0.0.0.0, so VERSION/LivePeer lost the onion NetAddr. Name the dummy version_socket and parse Domain as onion. Co-authored-by: Cursor --- crates/rbitcoin-net/src/peers.rs | 35 +++++++++++++++++++++++++++--- crates/rbitcoin-net/src/service.rs | 7 ++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 3baf13938..1936674bc 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -130,19 +130,34 @@ pub enum DialTarget { } impl DialTarget { - pub fn peer_hint(&self) -> SocketAddr { + pub(crate) fn from_net(addr: crate::NetAddr) -> Self { + match addr { + crate::NetAddr::Ip(s) => Self::Socket(s), + other => Self::Domain { + host: other.host_str(), + port: other.port(), + }, + } + } + + /// VERSION v1 `Address` field. Onion has no SocketAddr; Core uses 0.0.0.0. + pub fn version_socket(&self) -> SocketAddr { match self { Self::Socket(addr) => *addr, Self::Domain { port, .. } => SocketAddr::from(([0, 0, 0, 0], *port)), } } + pub fn peer_hint(&self) -> SocketAddr { + self.version_socket() + } + pub fn net_addr(&self) -> crate::NetAddr { match self { Self::Socket(addr) => crate::NetAddr::Ip(*addr), Self::Domain { host, port } => format!("{host}:{port}") .parse() - .unwrap_or(crate::NetAddr::Ip(self.peer_hint())), + .expect("DialTarget::Domain is host:port from NetAddr::Onion"), } } } @@ -2119,7 +2134,7 @@ impl PeerHub { let g = self.dial_tx.lock().unwrap_or_else(|e| e.into_inner()); let tx = g.as_ref().ok_or("no dialer attached")?; tx.send(DialRequest { - target: DialTarget::Socket(addr), + target: DialTarget::from_net(crate::NetAddr::Ip(addr)), typ, }) .map_err(|_| "dialer closed".to_string()) @@ -3144,4 +3159,18 @@ mod tests { == crate::NetAddr::Ip(SocketAddr::from((Ipv4Addr::new(1, 2, 3, 4), 18444))))); assert_eq!(ents.len(), 2, "I2P is not stored until plan 04"); } + + #[test] + fn dial_target_onion_keeps_net_addr_not_unspecified() { + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let t = DialTarget::from_net(onion); + assert!(matches!(t, DialTarget::Domain { .. }), "{t:?}"); + assert_eq!(t.net_addr(), onion); + let hint = t.version_socket(); + assert!(hint.ip().is_unspecified(), "{hint}"); + assert_eq!(hint.port(), 8333); + } } diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index 0884957d2..0a63af95e 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -546,12 +546,9 @@ async fn prepare_outbound_session( dialer: crate::socks::Dialer, ) -> Result { rbitcoin_log::debug!("{}", crate::peers::trying_connection_log(typ, &peer)); - let stream = match &peer { - DialTarget::Socket(addr) => dialer.connect_net(crate::NetAddr::Ip(*addr)).await?, - DialTarget::Domain { host, port } => dialer.connect_domain(host, *port).await?, - }; let peer_net = peer.net_addr(); - let peer_hint = peer.peer_hint(); + let stream = dialer.connect_net(peer_net).await?; + let peer_hint = peer.version_socket(); let bind = stream.local_addr().unwrap_or(local); let height = hub.tip_height().map(|h| h as i32).unwrap_or(0); // Core adds CNode before VERSION. Provisional row so getpeerinfo is non-empty