diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 06d613cf8..2e574604a 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -1159,6 +1159,263 @@ mod tests { assert!(cr.contains("CR"), "{cr}"); tor.apply_kv("tor_control_password", "pw").unwrap(); assert_eq!(tor.tor.password.as_deref(), Some("pw")); + + // Authority secret file, redaction, and key-utils base58check. + { + let dir = tmp_datadir(); + std::fs::create_dir_all(&dir).unwrap(); + let mut c = NodeConfig::default().with_datadir(dir.clone()); + let key = dir.join("sv2-authority.key"); + std::fs::write(&key, format!("{}\n", "07".repeat(32))).unwrap(); + let path = key.to_str().unwrap(); + assert_eq!( + c.apply_kv("sv2_tp_authority_sec_file", path).unwrap(), + ConfApply::Applied + ); + assert_eq!( + c.sv2_tp_authority_sec, + Some(crate::config::Sv2AuthoritySecret([7; 32])) + ); + let bad = dir.join("sv2-bad.key"); + std::fs::write(&bad, "00".repeat(32)).unwrap(); + let missing = dir.join("sv2-missing.key"); + for p in [&bad, &missing] { + let e = format!( + "{}", + c.apply_kv("sv2_tp_authority_sec_file", p.to_str().unwrap()) + .unwrap_err() + ); + assert!(e.contains("sv2_tp_authority_sec_file"), "{e}"); + assert!(!e.contains(&"00".repeat(32)), "never echo the key: {e}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + { + let hex = "07".repeat(32); + let mut c = NodeConfig::default(); + c.apply_kv("sv2_tp_authority_sec", &hex).unwrap(); + let dbg = format!("{c:?}"); + assert!(dbg.contains("sv2_tp_authority_sec"), "{dbg}"); + assert!(!dbg.contains("[7, 7"), "never print the key: {dbg}"); + assert!(!dbg.contains(&hex), "never print the key: {dbg}"); + let e = format!( + "{}", + c.apply_kv("sv2_tp_authority_sec_file", &hex).unwrap_err() + ); + assert!(e.contains("sv2_tp_authority_sec_file"), "{e}"); + assert!(!e.contains(&hex), "never echo the key: {e}"); + } + { + let secret = "zmBEmPhqo3A92FkiLVvyCz6htc3e53ph3ZbD4ASqGaLjwnFLi"; + let dir = tmp_datadir(); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("sv2-authority.key"); + std::fs::write(&file, format!("{secret}\n")).unwrap(); + for (k, v) in [ + ("sv2_tp_authority_sec", secret), + ("sv2_tp_authority_sec_file", file.to_str().unwrap()), + ] { + let mut c = NodeConfig::default().with_datadir(dir.clone()); + assert_eq!(c.apply_kv(k, v).unwrap(), ConfApply::Applied); + let kp = bitcoin::secp256k1::Keypair::from_seckey_slice( + &bitcoin::secp256k1::Secp256k1::new(), + &c.sv2_tp_authority_sec.unwrap().0, + ) + .unwrap(); + let mut pk = vec![1, 0]; + pk.extend(kp.x_only_public_key().0.serialize()); + assert_eq!( + bitcoin::base58::encode_check(&pk), + "9bDuixKmZqAJnrmP746n8zU1wyAQRrus7th9dxnkPg6RzQvCnan" + ); + } + let mut c = NodeConfig::default(); + let bad_check = format!("{}1", &secret[..secret.len() - 1]); + let e = format!( + "{}", + c.apply_kv("sv2_tp_authority_sec", &bad_check).unwrap_err() + ); + assert!(e.contains("sv2_tp_authority_sec"), "{e}"); + assert!(!e.contains(&bad_check), "never echo the key: {e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + // Scripthash create cap and Esplora block-template default. + { + let mut c = NodeConfig::default(); + assert_eq!(c.max_sh_creates, rbitcoin_query::DEFAULT_MAX_SH_CREATES); + assert!(!c.esplora_block_template); + assert_eq!( + c.apply_kv("max_sh_creates", "100").unwrap(), + ConfApply::Applied + ); + assert_eq!(c.max_sh_creates, 100); + match c.apply_kv("maxshcreates", "7").unwrap() { + ConfApply::Unknown(k) => assert_eq!(k, "maxshcreates"), + other => panic!("{other:?}"), + } + assert_eq!(c.apply_kv("sh_index", "1").unwrap(), ConfApply::Applied); + assert!(c.shindex); + assert_eq!(c.apply_kv("sp_tweaks", "1").unwrap(), ConfApply::Applied); + assert!(c.sptweaks); + assert_eq!( + c.apply_kv("sp_tweaks_dust", "546").unwrap(), + ConfApply::Applied + ); + assert_eq!(c.sptweaks_dust, 546); + assert_eq!(c.max_sh_creates, 100); + let bad = c.apply_kv("max_sh_creates", "nope").unwrap_err(); + assert!( + format!("{bad}").contains("max_sh_creates"), + "garbage must name the knob: {bad}" + ); + assert_eq!( + c.apply_kv("esplora_block_template", "1").unwrap(), + ConfApply::Applied + ); + assert!(c.esplora_block_template); + match c.apply_kv("esplorablocktemplate", "0").unwrap() { + ConfApply::Unknown(k) => assert_eq!(k, "esplorablocktemplate"), + other => panic!("{other:?}"), + } + assert!(c.esplora_block_template); + } + + // Default datadir is cwd-relative, with native separators. + { + let p = NodeConfig::default_datadir(); + assert_eq!(p, PathBuf::from(".").join("datadir")); + assert_eq!(NodeConfig::default().datadir.path(), p.as_path()); + let store = p.join("store"); + #[cfg(windows)] + { + let s = store.to_string_lossy(); + assert!( + !s.contains('/'), + "default datadir must use Windows separators, got {s}" + ); + assert_eq!(p.to_str(), Some(r".\datadir")); + } + #[cfg(not(windows))] + { + assert_eq!(p.to_str(), Some("./datadir")); + assert_eq!(store.to_str(), Some("./datadir/store")); + } + } + + // Inbound env is read once and not written back. This test already holds the env lock. + { + let prev_in = std::env::var_os("RBITCOIN_P2P_MAX_INBOUND"); + std::env::set_var("RBITCOIN_P2P_MAX_INBOUND", "99"); + let mut cfg = NodeConfig::default(); + assert!(!cfg.listen.max_inbound_explicit); + cfg.absorb_inbound_env(); + assert_eq!(cfg.listen.max_inbound, 99); + assert_eq!( + std::env::var("RBITCOIN_P2P_MAX_INBOUND").as_deref(), + Ok("99"), + "absorb must not rewrite process env" + ); + let mut explicit = NodeConfig::default(); + explicit.listen.max_inbound = 12; + explicit.listen.max_inbound_explicit = true; + explicit.absorb_inbound_env(); + assert_eq!( + explicit.listen.max_inbound, 12, + "explicit CLI/conf wins over env" + ); + match prev_in { + Some(v) => std::env::set_var("RBITCOIN_P2P_MAX_INBOUND", v), + None => std::env::remove_var("RBITCOIN_P2P_MAX_INBOUND"), + } + } + + // max_inbound in conf is explicit. Core maxconnections is not a node key. + { + let dir = tmp_datadir(); + std::fs::create_dir_all(&dir).unwrap(); + let conf = dir.join("mi.conf"); + std::fs::write(&conf, "max_inbound=40\n").unwrap(); + let mut cfg = NodeConfig::default().with_datadir(dir.join("d")); + cfg.merge_conf_file(&conf).unwrap(); + assert_eq!(cfg.listen.max_inbound, 40); + assert!(cfg.listen.max_inbound_explicit); + let conf2 = dir.join("mc.conf"); + std::fs::write(&conf2, "maxconnections=32\n").unwrap(); + let mut cfg2 = NodeConfig::default().with_datadir(dir.join("d2")); + cfg2.merge_conf_file(&conf2).unwrap(); + assert_eq!( + cfg2.listen.max_inbound, + crate::config::DEFAULT_MAX_INBOUND, + "Core maxconnections is unknown on the node (shim-only)" + ); + assert!(!cfg2.listen.max_inbound_explicit); + let _ = std::fs::remove_dir_all(&dir); + } + + // A file where store/ should be is a datadir error. + { + let dir = tmp_datadir(); + let cfg = NodeConfig::default().with_datadir(&dir); + cfg.ensure_datadir().unwrap(); + let _ = std::fs::remove_dir_all(dir.join("store")); + std::fs::write(dir.join("store"), b"x").unwrap(); + let err = cfg.ensure_datadir().unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("store") || msg.contains("datadir") || msg.contains("File exists"), + "{msg}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + // Cookie file is checked at validate, before RPC binds. + { + let dir = tmp_datadir(); + std::fs::create_dir_all(&dir).unwrap(); + let cookie = dir.join("rpc.cookie"); + let mut cfg = NodeConfig::default().with_datadir(dir.clone()); + cfg.rpc.cookie_file = Some(cookie.clone()); + cfg.rpc.listen_default = true; + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("read RPC cookie"), "missing file: {err}"); + std::fs::write(&cookie, "__cookie__:secret\n").unwrap(); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("line ending"), "{err}"); + std::fs::write(&cookie, "__cookie__:secret").unwrap(); + cfg.validate() + .expect("Core-format cookie with --rpc-listen"); + cfg.rpc.listen_default = false; + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("TCP only"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + // Hostnames are DNS connects. An empty connect and an onion are refused. + { + let mut cfg = NodeConfig::default(); + cfg.apply_kv("head_scale", "tiny").unwrap(); + assert_eq!(cfg.head_scale, HeadScale::Tiny); + cfg.apply_kv("connect", "tank-0001:18444").unwrap(); + cfg.apply_kv("connect", "bad").unwrap(); + cfg.apply_kv("connect", "tank-0").unwrap(); + cfg.apply_kv("connect", "127.0.0.1:18444").unwrap(); + assert!(cfg + .listen + .connect_dns + .iter() + .any(|h| h == "tank-0001:18444")); + assert!(cfg.listen.connect_dns.iter().any(|h| h == "bad")); + assert!(cfg.listen.connect_dns.iter().any(|h| h == "tank-0")); + assert_eq!(cfg.listen.connect.len(), 1); + assert!(cfg.apply_kv("connect", "").is_err()); + assert!(cfg + .apply_kv("connect", "short.onion:8333") + .unwrap_err() + .to_string() + .contains("bad")); + assert!(cfg.listen.has_pinned_connect()); + } } include!("overlay_config_journey.rs"); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 2321e9460..aa067196f 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -1700,132 +1700,6 @@ mod tests { } } - #[test] - fn sv2_tp_authority_sec_file_reads_the_key_off_argv() { - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let mut c = NodeConfig::default().with_datadir(dir.clone()); - let key = dir.join("sv2-authority.key"); - std::fs::write(&key, format!("{}\n", "07".repeat(32))).unwrap(); - let path = key.to_str().unwrap(); - assert_eq!( - c.apply_kv("sv2_tp_authority_sec_file", path).unwrap(), - ConfApply::Applied - ); - assert_eq!(c.sv2_tp_authority_sec, Some(Sv2AuthoritySecret([7; 32]))); - - let bad = dir.join("sv2-bad.key"); - std::fs::write(&bad, "00".repeat(32)).unwrap(); - let missing = dir.join("sv2-missing.key"); - for p in [&bad, &missing] { - let e = format!( - "{}", - c.apply_kv("sv2_tp_authority_sec_file", p.to_str().unwrap()) - .unwrap_err() - ); - assert!(e.contains("sv2_tp_authority_sec_file"), "{e}"); - assert!(!e.contains(&"00".repeat(32)), "never echo the key: {e}"); - } - } - - /// An operator who passes the key itself to the file knob, or logs the - /// config, must not see the key printed. - #[test] - fn sv2_tp_authority_secret_never_prints() { - let hex = "07".repeat(32); - let mut c = NodeConfig::default(); - c.apply_kv("sv2_tp_authority_sec", &hex).unwrap(); - let dbg = format!("{c:?}"); - assert!(dbg.contains("sv2_tp_authority_sec"), "{dbg}"); - assert!(!dbg.contains("[7, 7"), "never print the key: {dbg}"); - assert!(!dbg.contains(&hex), "never print the key: {dbg}"); - - let e = format!( - "{}", - c.apply_kv("sv2_tp_authority_sec_file", &hex).unwrap_err() - ); - assert!(e.contains("sv2_tp_authority_sec_file"), "{e}"); - assert!(!e.contains(&hex), "never echo the key: {e}"); - } - - /// key-utils 1.2.0 vector: SRI configs carry the authority secret as - /// `Secp256k1SecretKey` base58check; its pubkey is the one the TP prints. - #[test] - fn sv2_tp_authority_sec_takes_key_utils_base58check() { - let secret = "zmBEmPhqo3A92FkiLVvyCz6htc3e53ph3ZbD4ASqGaLjwnFLi"; - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("sv2-authority.key"); - std::fs::write(&file, format!("{secret}\n")).unwrap(); - for (k, v) in [ - ("sv2_tp_authority_sec", secret), - ("sv2_tp_authority_sec_file", file.to_str().unwrap()), - ] { - let mut c = NodeConfig::default().with_datadir(dir.clone()); - assert_eq!(c.apply_kv(k, v).unwrap(), ConfApply::Applied); - let kp = bitcoin::secp256k1::Keypair::from_seckey_slice( - &bitcoin::secp256k1::Secp256k1::new(), - &c.sv2_tp_authority_sec.unwrap().0, - ) - .unwrap(); - let mut pk = vec![1, 0]; - pk.extend(kp.x_only_public_key().0.serialize()); - assert_eq!( - bitcoin::base58::encode_check(&pk), - "9bDuixKmZqAJnrmP746n8zU1wyAQRrus7th9dxnkPg6RzQvCnan" - ); - } - let mut c = NodeConfig::default(); - let bad_check = format!("{}1", &secret[..secret.len() - 1]); - let e = format!( - "{}", - c.apply_kv("sv2_tp_authority_sec", &bad_check).unwrap_err() - ); - assert!(e.contains("sv2_tp_authority_sec"), "{e}"); - assert!(!e.contains(&bad_check), "never echo the key: {e}"); - } - - #[test] - fn max_sh_creates_and_esplora_block_template_apply_kv() { - let mut c = NodeConfig::default(); - assert_eq!(c.max_sh_creates, rbitcoin_query::DEFAULT_MAX_SH_CREATES); - assert!(!c.esplora_block_template); - assert_eq!( - c.apply_kv("max_sh_creates", "100").unwrap(), - ConfApply::Applied - ); - assert_eq!(c.max_sh_creates, 100); - match c.apply_kv("maxshcreates", "7").unwrap() { - ConfApply::Unknown(k) => assert_eq!(k, "maxshcreates"), - other => panic!("{other:?}"), - } - assert_eq!(c.apply_kv("sh_index", "1").unwrap(), ConfApply::Applied); - assert!(c.shindex); - assert_eq!(c.apply_kv("sp_tweaks", "1").unwrap(), ConfApply::Applied); - assert!(c.sptweaks); - assert_eq!( - c.apply_kv("sp_tweaks_dust", "546").unwrap(), - ConfApply::Applied - ); - assert_eq!(c.sptweaks_dust, 546); - assert_eq!(c.max_sh_creates, 100); - let bad = c.apply_kv("max_sh_creates", "nope").unwrap_err(); - assert!( - format!("{bad}").contains("max_sh_creates"), - "garbage must name the knob: {bad}" - ); - assert_eq!( - c.apply_kv("esplora_block_template", "1").unwrap(), - ConfApply::Applied - ); - assert!(c.esplora_block_template); - match c.apply_kv("esplorablocktemplate", "0").unwrap() { - ConfApply::Unknown(k) => assert_eq!(k, "esplorablocktemplate"), - other => panic!("{other:?}"), - } - assert!(c.esplora_block_template); - } - #[test] fn concatenated_index_conf_keys_are_unknown() { let mut c = NodeConfig::default(); @@ -1902,28 +1776,6 @@ mod tests { assert_eq!(c2.net_perms.whitebind.len(), 1); } - #[test] - fn default_datadir_is_native_cwd_relative() { - let p = NodeConfig::default_datadir(); - assert_eq!(p, PathBuf::from(".").join("datadir")); - assert_eq!(NodeConfig::default().datadir.path(), p.as_path()); - let store = p.join("store"); - #[cfg(windows)] - { - let s = store.to_string_lossy(); - assert!( - !s.contains('/'), - "default datadir must use Windows separators, got {s}" - ); - assert_eq!(p.to_str(), Some(r".\datadir")); - } - #[cfg(not(windows))] - { - assert_eq!(p.to_str(), Some("./datadir")); - assert_eq!(store.to_str(), Some("./datadir/store")); - } - } - #[test] fn store_layout_default_is_mainnet_tiny_builder_is_tiny() { let mainnet = NodeConfig::default(); @@ -2005,37 +1857,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Env is an input when inbound was not explicit; never published back. - #[test] - fn absorb_inbound_env_reads_but_does_not_write() { - let _g = OPERATOR_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prev_in = std::env::var_os("RBITCOIN_P2P_MAX_INBOUND"); - std::env::set_var("RBITCOIN_P2P_MAX_INBOUND", "99"); - let mut cfg = NodeConfig::default(); - assert!(!cfg.listen.max_inbound_explicit); - cfg.absorb_inbound_env(); - assert_eq!(cfg.listen.max_inbound, 99); - assert_eq!( - std::env::var("RBITCOIN_P2P_MAX_INBOUND").as_deref(), - Ok("99"), - "absorb must not rewrite process env" - ); - let mut explicit = NodeConfig::default(); - explicit.listen.max_inbound = 12; - explicit.listen.max_inbound_explicit = true; - explicit.absorb_inbound_env(); - assert_eq!( - explicit.listen.max_inbound, 12, - "explicit CLI/conf wins over env" - ); - match prev_in { - Some(v) => std::env::set_var("RBITCOIN_P2P_MAX_INBOUND", v), - None => std::env::remove_var("RBITCOIN_P2P_MAX_INBOUND"), - } - } - #[test] fn operator_knob_defaults_and_fields() { let mut cfg = NodeConfig::default(); @@ -2048,29 +1869,6 @@ mod tests { ); } - #[test] - fn max_inbound_conf_is_explicit_not_core_total_slots() { - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let conf = dir.join("mi.conf"); - std::fs::write(&conf, "max_inbound=40\n").unwrap(); - let mut cfg = NodeConfig::default().with_datadir(dir.join("d")); - cfg.merge_conf_file(&conf).unwrap(); - assert_eq!(cfg.listen.max_inbound, 40); - assert!(cfg.listen.max_inbound_explicit); - - let conf2 = dir.join("mc.conf"); - std::fs::write(&conf2, "maxconnections=32\n").unwrap(); - let mut cfg2 = NodeConfig::default().with_datadir(dir.join("d2")); - cfg2.merge_conf_file(&conf2).unwrap(); - assert_eq!( - cfg2.listen.max_inbound, DEFAULT_MAX_INBOUND, - "Core maxconnections is unknown on the node (shim-only)" - ); - assert!(!cfg2.listen.max_inbound_explicit); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn native_permission_and_ibd_knobs_apply() { let mut cfg = NodeConfig::default(); @@ -2129,45 +1927,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[test] - fn ensure_datadir_rejects_file_as_subdir() { - let dir = tmp(); - let cfg = NodeConfig::default().with_datadir(&dir); - cfg.ensure_datadir().unwrap(); - // Make store a file so recreate fails. - let _ = std::fs::remove_dir_all(dir.join("store")); - std::fs::write(dir.join("store"), b"x").unwrap(); - let err = cfg.ensure_datadir().unwrap_err(); - let msg = format!("{err}"); - assert!( - msg.contains("store") || msg.contains("datadir") || msg.contains("File exists"), - "{msg}" - ); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn validate_checks_rpc_cookie_at_startup() { - let dir = tmp(); - std::fs::create_dir_all(&dir).unwrap(); - let cookie = dir.join("rpc.cookie"); - let mut cfg = NodeConfig::default().with_datadir(dir.clone()); - cfg.rpc.cookie_file = Some(cookie.clone()); - cfg.rpc.listen_default = true; - let err = cfg.validate().unwrap_err().to_string(); - assert!(err.contains("read RPC cookie"), "missing file: {err}"); - std::fs::write(&cookie, "__cookie__:secret\n").unwrap(); - let err = cfg.validate().unwrap_err().to_string(); - assert!(err.contains("line ending"), "{err}"); - std::fs::write(&cookie, "__cookie__:secret").unwrap(); - cfg.validate() - .expect("Core-format cookie with --rpc-listen"); - cfg.rpc.listen_default = false; - let err = cfg.validate().unwrap_err().to_string(); - assert!(err.contains("TCP only"), "{err}"); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn validate_rejects_zero_peer_caps_and_empty_datadir() { let mut cfg = NodeConfig::default(); @@ -2285,30 +2044,4 @@ mod tests { 1 ); } - - #[test] - fn connect_hostname_is_dns_not_netaddr() { - let mut cfg = NodeConfig::default(); - cfg.apply_kv("head_scale", "tiny").unwrap(); - assert_eq!(cfg.head_scale, HeadScale::Tiny); - cfg.apply_kv("connect", "tank-0001:18444").unwrap(); - cfg.apply_kv("connect", "bad").unwrap(); - cfg.apply_kv("connect", "tank-0").unwrap(); - cfg.apply_kv("connect", "127.0.0.1:18444").unwrap(); - assert!(cfg - .listen - .connect_dns - .iter() - .any(|h| h == "tank-0001:18444")); - assert!(cfg.listen.connect_dns.iter().any(|h| h == "bad")); - assert!(cfg.listen.connect_dns.iter().any(|h| h == "tank-0")); - assert_eq!(cfg.listen.connect.len(), 1); - assert!(cfg.apply_kv("connect", "").is_err()); - assert!(cfg - .apply_kv("connect", "short.onion:8333") - .unwrap_err() - .to_string() - .contains("bad")); - assert!(cfg.listen.has_pinned_connect()); - } }