From dfb1d69298921995cb0fbca52c9f60b866756bd6 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:51:08 -0700 Subject: [PATCH 1/5] node: allow --max-inbound 0 for outbound-only Home nodes with no inbound slots are a valid start posture. Outbound still requires max_outbound >= 1. Co-authored-by: Cursor --- crates/rbitcoin-node/src/cli.rs | 18 ++++++++++++---- crates/rbitcoin-node/src/config.rs | 33 ++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 4ca737670..f8a7b25cd 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -712,6 +712,20 @@ mod tests { } } + #[test] + fn max_inbound_zero_is_allowed() { + let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); + let cfg = ready_config(["rbitcoin-node", "--max-inbound", "0"]); + assert_eq!(cfg.listen.max_inbound, 0); + assert!(cfg.listen.max_inbound_explicit); + cfg.validate() + .expect("CLI --max-inbound 0 must assemble and validate"); + let out = NodeConfig::default() + .apply_kv("max_outbound", "0") + .unwrap_err(); + assert!(format!("{out}").contains("max_outbound")); + } + #[test] fn kebab_seed_node_and_min_relay_tx_fee_parse() { let seeds = ready_config(["rbitcoin-node", "--seed-node", "127.0.0.1:8333"]); @@ -1006,10 +1020,6 @@ mod tests { cli_main(["rbitcoin-node", "--max-inbound"]), ExitCode::from(2), ); - assert_exit( - cli_main(["rbitcoin-node", "--max-inbound", "0"]), - ExitCode::from(2), - ); assert_exit( cli_main(["rbitcoin-node", "--max-inbound", "nope"]), ExitCode::from(2), diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 4fab7d61d..84f2ea4fb 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -448,9 +448,6 @@ impl NodeConfig { if self.listen.max_outbound == 0 { return Err(NodeError::Config("max-outbound must be >= 1".into())); } - if self.listen.max_inbound == 0 { - return Err(NodeError::Config("max-inbound must be >= 1".into())); - } if (self.signet_challenge.is_some() || self.signet_block_time.is_some()) && self.network != Network::Signet { @@ -869,9 +866,6 @@ impl NodeConfig { let n: u32 = val .parse() .map_err(|e| NodeError::Config(format!("conf max_inbound: {e}")))?; - if n == 0 { - return Err(NodeError::Config("conf max_inbound must be >= 1".into())); - } self.listen.max_inbound = n; self.listen.max_inbound_explicit = true; } @@ -1643,14 +1637,37 @@ mod tests { assert!(cfg.validate().is_err()); cfg.listen.max_outbound = 1; cfg.listen.max_inbound = 0; - assert!(cfg.validate().is_err()); - cfg.listen.max_inbound = 1; assert!(cfg.validate().is_ok()); assert_eq!(cfg.milestone(), Milestone::NONE); cfg.milestone_height = 10; assert_eq!(cfg.milestone().height, 10); } + #[test] + fn max_inbound_zero_is_allowed() { + let mut c = NodeConfig::default().with_datadir(tmp()); + assert_eq!(c.apply_kv("max_inbound", "0").unwrap(), ConfApply::Applied); + assert_eq!(c.listen.max_inbound, 0); + assert!(c.listen.max_inbound_explicit); + c.validate() + .expect("max_inbound=0 is outbound-only, not an error"); + + let err = NodeConfig::default() + .apply_kv("max_outbound", "0") + .unwrap_err(); + assert!( + format!("{err}").contains("max_outbound"), + "max_outbound=0 must still fail: {err}" + ); + let mut o = NodeConfig::default().with_datadir(tmp()); + o.listen.max_outbound = 0; + let verr = o.validate().unwrap_err().to_string(); + assert!( + verr.contains("max-outbound"), + "validate must still reject max_outbound=0: {verr}" + ); + } + #[test] fn conf_bare_network_flags_and_bad_line() { let dir = tmp(); From 31f6f82f62595cedfd8fe68a1f9b8ef9e2a48b19 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:13:37 -0700 Subject: [PATCH 2/5] node: --listen=0 / --no-listen is outbound-only bind Distinguish omitted listen (loopback default) from explicit off so a home node can start without a clearnet P2P socket. Co-authored-by: Cursor --- crates/rbitcoin-node/src/cli.rs | 55 +++++++++++++++++++++++- crates/rbitcoin-node/src/config.rs | 68 ++++++++++++++++++++++++------ crates/rbitcoin-node/src/run.rs | 2 +- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index f8a7b25cd..fc5c845a7 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -128,7 +128,12 @@ fn apply_operator_kvs(config: &mut NodeConfig, kvs: Vec<(String, String)>) -> Re let mut saw_seednode = false; for (key, val) in kvs { if key == "listen" && !saw_listen { - config.listen.p2p = None; + config.listen.p2p = crate::config::P2pListen::Auto; + config.listen.p2p_extra.clear(); + saw_listen = true; + } + if key == "no_listen" && !saw_listen { + config.listen.p2p = crate::config::P2pListen::Auto; config.listen.p2p_extra.clear(); saw_listen = true; } @@ -288,7 +293,7 @@ fn operator_usage() -> String { "rbitcoin-node {} — usage:\n\ rbitcoin-node [--conf FILE] [--datadir PATH] [--datadir-cold PATH] [--network NET] \\\n\ [--signet-challenge HEX] [--signet-block-time SECS] \\\n\ - [--listen ADDR] [--connect ADDR]... [--seed-node HOST]... [--proxy HOST:PORT] [--onion HOST:PORT] [--proxy-randomize[=0|1]] \\\n\ + [--listen ADDR] [--no-listen] [--connect ADDR]... [--seed-node HOST]... [--proxy HOST:PORT] [--onion HOST:PORT] [--proxy-randomize[=0|1]] \\\n\ [--electrum-listen ADDR] [--esplora-listen ADDR] \\\n\ [--sh-index] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--esplora-block-template] \\\n\ [--rpc] [--rpc-listen [ADDR]] [--rpc-token-file PATH] [--rpc-work-queue N] \\\n\ @@ -370,6 +375,7 @@ fn is_bool_key(key: &str) -> bool { | "net_permission_relay" | "net_permission_force_relay" | "no_seeds" + | "no_listen" | "proxy_randomize" | "inhibit_suspend" | "trusted" @@ -562,6 +568,7 @@ mod tests { "--proxy", "--onion", "--proxy-randomize", + "--no-listen", ] { assert!(h.contains(flag), "help must list {flag}"); } @@ -591,6 +598,7 @@ mod tests { "--blocks-dir", "--whitelist-relay", "--whitelist-forcerelay", + "--nolisten", ] { assert!(!h.contains(concat), "help must not advertise {concat}"); } @@ -726,6 +734,49 @@ mod tests { assert!(format!("{out}").contains("max_outbound")); } + #[test] + fn listen_zero_does_not_default_loopback() { + let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); + let mut c = NodeConfig::default(); + assert_eq!(c.apply_kv("listen", "0").unwrap(), ConfApply::Applied); + assert_eq!(c.listen.p2p, crate::config::P2pListen::Off); + assert!(c.listen.p2p_bind_addr(Network::Regtest).is_none()); + + let n = ready_config(["rbitcoin-node", "--no-listen"]); + assert_eq!(n.listen.p2p, crate::config::P2pListen::Off); + assert!(n.listen.p2p_bind_addr(Network::Regtest).is_none()); + + let eq = ready_config(["rbitcoin-node", "--listen=0"]); + assert_eq!(eq.listen.p2p, crate::config::P2pListen::Off); + + let bound = ready_config(["rbitcoin-node", "--listen", "127.0.0.1:18444"]); + assert_eq!( + bound.listen.p2p, + crate::config::P2pListen::Socket("127.0.0.1:18444".parse().unwrap()) + ); + assert_eq!( + bound.listen.p2p_bind_addr(Network::Regtest), + Some("127.0.0.1:18444".parse().unwrap()) + ); + + let auto = NodeConfig::default(); + assert_eq!(auto.listen.p2p, crate::config::P2pListen::Auto); + assert_eq!( + auto.listen.p2p_bind_addr(Network::Regtest), + Some("127.0.0.1:18444".parse().unwrap()) + ); + + let h = operator_usage(); + assert!( + h.contains("--no-listen"), + "help must list kebab --no-listen" + ); + assert!( + !h.contains("--nolisten"), + "help must not advertise concatenated --nolisten" + ); + } + #[test] fn kebab_seed_node_and_min_relay_tx_fee_parse() { let seeds = ready_config(["rbitcoin-node", "--seed-node", "127.0.0.1:8333"]); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 84f2ea4fb..6e1685be3 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -11,6 +11,15 @@ use std::path::{Path, PathBuf}; /// Default max concurrent inbound P2P sessions (same as net `DEFAULT_MAX_INBOUND`). pub const DEFAULT_MAX_INBOUND: u32 = rbitcoin_net::DEFAULT_MAX_INBOUND as u32; +/// P2P bind: omitted flag (loopback default), `--listen=0` / `--no-listen`, or an address. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum P2pListen { + #[default] + Auto, + Off, + Socket(SocketAddr), +} + /// Parse Core BTC/kvB (`0.00000001`) to sat/kvB. Negatives and junk fail. pub(crate) fn parse_btc_to_sat(s: &str) -> Result { let s = s.trim(); @@ -72,7 +81,7 @@ impl From for DatadirOpts { /// P2P / Electrum / Esplora listen and peer-count knobs. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ListenOpts { - pub p2p: Option, + pub p2p: P2pListen, pub p2p_extra: Vec, pub electrum: Option, pub esplora: Option, @@ -95,7 +104,7 @@ pub struct ListenOpts { impl Default for ListenOpts { fn default() -> Self { Self { - p2p: None, + p2p: P2pListen::Auto, p2p_extra: Vec::new(), electrum: None, esplora: None, @@ -121,6 +130,17 @@ impl ListenOpts { Some(proxy) => rbitcoin_net::Dialer::socks(proxy, self.proxy_randomize), } } + + pub fn p2p_bind_addr(&self, network: Network) -> Option { + match self.p2p { + P2pListen::Off => None, + P2pListen::Auto => Some(SocketAddr::from(( + [127, 0, 0, 1], + network.default_p2p_port(), + ))), + P2pListen::Socket(a) => Some(a), + } + } } /// Mempool size, persist, and policy overlays. @@ -326,7 +346,7 @@ impl NodeConfig { } pub fn with_p2p_listen(mut self, addr: SocketAddr) -> Self { - self.listen.p2p = Some(addr); + self.listen.p2p = P2pListen::Socket(addr); self } @@ -401,11 +421,11 @@ impl NodeConfig { } fn push_p2p_listen(&mut self, addr: SocketAddr) -> Result<(), NodeError> { - if self.listen.p2p == Some(addr) || self.listen.p2p_extra.contains(&addr) { + if self.listen.p2p == P2pListen::Socket(addr) || self.listen.p2p_extra.contains(&addr) { return Err(NodeError::Init("Duplicate binding configuration".into())); } - if self.listen.p2p.is_none() { - self.listen.p2p = Some(addr); + if matches!(self.listen.p2p, P2pListen::Auto | P2pListen::Off) { + self.listen.p2p = P2pListen::Socket(addr); } else { self.listen.p2p_extra.push(addr); } @@ -639,10 +659,23 @@ impl NodeConfig { ); } "listen" => { - let addr: SocketAddr = val - .parse() - .map_err(|e| NodeError::Config(format!("conf listen: {e}")))?; - self.push_p2p_listen(addr)?; + if is_listen_off(val) { + self.listen.p2p = P2pListen::Off; + self.listen.p2p_extra.clear(); + } else { + let addr: SocketAddr = val + .parse() + .map_err(|e| NodeError::Config(format!("conf listen: {e}")))?; + self.push_p2p_listen(addr)?; + } + } + "no_listen" => { + if parse_conf_bool(val) + .map_err(|e| NodeError::Config(format!("conf no_listen: {e}")))? + { + self.listen.p2p = P2pListen::Off; + self.listen.p2p_extra.clear(); + } } "connect" => { self.listen.connect.push( @@ -764,7 +797,9 @@ impl NodeConfig { "net_permission_bind" => { if !val.is_empty() { let g = rbitcoin_net::parse_whitebind(val).map_err(NodeError::Init)?; - if self.listen.p2p != Some(g.addr) && !self.listen.p2p_extra.contains(&g.addr) { + if self.listen.p2p != P2pListen::Socket(g.addr) + && !self.listen.p2p_extra.contains(&g.addr) + { self.push_p2p_listen(g.addr)?; } self.net_perms.whitebind.push(g); @@ -993,6 +1028,13 @@ fn parse_required_socket(val: &str, key: &str) -> Result .map_err(|e| NodeError::Config(format!("conf {key}: {e}"))) } +fn is_listen_off(val: &str) -> bool { + matches!( + val.to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) +} + fn is_conf_true(val: &str) -> bool { matches!( val.to_ascii_lowercase().as_str(), @@ -1295,7 +1337,7 @@ mod tests { .unwrap(); assert_eq!( c2.listen.p2p, - Some("127.0.0.1:18445".parse().unwrap()), + P2pListen::Socket("127.0.0.1:18445".parse().unwrap()), "net_permission_bind listens" ); assert_eq!(c2.net_perms.whitebind.len(), 1); @@ -1803,7 +1845,7 @@ mod tests { let mut cfg = NodeConfig::default().with_datadir(dir.join("d")); cfg.merge_conf_file(&conf).unwrap(); assert_eq!(cfg.network, Network::Regtest); - assert!(cfg.listen.p2p.is_some()); + assert!(matches!(cfg.listen.p2p, P2pListen::Socket(_))); assert_eq!(cfg.listen.connect.len(), 1); assert!(cfg.shindex); assert!(!cfg.sptweaks); diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 71cefeb2b..5f1809673 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -185,7 +185,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { apply_startup_index_mode(&handle.query, &config, params.taproot_height())?; let listen = config .listen - .p2p + .p2p_bind_addr(config.network) .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], default_port(config.network)))); let start_tip = handle.query.tip_height().map(|h| h.0).unwrap_or(0); From 32f8fcd66bace06c613fb710f6e041b658827db8 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:40:16 -0700 Subject: [PATCH 3/5] net: start P2P with no listener for --listen=0 Home nodes skip TcpListener bind. Outbound follow still handshakes; addconnection inbound stays refused. local_addr is 127.0.0.1:0. Co-authored-by: Cursor --- crates/rbitcoin-net/src/service.rs | 154 +++++++++++++++++++++++++---- crates/rbitcoin-node/src/run.rs | 57 +++++++---- 2 files changed, 175 insertions(+), 36 deletions(-) diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index f003f83a4..9c8d46d20 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -99,35 +99,91 @@ impl P2PNode { user_agent: String, max_inbound: usize, dialer: crate::socks::Dialer, + ) -> Result { + let bind = (max_inbound > 0).then_some(listen); + Self::start_inner( + bind, + query, + params, + milestone, + user_agent, + max_inbound, + dialer, + ) + .await + } + + /// Outbound dials only: no P2P `TcpListener`. `local_addr` is `127.0.0.1:0`. + pub async fn start_outbound_only( + query: Query, + params: ChainParams, + milestone: Milestone, + user_agent: String, + max_inbound: usize, + dialer: crate::socks::Dialer, + ) -> Result { + Self::start_inner( + None, + query, + params, + milestone, + user_agent, + max_inbound, + dialer, + ) + .await + } + + async fn start_inner( + listen: Option, + query: Query, + params: ChainParams, + milestone: Milestone, + user_agent: String, + max_inbound: usize, + dialer: crate::socks::Dialer, ) -> Result { let magic = magic_for_params(¶ms); let hub = Arc::new(ChainHub::new(query, params, milestone)); hub.ensure_genesis()?; let cache = hub.cache.clone(); let query = hub.query.clone(); - let listener = TcpListener::bind(listen).await?; - let local_addr = listener.local_addr()?; let shutdown = Arc::new(AtomicBool::new(false)); + let (listener, local_addr) = if let Some(addr) = listen { + let listener = TcpListener::bind(addr).await?; + let local_addr = listener.local_addr()?; + (Some(listener), local_addr) + } else { + (None, SocketAddr::from(([127, 0, 0, 1], 0))) + }; + let peers = PeerHub::new(); let (dial_tx, mut dial_rx) = tokio::sync::mpsc::unbounded_channel::(); peers.set_dialer(dial_tx); - let max_inbound = max_inbound.max(1); - let inbound_sem = inbound_semaphore(max_inbound); + let max_inbound = if listener.is_some() { + max_inbound.max(1) + } else { + 0 + }; + let inbound_sem = inbound_semaphore(max_inbound.max(1)); let session_tasks = Arc::new(Mutex::new(Vec::>::new())); - let accept_task = spawn_inbound_accept( - listener, - local_addr, - hub.clone(), - peers.clone(), - user_agent.clone(), - magic, - max_inbound, - inbound_sem.clone(), - shutdown.clone(), - session_tasks.clone(), - ); + let mut tasks = Vec::new(); + if let Some(listener) = listener { + tasks.push(spawn_inbound_accept( + listener, + local_addr, + hub.clone(), + peers.clone(), + user_agent.clone(), + magic, + max_inbound.max(1), + inbound_sem.clone(), + shutdown.clone(), + session_tasks.clone(), + )); + } let follow_live = Arc::new(AtomicUsize::new(0)); let dial_hub = hub.clone(); @@ -158,6 +214,7 @@ impl P2PNode { push_session_task(&sessions_dial, h); } }); + tasks.push(dial_task); Ok(Self { cache, @@ -167,7 +224,7 @@ impl P2PNode { magic, shutdown, follow_live, - tasks: vec![accept_task, dial_task], + tasks, session_tasks, peers, user_agent, @@ -806,4 +863,67 @@ mod tests { node.shutdown().await; let _ = std::fs::remove_dir_all(&dir); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn p2p_outbound_only_dials_without_listener() { + 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-outbound-only-{n}")); + std::fs::create_dir_all(dir.join("seed")).unwrap(); + std::fs::create_dir_all(dir.join("follow")).unwrap(); + let qa = Query::open_or_create_tiny(dir.join("seed")).unwrap(); + let qb = Query::open_or_create_tiny(dir.join("follow")).unwrap(); + let params = ChainParams::regtest(); + let seeder = P2PNode::start_with_agent( + "127.0.0.1:0".parse().unwrap(), + qa, + params.clone(), + Milestone::NONE, + "/rbitcoin:0.1.0(seed)/".into(), + crate::DEFAULT_MAX_INBOUND, + ) + .await + .unwrap(); + let mut follower = P2PNode::start_outbound_only( + qb, + params, + Milestone::NONE, + "/rbitcoin:0.1.0(follow)/".into(), + 0, + crate::socks::Dialer::Direct, + ) + .await + .unwrap(); + assert_eq!( + follower.local_addr, + "127.0.0.1:0".parse().unwrap(), + "outbound-only must not bind a P2P port" + ); + assert_eq!(follower.max_inbound, 0); + let inbound_err = follower + .peers + .addconnection(seeder.local_addr, PeerConnType::Inbound) + .unwrap_err(); + assert!( + inbound_err.contains("inbound"), + "addconnection inbound must refuse: {inbound_err}" + ); + + follower.follow_from(seeder.local_addr).await.unwrap(); + let mut linked = false; + for _ in 0..100 { + if follower.follow_live_count() >= 1 && !seeder.peers.snapshot().is_empty() { + linked = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + seeder.shutdown().await; + follower.shutdown().await; + let _ = std::fs::remove_dir_all(&dir); + assert!(linked, "outbound-only follower must handshake the seeder"); + } } diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 5f1809673..5b74ded94 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -183,10 +183,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { ); } apply_startup_index_mode(&handle.query, &config, params.taproot_height())?; - let listen = config - .listen - .p2p_bind_addr(config.network) - .unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], default_port(config.network)))); + let bind = config.listen.p2p_bind_addr(config.network); let start_tip = handle.query.tip_height().map(|h| h.0).unwrap_or(0); let run_started = Instant::now(); @@ -208,16 +205,31 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { let p2p_ua = rbitcoin_primitives::rbitcoin_subversion(env!("CARGO_PKG_VERSION"), &config.uacomments) .unwrap_or_else(|_| format!("/rbitcoin:{}/", env!("CARGO_PKG_VERSION"))); - let mut node = P2PNode::start_with_dialer( - listen, - query, - params.clone(), - milestone, - p2p_ua, - config.listen.max_inbound as usize, - config.listen.dialer(), - ) - .await + let mut node = match bind { + Some(listen) => { + P2PNode::start_with_dialer( + listen, + query, + params.clone(), + milestone, + p2p_ua, + config.listen.max_inbound as usize, + config.listen.dialer(), + ) + .await + } + None => { + P2PNode::start_outbound_only( + query, + params.clone(), + milestone, + p2p_ua, + config.listen.max_inbound as usize, + config.listen.dialer(), + ) + .await + } + } .map_err(|e| NodeError::Config(format!("p2p start: {e}")))?; for extra in &config.listen.p2p_extra { let bound = node @@ -325,11 +337,18 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { config.mempool.max_weight ); - info!( - "rbitcoin-node listening on {} ({})", - node.local_addr, - config.network.as_str() - ); + if node.local_addr.port() == 0 { + info!( + "rbitcoin-node P2P outbound-only ({})", + config.network.as_str() + ); + } else { + info!( + "rbitcoin-node listening on {} ({})", + node.local_addr, + config.network.as_str() + ); + } let shutdown = Shutdown::new(); spawn_signal_handler(shutdown.clone()); From 94e1ad0701b8b0ec70160c70265954b2821686ee Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:09:29 -0700 Subject: [PATCH 4/5] net: --no-discover suppresses self-announce Outbound-only home nodes must not leak a LAN or ISP address via addr messages or getnetworkinfo.localaddresses, even when --external-ip is set. Co-authored-by: Cursor --- crates/rbitcoin-net/src/peer_tests.rs | 33 ++++++++++++++++++++++++ crates/rbitcoin-net/src/peers.rs | 13 ++++++++++ crates/rbitcoin-node/src/cli.rs | 18 ++++++++++++- crates/rbitcoin-node/src/config.rs | 6 +++++ crates/rbitcoin-node/src/run.rs | 5 +++- crates/rbitcoin-rpc/src/methods_tests.rs | 4 +++ 6 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/rbitcoin-net/src/peer_tests.rs b/crates/rbitcoin-net/src/peer_tests.rs index 22f6efd81..cbcfc12aa 100644 --- a/crates/rbitcoin-net/src/peer_tests.rs +++ b/crates/rbitcoin-net/src/peer_tests.rs @@ -5475,6 +5475,39 @@ fn externalip_is_advertised_once_then_after_a_day() { ); } +#[test] +fn no_discover_suppresses_self_announce() { + use crate::peers::{PeerConnType, PeerHub}; + use bitcoin::p2p::address::Address; + 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); + hub.set_listen_port(18445); + hub.set_external_ips(vec![IpAddr::V4(Ipv4Addr::new(42, 42, 42, 42))]); + hub.set_discover(false); + assert!( + peer.take_local_addr_due(1_000).is_none(), + "--no-discover must suppress self-announce" + ); + assert!(peer.take_self_announce_msg().is_none()); + assert!(hub.rpc_local_addresses().is_empty()); +} + #[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 9305b5d8f..d4969b125 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -1073,6 +1073,8 @@ pub struct PeerHub { external_ips: Mutex>, /// P2P listen port used with advertised external IPs. listen_port: AtomicU16, + /// Core `-discover`. Off: never self-announce, even with `--external-ip`. + discover: AtomicBool, asmap: Mutex>>, /// Tip-mode mempool for Core `EraseForPeer` on disconnect. mempool: Mutex>>, @@ -1147,6 +1149,7 @@ impl PeerHub { peer_timeout_secs: AtomicU64::new(60), external_ips: Mutex::new(Vec::new()), listen_port: AtomicU16::new(0), + discover: AtomicBool::new(true), asmap: Mutex::new(None), mempool: Mutex::new(None), net_perms: Mutex::new(crate::net_permissions::NetPermTable::default()), @@ -1206,9 +1209,16 @@ impl PeerHub { self.listen_port.store(port, Ordering::Relaxed); } + pub fn set_discover(&self, on: bool) { + self.discover.store(on, Ordering::Relaxed); + } + /// `getnetworkinfo.localaddresses` rows for operator-advertised IPs. pub fn rpc_local_addresses(&self) -> Vec<(String, u16, i32)> { const LOCAL_MANUAL: i32 = 4; + if !self.discover.load(Ordering::Relaxed) { + return Vec::new(); + } let port = self.listen_port.load(Ordering::Relaxed); if port == 0 { return Vec::new(); @@ -1224,6 +1234,9 @@ impl PeerHub { } pub fn advertise_local_socket(&self) -> Option { + if !self.discover.load(Ordering::Relaxed) { + return None; + } let port = self.listen_port.load(Ordering::Relaxed); if port == 0 { return None; diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index fc5c845a7..6f9196baa 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -308,7 +308,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] [--smoke] [--inhibit-suspend]\n\n\ + [--no-seeds] [--no-listen] [--no-discover] [--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\ @@ -376,6 +376,7 @@ fn is_bool_key(key: &str) -> bool { | "net_permission_force_relay" | "no_seeds" | "no_listen" + | "no_discover" | "proxy_randomize" | "inhibit_suspend" | "trusted" @@ -569,6 +570,7 @@ mod tests { "--onion", "--proxy-randomize", "--no-listen", + "--no-discover", ] { assert!(h.contains(flag), "help must list {flag}"); } @@ -599,6 +601,7 @@ mod tests { "--whitelist-relay", "--whitelist-forcerelay", "--nolisten", + "--nodiscover", ] { assert!(!h.contains(concat), "help must not advertise {concat}"); } @@ -777,6 +780,19 @@ mod tests { ); } + #[test] + fn no_discover_conf() { + let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); + assert!(NodeConfig::default().listen.discover); + let off = ready_config(["rbitcoin-node", "--no-discover"]); + assert!(!off.listen.discover); + let mut c = NodeConfig::default(); + assert_eq!(c.apply_kv("no_discover", "1").unwrap(), ConfApply::Applied); + assert!(!c.listen.discover); + c.apply_kv("no_discover", "0").unwrap(); + assert!(c.listen.discover); + } + #[test] fn kebab_seed_node_and_min_relay_tx_fee_parse() { let seeds = ready_config(["rbitcoin-node", "--seed-node", "127.0.0.1:8333"]); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 6e1685be3..05a07e062 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -99,6 +99,8 @@ pub struct ListenOpts { pub onion: Option, /// Fresh SOCKS USERPASS per peer (Core `-proxyrandomize`; default on). pub proxy_randomize: bool, + /// Core `-discover` (default on). Off: no self-announce / localaddresses. + pub discover: bool, } impl Default for ListenOpts { @@ -119,6 +121,7 @@ impl Default for ListenOpts { proxy: None, onion: None, proxy_randomize: true, + discover: true, } } } @@ -677,6 +680,9 @@ impl NodeConfig { self.listen.p2p_extra.clear(); } } + "no_discover" => { + self.listen.discover = !is_conf_true(val); + } "connect" => { self.listen.connect.push( val.parse() diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 5b74ded94..ba1a91ccc 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -316,7 +316,10 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { if let Some(secs) = config.listen.peer_timeout_secs { node.peers.set_peer_timeout_secs(secs); } - node.peers.set_listen_port(listen.port()); + node.peers.set_discover(config.listen.discover); + if let Some(addr) = bind { + node.peers.set_listen_port(addr.port()); + } if !config.listen.external_ips.is_empty() { node.peers .set_external_ips(config.listen.external_ips.clone()); diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 52eb2aa5c..c69663b02 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -4215,6 +4215,10 @@ fn getnetworkinfo_localaddresses_from_externalip() { assert_eq!(addrs[0]["address"], "42.42.42.42"); assert_eq!(addrs[0]["port"], 18445); assert_eq!(addrs[0]["score"], 4); + ctx.peers.as_ref().unwrap().set_discover(false); + let info = dispatch(&ctx, "getnetworkinfo", vec![]).unwrap(); + let addrs = info["localaddresses"].as_array().expect("array"); + assert!(addrs.is_empty(), "no-discover localaddresses: {info}"); let _ = std::fs::remove_dir_all(&dir); } From f7dbfd8ca3245e1be9b36ff41ff59001daeaa0b2 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:12:18 -0700 Subject: [PATCH 5/5] docs+nix: first-class --no-listen / --max-inbound 0 / --no-discover Outbound-only NixOS units skip the P2P bind, inbound cap, self-announce, and firewall hole. Default ExecStart still passes --listen. Co-authored-by: Cursor --- OPERATOR.md | 9 +++++++- nix/modules/rbitcoin.nix | 38 +++++++++++++++++++++++++++++---- nix/tests/nixos-module-eval.nix | 27 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/OPERATOR.md b/OPERATOR.md index c104f2f04..727a445ec 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -366,13 +366,15 @@ 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` | | `--connect ADDR` | `connect=` (repeatable) | seeds | | `--proxy HOST:PORT` | `proxy=` | unset — SOCKS5 for all P2P outbound | | `--onion HOST:PORT` | `onion=` | unset — SOCKS5 for onion destinations | | `--proxy-randomize[=0\|1]` | `proxy_randomize=` | **on** — fresh SOCKS username per peer (Tor circuit isolation) | | `--milestone HEIGHT` | `milestone=` | network default (mainnet 840000) | | `--max-outbound N` | `max_outbound=` | 16 live download peers | -| `--max-inbound N` | `max_inbound=` | 125 inbound sessions | +| `--max-inbound N` | `max_inbound=` | 125 inbound sessions; **0** = no inbound slots (outbound-only) | | `--mempool-size-mb N` | `mempool_size_mb=` | ~300 MiB weight | | `--conf FILE` | | none | | `--log-level LEVEL` | `log_level=` | `info` | @@ -436,6 +438,11 @@ pass `--connect ADDR` (or reuse a `peers` file). `--proxy-randomize` (default on) uses a fresh SOCKS username per peer so Tor isolates circuits. `--onion HOST:PORT` stores a separate SOCKS endpoint for onion destinations. +`--listen=0` / `--no-listen` starts without a P2P TCP bind (no ISP port +forward). `--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. + `--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/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index 356e724b2..06e850b5c 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -45,11 +45,23 @@ let cfg.dataDir "--network" cfg.network - "--listen" - (socket cfg.p2p.address cfg.p2p.port) + ] + ++ ( + if cfg.p2p.listen then + [ + "--listen" + (socket cfg.p2p.address cfg.p2p.port) + ] + else + [ "--no-listen" ] + ) + ++ [ "--log-level" cfg.logLevel ] + ++ optional (cfg.p2p.maxInbound != 125) "--max-inbound" + ++ optional (cfg.p2p.maxInbound != 125) (toString cfg.p2p.maxInbound) + ++ optional (!cfg.p2p.discover) "--no-discover" ++ optional (cfg.coldDataDir != null) "--datadir-cold" ++ optional (cfg.coldDataDir != null) cfg.coldDataDir ++ optional cfg.rpc.enable "--rpc-listen" @@ -187,10 +199,28 @@ in description = "Bitcoin P2P listen port."; }; + listen = mkOption { + type = types.bool; + default = true; + description = "Bind a P2P listen socket. Set false for outbound-only (no ISP port forward)."; + }; + + maxInbound = mkOption { + type = types.ints.unsigned; + default = 125; + description = "Inbound P2P session cap. 0 with listen=false is outbound-only."; + }; + + discover = mkOption { + type = types.bool; + default = true; + description = "Advertise local addresses to peers. Off with --no-discover."; + }; + openFirewall = mkOption { type = types.bool; default = false; - description = "Open the P2P listen port in the NixOS firewall."; + description = "Open the P2P listen port in the NixOS firewall. Ignored when listen is false."; }; }; @@ -310,7 +340,7 @@ in }; networking.firewall.allowedTCPPorts = - optional cfg.p2p.openFirewall cfg.p2p.port + optional (cfg.p2p.openFirewall && cfg.p2p.listen) cfg.p2p.port ++ optional (cfg.electrum.enable && cfg.electrum.openFirewall) cfg.electrum.port ++ optional (cfg.esplora.enable && cfg.esplora.openFirewall) cfg.esplora.port; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index f8ff099e5..beb8d5cea 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -59,10 +59,32 @@ let ]; }; defaultCfg = defaultSystem.config.services.rbitcoin; + listenOffSystem = nixpkgs.lib.nixosSystem { + inherit (pkgs.stdenv.hostPlatform) system; + modules = [ + module + { + services.rbitcoin = { + enable = true; + package = fakePackage; + p2p = { + listen = false; + maxInbound = 0; + discover = false; + openFirewall = true; + }; + }; + } + ]; + }; + listenOffExec = listenOffSystem.config.systemd.services.rbitcoin.serviceConfig.ExecStart; in assert defaultCfg.package == expectedPackage; assert defaultCfg.network == "mainnet"; assert defaultCfg.p2p.port == 8333; +assert defaultCfg.p2p.listen == true; +assert defaultCfg.p2p.maxInbound == 125; +assert defaultCfg.p2p.discover == true; assert defaultCfg.rpc.port == 8332; assert defaultCfg.proxy == null; assert defaultCfg.onionProxy == null; @@ -92,4 +114,9 @@ assert builtins.match ".*--log-level debug.*" execStart != null; assert builtins.match ".*--max-outbound 8.*" execStart != null; assert builtins.match ".*--proxy 127.0.0.1:9050.*" execStart != null; assert builtins.match ".*--onion 127.0.0.1:9050.*" execStart != null; +assert builtins.match ".*--no-listen.*" listenOffExec != null; +assert builtins.match ".*--listen .*" listenOffExec == null; +assert builtins.match ".*--max-inbound 0.*" listenOffExec != null; +assert builtins.match ".*--no-discover.*" listenOffExec != null; +assert listenOffSystem.config.networking.firewall.allowedTCPPorts == [ ]; pkgs.runCommand "rbitcoin-nixos-module-eval" { } "touch $out"