diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c77d657..3487e9fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ before 1.0). before the next slice. Coverage and native `windows` / `macos` stay GitHub Actions. See [`docs/how-we-plan.md`](docs/how-we-plan.md). +- **Hostname `--connect` / `addnode` (Q-68):** resolve host strings at each + dial (`localhost` and missing port use the network P2P default). `addnode add` + and `--connect` retry on a seconds-scale timer until a live session exists; + `--connect` still does not enable seed redial. Incomplete IBD at genesis with + `--connect` still enters tip-follow so a late peer can attach. + ## [0.7.0] — 2026-09-18 Named published **0.7** line. **Not 1.0.** Patch branch is `v0.7.x`. Schema 24 diff --git a/COMPAT.md b/COMPAT.md index 0abdb9b0e..8be40b9bb 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -102,7 +102,7 @@ Per-method notes, auth, and the shindex matrix live in |--------------|--------| | Control (`help`, `uptime`, `stop`, `getrpcinfo`, `echo`) | done (`syncwithvalidationinterfacequeue` omitted; functional proxy no-op for Core `sync_mempools`) | | Blockchain (`getblockchaininfo`, `getblockcount`, `getbestblockhash`, `getblockhash`, `getblock`/`header`, `getdifficulty`, `getblockstats`) | done (archive reconstruct; disk/progress real) | -| Network (`getnetworkinfo`, `getconnectioncount`, `getpeerinfo`, `addnode`, `disconnectnode`, `addconnection`) | done (BIP324 v2-only; peer `timeoffset` / `synced_*` from session state) | +| Network (`getnetworkinfo`, `getconnectioncount`, `getpeerinfo`, `addnode`, `disconnectnode`, `addconnection`) | done (BIP324 v2-only; peer `timeoffset` / `synced_*` from session state; hostname `addnode` / `--connect` resolve at dial and retry until live) | | Mempool / rawtx (`getmempool*`, `getrawtransaction`, `sendrawtransaction`, `testmempoolaccept`) | done (Libre; RPC `maxfeerate` / `maxburnamount` only) | | Coin / MiniWallet (`gettxout`, `scantxoutset` `raw(HEX)`) | done (Class A unspent walk — not a coins-DB) | | Index / tips (`getindexinfo`, `getchaintips`, `waitforblock*`) | done (`txindex` = Class A reconstruct) | diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index 2ac584d7b..81d01cbd1 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -49,8 +49,8 @@ pub use peer::{ }; pub use peer_dos::DEFAULT_MAX_INBOUND; pub use peers::{ - parse_peer_addr, pick_stale_follow_evict, DialRequest, LivePeer, PeerConnType, PeerHub, - PeerInfo, PeerOut, PingAction, + parse_peer_addr, parse_peer_addr_with_port, pick_stale_follow_evict, DialRequest, LivePeer, + PeerConnType, PeerHub, PeerInfo, PeerOut, PingAction, }; pub use rbitcoin_mempool::AcceptError; pub(crate) use rbitcoin_mempool::MempoolGraphStats; diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs index d10123e10..bb66819bb 100644 --- a/crates/rbitcoin-net/src/peers.rs +++ b/crates/rbitcoin-net/src/peers.rs @@ -8,7 +8,7 @@ use bitcoin::p2p::ServiceFlags; use bitcoin::{BlockHash, Wtxid}; use std::collections::{HashMap, HashSet, VecDeque}; use std::hash::Hash; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; use tokio::sync::mpsc; @@ -1018,7 +1018,9 @@ pub struct PeerInfo { pub struct PeerHub { next_id: AtomicU64, live: RwLock>>, - added: Mutex>, + added: Mutex>, + connect: Mutex>, + connect_default_port: AtomicU16, dial_tx: Mutex>>, /// Peers we asked to send us compact (BIP152 HB, max 3, prefer outbound). hb_selected: Mutex>, @@ -1108,6 +1110,8 @@ impl PeerHub { next_id: AtomicU64::new(0), live: RwLock::new(HashMap::new()), added: Mutex::new(HashSet::new()), + connect: Mutex::new(Vec::new()), + connect_default_port: AtomicU16::new(0), dial_tx: Mutex::new(None), hb_selected: Mutex::new(Vec::new()), mock_now: AtomicU64::new(0), @@ -1771,29 +1775,84 @@ impl PeerHub { .cloned() } - pub fn addnode(&self, addr: SocketAddr, cmd: &str) -> Result<(), String> { + pub fn addnode(&self, node: &str, cmd: &str) -> Result<(), String> { match cmd { - "onetry" => self.dial(addr, PeerConnType::Manual), + "onetry" => { + let addr = self.parse_added_addr(node)?; + self.dial(addr, PeerConnType::Manual) + } "add" => { self.added .lock() .unwrap_or_else(|e| e.into_inner()) - .insert(addr); - let _ = self.dial(addr, PeerConnType::Manual); + .insert(node.to_string()); + if let Ok(addr) = self.parse_added_addr(node) { + let _ = self.dial(addr, PeerConnType::Manual); + } Ok(()) } "remove" => { self.added .lock() .unwrap_or_else(|e| e.into_inner()) - .remove(&addr); - self.disconnect_addr(addr); + .remove(node); + if let Ok(addr) = self.parse_added_addr(node) { + self.disconnect_addr(addr); + } Ok(()) } other => Err(format!("unknown addnode command {other}")), } } + fn parse_added_addr(&self, node: &str) -> Result { + let port = self.connect_default_port.load(Ordering::Relaxed); + let default_port = (port != 0).then_some(port); + parse_peer_addr_with_port(node, default_port).map_err(|e| e.to_string()) + } + + pub fn set_connect_hosts(&self, hosts: Vec, default_port: u16) { + self.connect_default_port + .store(default_port, Ordering::Relaxed); + *self.connect.lock().unwrap_or_else(|e| e.into_inner()) = hosts; + } + + fn is_addr_live(&self, addr: SocketAddr) -> bool { + self.snapshot().iter().any(|p| p.addr == addr) + } + + /// Dial remembered `addnode add` / `--connect` hosts that are not live. + pub fn redial_remembered(&self) { + let added: Vec = self + .added + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .cloned() + .collect(); + for host in added { + let Ok(addr) = self.parse_added_addr(&host) else { + continue; + }; + if !self.is_addr_live(addr) { + let _ = self.dial(addr, PeerConnType::Manual); + } + } + let hosts: Vec = self + .connect + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + for host in hosts { + let Ok(addr) = self.parse_added_addr(&host) else { + continue; + }; + if !self.is_addr_live(addr) { + let _ = self.dial(addr, PeerConnType::OutboundFullRelay); + } + } + } + /// Select `id` as a BIP152 high-bandwidth peer (we send them sendcmpct(1)). /// Evicts the oldest inbound if we already have 3; never evict the last outbound /// when adding an inbound. @@ -2015,10 +2074,52 @@ fn service_flags_u64(f: ServiceFlags) -> u64 { f.to_u64() } -/// Parse Core `ip:port` / `[v6]:port`. +/// Parse Core `ip:port` / `[v6]:port`. Hostnames need [`parse_peer_addr_with_port`]. pub fn parse_peer_addr(s: &str) -> Result { - s.parse() - .map_err(|_| NetError::Encode(format!("bad peer address {s}"))) + parse_peer_addr_with_port(s, None) +} + +/// Parse `ip:port`, `[v6]:port`, `host:port`, or `host` (uses `default_port`). +/// +/// Hostnames resolve at call time (`ToSocketAddrs`) so kube-dns / late DNS can +/// appear after listen. Dual-stack names prefer IPv4 so a `127.0.0.1` listener +/// is reached via `localhost`. +pub fn parse_peer_addr_with_port( + s: &str, + default_port: Option, +) -> Result { + let bad = || NetError::Encode(format!("bad peer address {s}")); + if let Ok(addr) = s.parse::() { + return Ok(addr); + } + if let Ok(ip) = s.parse::() { + let port = default_port.ok_or_else(bad)?; + return Ok(SocketAddr::new(ip, port)); + } + let (host, port) = match s.rsplit_once(':') { + Some((h, p)) if !h.is_empty() && !h.starts_with('[') => match p.parse::() { + Ok(port) => (h, port), + Err(_) => { + let port = default_port.ok_or_else(bad)?; + (s, port) + } + }, + _ => { + let port = default_port.ok_or_else(bad)?; + (s, port) + } + }; + if host.is_empty() { + return Err(bad()); + } + let with_port = format!("{host}:{port}"); + let addrs: Vec = with_port.to_socket_addrs().map_err(|_| bad())?.collect(); + addrs + .iter() + .copied() + .find(|a| a.is_ipv4()) + .or_else(|| addrs.first().copied()) + .ok_or_else(bad) } #[cfg(test)] @@ -2753,8 +2854,34 @@ mod tests { #[test] fn addnode_unknown_command() { let hub = PeerHub::new(); - let a = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1); - assert!(hub.addnode(a, "nope").is_err()); + assert!(hub.addnode("127.0.0.1:1", "nope").is_err()); + } + + #[test] + fn redial_remembered_dials_added_and_connect_hosts() { + let hub = PeerHub::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + hub.set_dialer(tx); + let added = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 18444); + hub.addnode("127.0.0.1:18444", "add").unwrap(); + let first = rx.try_recv().expect("addnode dials once"); + assert_eq!(first.addr, added); + assert_eq!(first.typ, PeerConnType::Manual); + + hub.set_connect_hosts(vec!["127.0.0.1:18445".into()], 18444); + hub.redial_remembered(); + let mut got = Vec::new(); + while let Ok(r) = rx.try_recv() { + got.push((r.addr.port(), r.typ)); + } + assert!( + got.contains(&(18444, PeerConnType::Manual)), + "added not live must redial: {got:?}" + ); + assert!( + got.contains(&(18445, PeerConnType::OutboundFullRelay)), + "--connect not live must redial: {got:?}" + ); } #[test] @@ -2930,4 +3057,35 @@ mod tests { assert_eq!(expired.len(), 1000); assert_ne!(a, expired); } + + #[test] + fn parse_peer_addr_localhost_and_default_port() { + let with_port = parse_peer_addr("localhost:18444").expect("localhost:port"); + assert_eq!(with_port.port(), 18444); + assert!(with_port.ip().is_loopback(), "{with_port}"); + + let no_port = + parse_peer_addr_with_port("localhost", Some(18444)).expect("localhost default"); + assert_eq!(no_port.port(), 18444); + assert!(no_port.ip().is_loopback(), "{no_port}"); + + let lit: SocketAddr = "127.0.0.1:18444".parse().unwrap(); + assert_eq!(parse_peer_addr("127.0.0.1:18444").unwrap(), lit); + + let err = parse_peer_addr_with_port("not-a-real-host.invalid", Some(18444)).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("bad peer address"), "{s}"); + } + + #[test] + fn addnode_add_keeps_unresolved_host_for_redial() { + let hub = PeerHub::new(); + let (tx, mut rx) = mpsc::unbounded_channel(); + hub.set_dialer(tx); + hub.set_connect_hosts(vec![], 18444); + hub.addnode("not-a-real-host.invalid", "add").unwrap(); + assert!(rx.try_recv().is_err(), "unresolved add must not dial"); + hub.redial_remembered(); + assert!(rx.try_recv().is_err(), "still unresolved: skip this tick"); + } } diff --git a/crates/rbitcoin-net/src/service.rs b/crates/rbitcoin-net/src/service.rs index 141474c1d..62967c678 100644 --- a/crates/rbitcoin-net/src/service.rs +++ b/crates/rbitcoin-net/src/service.rs @@ -134,6 +134,22 @@ impl P2PNode { } }); + const CONNECT_RETRY_SECS: u64 = 2; + let retry_peers = peers.clone(); + let retry_shutdown = shutdown.clone(); + let retry_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(CONNECT_RETRY_SECS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + interval.tick().await; + loop { + interval.tick().await; + if retry_shutdown.load(Ordering::SeqCst) { + break; + } + retry_peers.redial_remembered(); + } + }); + Ok(Self { cache, query, @@ -142,7 +158,7 @@ impl P2PNode { magic, shutdown, follow_live, - tasks: vec![accept_task, dial_task], + tasks: vec![accept_task, dial_task, retry_task], session_tasks, peers, user_agent, diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 93c13403c..31b0f9c7f 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -75,7 +75,7 @@ pub struct ListenOpts { pub p2p_extra: Vec, pub electrum: Option, pub esplora: Option, - pub connect: Vec, + pub connect: Vec, pub seednodes: Vec, pub use_seeds: bool, pub max_outbound: u32, @@ -641,10 +641,12 @@ impl NodeConfig { self.push_p2p_listen(addr)?; } "connect" => { - self.listen.connect.push( - val.parse() - .map_err(|e| NodeError::Config(format!("conf connect: {e}")))?, - ); + if val.is_empty() { + return Err(NodeError::Config( + "conf connect requires host[:port]".into(), + )); + } + self.listen.connect.push(val.to_string()); } "seed_node" => { if !val.is_empty() { @@ -1056,6 +1058,14 @@ mod tests { ConfApply::Unknown(k) => assert_eq!(k, "not-a-real-key"), other => panic!("{other:?}"), } + assert_eq!( + c.apply_kv("connect", "localhost").unwrap(), + ConfApply::Applied + ); + assert_eq!( + c.listen.connect.last().map(String::as_str), + Some("localhost") + ); } #[test] @@ -1421,6 +1431,7 @@ mod tests { Some(std::path::Path::new("/tmp/ip_asn.dat")) ); assert_eq!(cfg.listen.connect.len(), 1); + assert_eq!(cfg.listen.connect[0], "127.0.0.1:38333"); assert_eq!( cfg.datadir.cold.as_deref(), Some(std::path::Path::new("/mnt/hdd/rbtc-cold")) @@ -1771,6 +1782,7 @@ mod tests { assert_eq!(cfg.network, Network::Regtest); assert!(cfg.listen.p2p.is_some()); assert_eq!(cfg.listen.connect.len(), 1); + assert_eq!(cfg.listen.connect[0], "127.0.0.1:18445"); assert!(cfg.shindex); assert!(!cfg.sptweaks); assert!(cfg.listen.electrum.is_some()); diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 3ac457d26..de6befdb8 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -6,9 +6,9 @@ use rbitcoin_electrum::{run_electrum, ElectrumConfig, ElectrumHandle, TipNotify} use rbitcoin_esplora::{run_esplora, BlockTemplateFn, EsploraConfig, EsploraHandle}; use rbitcoin_log::{debug, enabled, info, warn, Level}; use rbitcoin_net::{ - default_port, format_serve_perf, format_tip_perf_sizes, netgroup, read_proc_rss, - sample_reset_serve_perf, AddrMan, AsMap, BlockingRegion, ChainHub, IbdConfig, MempoolHub, - P2PNode, PeerConnType, TipEvent, TipPerfSizes, + default_port, format_serve_perf, format_tip_perf_sizes, netgroup, parse_peer_addr_with_port, + read_proc_rss, sample_reset_serve_perf, AddrMan, AsMap, BlockingRegion, ChainHub, IbdConfig, + MempoolHub, P2PNode, PeerConnType, TipEvent, TipPerfSizes, }; use rbitcoin_primitives::Network; use rbitcoin_query::{spawn_sh_writebehind, Query}; @@ -376,7 +376,12 @@ 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()); node.peers.set_asmap(asmap); - for c in &config.listen.connect { + node.peers.set_connect_hosts( + config.listen.connect.clone(), + config.network.default_p2p_port(), + ); + let connect_addrs = resolve_connect_addrs(&config.listen.connect, config.network); + for c in &connect_addrs { addrman.add(*c); } if should_resolve_default_seeds(&config) { @@ -403,8 +408,8 @@ 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 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 targets = follow_dial_targets(&connect_addrs, &addrman, max_out, &occupied); + let ibd_targets = follow_dial_targets(&connect_addrs, &addrman, candidate_n, &occupied); let catch_up = run_ibd_or_skip( &node, &ibd_targets, @@ -416,6 +421,12 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { ) .await; + let catch_up = catch_up_with_connect( + catch_up, + !config.listen.connect.is_empty(), + shutdown.requested(), + ); + // Still enter tip-follow when work is below `--min-chain-work` so later // blocks can raise the tip. Relay / getheaders stay gated on the hub floor. if catch_up.is_complete() && !tip_meets_min_work(&config, &node.hub) { @@ -1062,6 +1073,19 @@ pub(crate) fn catch_up_after_err(tip: u32, index_is_tip: bool, shutdown: bool) - } } +/// `--connect` at genesis still follows: the peer may listen after we dial. +pub(crate) fn catch_up_with_connect( + catch_up: CatchUp, + has_connect: bool, + shutdown: bool, +) -> CatchUp { + if catch_up.is_complete() || shutdown || !has_connect { + catch_up + } else { + CatchUp::complete_dial_failed() + } +} + fn apply_startup_index_mode( query: &Query, config: &NodeConfig, @@ -1594,6 +1618,14 @@ pub(crate) fn load_asmap(datadir: &Path, configured: Option<&Path>) -> Option Vec { + let port = network.default_p2p_port(); + connect + .iter() + .filter_map(|s| parse_peer_addr_with_port(s, Some(port)).ok()) + .collect() +} + /// `--connect` is operator-pinned: no netgroup filter. Otherwise rank + diversity. pub(crate) fn follow_dial_targets( connect: &[SocketAddr], @@ -1873,6 +1905,22 @@ mod tests { assert_eq!(catch_up_after_err(10, false, false), CatchUp::Incomplete); assert_eq!(catch_up_after_err(0, true, false), CatchUp::Incomplete); assert_eq!(catch_up_after_err(10, true, true), CatchUp::Incomplete); + assert_eq!( + catch_up_with_connect(CatchUp::Incomplete, true, false), + CatchUp::complete_dial_failed() + ); + assert_eq!( + catch_up_with_connect(CatchUp::Incomplete, false, false), + CatchUp::Incomplete + ); + assert_eq!( + catch_up_with_connect(CatchUp::complete(), true, false), + CatchUp::complete() + ); + assert_eq!( + catch_up_with_connect(CatchUp::Incomplete, true, true), + CatchUp::Incomplete + ); } #[test] @@ -2260,7 +2308,7 @@ mod tests { let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); cfg.listen.use_seeds = false; // Blackhole / closed port: connect fails fast under FOLLOW_CONNECT_SECS. - cfg.listen.connect = vec!["127.0.0.1:1".parse().unwrap()]; + cfg.listen.connect = vec!["127.0.0.1:1".into()]; cfg.max_run_secs = Some(0); // Dead connect should fail fast (FOLLOW_CONNECT_SECS); 20s bound for hang detection. let result = tokio::time::timeout(Duration::from_secs(20), run_p2p(cfg)).await; @@ -2389,7 +2437,7 @@ mod tests { let mut cfg = tiny_regtest(&dir).with_p2p_listen("127.0.0.1:0".parse().unwrap()); cfg.listen.use_seeds = false; - cfg.listen.connect = vec!["127.0.0.1:1".parse().unwrap()]; + cfg.listen.connect = vec!["127.0.0.1:1".into()]; cfg.max_run_secs = Some(0); let result = tokio::time::timeout(Duration::from_secs(20), run_p2p(cfg)).await; assert!(result.is_ok(), "run_p2p timed out"); diff --git a/crates/rbitcoin-rpc/src/methods/net.rs b/crates/rbitcoin-rpc/src/methods/net.rs index 18aa8864a..31517c4e5 100644 --- a/crates/rbitcoin-rpc/src/methods/net.rs +++ b/crates/rbitcoin-rpc/src/methods/net.rs @@ -2,6 +2,7 @@ use super::*; use bitcoin::hashes::Hash; use rbitcoin_net::MempoolHub; use serde_json::{json, Value}; +use std::net::SocketAddr; use std::sync::atomic::Ordering; pub(crate) fn getnettotals(ctx: &RpcContext) -> Value { @@ -172,15 +173,24 @@ pub(crate) fn require_peers(ctx: &RpcContext) -> Result<&rbitcoin_net::PeerHub, .ok_or_else(|| rpc_error(ERR_MISC, "P2P session table not attached")) } +fn rpc_peer_addr(ctx: &RpcContext, s: &str) -> Result { + rbitcoin_net::parse_peer_addr_with_port(s, Some(ctx.network.default_p2p_port())) + .map_err(|e| rpc_error(ERR_INVALID_PARAMS, e.to_string())) +} + pub(crate) fn addnode(ctx: &RpcContext, params: &RpcParams) -> Result { params.reject_unknown(&["node", "command", "v2transport"])?; let hub = require_peers(ctx)?; let node = params.req_str(0, "node")?; let cmd = params.req_str(1, "command")?; let _v2 = params.opt_bool(2, "v2transport")?; - let addr = rbitcoin_net::parse_peer_addr(node) - .map_err(|e| rpc_error(ERR_INVALID_PARAMS, e.to_string()))?; - hub.addnode(addr, cmd).map_err(|e| rpc_error(ERR_MISC, e))?; + hub.addnode(node, cmd).map_err(|e| { + if e.contains("bad peer address") { + rpc_error(ERR_INVALID_PARAMS, e) + } else { + rpc_error(ERR_MISC, e) + } + })?; Ok(Value::Null) } @@ -197,8 +207,7 @@ pub(crate) fn disconnectnode(ctx: &RpcContext, params: &RpcParams) -> Result Result>(), + b.peers + .snapshot() + .iter() + .map(|p| (p.inbound, p.conn_type, p.handshake_complete)) + .collect::>() + ) + }, + ) + .await; + a.shutdown().await; + b.shutdown().await; + }; + let wall = llvm_cov_wall(30, 90); + tokio::time::timeout(wall, fut).await.unwrap_or_else(|_| { + panic!("addnode_add_retries_until_peer_listens wall timeout ({wall:?})") + }); +} diff --git a/crates/rbitcoin-test/tests/scenarios.rs b/crates/rbitcoin-test/tests/scenarios.rs index 219c5032e..3e0aef4f6 100644 --- a/crates/rbitcoin-test/tests/scenarios.rs +++ b/crates/rbitcoin-test/tests/scenarios.rs @@ -189,8 +189,7 @@ fn node_cli_and_surface_smoke() { assert!(!exit_success(node_cli_main(["rbitcoin-node", "--connect"]))); assert!(!exit_success(node_cli_main([ "rbitcoin-node", - "--connect", - "bad" + "--connect=" ]))); assert!(!exit_success(node_cli_main([ "rbitcoin-node", diff --git a/docs/core-functional.md b/docs/core-functional.md index f63f2f9bf..b4524e3b6 100644 --- a/docs/core-functional.md +++ b/docs/core-functional.md @@ -37,8 +37,10 @@ never calls this. `scripts/core-functional/bitcoind` is the TestNode binary: `-datadir=DIR` → `--datadir DIR/regtest` (`bitcoind.pid` under `DIR/regtest`), -`-rpcport`/`-port`/`bitcoin.conf` → `--rpc-listen` / `--listen` on -127.0.0.1, `--no-seeds`. The node writes `{datadir}/rpc.token` (Bearer); +`-rpcport`/`-port`/`bitcoin.conf` → `--rpc-listen` on 127.0.0.1 and +`--listen` from `-bind`/`-port` (bare `-port` is 0.0.0.0; TestNode +`bind=127.0.0.1` stays loopback), `--no-seeds`. Helm `rpcbind=0.0.0.0` +binds the **proxy** all-interfaces. The node writes `{datadir}/rpc.token` (Bearer); the shim mirrors `__cookie__:` to `{datadir}/.cookie` so Core TestNode cookie + HTTP Basic still work on the **proxy** public port. The proxy forwards `Authorization: Bearer` to the node (TCP is Bearer-only). @@ -274,3 +276,78 @@ no `store/` (clean-chain starts stay empty). python3 scripts/core-functional/create_cache.py --ensure ./scripts/core-functional/create_cache.test.sh ``` + +## Warnet lab (all-rbitcoin tanks) + +Not an operator musl Release. Image tag `rbitcoin-warnet:local`. Warnet +stays Core Helm (`bitcoin.conf`, `rpcuser`/`rpcpassword`, `addnode=tank-N`, +`pidof bitcoind`, `kubectl exec bitcoin-cli`). The node is not taught Core +conf; the Python shim translates. + +Needs Docker + kind (or minikube) on an **operator host**. Do not open +mainnet. Wallet keys are RAM-only; a pod restart drops `miner`. + +```bash +./scripts/core-functional/init-submodule.sh +cargo build -p rbitcoin-node +docker build -t rbitcoin-warnet:local \ + --build-arg NODE_BIN=target/dev/debug/rbitcoin-node \ + -f scripts/core-functional/warnet/Dockerfile . +kind load docker-image rbitcoin-warnet:local +``` + +`scripts/core-functional/warnet/Dockerfile.test.sh` pins the Dockerfile +and entrypoint text (no Docker required). PID 1 is a `python3` binary +copied as `bitcoind` so `pidof bitcoind` works. `BITCOIN_DATA` defaults to +`/root/.bitcoin`. `RBITCOIN_LOG_STDOUT=1` tees mapped debug lines to +stdout for `kubectl logs`. + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install warnet +warnet setup +warnet new /tmp/rbtc-warnet +``` + +Replace `networks//network.yaml` with three tanks, same image, ring +`addnode`, unique `rpcpassword`, no LN, no snapshot: + +```yaml +nodes: + - name: tank-0000 + image: { repository: rbitcoin-warnet, tag: local, pullPolicy: Never } + global: { chain: regtest, rpcpassword: secret0 } + addnode: [tank-0001] + - name: tank-0001 + image: { repository: rbitcoin-warnet, tag: local, pullPolicy: Never } + global: { chain: regtest, rpcpassword: secret1 } + addnode: [tank-0002] + - name: tank-0002 + image: { repository: rbitcoin-warnet, tag: local, pullPolicy: Never } + global: { chain: regtest, rpcpassword: secret2 } + addnode: [tank-0000] +caddy: { enabled: false } +fork_observer: { enabled: false } +``` + +`node-defaults.yaml`: `chain: regtest` only. Liveness is `pidof bitcoind`. + +```bash +warnet deploy /tmp/rbtc-warnet/networks/ +warnet status +warnet bitcoin rpc tank-0000 getblockcount +warnet run /tmp/rbtc-warnet/scenarios/miner_std.py -- --interval=10 --mature +warnet bitcoin rpc tank-0000 getblockcount # >= 101 after --mature +warnet bitcoin rpc tank-0001 getblockcount # same height +warnet bitcoin rpc tank-0002 getblockcount +warnet bitcoin peers tank-0000 +warnet down +``` + +Pass: all three heights match and are > 0; peers show the ring; miner_std +commander is `running` then can be `warnet stop`'d. + +Fail classes: CrashLoop `pidof`; 401 on RPC; height only on tank-0000 +(DNS/retry/bind); `listwalletdir` / `createwallet` errors in commander +logs. `pullPolicy: Never` without `kind load` is ImagePullBackOff. + diff --git a/docs/quality.md b/docs/quality.md index 43f1f45ad..f2e57b689 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -33,7 +33,7 @@ evidence (failed Core corpus, new dual path, red required CI, MSRV drift). | 6 | **Q-67** | `asked_blocks` clone on hold | `hold_body` clones `asked_blocks` before `held_bodies` insert so the read lock does not overlap the write (`HeldBodies::insert` already takes `&HashSet`). Bound is `MAX_SERVE_BLOCKS` × peers. Follow-up: pass the read guard with a documented lock order, or keep the clone as a named trade. Owner: `crates/rbitcoin-net/src/chain.rs`. | R-ids were the 2026-08-12 slice. Canonical id is **bold**. Do not start -**R-11+**. Next unused Q-id is **Q-68**. +**R-11+**. Next unused Q-id is **Q-69**. Close work by **moving the Open row into CHANGELOG** in the same edit as the landing change (do not grow a Completed museum here). New item: insert diff --git a/docs/rpc.md b/docs/rpc.md index 7b032d1cc..f78f478ed 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -83,7 +83,7 @@ still wait for durable SH when shindex is on. | `ping` | All networks. Queues a ping on each live session (`null`). | | `addpeeraddress` | Hidden Core name. Inserts `{address,port}` into addrman RAM (does not rewrite `peers` per call). | | `getnodeaddresses` | Sample from addrman (`count=0` → all). Optional `network` filter. | -| `addnode` / `disconnectnode` / `addconnection` | All networks. `addnode onetry` / `add` dial; `disconnectnode` by `nodeid` or address | +| `addnode` / `disconnectnode` / `addconnection` | All networks. Hostnames resolve at dial (network default P2P port if omitted). `addnode add` and `--connect` retry until a live session. `addnode onetry` dials once. `disconnectnode` by `nodeid` or address | | `getmempoolinfo` / `getrawmempool` / `getmempoolentry` | MempoolHub. `maxmempool` is the operator weight budget (`--mempool-size-mb`). `ancestorcount` / `descendantcount` (and size/fee sums) walk the cluster graph. Verbose `fees.{base,modified,ancestor,descendant,chunk}` and `chunkweight` include `prioritisetransaction` deltas; top-level `ancestorfees` / `descendantfees` stay base satoshis. `unbroadcastcount` / `unbroadcast` track `sendrawtransaction` txs until a peer getdata's them. `orphanage.{size,bytes}` is the parked missing-parent side pool (vsize). `permitbaremultisig` is always `true` (Libre has no Core `IsStandard` bare-multisig gate; `--permitbaremultisig` is not a node flag). | | `getorphantxs` | Hidden operator dump. Verbosity 0 txids, 1 details + `from` peer ids, 2 + hex. Not listed by `help` / `getrpcinfo`. Counts-only `getmempoolinfo.orphanage` is not a substitute. | | `getrawtransaction` | Class A + mempool. Optional Core `blockhash` arg is accepted and ignored. Verbose objects share `tx_to_json` with `decoderawtransaction` / `getblock` verbosity 2 (`scriptSig`, `scriptPubKey.type`). | diff --git a/scripts/core-functional/bitcoin-cli b/scripts/core-functional/bitcoin-cli index 7a4508dcb..374da6545 100755 --- a/scripts/core-functional/bitcoin-cli +++ b/scripts/core-functional/bitcoin-cli @@ -7,7 +7,9 @@ parallel. Core's TestNodeCLI shells out to this binary. from __future__ import annotations +import base64 import json +import os import sys import urllib.error import urllib.request @@ -45,6 +47,18 @@ def cookie_auth(datadir: Path) -> str | None: return None +def default_datadir() -> Path | None: + env = os.environ.get("BITCOIN_DATA") + if env: + return Path(env) + home = os.environ.get("HOME") + if home: + p = Path(home) / ".bitcoin" + if p.is_dir(): + return p + return None + + def main(argv: list[str]) -> int: datadir: Path | None = None rpcport: int | None = None @@ -84,6 +98,8 @@ def main(argv: list[str]) -> int: if not positional: print("too few parameters (need at least command)", file=sys.stderr) return 1 + if datadir is None: + datadir = default_datadir() if datadir is None: print("error: -datadir is required", file=sys.stderr) return 1 @@ -126,10 +142,14 @@ def main(argv: list[str]) -> int: method="POST", ) if cookie: - import base64 - tok = base64.b64encode(cookie.encode()).decode() req.add_header("Authorization", f"Basic {tok}") + else: + user = conf_value(datadir, "rpcuser") or "user" + password = conf_value(datadir, "rpcpassword") + if password: + tok = base64.b64encode(f"{user}:{password}".encode()).decode() + req.add_header("Authorization", f"Basic {tok}") try: with urllib.request.urlopen(req, timeout=120) as resp: raw = resp.read().decode() diff --git a/scripts/core-functional/bitcoin-cli.test.sh b/scripts/core-functional/bitcoin-cli.test.sh new file mode 100755 index 000000000..ba5b8e86d --- /dev/null +++ b/scripts/core-functional/bitcoin-cli.test.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Contract pin for the test-only bitcoin-cli shim (no cargo). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CLI="$ROOT/scripts/core-functional/bitcoin-cli" +PASS=0 +FAIL=0 + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/rbitcoin-cli-shim.XXXXXX")" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +out="$(env -u BITCOIN_DATA HOME="$WORKDIR/nohome" "$CLI" getblockcount 2>&1)" || true +if printf '%s' "$out" | grep -q -- "datadir is required"; then + echo "ok - missing HOME/.bitcoin still requires datadir" + PASS=$((PASS + 1)) +else + echo "not ok - missing HOME/.bitcoin still requires datadir (got: $out)" + FAIL=$((FAIL + 1)) +fi + +HOME_DD="$WORKDIR/home" +mkdir -p "$HOME_DD/.bitcoin/regtest" +printf 'rpcport=18443\nrpcuser=user\nrpcpassword=secret0\n' >"$HOME_DD/.bitcoin/bitcoin.conf" +out="$(env -u BITCOIN_DATA HOME="$HOME_DD" "$CLI" getblockcount 2>&1)" || true +if printf '%s' "$out" | grep -q -- "datadir is required"; then + echo "not ok - HOME/.bitcoin default datadir still hard-fails (got: $out)" + FAIL=$((FAIL + 1)) +else + echo "ok - HOME/.bitcoin default datadir" + PASS=$((PASS + 1)) +fi + +python3 - "$CLI" "$WORKDIR" <<'PY' +import base64 +import json +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +cli, workdir = sys.argv[1], Path(sys.argv[2]) +got = {"auth": None, "method": None} + + +class H(BaseHTTPRequestHandler): + def log_message(self, *args): + return + + def do_POST(self): + n = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(n) + got["auth"] = self.headers.get("Authorization", "") + item = json.loads(raw.decode()) + got["method"] = item.get("method") + body = json.dumps({"result": 7, "error": None, "id": item.get("id")}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +httpd = HTTPServer(("127.0.0.1", 0), H) +port = httpd.server_address[1] +threading.Thread(target=httpd.serve_forever, daemon=True).start() +home = workdir / "rpc-home" +dd = home / ".bitcoin" +dd.mkdir(parents=True) +(dd / "bitcoin.conf").write_text( + f"rpcport={port}\nrpcuser=user\nrpcpassword=secret0\n" +) +env = os.environ.copy() +env["HOME"] = str(home) +env.pop("BITCOIN_DATA", None) +r = subprocess.run( + [cli, "getblockcount"], + env=env, + capture_output=True, + text=True, + timeout=10, +) +assert r.returncode == 0, r.stderr +assert r.stdout.strip() == "7", r.stdout +want = "Basic " + base64.b64encode(b"user:secret0").decode() +assert got["auth"] == want, got["auth"] +assert got["method"] == "getblockcount" +httpd.shutdown() +print("ok - bitcoin-cli conf rpcuser/rpcpassword Basic") +PY + +echo +echo "$PASS passed, $FAIL failed (plus python pin above)" +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi diff --git a/scripts/core-functional/bitcoind b/scripts/core-functional/bitcoind index c4d50577e..de4987698 100755 --- a/scripts/core-functional/bitcoind +++ b/scripts/core-functional/bitcoind @@ -327,6 +327,20 @@ def listen_targets( return seen +def proxy_bind_host(rpcbind: str) -> str: + """Helm `rpcbind=0.0.0.0` / `[::]` binds the test proxy all-interfaces.""" + host = (rpcbind or "").strip() + if host.startswith("["): + inner = host[1:].split("]", 1)[0] + if inner in ("::", "::0"): + return "0.0.0.0" + else: + h = host.split(":", 1)[0] + if h in ("0.0.0.0", "::"): + return "0.0.0.0" + return "127.0.0.1" + + def maybe_seed_store(node_dir: Path) -> None: """Copy the rbitcoin 199-block store into a Core cache-shaped datadir. @@ -407,8 +421,8 @@ def _parse_bind(val: str) -> tuple[str, int | None, bool]: def translate( argv: list[str], -) -> tuple[list[str], Path, int, Path | None, list[str], bool, list[int]]: - """Return (node argv, datadir, rpcport, blocksdir, bound_lines, rpc_proxy, lock_fds).""" +) -> tuple[list[str], Path, int, Path | None, list[str], bool, list[int], str]: + """Return (node argv, datadir, rpcport, blocksdir, bound_lines, rpc_proxy, lock_fds, proxy_host).""" datadir: Path | None = None rpcport: str | None = None p2pport: str | None = None @@ -423,6 +437,7 @@ def translate( listen_off = False whitebind_vals: list[str] = [] rpc_proxy = True + addnodes: list[str] = [] i = 1 while i < len(argv): a = argv[i] @@ -469,6 +484,7 @@ def translate( "walletdir", "wallet", "checkblocks", + "addnode", ): i += 1 if i >= len(argv): @@ -484,6 +500,8 @@ def translate( file=sys.stderr, ) raise SystemExit(1) + elif key == "addnode" and val: + addnodes.append(val) elif key == "rpcport": rpcport = parse_port(val, "-rpcport") elif key == "port": @@ -554,7 +572,11 @@ def translate( raise SystemExit(0) if datadir is None: - raise SystemExit("bitcoind: -datadir is required") + env_dd = os.environ.get("BITCOIN_DATA") + if env_dd: + datadir = Path(env_dd) + else: + raise SystemExit("bitcoind: -datadir is required") conf = read_conf(datadir / "bitcoin.conf") if conf.get("regtest") in ("1", "true"): @@ -587,6 +609,11 @@ def translate( node_dir = datadir / network node_dir.mkdir(parents=True, exist_ok=True) + pw = conf.get("rpcpassword") or "" + if pw: + tok = node_dir / "rpc.token" + tok.write_text(pw + "\n") + tok.chmod(0o600) maybe_seed_store(node_dir) mp = node_dir / "mempool" @@ -624,6 +651,7 @@ def translate( listens = listen_targets(binds, port_n) if not listens: listens = [f"127.0.0.1:{port_n}"] + proxy_host = proxy_bind_host(conf.get("rpcbind") or "") cmd = [ str(node), "--network", @@ -644,6 +672,15 @@ def translate( if log_level: cmd += ["--log-level", log_level] cmd += extra_node_args + p2p_default = {"regtest": "18444", "signet": "38333", "testnet": "18333"}.get( + network, "8333" + ) + conf_path = datadir / "bitcoin.conf" + for host in read_conf_keys(conf_path, "addnode") + addnodes: + if not host: + continue + spec = host if (":" in host and not host.endswith(":")) else f"{host}:{p2p_default}" + cmd += ["--connect", spec] bound_lines = [f"Bound to {addr}" for addr in listens] @@ -655,6 +692,7 @@ def translate( bound_lines, rpc_proxy, lock_fds, + proxy_host, ) @@ -691,11 +729,13 @@ def main(argv: list[str] | None = None) -> int: bound_lines, rpc_proxy, _lock_fds, + proxy_host, ) = translate(argv) pid_path = node_dir / "bitcoind.pid" if print_cmd: print(" ".join(cmd)) print(f"pidfile {pid_path}", file=sys.stderr) + print(f"proxy {proxy_host}:{public_rpc}", file=sys.stderr) return 0 pid_path.write_text(f"{os.getpid()}\n") # TestNode tails this process's stderr and requires it empty on a clean @@ -725,11 +765,19 @@ def main(argv: list[str] | None = None) -> int: def _pump() -> None: assert child.stdout is not None + tee = bool(os.environ.get("RBITCOIN_LOG_STDOUT")) for raw in iter(child.stdout.readline, b""): log_f.write(raw) + if tee: + sys.stdout.buffer.write(raw) + sys.stdout.buffer.flush() text = raw.decode("utf-8", errors="replace") for mapped in apply_line(text, rules): - log_f.write((mapped + "\n").encode()) + line = (mapped + "\n").encode() + log_f.write(line) + if tee: + sys.stdout.buffer.write(line) + sys.stdout.buffer.flush() log_f.flush() pump = threading.Thread(target=_pump, daemon=True) @@ -804,7 +852,7 @@ def main(argv: list[str] | None = None) -> int: return _child_init_exit() try: - proxy = RpcProxy(("127.0.0.1", public_rpc), node_url, _cookie) + proxy = RpcProxy((proxy_host, public_rpc), node_url, _cookie) except OSError: if child.poll() is None: child.terminate() diff --git a/scripts/core-functional/bitcoind.test.sh b/scripts/core-functional/bitcoind.test.sh index 9b2a8731c..a3f0c2cdc 100755 --- a/scripts/core-functional/bitcoind.test.sh +++ b/scripts/core-functional/bitcoind.test.sh @@ -585,6 +585,61 @@ else FAIL=$((FAIL + 1)) fi +# Warnet Helm conf: rpcbind all-interfaces for the proxy; P2P bind still +# follows -bind / bare -port (0.0.0.0). TestNode extra_conf bind=127.0.0.1 stays loopback. +WARNET_DD="$WORKDIR/warnet-dd" +mkdir -p "$WARNET_DD" +cat >"$WARNET_DD/bitcoin.conf" <<'EOF' +rpcbind=0.0.0.0 +rpcport=18443 +listen=1 +[regtest] +rpcuser=user +rpcpassword=secret0 +addnode=tank-0001 +EOF +OUTW="$("$SHIM" --print-cmd -datadir="$WARNET_DD" -regtest 2>"$WORKDIR/warnet.err")" || OUTW="" +if printf '%s' "$OUTW" | grep -q -- "--listen 0.0.0.0:18444" \ + && printf '%s' "$OUTW" | grep -q -- "--connect tank-0001:18444" \ + && grep -q -- "proxy 0.0.0.0:18443" "$WORKDIR/warnet.err"; then + echo "ok - warnet conf listen/rpcbind all-interfaces" + PASS=$((PASS + 1)) +else + echo "not ok - warnet conf listen/rpcbind (cmd: $OUTW err: $(cat "$WORKDIR/warnet.err" 2>/dev/null))" + FAIL=$((FAIL + 1)) +fi + +# listen=1 must not override an explicit -bind (Core). +OUTWB="$("$SHIM" --print-cmd -datadir="$WARNET_DD" -regtest -bind=127.0.0.1:19333 2>/dev/null)" || OUTWB="" +if printf '%s' "$OUTWB" | grep -q -- "--listen 127.0.0.1:19333" \ + && ! printf '%s' "$OUTWB" | grep -q -- "--listen 0.0.0.0:19333"; then + echo "ok - listen=1 ignored when -bind set" + PASS=$((PASS + 1)) +else + echo "not ok - listen=1 ignored when -bind set (got: $OUTWB)" + FAIL=$((FAIL + 1)) +fi + +ENV_DD="$WORKDIR/env-datadir" +mkdir -p "$ENV_DD" +OUT_ENV="$(env BITCOIN_DATA="$ENV_DD" "$SHIM" --print-cmd -regtest 2>/dev/null)" || OUT_ENV="" +if printf '%s' "$OUT_ENV" | grep -q -- "--datadir $ENV_DD/regtest"; then + echo "ok - BITCOIN_DATA supplies default datadir" + PASS=$((PASS + 1)) +else + echo "not ok - BITCOIN_DATA default datadir (got: $OUT_ENV)" + FAIL=$((FAIL + 1)) +fi + +if [[ -f "$WARNET_DD/regtest/rpc.token" ]] \ + && grep -qx -- "secret0" "$WARNET_DD/regtest/rpc.token"; then + echo "ok - rpcpassword seeds rpc.token" + PASS=$((PASS + 1)) +else + echo "not ok - rpcpassword seeds rpc.token ($(cat "$WARNET_DD/regtest/rpc.token" 2>/dev/null || echo missing))" + FAIL=$((FAIL + 1)) +fi + # Live smoke when a real node binary is on disk (optional in this script). REAL="" if [[ -n "${RBITCOIN_NODE_REAL:-}" && -x "${RBITCOIN_NODE_REAL}" ]]; then diff --git a/scripts/core-functional/nightly.sh b/scripts/core-functional/nightly.sh index 623e9e9f5..fc37bd00a 100755 --- a/scripts/core-functional/nightly.sh +++ b/scripts/core-functional/nightly.sh @@ -12,6 +12,8 @@ cd "$ROOT" ./scripts/core-functional/init-submodule.sh "$HERE/bitcoind.test.sh" +"$HERE/bitcoin-cli.test.sh" +"$HERE/warnet/Dockerfile.test.sh" "$HERE/map_debuglog_test.sh" "$HERE/rpc_util_validateaddress.test.sh" "$HERE/check_inventory_test.sh" diff --git a/scripts/core-functional/rpc_proxy.py b/scripts/core-functional/rpc_proxy.py index 7cbb8c706..359a4516f 100644 --- a/scripts/core-functional/rpc_proxy.py +++ b/scripts/core-functional/rpc_proxy.py @@ -38,6 +38,41 @@ def node_authorization(cookie_line: str) -> str: return f"Bearer {token}" +def parse_basic_userpass(authorization: str) -> tuple[str, str] | None: + header = authorization.strip() + rest = header[6:] if header[:6].lower() == "basic " else None + if rest is None: + return None + try: + raw = base64.b64decode(rest.strip()) + s = raw.decode() + except (ValueError, UnicodeDecodeError): + return None + if ":" not in s: + return None + user, password = s.split(":", 1) + return user, password + + +def token_from_cookie_line(cookie: str) -> str: + if cookie.startswith("__cookie__:"): + return cookie.split(":", 1)[1] + return cookie + + +def authorization_ok(authorization: str, cookie_line: str | None) -> bool: + if not cookie_line: + return True + want = "Basic " + base64.b64encode(cookie_line.encode()).decode() + if authorization == want: + return True + parsed = parse_basic_userpass(authorization) + if parsed is None: + return False + _user, password = parsed + return password == token_from_cookie_line(cookie_line) + + def core_btc_kvb_to_sat_vb(value: Any) -> int: """Core `maxfeerate` BTC/kvB → node sat/vB. `>= 1` is Core `-8`.""" if value is None: @@ -165,10 +200,8 @@ def shutdown(self) -> None: def handle_http(self, raw: bytes, authorization: str) -> tuple[int, bytes]: cookie = self.cookie_line() - if cookie: - want = "Basic " + base64.b64encode(cookie.encode()).decode() - if authorization != want: - return 401, b'{"error":"unauthorized"}\n' + if not authorization_ok(authorization, cookie): + return 401, b'{"error":"unauthorized"}\n' try: payload = json.loads(raw.decode() or "null") except (UnicodeDecodeError, json.JSONDecodeError): diff --git a/scripts/core-functional/rpc_proxy.test.sh b/scripts/core-functional/rpc_proxy.test.sh index 4d05add71..1df35aa5d 100755 --- a/scripts/core-functional/rpc_proxy.test.sh +++ b/scripts/core-functional/rpc_proxy.test.sh @@ -154,6 +154,29 @@ assert body["error"] is None st, _body = call("getblockcount", auth=False) assert st == 401, st +def call_basic(user, password): + tok = base64.b64encode(f"{user}:{password}".encode()).decode() + req = urllib.request.Request( + f"http://127.0.0.1:{listen_port}/", + data=json.dumps( + {"jsonrpc": "1.0", "id": 1, "method": "getblockcount", "params": []} + ).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Basic " + tok, + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status + except urllib.error.HTTPError as e: + return e.code + +assert call_basic("user", "secret") == 200 +assert call_basic("__cookie__", "secret") == 200 +assert call_basic("user", "wrong") == 401 + proxy.register("echo", lambda p: p) st, body = call("echo") assert body["result"] == [], body diff --git a/scripts/core-functional/rpc_wallet.py b/scripts/core-functional/rpc_wallet.py index 040ebcdb4..b6745a441 100644 --- a/scripts/core-functional/rpc_wallet.py +++ b/scripts/core-functional/rpc_wallet.py @@ -133,6 +133,7 @@ def register(self) -> None: p.register("createwallet", self.createwallet) p.register("loadwallet", self.loadwallet) p.register("listwallets", self.listwallets) + p.register("listwalletdir", self.listwalletdir) p.register("getwalletinfo", self.getwalletinfo) p.register("importdescriptors", self.importdescriptors) p.register("importprivkey", self.importprivkey) @@ -206,6 +207,9 @@ def loadwallet(self, params: Any) -> dict[str, Any]: def listwallets(self, _params: Any) -> list[str]: return list(self.wallets.keys()) + def listwalletdir(self, _params: Any) -> dict[str, Any]: + return {"wallets": [{"name": n} for n in self.wallets]} + def getwalletinfo(self, _params: Any) -> dict[str, Any]: w = self._cur() bal = self._balance_sat(w, minconf=0) diff --git a/scripts/core-functional/rpc_wallet.test.sh b/scripts/core-functional/rpc_wallet.test.sh index 7506ce02c..9e7c2ae03 100755 --- a/scripts/core-functional/rpc_wallet.test.sh +++ b/scripts/core-functional/rpc_wallet.test.sh @@ -198,10 +198,21 @@ def call(method, params=None): body = call("getbalance") assert body["error"] and body["error"]["code"] == -18, body -body = call("createwallet", ["default_wallet"]) +body = call("listwalletdir") +assert body["error"] is None, body +assert body["result"] == {"wallets": []}, body + +body = call( + "createwallet", + {"wallet_name": "default_wallet", "descriptors": True, "load_on_startup": True}, +) assert body["error"] is None, body assert body["result"]["name"] == "default_wallet", body +body = call("listwalletdir") +assert body["error"] is None, body +assert body["result"]["wallets"] == [{"name": "default_wallet"}], body + from test_framework.descriptors import descsum_create desc = descsum_create(f"combo({CACHE_WIF_0})") diff --git a/scripts/core-functional/warnet/Dockerfile b/scripts/core-functional/warnet/Dockerfile new file mode 100644 index 000000000..044ed4c0f --- /dev/null +++ b/scripts/core-functional/warnet/Dockerfile @@ -0,0 +1,35 @@ +# Lab image for an all-rbitcoin Warnet tank. Not an operator Release. +# Build from the repo root after `cargo build -p rbitcoin-node` and +# `./scripts/core-functional/init-submodule.sh`: +# +# docker build -t rbitcoin-warnet:local \ +# --build-arg NODE_BIN=target/dev/debug/rbitcoin-node \ +# -f scripts/core-functional/warnet/Dockerfile . + +FROM debian:bookworm-slim + +ARG NODE_BIN=target/dev/debug/rbitcoin-node + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 ca-certificates procps \ + && rm -rf /var/lib/apt/lists/* \ + && cp /usr/bin/python3 /usr/local/bin/bitcoind + +COPY ${NODE_BIN} /usr/local/bin/rbitcoin-node +COPY scripts/core-functional/bitcoind /opt/rbitcoin/shim/bitcoind +COPY scripts/core-functional/bitcoin-cli /usr/local/bin/bitcoin-cli +COPY scripts/core-functional/*.py /opt/rbitcoin/shim/ +COPY scripts/core-functional/debuglog_map.toml /opt/rbitcoin/shim/ +COPY third_party/bitcoin/test/functional/test_framework /opt/rbitcoin/functional/test_framework +COPY scripts/core-functional/warnet/entrypoint.sh /entrypoint.sh + +RUN chmod +x /usr/local/bin/rbitcoin-node /usr/local/bin/bitcoin-cli \ + /opt/rbitcoin/shim/bitcoind /entrypoint.sh \ + && chmod 755 /usr/local/bin/bitcoind + +ENV BITCOIN_DATA=/root/.bitcoin +ENV RBITCOIN_NODE=/usr/local/bin/rbitcoin-node +ENV RBITCOIN_LOG_STDOUT=1 +ENV PYTHONPATH=/opt/rbitcoin/shim:/opt/rbitcoin/functional + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/scripts/core-functional/warnet/Dockerfile.test.sh b/scripts/core-functional/warnet/Dockerfile.test.sh new file mode 100755 index 000000000..b18ae4f3d --- /dev/null +++ b/scripts/core-functional/warnet/Dockerfile.test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Text contract for the Warnet lab image (no docker required). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +PASS=0 +FAIL=0 + +ok() { echo "ok - $1"; PASS=$((PASS + 1)); } +bad() { echo "not ok - $1"; FAIL=$((FAIL + 1)); } + +EP="$HERE/entrypoint.sh" +DF="$HERE/Dockerfile" + +if [[ -f "$EP" ]] && grep -q 'BITCOIN_DATA:-/root/.bitcoin' "$EP" \ + && grep -q 'exec' "$EP"; then + ok "entrypoint defaults -datadir to BITCOIN_DATA or /root/.bitcoin" +else + bad "entrypoint defaults -datadir to BITCOIN_DATA or /root/.bitcoin" +fi + +if [[ -f "$DF" ]] \ + && grep -q 'rbitcoin-node' "$DF" \ + && grep -q 'bitcoind' "$DF" \ + && grep -q 'bitcoin-cli' "$DF" \ + && grep -q 'test_framework' "$DF" \ + && grep -q 'RBITCOIN_LOG_STDOUT' "$DF"; then + ok "Dockerfile copies node, shims, test_framework, log tee" +else + bad "Dockerfile copies node, shims, test_framework, log tee" +fi + +echo +echo "$PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/core-functional/warnet/entrypoint.sh b/scripts/core-functional/warnet/entrypoint.sh new file mode 100755 index 000000000..5199b03b9 --- /dev/null +++ b/scripts/core-functional/warnet/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e +DATADIR="${BITCOIN_DATA:-/root/.bitcoin}" +export BITCOIN_DATA="$DATADIR" +export RBITCOIN_NODE="${RBITCOIN_NODE:-/usr/local/bin/rbitcoin-node}" +export RBITCOIN_LOG_STDOUT="${RBITCOIN_LOG_STDOUT:-1}" +export PYTHONPATH="${PYTHONPATH:-/opt/rbitcoin/shim:/opt/rbitcoin/functional}" +mkdir -p "$DATADIR" +if [ "$#" -eq 0 ]; then + exec /usr/local/bin/bitcoind /opt/rbitcoin/shim/bitcoind -datadir="$DATADIR" +fi +exec /usr/local/bin/bitcoind /opt/rbitcoin/shim/bitcoind "$@"