diff --git a/OPERATOR.md b/OPERATOR.md index 14464680d..dbfe07987 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -369,7 +369,8 @@ Clean smoke: | `--no-listen` / `--listen=0` | `listen=0` / `no_listen=` | bind a loopback default; **off** = no clearnet P2P socket | | `--listen-onion` | `listen_onion=` | **off** — loopback P2P + `ADD_ONION` (`{datadir}/onion/p2p.priv`); needs `--tor-control` and `--max-inbound` > 0 | | `--no-discover` | `no_discover=` | discover **on**; flag off = no home-IP self-announce; P2P/wallet onions still listed | -| `--only-net NET` | `only_net=` | all nets; repeatable `ipv4` / `ipv6` / `onion` / `i2p` (`cjdns` later) | +| `--only-net NET` | `only_net=` | all nets; repeatable `ipv4` / `ipv6` / `onion` / `i2p` / `cjdns` | +| `--cjdns-reachable` | `cjdns_reachable=` | **off** — `fc00::/8` is unroutable until set; `--only-net=cjdns` requires it | | `--connect ADDR` | `connect=` (repeatable) | seeds; `IP:port`, Tor v3 `.onion:port`, or `{52}.b32.i2p:port` | | `--proxy HOST:PORT` | `proxy=` | unset — SOCKS5 for all P2P outbound | | `--onion HOST:PORT` | `onion=` | unset — SOCKS5 for onion destinations | @@ -453,7 +454,7 @@ sends `tx`, and disconnects. This is **not** Dandelion++. If that one-shot fails, the tx stays in the mempool and is still not INV'd; confirmation can still arrive in a block. `--onion HOST:PORT` stores a separate SOCKS endpoint for onion destinations. -`--only-net onion` (repeatable with `ipv4`/`ipv6`/`i2p`) filters dial and learn; +`--only-net onion` (repeatable with `ipv4`/`ipv6`/`i2p`/`cjdns`) 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). @@ -488,10 +489,18 @@ error. Unset: I2P rows may still load from `peers` v2 but are not dialed. `--only-net i2p` without `--i2p-sam` is a start error. `--i2p-accept-incoming` creates a persistent local destination (`{datadir}/i2p/p2p.priv`, 0600) and `STREAM FORWARD`s to the P2P bind. With `--listen=0` that is a start error -naming `--listen` (overlay incoming still needs a loopback P2P accept). NixOS: +unless `--listen-onion` provides a loopback accept. NixOS: `services.rbitcoin.i2p.sam` / `i2p.acceptIncoming`; the unit `After`/`Wants` `i2pd.service` when SAM is set. Do not start i2pd from this module. +`--cjdns-reachable` treats BIP155 `fc00::/8` as the kernel CJDNS overlay: dial +with ordinary TCP (OS routing), advertise a `--listen` on that IPv6, keep +tagged rows in `peers` v2. Off (default): do not dial CJDNS; do not treat +`fc00::/8` as advertisable. `--only-net=cjdns` without the flag is a start +error. `--listen [fc00:…]:port` binds that address when the OS has it; no +cjdns daemon in-process and no TUN in CI. NixOS: `cjdns.reachable`; +`After`/`Wants` `cjdns.service`. Do not start a cjdns router from this module. + `--datadir` holds the node root (`store/`, `mempool/`, `peers`, `rpc.token`, `rpc.sock`). Omit `--datadir-cold` and cold files live there too. Set it to put the large rarely-read Class A **inwit** stem (`inwit.body` + `inwit.loc`, ~486 GiB + loc diff --git a/crates/rbitcoin-net/src/ephemeral.rs b/crates/rbitcoin-net/src/ephemeral.rs index 0c628a60d..258057c38 100644 --- a/crates/rbitcoin-net/src/ephemeral.rs +++ b/crates/rbitcoin-net/src/ephemeral.rs @@ -25,6 +25,7 @@ pub(crate) fn isolated_broadcast_targets(am: &AddrMan, max: usize) -> Vec onions.push(e.addr), NetAddr::Ip(_) => ips.push(e.addr), NetAddr::I2p { .. } => {} + NetAddr::Cjdns { .. } => {} } } let mut out = Vec::new(); @@ -114,7 +115,12 @@ pub fn spawn_isolated_broadcast_loop( } let mut rx = mp.subscribe_isolated(); tokio::spawn(async move { - while let Ok(txid) = rx.recv().await { + loop { + let txid = match rx.recv().await { + Ok(txid) => txid, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; let Some(tx) = mp.get_tx(&txid) else { continue; }; @@ -428,6 +434,38 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn ephemeral_broadcast_loop_survives_lagged_kicks() { + use bitcoin::hashes::Hash; + use bitcoin::Txid; + + let (dir, hub) = crate::chain::tiny_regtest_hub_labeled("iso-lag"); + let mp = MempoolHub::open(dir.join("mp"), Arc::clone(&hub.query)).unwrap(); + mp.set_isolated_broadcast(true); + let am = Arc::new(Mutex::new(AddrMan::new())); + let h = spawn_isolated_broadcast_loop( + mp.clone(), + Dialer::Direct, + am, + Magic::REGTEST, + "/rbitcoin:test/".into(), + ); + tokio::time::sleep(Duration::from_millis(20)).await; + for i in 0..40u8 { + mp.mark_local_origin(Txid::from_byte_array([i; 32])); + } + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !h.is_finished(), + "broadcast Lagged must not stop isolated send" + ); + mp.mark_local_origin(dummy_tx().compute_txid()); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(!h.is_finished()); + h.abort(); + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn ephemeral_broadcast_known_tx_no_addrman_targets() { let tx = dummy_tx(); diff --git a/crates/rbitcoin-net/src/ibd/assign.rs b/crates/rbitcoin-net/src/ibd/assign.rs index a92898b2d..1b6190b37 100644 --- a/crates/rbitcoin-net/src/ibd/assign.rs +++ b/crates/rbitcoin-net/src/ibd/assign.rs @@ -1283,6 +1283,10 @@ mod tests { PeerSlot { id, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18444 + id as u16), + net: crate::NetAddr::from_socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 18444 + id as u16, + )), cmd_tx, in_flight: HashSet::new(), peer_height: 100, diff --git a/crates/rbitcoin-net/src/ibd/dial.rs b/crates/rbitcoin-net/src/ibd/dial.rs index ffb3229a4..31437abbb 100644 --- a/crates/rbitcoin-net/src/ibd/dial.rs +++ b/crates/rbitcoin-net/src/ibd/dial.rs @@ -336,7 +336,7 @@ pub(crate) fn apply_dial_result(book: &mut AddrMan, result: &DialBatchResult) { book.note_attempt_addr(addr); } for s in &result.slots { - book.note_connected(s.addr); + book.note_connected_addr(s.net); } for &(addr, kind) in &result.failed { book.note_connect_failed_addr(addr, kind == DialFailKind::Incompatible); @@ -442,8 +442,7 @@ pub(crate) fn dial_blocked_addrs( cooldown: &HashMap, now: Instant, ) -> HashSet { - let mut blocked: HashSet = - slots.iter().map(|s| crate::NetAddr::Ip(s.addr)).collect(); + let mut blocked: HashSet = slots.iter().map(|s| s.net).collect(); for (&addr, &until) in cooldown { if until > now { blocked.insert(crate::NetAddr::Ip(addr)); @@ -454,7 +453,11 @@ pub(crate) fn dial_blocked_addrs( /// Live slot addrs whose netgroups occupy outbound diversity (cooldown is exclude-only). pub(crate) fn alive_dial_addrs(slots: &[PeerSlot]) -> Vec { - slots.iter().filter(|s| s.alive).map(|s| s.addr).collect() + slots + .iter() + .filter(|s| s.alive) + .filter_map(|s| s.net.socket_addr()) + .collect() } pub(crate) fn expire_addr_cooldown(cooldown: &mut HashMap, now: Instant) { @@ -648,6 +651,7 @@ mod tests { PeerSlot { id, addr: a, + net: crate::NetAddr::from_socket(a), cmd_tx, in_flight: HashSet::new(), peer_height: 0, 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 e2e1533fc..adc12c3f0 100644 --- a/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs +++ b/crates/rbitcoin-net/src/ibd/events/confirm_reject_tests.rs @@ -1018,6 +1018,7 @@ fn confirmed_height_mids_blocked_while_densify_ahead_leaves_tip_hole() { let slot = PeerSlot { id: 0, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18444), + net: crate::NetAddr::from_socket(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18444)), cmd_tx, in_flight: HashSet::new(), peer_height: 100, @@ -1201,6 +1202,7 @@ fn zombie_pending_mid_at_confirmed_height_never_reget() { let slot = PeerSlot { id: 0, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18445), + net: crate::NetAddr::from_socket(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18445)), cmd_tx, in_flight: HashSet::new(), peer_height: 100, @@ -1800,6 +1802,7 @@ fn apply_peer_event_body_and_control_surface() { PeerSlot { id, addr: a, + net: crate::NetAddr::from_socket(a), cmd_tx, in_flight: HashSet::new(), peer_height: 10, @@ -2045,6 +2048,10 @@ fn apply_peer_event_repeat_headers_skips_ensure_header_fk() { PeerSlot { id: 1, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 1, 0, 1)), 18444), + net: crate::NetAddr::from_socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(10, 1, 0, 1)), + 18444, + )), cmd_tx, in_flight: HashSet::new(), peer_height: 10, @@ -2186,6 +2193,10 @@ fn apply_peer_event_block_framed_bq_horizon_and_headers_done() { PeerSlot { id, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 18444), + net: crate::NetAddr::from_socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 18444, + )), cmd_tx, in_flight: HashSet::new(), peer_height: 5, @@ -2471,6 +2482,10 @@ fn block_framed_raw_offers_body_queue_with_confirm_feed() { PeerSlot { id, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 18444), + net: crate::NetAddr::from_socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 18444, + )), cmd_tx, in_flight: HashSet::new(), peer_height: 5, @@ -2608,6 +2623,10 @@ fn known_headers_re_admit_to_ordered_after_tip_drain() { PeerSlot { id, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 18444), + net: crate::NetAddr::from_socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 18444, + )), cmd_tx, in_flight: HashSet::new(), peer_height: 5, @@ -2759,6 +2778,7 @@ fn path_slot_first_wins_chained_via_headers() { PeerSlot { id, addr: a, + net: crate::NetAddr::from_socket(a), cmd_tx, in_flight: HashSet::new(), peer_height: 10, diff --git a/crates/rbitcoin-net/src/ibd/mod.rs b/crates/rbitcoin-net/src/ibd/mod.rs index 41b723d8a..6b209f227 100644 --- a/crates/rbitcoin-net/src/ibd/mod.rs +++ b/crates/rbitcoin-net/src/ibd/mod.rs @@ -674,9 +674,7 @@ pub async fn ibd_cancellable( let mut n = 0usize; for s in result.slots { // Race: same addr may have connected on another path. - if blocked.contains(&crate::NetAddr::Ip(s.addr)) - || st.slots.iter().any(|x| x.addr == s.addr) - { + if blocked.contains(&s.net) || st.slots.iter().any(|x| x.net == s.net) { warn!( "ibd: drop duplicate/cooldown dial peer[{}] {}", s.id, s.addr diff --git a/crates/rbitcoin-net/src/ibd/peer_io.rs b/crates/rbitcoin-net/src/ibd/peer_io.rs index a8dfebc93..180fe0bf9 100644 --- a/crates/rbitcoin-net/src/ibd/peer_io.rs +++ b/crates/rbitcoin-net/src/ibd/peer_io.rs @@ -89,6 +89,7 @@ impl PeerEventSinks { pub(crate) struct PeerSlot { pub id: usize, pub addr: SocketAddr, + pub net: crate::NetAddr, pub cmd_tx: mpsc::UnboundedSender, /// Hashes currently requested from this peer. pub in_flight: HashSet, @@ -426,6 +427,7 @@ pub(crate) async fn spawn_peer( Ok(PeerSlot { id, addr: version_socket, + net: addr, cmd_tx, in_flight: HashSet::new(), peer_height, @@ -517,6 +519,10 @@ mod tests { PeerSlot { id, addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 18444), + net: crate::NetAddr::from_socket(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + 18444, + )), cmd_tx, in_flight: HashSet::new(), peer_height: 100, diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 37281926a..35ac3a9ec 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -48,7 +48,7 @@ pub use net_permissions::{ apply_implicit, parse_whitebind, parse_whitelist, NetPermTable, NetPermissionFlags, WhitebindGrant, WhitelistGrant, DEFAULT_WHITELISTFORCERELAY, DEFAULT_WHITELISTRELAY, }; -pub use netaddr::{addr_allowed, NetAddr, OnlyNet}; +pub use netaddr::{is_cjdns_ip, 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 ed499a180..408728951 100644 --- a/crates/rbitcoin-net/src/netaddr.rs +++ b/crates/rbitcoin-net/src/netaddr.rs @@ -4,7 +4,7 @@ use crate::error::NetError; use bitcoin::p2p::address::{AddrV2, AddrV2Message}; use sha3::{Digest, Sha3_256}; use std::fmt; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::str::FromStr; const B32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; @@ -19,6 +19,7 @@ pub enum OnlyNet { Ipv6, Onion, I2p, + Cjdns, } impl OnlyNet { @@ -28,7 +29,7 @@ impl OnlyNet { "ipv6" => Ok(Self::Ipv6), "onion" => Ok(Self::Onion), "i2p" => Ok(Self::I2p), - "cjdns" => Err(format!("unknown network {s} (not yet implemented)")), + "cjdns" => Ok(Self::Cjdns), other => Err(format!("unknown network {other}")), } } @@ -39,12 +40,13 @@ impl OnlyNet { (Self::Ipv6, NetAddr::Ip(s)) => s.is_ipv6(), (Self::Onion, NetAddr::Onion { .. }) => true, (Self::I2p, NetAddr::I2p { .. }) => true, + (Self::Cjdns, NetAddr::Cjdns { .. }) => true, _ => false, } } } -pub fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { +pub(crate) fn addr_allowed(addr: NetAddr, only: &[OnlyNet]) -> bool { only.is_empty() || only.iter().any(|n| n.matches_addr(addr)) } @@ -53,6 +55,12 @@ pub enum NetAddr { Ip(SocketAddr), Onion { pk: [u8; 32], port: u16 }, I2p { dest: [u8; 32], port: u16 }, + Cjdns { ip: Ipv6Addr, port: u16 }, +} + +/// BIP155 / Core: CJDNS overlay is `fc00::/8`, not RFC4193 `fc00::/7`. +pub fn is_cjdns_ip(ip: Ipv6Addr) -> bool { + ip.octets()[0] == 0xfc } impl fmt::Display for NetAddr { @@ -65,6 +73,7 @@ impl fmt::Display for NetAddr { NetAddr::I2p { dest, port } => { write!(f, "{}{I2P_SUFFIX}:{port}", encode_i2p_name(&dest)) } + NetAddr::Cjdns { ip, port } => write!(f, "{}", SocketAddr::from((ip, port))), } } } @@ -83,7 +92,7 @@ impl NetAddr { return None; } match &msg.addr { - AddrV2::Ipv4(_) | AddrV2::Ipv6(_) => msg.socket_addr().ok().map(NetAddr::Ip), + AddrV2::Ipv4(_) | AddrV2::Ipv6(_) => msg.socket_addr().ok().map(NetAddr::from_socket), AddrV2::TorV3(pk) => Some(NetAddr::Onion { pk: *pk, port: msg.port, @@ -92,6 +101,10 @@ impl NetAddr { dest: *dest, port: msg.port, }), + AddrV2::Cjdns(ip) if is_cjdns_ip(*ip) => Some(NetAddr::Cjdns { + ip: *ip, + port: msg.port, + }), AddrV2::TorV2(_) | AddrV2::Cjdns(_) | AddrV2::Unknown(_, _) => None, } } @@ -104,12 +117,24 @@ impl NetAddr { }, NetAddr::Onion { pk, .. } => AddrV2::TorV3(pk), NetAddr::I2p { dest, .. } => AddrV2::I2p(dest), + NetAddr::Cjdns { ip, .. } => AddrV2::Cjdns(ip), + } + } + + pub fn from_socket(s: SocketAddr) -> Self { + match s.ip() { + IpAddr::V6(v) if is_cjdns_ip(v) => NetAddr::Cjdns { + ip: v, + port: s.port(), + }, + _ => NetAddr::Ip(s), } } pub fn socket_addr(self) -> Option { match self { NetAddr::Ip(s) => Some(s), + NetAddr::Cjdns { ip, port } => Some(SocketAddr::from((ip, port))), NetAddr::Onion { .. } | NetAddr::I2p { .. } => None, } } @@ -117,6 +142,7 @@ impl NetAddr { pub fn is_ipv6(self) -> bool { match self { NetAddr::Ip(s) => s.is_ipv6(), + NetAddr::Cjdns { .. } => true, NetAddr::Onion { .. } | NetAddr::I2p { .. } => false, } } @@ -124,13 +150,16 @@ impl NetAddr { pub fn port(self) -> u16 { match self { NetAddr::Ip(s) => s.port(), - NetAddr::Onion { port, .. } | NetAddr::I2p { port, .. } => port, + NetAddr::Onion { port, .. } + | NetAddr::I2p { port, .. } + | NetAddr::Cjdns { port, .. } => port, } } pub fn host_str(self) -> String { match self { NetAddr::Ip(s) => s.ip().to_string(), + NetAddr::Cjdns { ip, .. } => ip.to_string(), NetAddr::Onion { pk, .. } => format!("{}.onion", encode_onion_name(&pk)), NetAddr::I2p { dest, .. } => format!("{}{I2P_SUFFIX}", encode_i2p_name(&dest)), } @@ -142,6 +171,7 @@ impl NetAddr { NetAddr::Ip(_) => "ipv6", NetAddr::Onion { .. } => "onion", NetAddr::I2p { .. } => "i2p", + NetAddr::Cjdns { .. } => "cjdns", } } } @@ -167,7 +197,7 @@ fn parse_net_addr(s: &str) -> Result { return Ok(NetAddr::Onion { pk, port }); } s.parse() - .map(NetAddr::Ip) + .map(NetAddr::from_socket) .map_err(|_| NetError::Encode(format!("bad peer address {s}"))) } @@ -325,7 +355,7 @@ fn b32_decode(s: &str) -> Option<[u8; 35]> { #[cfg(test)] mod tests { use super::*; - use std::net::{IpAddr, Ipv4Addr}; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; #[test] fn netaddr_onion_parse_roundtrip() { @@ -345,7 +375,9 @@ mod tests { ); assert_eq!(port, 8333); } - NetAddr::Ip(_) | NetAddr::I2p { .. } => panic!("expected onion"), + NetAddr::Ip(_) | NetAddr::I2p { .. } | NetAddr::Cjdns { .. } => { + panic!("expected onion") + } } assert!( "qg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" @@ -400,4 +432,37 @@ mod tests { ); assert!("short.b32.i2p:1".parse::().is_err()); } + + #[test] + fn netaddr_cjdns_addrv2_roundtrip() { + let ip = Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7); + let msg = AddrV2Message { + time: 1, + services: bitcoin::p2p::ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(ip), + port: 8333, + }; + let a = NetAddr::from_addrv2(&msg).expect("cjdns addrv2"); + assert_eq!(a, NetAddr::Cjdns { ip, port: 8333 }); + assert_eq!(a.to_addrv2(), AddrV2::Cjdns(ip)); + assert_eq!(a.network_label(), "cjdns"); + assert_eq!(a.port(), 8333); + assert_eq!(a.socket_addr(), Some(SocketAddr::from((ip, 8333)))); + let parsed: NetAddr = a.to_string().parse().unwrap(); + assert_eq!(parsed, a); + assert!(OnlyNet::Cjdns.matches_addr(a)); + assert!(!OnlyNet::Ipv6.matches_addr(a)); + } + + #[test] + fn cjdns_rejects_global_unicast() { + let ip = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); + let msg = AddrV2Message { + time: 1, + services: bitcoin::p2p::ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(ip), + port: 8333, + }; + assert!(NetAddr::from_addrv2(&msg).is_none()); + } } diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs index 55c74178f..dbd1403f8 100644 --- a/crates/rbitcoin-net/src/peer.rs +++ b/crates/rbitcoin-net/src/peer.rs @@ -17,7 +17,7 @@ use crate::v2::{ }; use bitcoin::bip152::{BlockTransactions, BlockTransactionsRequest, HeaderAndShortIds}; use bitcoin::hashes::Hash; -use bitcoin::p2p::address::{AddrV2, AddrV2Message, Address}; +use bitcoin::p2p::address::{AddrV2Message, Address}; use bitcoin::p2p::message::{NetworkMessage, RawNetworkMessage}; use bitcoin::p2p::message_blockdata::{GetBlocksMessage, GetHeadersMessage, Inventory}; use bitcoin::p2p::message_compact_blocks::{BlockTxn, CmpctBlock, GetBlockTxn, SendCmpct}; @@ -307,34 +307,34 @@ pub fn advertising_address_log(addr_port: impl std::fmt::Display, peer: u64) -> format!("p2p: Advertising address {addr_port} to peer={peer}") } -fn addrv2_from_sock(now: u32, sock: SocketAddr) -> AddrV2Message { - AddrV2Message { - time: now, - services: local_service_flags(), - addr: match sock.ip() { - std::net::IpAddr::V4(v) => AddrV2::Ipv4(v), - std::net::IpAddr::V6(v) => AddrV2::Ipv6(v), - }, - port: sock.port(), - } -} - fn queue_addr_list( out: &mpsc::UnboundedSender, - addrs: Vec<(u32, Address)>, + addrs: Vec<(u32, crate::NetAddr)>, v2: bool, ) -> Result<(), NetError> { + let services = local_service_flags(); if v2 { let list: Vec = addrs .into_iter() - .filter_map(|(t, a)| { - let sock = a.socket_addr().ok()?; - Some(addrv2_from_sock(t, sock)) + .map(|(t, a)| AddrV2Message { + time: t, + services, + addr: a.to_addrv2(), + port: a.port(), }) .collect(); queue_out(out, NetworkMessage::AddrV2(list)) } else { - queue_out(out, NetworkMessage::Addr(addrs)) + let list: Vec<(u32, Address)> = addrs + .into_iter() + .filter_map(|(t, a)| match a { + crate::NetAddr::Ip(s) => Some((t, Address::new(&s, services))), + crate::NetAddr::Onion { .. } + | crate::NetAddr::I2p { .. } + | crate::NetAddr::Cjdns { .. } => None, + }) + .collect(); + queue_out(out, NetworkMessage::Addr(list)) } } @@ -3737,11 +3737,11 @@ fn on_getaddr( let bind = session .map(|s| s.addrbind) .unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 0))); + let v2 = session.is_some_and(|s| s.wants_addrv2()); let addrs = match session.and_then(|s| s.peer_hub()) { - Some(ph) => ph.addr_response_for_bind(bind), + Some(ph) => ph.addr_response_net(bind, v2), None => Vec::new(), }; - let v2 = session.is_some_and(|s| s.wants_addrv2()); queue_addr_list(out_tx, addrs, v2)?; Ok(()) } diff --git a/crates/rbitcoin-net/src/peer_tests.rs b/crates/rbitcoin-net/src/peer_tests.rs index f04dacecb..0cea54c68 100644 --- a/crates/rbitcoin-net/src/peer_tests.rs +++ b/crates/rbitcoin-net/src/peer_tests.rs @@ -1502,6 +1502,13 @@ fn local_origin_not_inv_on_standing_peer() { } other => panic!("expected WTx inv, got {other:?}"), } + let tid = local.compute_txid(); + assert!(hub.mempool().unwrap().is_local_origin(&tid)); + assert_eq!(hub.mempool().unwrap().remove_for_block(&[tid]), 1); + assert!( + !hub.mempool().unwrap().is_local_origin(&tid), + "mempool evict must drop isolated skip" + ); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 3b7919f12..e9bdcccba 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -133,6 +133,7 @@ impl DialTarget { pub(crate) fn from_net(addr: crate::NetAddr) -> Self { match addr { crate::NetAddr::Ip(s) => Self::Socket(s), + crate::NetAddr::Cjdns { ip, port } => Self::Socket(SocketAddr::from((ip, port))), other => Self::Domain { host: other.host_str(), port: other.port(), @@ -140,7 +141,7 @@ impl DialTarget { } } - /// VERSION v1 `Address` field. Onion has no SocketAddr; Core uses 0.0.0.0. + /// VERSION v1 `Address` field. Overlay Domain has no SocketAddr; Core uses 0.0.0.0. pub fn version_socket(&self) -> SocketAddr { match self { Self::Socket(addr) => *addr, @@ -154,10 +155,10 @@ impl DialTarget { pub fn net_addr(&self) -> crate::NetAddr { match self { - Self::Socket(addr) => crate::NetAddr::Ip(*addr), + Self::Socket(addr) => crate::NetAddr::from_socket(*addr), Self::Domain { host, port } => format!("{host}:{port}") .parse() - .expect("DialTarget::Domain is host:port from NetAddr::Onion"), + .expect("DialTarget::Domain is host:port from overlay NetAddr"), } } } @@ -474,11 +475,12 @@ impl LivePeer { } if let Some(sock) = sock { rbitcoin_log::debug!("{}", crate::peer::advertising_address_log(sock, self.id)); + let net = crate::NetAddr::from_socket(sock); v.push(AddrV2Message { time: t, services, - addr: crate::NetAddr::Ip(sock).to_addrv2(), - port: sock.port(), + addr: net.to_addrv2(), + port: net.port(), }); } if v.is_empty() { @@ -486,7 +488,10 @@ impl LivePeer { } Some(NetworkMessage::AddrV2(v)) } else { - let sock = sock?; + let sock = match sock { + Some(s) if matches!(crate::NetAddr::from_socket(s), crate::NetAddr::Ip(_)) => s, + _ => return None, + }; rbitcoin_log::debug!("{}", crate::peer::advertising_address_log(sock, self.id)); Some(NetworkMessage::Addr(vec![( t, @@ -1129,9 +1134,8 @@ pub struct PeerHub { pending_outbound_nonces: Mutex>, /// Shared addrman for GetAddr responses (optional until node wires it). addrman: Mutex>>>, - /// Per-listen GetAddr cache: canonical bind → (cached_at, addrs). - addr_response_cache: - Mutex)>>, + /// Per-listen GetAddr cache: canonical bind + addrv2 → (cached_at, addrs). + addr_response_cache: Mutex)>>, /// VERSION/VERACK handshake timeout seconds. Default 60. peer_timeout_secs: AtomicU64, /// Addresses we advertise (`getnetworkinfo.localaddresses`). @@ -1144,6 +1148,7 @@ pub struct PeerHub { discover: AtomicBool, /// Clearnet P2P bind (not onion-only loopback). Needed to gossip `--external-ip`. clearnet_listen: AtomicBool, + cjdns_reachable: AtomicBool, asmap: Mutex>>, /// Tip-mode mempool for Core `EraseForPeer` on disconnect. mempool: Mutex>>, @@ -1190,10 +1195,18 @@ fn addr_sample_seed(bind: SocketAddr, now: u64) -> u64 { s } -fn ip_is_advertisable(ip: &IpAddr) -> bool { +fn ip_is_advertisable(ip: &IpAddr, cjdns_reachable: bool) -> bool { match ip { IpAddr::V4(v) => !(v.is_unspecified() || v.is_loopback() || v.is_private()), - IpAddr::V6(v) => !(v.is_unspecified() || v.is_loopback()), + IpAddr::V6(v) => { + if v.is_unspecified() || v.is_loopback() { + return false; + } + if crate::netaddr::is_cjdns_ip(*v) { + return cjdns_reachable; + } + true + } } } @@ -1222,6 +1235,7 @@ impl PeerHub { listen_port: AtomicU16::new(0), discover: AtomicBool::new(true), clearnet_listen: AtomicBool::new(true), + cjdns_reachable: AtomicBool::new(false), asmap: Mutex::new(None), mempool: Mutex::new(None), net_perms: Mutex::new(crate::net_permissions::NetPermTable::default()), @@ -1301,6 +1315,10 @@ impl PeerHub { self.clearnet_listen.store(on, Ordering::Relaxed); } + pub fn set_cjdns_reachable(&self, on: bool) { + self.cjdns_reachable.store(on, Ordering::Relaxed); + } + pub fn set_listen_port(&self, port: u16) { self.listen_port.store(port, Ordering::Relaxed); } @@ -1359,7 +1377,8 @@ impl PeerHub { return None; } let g = self.external_ips.lock().unwrap_or_else(|e| e.into_inner()); - let ip = g.iter().copied().find(ip_is_advertisable)?; + let cjdns = self.cjdns_reachable.load(Ordering::Relaxed); + let ip = g.iter().copied().find(|ip| ip_is_advertisable(ip, cjdns))?; Some(SocketAddr::new(ip, port)) } @@ -1388,19 +1407,20 @@ impl PeerHub { /// Core GetAddr reply: per-listen cache (24h) of /// [`crate::peer::MAX_ADDR_TO_SEND`] / [`crate::peer::MAX_PCT_ADDR_TO_SEND`] - /// of addrman. - pub fn addr_response_for_bind( + /// of addrman. `v2` includes onion / i2p / CJDNS; v1 ADDR is clearnet `Ip`. + pub(crate) fn addr_response_net( &self, bind: SocketAddr, - ) -> Vec<(u32, bitcoin::p2p::address::Address)> { + v2: bool, + ) -> Vec<(u32, crate::NetAddr)> { const CACHE_SECS: u64 = 24 * 60 * 60; - let bind = canonical_bind(bind); + let key = (canonical_bind(bind), v2); let now = self.now_secs(); let mut cache = self .addr_response_cache .lock() .unwrap_or_else(|e| e.into_inner()); - if let Some((cached_at, addrs)) = cache.get(&bind) { + if let Some((cached_at, addrs)) = cache.get(&key) { if now.saturating_sub(*cached_at) < CACHE_SECS { return addrs.clone(); } @@ -1416,9 +1436,10 @@ impl PeerHub { let g = am.lock().unwrap_or_else(|e| e.into_inner()); g.entries() }; - let addrs: Vec = entries + let addrs: Vec = entries .iter() - .filter_map(|e| e.addr.socket_addr()) + .map(|e| e.addr) + .filter(|a| v2 || matches!(a, crate::NetAddr::Ip(_))) .collect(); let n = addrs.len(); let pct_cap = (n * crate::peer::MAX_PCT_ADDR_TO_SEND / 100).max(1); @@ -1427,25 +1448,39 @@ impl PeerHub { return Vec::new(); } let mut idxs: Vec = (0..n).collect(); - let mut state = addr_sample_seed(bind, now); + let mut state = addr_sample_seed(key.0, now); for i in (1..idxs.len()).rev() { state = mix64(state); let j = (state as usize) % (i + 1); idxs.swap(i, j); } - let services = crate::peer::local_service_flags(); let mut out = Vec::with_capacity(cap); for &i in idxs.iter().take(cap) { - let addr = addrs[i]; - out.push(( - now as u32, - bitcoin::p2p::address::Address::new(&addr, services), - )); + out.push((now as u32, addrs[i])); } - cache.insert(bind, (now, out.clone())); + cache.insert(key, (now, out.clone())); out } + /// v1 ADDR view of [`Self::addr_response_net`] (clearnet `Ip` only). + pub fn addr_response_for_bind( + &self, + bind: SocketAddr, + ) -> Vec<(u32, bitcoin::p2p::address::Address)> { + let services = crate::peer::local_service_flags(); + self.addr_response_net(bind, false) + .into_iter() + .filter_map(|(t, a)| match a { + crate::NetAddr::Ip(s) => { + Some((t, bitcoin::p2p::address::Address::new(&s, services))) + } + crate::NetAddr::Onion { .. } + | crate::NetAddr::I2p { .. } + | crate::NetAddr::Cjdns { .. } => None, + }) + .collect() + } + /// Core: register local version nonce while an outbound handshake is open. pub fn note_outbound_nonce(&self, nonce: u64) { self.pending_outbound_nonces @@ -2000,9 +2035,9 @@ impl PeerHub { } 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), + match addr.socket_addr() { + Some(ip) => self.dial(ip, PeerConnType::Manual), + None => self.dial_domain(addr.host_str(), addr.port(), PeerConnType::Manual), } } @@ -2103,12 +2138,13 @@ impl PeerHub { } pub fn dial_net(&self, addr: crate::NetAddr, typ: PeerConnType) -> Result<(), String> { - match addr { - crate::NetAddr::Ip(ip) => self.dial(ip, typ), - crate::NetAddr::Onion { .. } | crate::NetAddr::I2p { .. } => { - self.dial_domain(addr.host_str(), addr.port(), typ) - } - } + 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::from_net(addr), + typ, + }) + .map_err(|_| "dialer closed".to_string()) } /// Outbound full-relay sessions eligible for stale-tip slot rotation. @@ -2143,11 +2179,19 @@ impl PeerHub { /// Live outbound full-relay addrs (not `noban`-gated) for diversity occupied. pub fn live_outbound_full_relay_addrs(&self) -> Vec { - let mut rows: Vec<(u64, SocketAddr)> = self + self.live_outbound_full_relay_nets() + .into_iter() + .filter_map(crate::NetAddr::socket_addr) + .collect() + } + + /// Overlay identity of live outbound full-relay peers (exclude for redial). + pub fn live_outbound_full_relay_nets(&self) -> Vec { + let mut rows: Vec<(u64, crate::NetAddr)> = self .live_peers() .into_iter() .filter(|p| p.conn_type == PeerConnType::OutboundFullRelay && !p.inbound) - .map(|p| (p.id, p.addr)) + .map(|p| (p.id, p.net)) .collect(); rows.sort_unstable_by_key(|(id, _)| *id); rows.into_iter().map(|(_, a)| a).collect() @@ -3282,4 +3326,160 @@ mod tests { book.entries() ); } + + #[test] + fn dial_target_cjdns_is_native_socket() { + use std::net::Ipv6Addr; + let ip = Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7); + let addr = crate::NetAddr::Cjdns { ip, port: 8333 }; + assert_eq!( + DialTarget::from_net(addr), + DialTarget::Socket(SocketAddr::from((ip, 8333))) + ); + assert_eq!(DialTarget::from_net(addr).net_addr(), addr); + } + + #[test] + fn dial_target_onion_and_i2p_roundtrip() { + 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 i2p = crate::NetAddr::I2p { + dest: [7u8; 32], + port: 8333, + }; + let t = DialTarget::from_net(i2p); + assert!(matches!(t, DialTarget::Domain { .. }), "{t:?}"); + assert_eq!(t.net_addr(), i2p); + } + + #[test] + fn learn_addrv2_keeps_cjdns() { + use bitcoin::p2p::address::{AddrV2, AddrV2Message}; + use std::net::Ipv6Addr; + + let hub = PeerHub::new(); + let am = Arc::new(Mutex::new(crate::seeds::AddrMan::new())); + hub.set_addrman(am.clone()); + let ip = Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7); + hub.learn_addrv2(&[AddrV2Message { + time: 1, + services: ServiceFlags::NETWORK, + addr: AddrV2::Cjdns(ip), + port: 8333, + }]); + let book = am.lock().unwrap_or_else(|e| e.into_inner()); + let want = crate::NetAddr::Cjdns { ip, port: 8333 }; + assert!( + book.entries().iter().any(|e| e.addr == want), + "CJDNS must stay in the book, got {:?}", + book.entries() + ); + } + + #[test] + fn fc00_not_advertisable_without_cjdns_reachable() { + use std::net::Ipv6Addr; + let hub = PeerHub::new(); + hub.set_discover(true); + hub.set_clearnet_listen(true); + hub.set_listen_port(8333); + hub.set_external_ips(vec![IpAddr::V6(Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7))]); + assert!(hub.advertise_local_socket().is_none()); + hub.set_cjdns_reachable(true); + let sock = hub.advertise_local_socket().expect("cjdns listen"); + assert_eq!( + sock.ip(), + IpAddr::V6(Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7)) + ); + assert_eq!(sock.port(), 8333); + } + + #[test] + fn getaddr_sample_keeps_overlay_addrs() { + use std::net::Ipv6Addr; + + fn sample(addr: crate::NetAddr) -> (Vec, usize) { + let hub = PeerHub::new(); + let mut am = crate::seeds::AddrMan::new(); + am.add_addr(addr); + hub.set_addrman(Arc::new(Mutex::new(am))); + let bind = SocketAddr::from(([127, 0, 0, 1], 18444)); + let got: Vec<_> = hub + .addr_response_net(bind, true) + .into_iter() + .map(|(_, a)| a) + .collect(); + (got, hub.addr_response_for_bind(bind).len()) + } + + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let i2p = crate::NetAddr::I2p { + dest: [0x11u8; 32], + port: 8333, + }; + let cjdns = crate::NetAddr::Cjdns { + ip: Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7), + port: 8333, + }; + let ip = crate::NetAddr::Ip(SocketAddr::from((Ipv4Addr::new(1, 2, 3, 4), 8333))); + for overlay in [onion, i2p, cjdns] { + let (got, v1) = sample(overlay); + assert_eq!(got, vec![overlay], "{overlay}"); + assert_eq!(v1, 0, "v1 ADDR omits overlay {overlay}"); + } + let (got, v1) = sample(ip); + assert_eq!(got, vec![ip]); + assert_eq!(v1, 1); + } + + #[test] + fn self_announce_cjdns_uses_addrv2_cjdns() { + use bitcoin::p2p::address::{AddrV2, Address}; + use bitcoin::p2p::message::NetworkMessage; + use bitcoin::p2p::message_network::VersionMessage; + use std::net::Ipv6Addr; + + let hub = PeerHub::new(); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18444); + let ver = VersionMessage { + version: 70016, + services: ServiceFlags::NETWORK, + timestamp: 0, + receiver: Address::new(&addr, ServiceFlags::NONE), + sender: Address::new(&addr, ServiceFlags::NONE), + nonce: 1, + user_agent: "/rbitcoin:test/".into(), + start_height: 0, + relay: true, + }; + let v1_peer = hub.register(addr, addr, &ver, false, PeerConnType::OutboundFullRelay); + hub.set_discover(true); + hub.set_clearnet_listen(true); + hub.set_cjdns_reachable(true); + hub.set_listen_port(8333); + hub.set_external_ips(vec![IpAddr::V6(Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7))]); + assert!( + v1_peer.take_self_announce_msg().is_none(), + "v1 ADDR must not re-encode CJDNS as IPv6" + ); + let v2_peer = hub.register(addr, addr, &ver, false, PeerConnType::OutboundFullRelay); + v2_peer.set_wants_addrv2(); + match v2_peer.take_self_announce_msg().expect("cjdns addrv2") { + NetworkMessage::AddrV2(v) => { + assert_eq!(v.len(), 1, "{v:?}"); + assert!(matches!(v[0].addr, AddrV2::Cjdns(_)), "{v:?}"); + assert_eq!(v[0].port, 8333); + } + other => panic!("expected AddrV2 CJDNS, got {other:?}"), + } + } } diff --git a/crates/rbitcoin-net/src/seeds.rs b/crates/rbitcoin-net/src/seeds.rs index bb777a801..cd711a43f 100644 --- a/crates/rbitcoin-net/src/seeds.rs +++ b/crates/rbitcoin-net/src/seeds.rs @@ -299,6 +299,7 @@ pub struct AddrMan { asmap: Option>, last_attempt: HashMap, only_net: Vec, + cjdns_reachable: bool, } impl AddrMan { @@ -332,10 +333,21 @@ impl AddrMan { self.only_net = only; } + pub fn set_cjdns_reachable(&mut self, on: bool) { + self.cjdns_reachable = on; + } + fn allowed(&self, addr: NetAddr) -> bool { addr_allowed(addr, &self.only_net) } + fn dialable(&self, addr: NetAddr) -> bool { + if matches!(addr, NetAddr::Cjdns { .. }) && !self.cjdns_reachable { + return false; + } + self.allowed(addr) + } + /// Like [`Self::take_dial_candidates`] plus onion rows when `--only-net` allows them. pub fn take_dial_candidates_net( &self, @@ -351,7 +363,7 @@ impl AddrMan { let mut out: Vec = self .take_dial_candidates(max, &ip_ex, occupied) .into_iter() - .map(NetAddr::Ip) + .map(NetAddr::from_socket) .collect(); if out.len() >= max { return out; @@ -361,7 +373,7 @@ impl AddrMan { break; } if !matches!(a, NetAddr::Onion { .. } | NetAddr::I2p { .. }) - || !self.allowed(a) + || !self.dialable(a) || exclude.contains(&a) { continue; @@ -374,7 +386,7 @@ impl AddrMan { } pub fn add(&mut self, addr: SocketAddr) { - self.add_addr(NetAddr::Ip(addr)); + self.add_addr(NetAddr::from_socket(addr)); } pub fn add_addr(&mut self, addr: NetAddr) { @@ -395,7 +407,7 @@ 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); + self.add_with_flags_addr(NetAddr::from_socket(addr), flags); } pub fn add_with_flags_addr(&mut self, addr: NetAddr, flags: PeerFlags) { @@ -414,7 +426,7 @@ 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) + self.add_learned_addr(NetAddr::from_socket(addr), cap) } pub fn add_learned_addr(&mut self, addr: NetAddr, cap: usize) -> bool { @@ -523,7 +535,7 @@ impl AddrMan { } pub fn flags(&self, addr: &SocketAddr) -> PeerFlags { - self.flags_of(&NetAddr::Ip(*addr)) + self.flags_of(&NetAddr::from_socket(*addr)) } fn flags_of(&self, addr: &NetAddr) -> PeerFlags { @@ -534,7 +546,7 @@ impl AddrMan { } pub fn entry(&self, addr: &SocketAddr) -> Option { - self.entry_of(&NetAddr::Ip(*addr)) + self.entry_of(&NetAddr::from_socket(*addr)) } fn entry_of(&self, addr: &NetAddr) -> Option { @@ -553,7 +565,7 @@ impl AddrMan { /// Successful BIP324 handshake. pub fn note_connected(&mut self, addr: SocketAddr) { - self.note_connected_addr(NetAddr::Ip(addr)); + self.note_connected_addr(NetAddr::from_socket(addr)); } pub fn note_connected_addr(&mut self, addr: NetAddr) { @@ -571,7 +583,7 @@ impl AddrMan { } pub(crate) fn note_attempt_at(&mut self, addr: SocketAddr, when: Instant) { - self.last_attempt.insert(NetAddr::Ip(addr), when); + self.last_attempt.insert(NetAddr::from_socket(addr), when); } pub fn note_attempt_addr(&mut self, addr: NetAddr) { @@ -580,13 +592,13 @@ impl AddrMan { fn recently_attempted(&self, addr: SocketAddr, now: Instant) -> bool { self.last_attempt - .get(&NetAddr::Ip(addr)) + .get(&NetAddr::from_socket(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.note_connect_failed_addr(NetAddr::Ip(addr), incompatible); + self.note_connect_failed_addr(NetAddr::from_socket(addr), incompatible); } pub fn note_connect_failed_addr(&mut self, addr: NetAddr, incompatible: bool) { @@ -604,7 +616,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(&NetAddr::Ip(addr)) { + if let Some(f) = self.by_addr.get_mut(&NetAddr::from_socket(addr)) { f.insert(PeerFlags::HAS_CONNECTED); f.apply_speed_sample(latency_ms, bytes_per_sec); } @@ -614,7 +626,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(&NetAddr::Ip(addr)) { + if let Some(f) = self.by_addr.get_mut(&NetAddr::from_socket(addr)) { f.insert(PeerFlags::HAS_CONNECTED); f.insert(PeerFlags::SLOW); f.remove(PeerFlags::FAST); @@ -664,7 +676,7 @@ impl AddrMan { .iter() .copied() .filter_map(|a| { - if !self.allowed(a) { + if !self.dialable(a) { return None; } let sock = a.socket_addr()?; @@ -1655,4 +1667,77 @@ mod tests { assert_eq!(got, vec![i2p]); assert!(am.take_dial_candidates(8, &HashSet::new(), &[]).is_empty()); } + + #[test] + fn cjdns_not_dialed_when_unreachable() { + use std::net::Ipv6Addr; + let ip = Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7); + let cjdns = NetAddr::Cjdns { ip, port: 8333 }; + let sock = SocketAddr::from((ip, 8333)); + let mut am = AddrMan::new(); + am.add_addr(cjdns); + am.add(addr(1)); + assert!(am + .take_dial_candidates(8, &HashSet::new(), &[]) + .contains(&addr(1))); + assert!( + !am.take_dial_candidates(8, &HashSet::new(), &[]) + .contains(&sock), + "fc00 must not dial without --cjdns-reachable" + ); + am.set_cjdns_reachable(true); + am.add_addr(cjdns); + let got = am.take_dial_candidates(8, &HashSet::new(), &[]); + assert!( + got.contains(&sock), + "reachable cjdns must dial, got {got:?}" + ); + } + + #[test] + fn only_net_cjdns_filters_ipv4() { + use std::net::Ipv6Addr; + let ip = Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7); + let cjdns = NetAddr::Cjdns { ip, port: 8333 }; + let sock = SocketAddr::from((ip, 8333)); + let mut am = AddrMan::new(); + am.set_cjdns_reachable(true); + am.add(addr(1)); + am.add_addr(cjdns); + am.set_only_net(vec![OnlyNet::Cjdns]); + let got = am.take_dial_candidates(8, &HashSet::new(), &[]); + assert_eq!(got, vec![sock]); + } + + #[test] + fn peers_file_roundtrip_cjdns() { + use std::net::Ipv6Addr; + let dir = std::env::temp_dir().join(format!( + "rbitcoin-peers-cjdns-{}-{}", + 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 cjdns = NetAddr::Cjdns { + ip: Ipv6Addr::new(0xfc00, 1, 2, 3, 4, 5, 6, 7), + port: 8333, + }; + let mut am = AddrMan::new(); + am.set_cjdns_reachable(true); + am.add_addr(cjdns); + am.save(&path).unwrap(); + let loaded = AddrMan::load(&path).unwrap(); + assert!( + loaded.entries().iter().any(|e| e.addr == cjdns), + "cjdns must persist, got {:?}", + loaded.entries() + ); + let body = std::fs::read_to_string(&path).unwrap(); + assert!(body.contains("fc00:"), "{body}"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index f08464da8..cb0a7994a 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -332,13 +332,7 @@ impl P2PNode { } pub async fn follow_from_net(&mut self, peer: crate::NetAddr) -> Result<(), NetError> { - let target = match peer { - crate::NetAddr::Ip(addr) => DialTarget::Socket(addr), - crate::NetAddr::Onion { .. } | crate::NetAddr::I2p { .. } => DialTarget::Domain { - host: peer.host_str(), - port: peer.port(), - }, - }; + let target = DialTarget::from_net(peer); let prepared = prepare_outbound_session( target, self.magic, diff --git a/crates/rbitcoin-net/src/socks.rs b/crates/rbitcoin-net/src/socks.rs index b3b623785..3af2fb588 100644 --- a/crates/rbitcoin-net/src/socks.rs +++ b/crates/rbitcoin-net/src/socks.rs @@ -70,7 +70,8 @@ pub enum Dialer { #[default] Direct, Socks { - proxy: SocketAddr, + proxy: Option, + onion: Option, randomize: bool, shared_creds: Option, Vec)>>, }, @@ -78,12 +79,24 @@ pub enum Dialer { impl Dialer { pub fn socks(proxy: SocketAddr, randomize: bool) -> Self { + Self::with_proxies(Some(proxy), None, randomize) + } + + pub fn with_proxies( + proxy: Option, + onion: Option, + randomize: bool, + ) -> Self { + if proxy.is_none() && onion.is_none() { + return Self::Direct; + } let shared_creds = (!randomize).then(|| { let creds = ProxyCreds::fresh(); Arc::new((creds.username, creds.password)) }); Self::Socks { proxy, + onion, randomize, shared_creds, } @@ -110,14 +123,22 @@ impl Dialer { proxy, randomize, shared_creds, - } => { - let creds = Self::dial_creds(*randomize, shared_creds.as_ref()); - socks5_connect(*proxy, target, creds.as_ref()).await - } + .. + } => match proxy { + Some(proxy) => { + let creds = Self::dial_creds(*randomize, shared_creds.as_ref()); + socks5_connect(*proxy, target, creds.as_ref()).await + } + None => Ok(TcpStream::connect(target).await?), + }, } } pub async fn connect_domain(&self, host: &str, port: u16) -> Result { + if host.ends_with(".onion") || host.ends_with(".b32.i2p") { + let addr: crate::NetAddr = format!("{host}:{port}").parse()?; + return self.connect_net(addr).await; + } match self { Dialer::Direct => { let mut addrs = tokio::net::lookup_host((host, port)).await?; @@ -128,17 +149,26 @@ impl Dialer { proxy, randomize, shared_creds, + .. } => { + let Some(proxy) = proxy else { + let mut addrs = tokio::net::lookup_host((host, port)).await?; + let addr = addrs.next().ok_or(NetError::Protocol("dns lookup empty"))?; + return self.connect(addr).await; + }; let creds = Self::dial_creds(*randomize, shared_creds.as_ref()); socks5_connect_domain(*proxy, host, port, creds.as_ref()).await } } } - pub async fn connect_isolated(&self, target: SocketAddr) -> Result { + pub(crate) async fn connect_isolated(&self, target: SocketAddr) -> Result { match self { Dialer::Direct => self.connect(target).await, - Dialer::Socks { proxy, .. } => dial_isolated(*proxy, target).await, + Dialer::Socks { proxy, .. } => match proxy { + Some(proxy) => dial_isolated(*proxy, target).await, + None => self.connect(target).await, + }, } } @@ -152,13 +182,19 @@ impl Dialer { Dialer::Direct => Err(NetError::Encode( "onion dial requires SOCKS (--proxy or --onion)".into(), )), - Dialer::Socks { proxy, .. } => { - dial_isolated_domain(*proxy, &addr.host_str(), port).await + Dialer::Socks { proxy, onion, .. } => { + let socks = onion.or(*proxy).ok_or_else(|| { + NetError::Encode("onion dial requires SOCKS (--proxy or --onion)".into()) + })?; + dial_isolated_domain(socks, &addr.host_str(), port).await } }, crate::NetAddr::I2p { .. } => { Err(NetError::Encode("i2p dial requires SAM (--i2p-sam)".into())) } + crate::NetAddr::Cjdns { ip, port } => { + Ok(TcpStream::connect(SocketAddr::from((ip, port))).await?) + } } } @@ -166,16 +202,31 @@ impl Dialer { match addr { crate::NetAddr::Ip(s) => self.connect(s).await, crate::NetAddr::Onion { port, .. } => { - if matches!(self, Dialer::Direct) { + let socks = match self { + Dialer::Direct => None, + Dialer::Socks { proxy, onion, .. } => onion.or(*proxy), + }; + let Some(socks) = socks else { return Err(NetError::Encode( "onion dial requires SOCKS (--proxy or --onion)".into(), )); - } - self.connect_domain(&addr.host_str(), port).await + }; + let creds = match self { + Dialer::Socks { + randomize, + shared_creds, + .. + } => Self::dial_creds(*randomize, shared_creds.as_ref()), + Dialer::Direct => None, + }; + socks5_connect_domain(socks, &addr.host_str(), port, creds.as_ref()).await } crate::NetAddr::I2p { .. } => { crate::i2p_sam::stream_connect_installed(&addr.host_str()).await } + crate::NetAddr::Cjdns { ip, port } => { + Ok(TcpStream::connect(SocketAddr::from((ip, port))).await?) + } } } } @@ -211,17 +262,12 @@ async fn greet(s: &mut TcpStream, creds: Option<&ProxyCreds>) -> Result<(), NetE { return Err(NetError::Protocol("socks username/password length")); } - s.write_all(&[5, 2, 0x00, 0x02]).await?; + s.write_all(&[5, 1, 0x02]).await?; let mut sel = [0u8; 2]; s.read_exact(&mut sel).await?; - if sel[0] != 5 { + if sel[0] != 5 || sel[1] != 0x02 { return Err(NetError::Protocol("socks method rejected")); } - match sel[1] { - 0x00 => return Ok(()), - 0x02 => {} - _ => return Err(NetError::Protocol("socks method rejected")), - } let mut auth = Vec::with_capacity(3 + c.username.len() + c.password.len()); auth.push(1); auth.push(c.username.len() as u8); @@ -390,7 +436,7 @@ mod tests { } #[tokio::test] - async fn randomize_off_with_creds_still_connects_on_noauth_proxy() { + async fn socks_creds_offer_only_userpass() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy = listener.local_addr().unwrap(); let target = SocketAddr::from((Ipv4Addr::new(203, 0, 113, 7), 8333)); @@ -401,11 +447,23 @@ mod tests { let nmethods = ver_n[1] as usize; let mut methods = vec![0u8; nmethods]; s.read_exact(&mut methods).await.unwrap(); - assert!( - methods.contains(&0x00) && methods.contains(&0x02), - "{methods:?}" + assert_eq!( + methods, + vec![0x02], + "creds must not offer NOAUTH: {methods:?}" ); - s.write_all(&[5, 0x00]).await.unwrap(); + s.write_all(&[5, 0x02]).await.unwrap(); + let mut ver = [0u8; 1]; + s.read_exact(&mut ver).await.unwrap(); + let mut ulen = [0u8; 1]; + s.read_exact(&mut ulen).await.unwrap(); + let mut user = vec![0u8; ulen[0] as usize]; + s.read_exact(&mut user).await.unwrap(); + let mut plen = [0u8; 1]; + s.read_exact(&mut plen).await.unwrap(); + let mut pass = vec![0u8; plen[0] as usize]; + s.read_exact(&mut pass).await.unwrap(); + s.write_all(&[1, 0]).await.unwrap(); let mut hdr = [0u8; 4]; s.read_exact(&mut hdr).await.unwrap(); assert_eq!(hdr[3], 1); @@ -758,4 +816,30 @@ mod tests { "onion must not TcpStream::connect / local DNS" ); } + + #[tokio::test] + async fn onion_only_dialer_keeps_clearnet_direct() { + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let socks = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let onion_proxy = socks.local_addr().unwrap(); + let echo = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let echo_addr = echo.local_addr().unwrap(); + let echo_task = tokio::spawn(async move { + let _ = echo.accept().await; + }); + let d = Dialer::with_proxies(None, Some(onion_proxy), false); + d.connect(echo_addr).await.unwrap(); + echo_task.await.unwrap(); + + let server = tokio::spawn(accept_domain_connect( + socks, + b"pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion", + 8333, + )); + d.connect_net(onion).await.unwrap(); + server.await.unwrap(); + } } diff --git a/crates/rbitcoin-net/src/tx_relay.rs b/crates/rbitcoin-net/src/tx_relay.rs index 469cbfd01..83cf772eb 100644 --- a/crates/rbitcoin-net/src/tx_relay.rs +++ b/crates/rbitcoin-net/src/tx_relay.rs @@ -848,6 +848,7 @@ impl MempoolHub { fn unindex_txid(&self, txid: &Txid) { self.sh_index.lock().unwrap().remove(txid); self.remove_relay_maps(txid); + self.local_origin.lock().unwrap().remove(txid); let mut u = self.unbroadcast.lock().unwrap(); if u.remove(txid) { persist_unbroadcast_file(&self.dir, &u); diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 57673f236..0290bdc14 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -310,7 +310,7 @@ fn operator_usage() -> String { [--min-chain-work HEX] [--max-tip-age SECS] [--check-blocks N] [--mock-time UNIX] \\\n\ [--block-version N] [--block-min-tx-fee BTC] [--alert-notify CMD] [--startup-notify CMD] \\\n\ [--max-run-secs N] [--log-level LEVEL] [--api-log PATH] [--asmap PATH] \\\n\ - [--no-seeds] [--no-listen] [--no-discover] [--listen-onion] [--smoke] [--inhibit-suspend]\n\n\ + [--no-seeds] [--no-listen] [--no-discover] [--listen-onion] [--cjdns-reachable] [--smoke] [--inhibit-suspend]\n\n\ Networks: mainnet|testnet|signet|regtest.\n\ Custom Signet: --signet-challenge HEX [--signet-block-time SECS].\n\ Log level: error|warn|info|debug|trace|off (CLI > conf log_level > RBITCOIN_LOG / RUST_LOG).\n\ @@ -329,6 +329,7 @@ Peers: --max-outbound (default 16 live download), --max-inbound (default 125).\n --i2p-sam [HOST:PORT] SAM v3 to system i2pd (default 127.0.0.1:7656). --only-net=i2p requires it.\n\ --i2p-accept-incoming persist {{datadir}}/i2p/p2p.priv and STREAM FORWARD to the P2P bind. Needs --listen.\n\ --listen-onion ADD_ONION the P2P port (loopback bind even with --no-listen). Needs --tor-control and --max-inbound > 0.\n\ + --cjdns-reachable treat fc00::/8 as CJDNS (dial and advertise). --only-net=cjdns requires it.\n\ --trusted / --always-relay / --relay are inbound permission knobs.\n\ --net-permission / --net-permission-bind are CIDR or bind grants (noban, relay, …; IPv4 and IPv6).\n\ --net-permission-relay (default on) / --net-permission-force-relay (default off) are implicit bits on a bare CIDR grant.\n\ @@ -388,6 +389,7 @@ fn is_bool_key(key: &str) -> bool { | "no_listen" | "no_discover" | "listen_onion" + | "cjdns_reachable" | "proxy_randomize" | "i2p_accept_incoming" | "inhibit_suspend" @@ -589,6 +591,7 @@ mod tests { "--no-listen", "--no-discover", "--listen-onion", + "--cjdns-reachable", "--tor-control", "--tor-control-cookie", "--tor-control-password", @@ -626,6 +629,7 @@ mod tests { "--nolisten", "--nodiscover", "--listenonion", + "--cjdnsreachable", "--torcontrol", "--i2psam", ] { @@ -668,7 +672,7 @@ mod tests { rbitcoin_net::Dialer::Socks { proxy, randomize, .. } => { - assert_eq!(proxy, "127.0.0.1:9050".parse().unwrap()); + assert_eq!(proxy, Some("127.0.0.1:9050".parse().unwrap())); assert!(randomize); } other => panic!("expected socks dialer, got {other:?}"), @@ -726,6 +730,49 @@ mod tests { ); } + #[test] + fn onion_only_dialer_does_not_socks_clearnet() { + let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); + let split = ready_config([ + "rbitcoin-node", + "--proxy", + "127.0.0.1:9050", + "--onion", + "127.0.0.1:9051", + ]); + match split.listen.dialer() { + rbitcoin_net::Dialer::Socks { proxy, onion, .. } => { + assert_eq!(proxy, split.listen.proxy); + assert_eq!(onion, split.listen.onion); + } + other => panic!("expected split socks dialer, got {other:?}"), + } + let onion_only = ready_config(["rbitcoin-node", "--onion", "127.0.0.1:9051"]); + match onion_only.listen.dialer() { + rbitcoin_net::Dialer::Socks { proxy, onion, .. } => { + assert!(proxy.is_none(), "onion-only must not SOCKS clearnet"); + assert_eq!(onion, Some("127.0.0.1:9051".parse().unwrap())); + } + other => panic!("expected onion-only socks dialer, got {other:?}"), + } + match onion_only.listen.isolated_dialer() { + rbitcoin_net::Dialer::Socks { + proxy, + onion, + randomize, + .. + } => { + assert!(proxy.is_none()); + assert_eq!(onion, Some("127.0.0.1:9051".parse().unwrap())); + assert!( + randomize, + "isolated broadcast always randomizes SOCKS creds" + ); + } + other => panic!("expected onion-only isolated dialer, got {other:?}"), + } + } + #[test] fn proxy_randomize_defaults_on() { let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); @@ -840,6 +887,27 @@ mod tests { assert!(err.contains("tor-control"), "{err}"); } + #[test] + fn listen_cjdns_addr_parses() { + let mut c = NodeConfig::default(); + c.apply_kv("listen", "[fc00:1:2:3:4:5:6:7]:8333").unwrap(); + match c.listen.p2p { + crate::config::P2pListen::Socket(a) => { + assert!(a.is_ipv6(), "{a}"); + assert_eq!(a.port(), 8333); + let ip = match a.ip() { + std::net::IpAddr::V6(v) => v, + other => panic!("expected v6, got {other}"), + }; + assert!(rbitcoin_net::is_cjdns_ip(ip), "{ip}"); + } + other => panic!("expected socket listen, got {other:?}"), + } + let off = ready_config(["rbitcoin-node", "--no-listen"]); + assert_eq!(off.listen.p2p, crate::config::P2pListen::Off); + assert!(off.listen.p2p_bind_addr(Network::Regtest).is_none()); + } + #[test] fn connect_onion_and_ipv4() { let mut c = NodeConfig::default(); @@ -899,7 +967,13 @@ mod tests { let mut i2p_ok = NodeConfig::default(); i2p_ok.apply_kv("only_net", "i2p").unwrap(); assert_eq!(i2p_ok.listen.only_net, vec![rbitcoin_net::OnlyNet::I2p]); - assert!(NodeConfig::default().apply_kv("only_net", "cjdns").is_err()); + let mut cjdns = NodeConfig::default(); + cjdns.apply_kv("only_net", "cjdns").unwrap(); + assert_eq!(cjdns.listen.only_net, vec![rbitcoin_net::OnlyNet::Cjdns]); + let err = cjdns.validate().unwrap_err().to_string(); + assert!(err.contains("cjdns-reachable"), "{err}"); + cjdns.apply_kv("cjdns_reachable", "1").unwrap(); + cjdns.validate().unwrap(); let ok = ready_config([ "rbitcoin-node", "--only-net", @@ -1079,6 +1153,34 @@ mod tests { assert_eq!(ok.listen.i2p_sam, Some("127.0.0.1:7656".parse().unwrap())); } + #[test] + fn only_net_cjdns_without_reachable_is_config_error() { + let mut c = NodeConfig::default(); + c.apply_kv("only_net", "cjdns").unwrap(); + let err = c.validate().unwrap_err().to_string(); + assert!(err.contains("cjdns-reachable"), "{err}"); + c.apply_kv("cjdns_reachable", "1").unwrap(); + c.validate().unwrap(); + let ok = ready_config(["rbitcoin-node", "--only-net", "cjdns", "--cjdns-reachable"]); + assert_eq!(ok.listen.only_net, vec![rbitcoin_net::OnlyNet::Cjdns]); + assert!(ok.listen.cjdns_reachable); + ok.validate().unwrap(); + } + + #[test] + fn cjdns_connect_without_reachable_is_config_error() { + let mut c = NodeConfig::default(); + c.apply_kv("connect", "[fc00:1:2:3:4:5:6:7]:8333").unwrap(); + assert!(matches!( + c.listen.connect[0], + rbitcoin_net::NetAddr::Cjdns { .. } + )); + let err = c.validate().unwrap_err().to_string(); + assert!(err.contains("cjdns-reachable"), "{err}"); + c.apply_kv("cjdns_reachable", "1").unwrap(); + c.validate().unwrap(); + } + #[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 f5d2f6852..bee9d94d4 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -95,7 +95,7 @@ pub struct ListenOpts { pub peer_timeout_secs: Option, /// SOCKS5 for all P2P outbound (`--proxy`). pub proxy: Option, - /// SOCKS5 for onion destinations (`--onion`); stored until plan 02. + /// SOCKS5 for onion destinations (`--onion`); clearnet stays `--proxy` or direct. pub onion: Option, /// Fresh SOCKS USERPASS per peer (Core `-proxyrandomize`; default on). pub proxy_randomize: bool, @@ -109,6 +109,8 @@ pub struct ListenOpts { pub i2p_accept_incoming: bool, /// Loopback P2P accept + Tor `ADD_ONION` (`--listen-onion`). pub listen_onion: bool, + /// Kernel CJDNS overlay (`fc00::/8`) is routable (`--cjdns-reachable`). + pub cjdns_reachable: bool, } impl Default for ListenOpts { @@ -134,23 +136,18 @@ impl Default for ListenOpts { i2p_sam: None, i2p_accept_incoming: false, listen_onion: false, + cjdns_reachable: false, } } } impl ListenOpts { pub fn dialer(&self) -> rbitcoin_net::Dialer { - match self.proxy { - None => rbitcoin_net::Dialer::Direct, - Some(proxy) => rbitcoin_net::Dialer::socks(proxy, self.proxy_randomize), - } + rbitcoin_net::Dialer::with_proxies(self.proxy, self.onion, self.proxy_randomize) } pub fn isolated_dialer(&self) -> rbitcoin_net::Dialer { - match self.proxy.or(self.onion) { - Some(proxy) => rbitcoin_net::Dialer::socks(proxy, true), - None => rbitcoin_net::Dialer::Direct, - } + rbitcoin_net::Dialer::with_proxies(self.proxy, self.onion, true) } pub fn p2p_bind_addr(&self, network: Network) -> Option { @@ -542,6 +539,11 @@ impl NodeConfig { "signet-block-time must be greater than zero".into(), )); } + self.validate_only_net()?; + self.validate_hidden_inbound() + } + + fn validate_only_net(&self) -> Result<(), NodeError> { if self.listen.only_net.contains(&rbitcoin_net::OnlyNet::Onion) && self.listen.proxy.is_none() && self.listen.onion.is_none() @@ -557,6 +559,28 @@ impl NodeConfig { "only-net=i2p requires SAM (--i2p-sam)".into(), )); } + if self.listen.only_net.contains(&rbitcoin_net::OnlyNet::Cjdns) + && !self.listen.cjdns_reachable + { + return Err(NodeError::Config( + "only-net=cjdns requires --cjdns-reachable".into(), + )); + } + if self + .listen + .connect + .iter() + .any(|a| matches!(a, rbitcoin_net::NetAddr::Cjdns { .. })) + && !self.listen.cjdns_reachable + { + return Err(NodeError::Config( + "connect to a CJDNS address requires --cjdns-reachable".into(), + )); + } + Ok(()) + } + + fn validate_hidden_inbound(&self) -> Result<(), NodeError> { if self.listen.i2p_accept_incoming { if self.listen.i2p_sam.is_none() { return Err(NodeError::Config( @@ -828,6 +852,10 @@ impl NodeConfig { self.listen.listen_onion = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf listen_onion: {e}")))?; } + "cjdns_reachable" => { + self.listen.cjdns_reachable = parse_conf_bool(val) + .map_err(|e| NodeError::Config(format!("conf cjdns_reachable: {e}")))?; + } "proxy_randomize" => { self.listen.proxy_randomize = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf proxy_randomize: {e}")))?; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index f239f032d..ea44e529c 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -458,6 +458,9 @@ 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()); + addrman.set_cjdns_reachable(config.listen.cjdns_reachable); + node.peers + .set_cjdns_reachable(config.listen.cjdns_reachable); node.peers.set_asmap(asmap); for c in &config.listen.connect { addrman.add_addr(*c); @@ -497,7 +500,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { let max_out = config.listen.max_outbound.max(1) as usize; let candidate_n = max_out.saturating_mul(2).clamp(16, 48); - let occupied = node.peers.live_outbound_full_relay_addrs(); + let occupied = node.peers.live_outbound_full_relay_nets(); let targets = follow_dial_targets(&config.listen.connect, &addrman, max_out, &occupied); let ibd_targets = follow_dial_targets(&config.listen.connect, &addrman, candidate_n, &occupied); let catch_up = run_ibd_or_skip( @@ -1796,15 +1799,18 @@ pub(crate) fn follow_dial_targets( connect: &[rbitcoin_net::NetAddr], book: &AddrMan, max: usize, - occupied: &[SocketAddr], + occupied: &[rbitcoin_net::NetAddr], ) -> Vec { if !connect.is_empty() { connect.to_vec() } else { - book.take_outbound_occupied(max, occupied) - .into_iter() - .map(rbitcoin_net::NetAddr::Ip) - .collect() + let exclude: std::collections::HashSet<_> = occupied.iter().copied().collect(); + let socks: Vec = occupied + .iter() + .copied() + .filter_map(rbitcoin_net::NetAddr::socket_addr) + .collect(); + book.take_dial_candidates_net(max, &exclude, &socks) } } @@ -1903,7 +1909,10 @@ mod tests { IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8333, ))]; - let occupied = vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 0, 9)), 8333)]; + let occupied = vec![rbitcoin_net::NetAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 0, 9)), + 8333, + ))]; let want = vec![rbitcoin_net::NetAddr::Ip(SocketAddr::new( IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 8333, @@ -1933,11 +1942,30 @@ mod tests { let other = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 3, 0, 1)), 8333); am.add(same); am.add(other); - let occupied = vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 0, 9)), 8333)]; + let occupied = vec![rbitcoin_net::NetAddr::Ip(SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(1, 2, 0, 9)), + 8333, + ))]; let got = follow_dial_targets(&[], &am, 1, &occupied); assert_eq!(got, vec![rbitcoin_net::NetAddr::Ip(other)]); } + #[test] + fn follow_dial_targets_picks_addrman_onion() { + let onion: rbitcoin_net::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:8333" + .parse() + .unwrap(); + let mut am = AddrMan::new(); + am.set_only_net(vec![rbitcoin_net::OnlyNet::Onion]); + am.add_addr(onion); + assert_eq!(follow_dial_targets(&[], &am, 1, &[]), vec![onion]); + assert!( + follow_dial_targets(&[], &am, 1, &[onion]).is_empty(), + "live onion net must not be re-dialed via 0.0.0.0 hint" + ); + } + #[test] fn load_asmap_missing_configured_is_none() { let dir = std::env::temp_dir().join(format!( diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 77ed5aa77..19a971a6f 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -41,6 +41,7 @@ let needTorControl = cfg.tor.control != null || cfg.electrum.hiddenService || cfg.esplora.hiddenService || cfg.p2p.listenOnion; needI2pSam = cfg.i2p.sam != null; + needCjdns = cfg.cjdns.reachable; torControlAddr = if cfg.tor.control != null then cfg.tor.control @@ -96,6 +97,7 @@ let ++ optional needI2pSam "--i2p-sam" ++ optional needI2pSam cfg.i2p.sam ++ optional cfg.i2p.acceptIncoming "--i2p-accept-incoming" + ++ optional cfg.cjdns.reachable "--cjdns-reachable" ++ optional cfg.esplora.hiddenService "--esplora-onion" ++ cfg.extraArgs; in @@ -213,7 +215,15 @@ in acceptIncoming = mkOption { type = types.bool; default = false; - description = "STREAM FORWARD to the P2P bind. Requires i2p.sam and p2p.listen. Persists {dataDir}/i2p/p2p.priv."; + description = "STREAM FORWARD to the P2P bind. Requires i2p.sam and (p2p.listen or p2p.listenOnion). Persists {dataDir}/i2p/p2p.priv."; + }; + }; + + cjdns = { + reachable = mkOption { + type = types.bool; + default = false; + description = "Treat fc00::/8 as CJDNS (dial and advertise). --only-net=cjdns requires this. After/Wants cjdns.service. No in-process router."; }; }; @@ -243,6 +253,7 @@ in "ipv6" "onion" "i2p" + "cjdns" ]); default = [ ]; description = "Restrict P2P to these networks. onion requires proxy or onionProxy; i2p requires i2p.sam."; @@ -376,8 +387,12 @@ in message = "services.rbitcoin.i2p.acceptIncoming requires i2p.sam"; } { - assertion = !cfg.i2p.acceptIncoming || cfg.p2p.listen; - message = "services.rbitcoin.i2p.acceptIncoming requires p2p.listen (STREAM FORWARD needs a P2P bind)"; + assertion = !cfg.i2p.acceptIncoming || cfg.p2p.listen || cfg.p2p.listenOnion; + message = "services.rbitcoin.i2p.acceptIncoming requires p2p.listen or p2p.listenOnion (STREAM FORWARD needs a P2P bind)"; + } + { + assertion = !(builtins.elem "cjdns" cfg.onlyNet) || cfg.cjdns.reachable; + message = "services.rbitcoin.onlyNet cjdns requires cjdns.reachable"; } ]; @@ -411,12 +426,14 @@ in "network-online.target" ] ++ optional needTorControl "tor.service" - ++ optional needI2pSam "i2pd.service"; + ++ optional needI2pSam "i2pd.service" + ++ optional needCjdns "cjdns.service"; after = [ "network-online.target" ] ++ optional needTorControl "tor.service" - ++ optional needI2pSam "i2pd.service"; + ++ optional needI2pSam "i2pd.service" + ++ optional needCjdns "cjdns.service"; environment = cfg.environment; serviceConfig = { diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 0bb7f5681..8d31fd602 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -34,6 +34,7 @@ let onlyNet = [ "onion" "i2p" + "cjdns" ]; tor = { control = "127.0.0.1:9051"; @@ -43,6 +44,7 @@ let sam = "127.0.0.1:7656"; acceptIncoming = true; }; + cjdns.reachable = true; p2p = { address = "127.0.0.1"; openFirewall = true; @@ -112,6 +114,7 @@ assert defaultCfg.electrum.hiddenService == false; assert defaultCfg.esplora.hiddenService == false; assert defaultCfg.i2p.sam == null; assert defaultCfg.i2p.acceptIncoming == false; +assert defaultCfg.cjdns.reachable == false; assert cfg.services.rbitcoin.p2p.port == 18444; assert cfg.services.rbitcoin.rpc.port == 18443; assert @@ -140,15 +143,19 @@ 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 ".*--only-net i2p.*" execStart != null; +assert builtins.match ".*--only-net cjdns.*" execStart != null; assert builtins.match ".*--tor-control 127.0.0.1:9051.*" execStart != null; assert builtins.match ".*--tor-control-cookie /run/tor/control.authcookie.*" execStart != null; assert builtins.match ".*--i2p-sam 127.0.0.1:7656.*" execStart != null; assert builtins.match ".*--i2p-accept-incoming.*" execStart != null; assert builtins.match ".*--listen-onion.*" execStart != null; +assert builtins.match ".*--cjdns-reachable.*" execStart != null; assert builtins.elem "tor.service" service.after; assert builtins.elem "tor.service" service.wants; assert builtins.elem "i2pd.service" service.after; assert builtins.elem "i2pd.service" service.wants; +assert builtins.elem "cjdns.service" service.after; +assert builtins.elem "cjdns.service" service.wants; assert builtins.match ".*--no-listen.*" listenOffExec != null; assert builtins.match ".*--listen .*" listenOffExec == null; assert builtins.match ".*--max-inbound 0.*" listenOffExec != null; diff --git a/nix/tests/nixos-module-runtime.nix b/nix/tests/nixos-module-runtime.nix index 21b56b40b..054077897 100644 --- a/nix/tests/nixos-module-runtime.nix +++ b/nix/tests/nixos-module-runtime.nix @@ -34,6 +34,7 @@ pkgs.testers.runNixOSTest { rpc.enable = true; tor.control = "127.0.0.1:9051"; i2p.sam = "127.0.0.1:7656"; + cjdns.reachable = true; extraArgs = [ "--max-outbound" "4" @@ -76,6 +77,7 @@ pkgs.testers.runNixOSTest { machine.succeed("grep -Fx -- '127.0.0.1:9051' /var/lib/rbitcoin-test/args") machine.succeed("grep -Fx -- '--listen-onion' /var/lib/rbitcoin-test/args") machine.succeed("grep -Fx -- '--i2p-sam' /var/lib/rbitcoin-test/args") + machine.succeed("grep -Fx -- '--cjdns-reachable' /var/lib/rbitcoin-test/args") machine.succeed("grep -Fx -- '127.0.0.1:7656' /var/lib/rbitcoin-test/args") machine.succeed("systemctl show -p After rbitcoin.service | grep -F tor.service") machine.succeed("systemctl show -p After rbitcoin.service | grep -F i2pd.service") diff --git a/scripts/ci-os-smoke.sh b/scripts/ci-os-smoke.sh index 535ab3eff..db366f162 100755 --- a/scripts/ci-os-smoke.sh +++ b/scripts/ci-os-smoke.sh @@ -66,9 +66,15 @@ done # A filter that matches nothing is a stale filter, not a pass: libtest reports # "running 0 tests" and exits 0, so the gate would silently shrink on a rename. +# Capture the list first: `grep -q` on cargo's pipe is SIGPIPE under `pipefail`. assert_filter_matches() { local pkg="$1" f="$2" - if ! cargo test -q -p "$pkg" --lib -- --list "$f" | grep -q ': test'; then + local listed + if ! listed="$(cargo test -q -p "$pkg" --lib -- --list "$f")"; then + echo "ci-os-smoke: failed to list tests for '$f' in $pkg" >&2 + exit 1 + fi + if ! grep -q ': test' <<<"$listed"; then echo "ci-os-smoke: stale filter '$f' matches no test in $pkg" >&2 exit 1 fi