diff --git a/OPERATOR.md b/OPERATOR.md index 67df7062e..14464680d 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -366,8 +366,9 @@ Clean smoke: | `--signet-challenge HEX` | `signet_challenge=` | default global Signet challenge | | `--signet-block-time SECS` | `signet_block_time=` | 600; requires a custom challenge | | `--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` | +| `--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) | | `--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 | @@ -458,7 +459,13 @@ 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 +forward). `--listen-onion` still binds **127.0.0.1** (ephemeral port) and +`ADD_ONION`s the network default P2P port (8333 / signet 38333 / …) to +that loopback (`{datadir}/onion/p2p.priv`, 0600). Needs `--tor-control` +and `--max-inbound` > 0. `--no-discover` still gossips that onion via +`addrv2` and lists it in `getnetworkinfo.localaddresses`; it does not +gossip `--external-ip`. `--max-inbound 0` refuses `--listen-onion`. +`--max-inbound 0` refuses inbound slots. `--no-discover` does not self-announce even when `--external-ip` is set. A later onion inbound bind does not require a public clearnet listen. diff --git a/crates/rbitcoin-net/src/netaddr.rs b/crates/rbitcoin-net/src/netaddr.rs index eed17a294..ed499a180 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::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::str::FromStr; const B32: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; @@ -96,6 +96,17 @@ impl NetAddr { } } + pub fn to_addrv2(self) -> AddrV2 { + match self { + NetAddr::Ip(s) => match s.ip() { + IpAddr::V4(v4) => AddrV2::Ipv4(v4), + IpAddr::V6(v6) => AddrV2::Ipv6(v6), + }, + NetAddr::Onion { pk, .. } => AddrV2::TorV3(pk), + NetAddr::I2p { dest, .. } => AddrV2::I2p(dest), + } + } + pub fn socket_addr(self) -> Option { match self { NetAddr::Ip(s) => Some(s), @@ -372,6 +383,7 @@ mod tests { }; let a = NetAddr::from_addrv2(&msg).expect("i2p addrv2"); assert_eq!(a, NetAddr::I2p { dest, port: 8333 }); + assert_eq!(a.to_addrv2(), AddrV2::I2p(dest)); let s = a.to_string(); assert!(s.ends_with(".b32.i2p:8333"), "{s}"); assert_eq!(s.parse::().unwrap(), a); diff --git a/crates/rbitcoin-net/src/peer_tests.rs b/crates/rbitcoin-net/src/peer_tests.rs index 1dfb7c17f..f04dacecb 100644 --- a/crates/rbitcoin-net/src/peer_tests.rs +++ b/crates/rbitcoin-net/src/peer_tests.rs @@ -5600,6 +5600,59 @@ fn no_discover_suppresses_self_announce() { assert!(hub.rpc_local_addresses().is_empty()); } +#[test] +fn self_announce_onion_not_external_ip() { + use crate::peers::{PeerConnType, PeerHub}; + use bitcoin::p2p::address::{AddrV2, Address}; + use bitcoin::p2p::message::NetworkMessage; + use bitcoin::p2p::message_network::VersionMessage; + use bitcoin::p2p::ServiceFlags; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + 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 peer = hub.register(addr, addr, &ver, false, PeerConnType::OutboundFullRelay); + peer.set_wants_addrv2(); + let onion: crate::NetAddr = + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion:18444" + .parse() + .unwrap(); + hub.set_p2p_onion(onion.host_str(), onion.port()); + hub.set_discover(false); + hub.set_clearnet_listen(false); + hub.set_external_ips(vec![IpAddr::V4(Ipv4Addr::new(42, 42, 42, 42))]); + hub.set_listen_port(18445); + assert!(hub.advertise_local_socket().is_none()); + match peer.take_self_announce_msg().expect("onion announce") { + NetworkMessage::AddrV2(v) => { + assert_eq!(v.len(), 1, "{v:?}"); + assert!(matches!(v[0].addr, AddrV2::TorV3(_))); + assert_eq!(v[0].port, 18444); + } + other => panic!("expected AddrV2 onion, got {other:?}"), + } + let rows = hub.rpc_local_addresses(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert!(rows[0].0.ends_with(".onion")); + assert_eq!(rows[0].1, 18444); + hub.set_discover(true); + assert!( + hub.advertise_local_socket().is_none(), + "onion-only loopback must not gossip --external-ip" + ); +} + #[test] fn redundant_verack_is_ignored_and_logged() { use bitcoin::consensus::encode::serialize; diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index 4f8cedbee..4362c52ae 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -1,7 +1,7 @@ //! Live P2P session table for RPC (`getpeerinfo` / `addnode` / `disconnectnode`). use crate::error::NetError; -use bitcoin::p2p::address::{AddrV2, AddrV2Message, Address}; +use bitcoin::p2p::address::{AddrV2Message, Address}; use bitcoin::p2p::message::NetworkMessage; use bitcoin::p2p::message_network::VersionMessage; use bitcoin::p2p::ServiceFlags; @@ -428,25 +428,56 @@ impl LivePeer { ) { return None; } - let sock = self.take_local_addr_due(self.clock_now())?; - rbitcoin_log::debug!("{}", crate::peer::advertising_address_log(sock, self.id)); - let now = self.clock_now() as u32; - Some(if self.wants_addrv2() { - NetworkMessage::AddrV2(vec![AddrV2Message { - time: now, - services: crate::peer::local_service_flags(), - addr: match sock.ip() { - IpAddr::V4(v) => AddrV2::Ipv4(v), - IpAddr::V6(v) => AddrV2::Ipv6(v), - }, - port: sock.port(), - }]) + let hub = self.owner.upgrade()?; + let onion = hub.p2p_onion(); + let sock = hub.advertise_local_socket(); + if onion.is_none() && sock.is_none() { + return None; + } + const DAY: u64 = 24 * 60 * 60; + let now = self.clock_now(); + let prev = self.next_local_addr_send.load(Ordering::Relaxed); + if prev != 0 && now < prev { + return None; + } + let next = now.saturating_add(DAY).max(1); + self.next_local_addr_send + .compare_exchange(prev, next, Ordering::Relaxed, Ordering::Relaxed) + .ok()?; + let services = crate::peer::local_service_flags(); + let t = now as u32; + if self.wants_addrv2() { + let mut v = Vec::new(); + if let Some(addr) = onion { + rbitcoin_log::debug!("{}", crate::peer::advertising_address_log(addr, self.id)); + v.push(AddrV2Message { + time: t, + services, + addr: addr.to_addrv2(), + port: addr.port(), + }); + } + if let Some(sock) = sock { + rbitcoin_log::debug!("{}", crate::peer::advertising_address_log(sock, self.id)); + v.push(AddrV2Message { + time: t, + services, + addr: crate::NetAddr::Ip(sock).to_addrv2(), + port: sock.port(), + }); + } + if v.is_empty() { + return None; + } + Some(NetworkMessage::AddrV2(v)) } else { - NetworkMessage::Addr(vec![( - now, - Address::new(&sock, crate::peer::local_service_flags()), - )]) - }) + let sock = sock?; + rbitcoin_log::debug!("{}", crate::peer::advertising_address_log(sock, self.id)); + Some(NetworkMessage::Addr(vec![( + t, + Address::new(&sock, services), + )])) + } } pub fn queue_self_announce_if_due(&self) { @@ -1091,10 +1122,13 @@ pub struct PeerHub { /// Addresses we advertise (`getnetworkinfo.localaddresses`). external_ips: Mutex>, wallet_onions: Mutex>, + p2p_onion: Mutex>, /// P2P listen port used with advertised external IPs. listen_port: AtomicU16, /// Core `-discover`. Off: never self-announce, even with `--external-ip`. discover: AtomicBool, + /// Clearnet P2P bind (not onion-only loopback). Needed to gossip `--external-ip`. + clearnet_listen: AtomicBool, asmap: Mutex>>, /// Tip-mode mempool for Core `EraseForPeer` on disconnect. mempool: Mutex>>, @@ -1169,8 +1203,10 @@ impl PeerHub { peer_timeout_secs: AtomicU64::new(60), external_ips: Mutex::new(Vec::new()), wallet_onions: Mutex::new(Vec::new()), + p2p_onion: Mutex::new(None), listen_port: AtomicU16::new(0), discover: AtomicBool::new(true), + clearnet_listen: AtomicBool::new(true), asmap: Mutex::new(None), mempool: Mutex::new(None), net_perms: Mutex::new(crate::net_permissions::NetPermTable::default()), @@ -1233,6 +1269,23 @@ impl PeerHub { .push((host, port)); } + pub fn set_p2p_onion(&self, host: String, port: u16) { + *self.p2p_onion.lock().unwrap_or_else(|e| e.into_inner()) = Some((host, port)); + } + + pub fn p2p_onion(&self) -> Option { + let (host, port) = self + .p2p_onion + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone()?; + format!("{host}:{port}").parse().ok() + } + + pub fn set_clearnet_listen(&self, on: bool) { + self.clearnet_listen.store(on, Ordering::Relaxed); + } + pub fn set_listen_port(&self, port: u16) { self.listen_port.store(port, Ordering::Relaxed); } @@ -1252,6 +1305,14 @@ impl PeerHub { .cloned() .map(|(address, port)| (address, port, LOCAL_MANUAL)) .collect(); + if let Some((host, port)) = self + .p2p_onion + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + { + rows.push((host, port, LOCAL_MANUAL)); + } if !self.discover.load(Ordering::Relaxed) { return rows; } @@ -1275,6 +1336,9 @@ impl PeerHub { if !self.discover.load(Ordering::Relaxed) { return None; } + if !self.clearnet_listen.load(Ordering::Relaxed) { + return None; + } let port = self.listen_port.load(Ordering::Relaxed); if port == 0 { return None; diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index 370c942bd..4b0d30b96 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -956,4 +956,86 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); assert!(linked, "outbound-only follower must handshake the seeder"); } + + #[tokio::test] + async fn listen_onion_binds_loopback_when_nolisten() { + let _live = live_p2p_lock().await; + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("rbitcoin-listen-onion-bind-{n}")); + std::fs::create_dir_all(&dir).unwrap(); + let q = Query::open_or_create_tiny(&dir).unwrap(); + let node = P2PNode::start_with_dialer( + "127.0.0.1:0".parse().unwrap(), + q, + ChainParams::regtest(), + Milestone::NONE, + "/rbitcoin:0.1.0(onion)/".into(), + crate::DEFAULT_MAX_INBOUND, + crate::socks::Dialer::Direct, + ) + .await + .unwrap(); + assert_eq!(node.local_addr.ip(), std::net::Ipv4Addr::LOCALHOST); + assert_ne!(node.local_addr.port(), 0); + node.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn listen_onion_loopback_inbound_handshake() { + use crate::peer::{connect_and_handshake_timed, HandshakePolicy}; + use bitcoin::p2p::Magic; + use tokio::net::TcpStream; + + let _live = live_p2p_lock().await; + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("rbitcoin-listen-onion-hs-{n}")); + std::fs::create_dir_all(&dir).unwrap(); + let q = Query::open_or_create_tiny(&dir).unwrap(); + let node = P2PNode::start_with_dialer( + "127.0.0.1:0".parse().unwrap(), + q, + ChainParams::regtest(), + Milestone::NONE, + "/rbitcoin:0.1.0(onion)/".into(), + crate::DEFAULT_MAX_INBOUND, + crate::socks::Dialer::Direct, + ) + .await + .unwrap(); + let stream = TcpStream::connect(node.local_addr).await.unwrap(); + let _hs = connect_and_handshake_timed( + Duration::from_secs(5), + stream, + Magic::REGTEST, + node.local_addr, + node.local_addr, + 0, + false, + "/rbitcoin:test/", + HandshakePolicy::plain(), + ) + .await + .unwrap(); + let mut inbound = false; + for _ in 0..200 { + if node.peers.snapshot().iter().any(|p| p.inbound) { + inbound = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + node.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + assert!( + inbound, + "loopback client must show as inbound in getpeerinfo" + ); + } } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index e05ad82c6..57673f236 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] [--smoke] [--inhibit-suspend]\n\n\ + [--no-seeds] [--no-listen] [--no-discover] [--listen-onion] [--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\ @@ -328,6 +328,7 @@ Peers: --max-outbound (default 16 live download), --max-inbound (default 125).\n --tor-control-cookie PATH (default /run/tor/control.authcookie). --tor-control-password PASS.\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\ --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\ @@ -386,6 +387,7 @@ fn is_bool_key(key: &str) -> bool { | "no_seeds" | "no_listen" | "no_discover" + | "listen_onion" | "proxy_randomize" | "i2p_accept_incoming" | "inhibit_suspend" @@ -586,6 +588,7 @@ mod tests { "--proxy-randomize", "--no-listen", "--no-discover", + "--listen-onion", "--tor-control", "--tor-control-cookie", "--tor-control-password", @@ -622,6 +625,7 @@ mod tests { "--whitelist-forcerelay", "--nolisten", "--nodiscover", + "--listenonion", "--torcontrol", "--i2psam", ] { @@ -802,6 +806,40 @@ mod tests { ); } + #[test] + fn listen_onion_binds_loopback_when_nolisten() { + let c = ready_config(["rbitcoin-node", "--no-listen", "--listen-onion"]); + assert!(c.listen.listen_onion); + assert_eq!(c.listen.p2p, crate::config::P2pListen::Off); + assert!(c.listen.p2p_bind_addr(Network::Regtest).is_none()); + assert_eq!( + c.listen.start_p2p_bind(Network::Regtest), + Some("127.0.0.1:0".parse().unwrap()) + ); + let mut conf = NodeConfig::default(); + conf.apply_kv("listen_onion", "1").unwrap(); + assert!(conf.listen.listen_onion); + } + + #[test] + fn listen_onion_refused_when_max_inbound_zero() { + let c = ready_config([ + "rbitcoin-node", + "--listen-onion", + "--max-inbound", + "0", + "--tor-control", + ]); + let err = c.validate().unwrap_err().to_string(); + assert!( + err.contains("listen-onion") && err.contains("max-inbound"), + "{err}" + ); + let no_tor = ready_config(["rbitcoin-node", "--listen-onion"]); + let err = no_tor.validate().unwrap_err().to_string(); + assert!(err.contains("tor-control"), "{err}"); + } + #[test] fn connect_onion_and_ipv4() { let mut c = NodeConfig::default(); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 898fc0e0e..f5d2f6852 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -107,6 +107,8 @@ pub struct ListenOpts { pub i2p_sam: Option, /// Persistent SAM destination + STREAM FORWARD to the P2P bind (`--i2p-accept-incoming`). pub i2p_accept_incoming: bool, + /// Loopback P2P accept + Tor `ADD_ONION` (`--listen-onion`). + pub listen_onion: bool, } impl Default for ListenOpts { @@ -131,6 +133,7 @@ impl Default for ListenOpts { only_net: Vec::new(), i2p_sam: None, i2p_accept_incoming: false, + listen_onion: false, } } } @@ -160,6 +163,18 @@ impl ListenOpts { P2pListen::Socket(a) => Some(a), } } + + /// Clearnet bind, or a loopback ephemeral port when `--listen-onion` is on + /// with `--no-listen`. + pub fn start_p2p_bind(&self, network: Network) -> Option { + if let Some(a) = self.p2p_bind_addr(network) { + return Some(a); + } + if self.listen_onion { + return Some(SocketAddr::from(([127, 0, 0, 1], 0))); + } + None + } } /// Mempool size, persist, and policy overlays. @@ -548,13 +563,25 @@ impl NodeConfig { "i2p-accept-incoming requires SAM (--i2p-sam)".into(), )); } - if matches!(self.listen.p2p, P2pListen::Off) { + if matches!(self.listen.p2p, P2pListen::Off) && !self.listen.listen_onion { return Err(NodeError::Config( "i2p-accept-incoming needs a P2P listener (--listen); --listen=0 has no loopback to STREAM FORWARD" .into(), )); } } + if self.listen.listen_onion { + if self.listen.max_inbound == 0 { + return Err(NodeError::Config( + "listen-onion requires --max-inbound greater than 0".into(), + )); + } + if self.tor.control.is_none() { + return Err(NodeError::Config( + "listen-onion requires --tor-control".into(), + )); + } + } Ok(()) } @@ -797,6 +824,10 @@ impl NodeConfig { self.listen.i2p_accept_incoming = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf i2p_accept_incoming: {e}")))?; } + "listen_onion" => { + self.listen.listen_onion = parse_conf_bool(val) + .map_err(|e| NodeError::Config(format!("conf listen_onion: {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 09767a0b5..f239f032d 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -183,7 +183,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { ); } apply_startup_index_mode(&handle.query, &config, params.taproot_height())?; - let bind = config.listen.p2p_bind_addr(config.network); + let bind = config.listen.start_p2p_bind(config.network); let start_tip = handle.query.tip_height().map(|h| h.0).unwrap_or(0); let run_started = Instant::now(); @@ -320,8 +320,10 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { node.peers.set_peer_timeout_secs(secs); } node.peers.set_discover(config.listen.discover); - if let Some(addr) = bind { - node.peers.set_listen_port(addr.port()); + node.peers + .set_clearnet_listen(!matches!(config.listen.p2p, crate::config::P2pListen::Off)); + if node.local_addr.port() != 0 { + node.peers.set_listen_port(node.local_addr.port()); } if !config.listen.external_ips.is_empty() { node.peers @@ -373,6 +375,18 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { .expect("control addr set when session exists") ); } + if config.listen.listen_onion { + let ctl = tor_ctl + .as_mut() + .expect("validate requires --tor-control with --listen-onion"); + let virt = config.network.default_p2p_port(); + let hs = ctl + .add_p2p_onion(config.datadir.path(), node.local_addr, virt) + .await?; + info!("p2p onion {}.onion:{}", hs.service_id, virt); + node.peers + .set_p2p_onion(format!("{}.onion", hs.service_id), virt); + } let mut i2p_sam = if let Some(addr) = config.listen.i2p_sam { let s = if config.listen.i2p_accept_incoming { let dest = config.datadir.path().join("i2p").join("p2p.priv"); diff --git a/crates/rbitcoin-node/src/tor_control.rs b/crates/rbitcoin-node/src/tor_control.rs index c8279b600..059a00bb6 100644 --- a/crates/rbitcoin-node/src/tor_control.rs +++ b/crates/rbitcoin-node/src/tor_control.rs @@ -215,6 +215,17 @@ impl TorControl { ) -> Result { self.add_named_onion(datadir, "esplora", bound).await } + + pub async fn add_p2p_onion( + &mut self, + datadir: &Path, + bound: SocketAddr, + virt: u16, + ) -> Result { + let key_path = datadir.join("onion").join("p2p.priv"); + let target = SocketAddr::from(([127, 0, 0, 1], bound.port())); + self.add_onion_persistent(&key_path, virt, target).await + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -814,4 +825,37 @@ mod tests { let _ = std::fs::remove_file(&cookie_path); let _ = std::fs::remove_dir_all(&dir); } + + #[tokio::test] + async fn p2p_add_onion_persists_key() { + let cookie = vec![0x11, 0x22]; + let (addr, log) = fake_control(Some(cookie.clone()), None).await; + let cookie_path = tmp_cookie(&cookie); + let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) + .await + .unwrap(); + let dir = std::env::temp_dir().join(format!( + "rbtc-tor-p2p-hs-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let bound: SocketAddr = "127.0.0.1:23456".parse().unwrap(); + let hs = ctl.add_p2p_onion(&dir, bound, 18444).await.unwrap(); + assert_eq!(hs.service_id, FAKE_SID); + let cmds = log.lock().unwrap().clone(); + let onion = cmds + .iter() + .find(|c| c.starts_with("ADD_ONION ")) + .cloned() + .expect("ADD_ONION"); + assert!(onion.contains("Port=18444,127.0.0.1:23456"), "{onion}"); + assert!(std::path::Path::new(&dir.join("onion").join("p2p.priv")).is_file()); + let hs2 = ctl.add_p2p_onion(&dir, bound, 18444).await.unwrap(); + assert_eq!(hs2.service_id, FAKE_SID); + let _ = std::fs::remove_file(&cookie_path); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index aa7b81efd..1a0634bed 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -4252,6 +4252,29 @@ fn getnetworkinfo_includes_electrum_onion() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn getnetworkinfo_includes_p2p_onion() { + use rbitcoin_net::PeerHub; + + let (mut ctx, dir) = ctx_empty(); + let hub = PeerHub::new(); + hub.set_discover(false); + hub.set_p2p_onion( + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion".into(), + 18444, + ); + ctx.peers = Some(hub); + let info = dispatch(&ctx, "getnetworkinfo", vec![]).unwrap(); + let addrs = info["localaddresses"].as_array().expect("array"); + assert_eq!(addrs.len(), 1, "{info}"); + assert_eq!( + addrs[0]["address"], + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion" + ); + assert_eq!(addrs[0]["port"], 18444); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn getpeerinfo_lists_registered_session() { use bitcoin::p2p::address::Address; diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index c58656a5c..77ed5aa77 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -39,12 +39,12 @@ let else "${address}:${toString port}"; - needTorControl = cfg.tor.control != null || cfg.electrum.hiddenService || cfg.esplora.hiddenService; + needTorControl = cfg.tor.control != null || cfg.electrum.hiddenService || cfg.esplora.hiddenService || cfg.p2p.listenOnion; needI2pSam = cfg.i2p.sam != null; torControlAddr = if cfg.tor.control != null then cfg.tor.control - else if cfg.electrum.hiddenService || cfg.esplora.hiddenService then + else if cfg.electrum.hiddenService || cfg.esplora.hiddenService || cfg.p2p.listenOnion then "127.0.0.1:9051" else null; @@ -72,6 +72,7 @@ let ++ optional (cfg.p2p.maxInbound != 125) "--max-inbound" ++ optional (cfg.p2p.maxInbound != 125) (toString cfg.p2p.maxInbound) ++ optional (!cfg.p2p.discover) "--no-discover" + ++ optional cfg.p2p.listenOnion "--listen-onion" ++ optional (cfg.coldDataDir != null) "--datadir-cold" ++ optional (cfg.coldDataDir != null) cfg.coldDataDir ++ optional cfg.rpc.enable "--rpc-listen" @@ -278,6 +279,12 @@ in description = "Advertise local addresses to peers. Off with --no-discover."; }; + listenOnion = mkOption { + type = types.bool; + default = false; + description = "ADD_ONION for the P2P port. Binds 127.0.0.1 even with p2p.listen = false. Requires tor.control (implied 127.0.0.1:9051) and maxInbound > 0. Gossip the onion, not a home IPv4, when discover is off."; + }; + openFirewall = mkOption { type = types.bool; default = false; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 870de07ad..0bb7f5681 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -46,6 +46,7 @@ let p2p = { address = "127.0.0.1"; openFirewall = true; + listenOnion = true; }; rpc.enable = true; electrum = { @@ -99,6 +100,7 @@ assert defaultCfg.p2p.port == 8333; assert defaultCfg.p2p.listen == true; assert defaultCfg.p2p.maxInbound == 125; assert defaultCfg.p2p.discover == true; +assert defaultCfg.p2p.listenOnion == false; assert defaultCfg.rpc.port == 8332; assert defaultCfg.proxy == null; assert defaultCfg.onionProxy == null; @@ -142,6 +144,7 @@ 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.elem "tor.service" service.after; assert builtins.elem "tor.service" service.wants; assert builtins.elem "i2pd.service" service.after; diff --git a/nix/tests/nixos-module-runtime.nix b/nix/tests/nixos-module-runtime.nix index 84455e298..21b56b40b 100644 --- a/nix/tests/nixos-module-runtime.nix +++ b/nix/tests/nixos-module-runtime.nix @@ -29,6 +29,7 @@ pkgs.testers.runNixOSTest { p2p = { address = "127.0.0.1"; port = 18445; + listenOnion = true; }; rpc.enable = true; tor.control = "127.0.0.1:9051"; @@ -73,6 +74,7 @@ pkgs.testers.runNixOSTest { machine.succeed("grep -Fx -- '4' /var/lib/rbitcoin-test/args") machine.succeed("grep -Fx -- '--tor-control' /var/lib/rbitcoin-test/args") 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 -- '127.0.0.1:7656' /var/lib/rbitcoin-test/args") machine.succeed("systemctl show -p After rbitcoin.service | grep -F tor.service")