diff --git a/Cargo.lock b/Cargo.lock index 700ca890e..692440ec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -435,6 +436,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -829,6 +839,8 @@ name = "rbitcoin-node" version = "0.7.99" dependencies = [ "bitcoin", + "getrandom 0.3.4", + "hmac", "libc", "mimalloc", "rbitcoin-consensus", @@ -842,6 +854,7 @@ dependencies = [ "rbitcoin-rpc", "rbitcoin-store", "serde_json", + "sha2", "tokio", ] @@ -1034,6 +1047,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha3" version = "0.10.9" @@ -1082,6 +1106,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" diff --git a/OPERATOR.md b/OPERATOR.md index 511a0c839..3f2f353e7 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -373,6 +373,9 @@ Clean smoke: | `--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) | +| `--tor-control [HOST:PORT]` | `tor_control=` | unset — no control connection; omit ADDR → `127.0.0.1:9051` | +| `--tor-control-cookie PATH` | `tor_control_cookie=` | `/run/tor/control.authcookie` when `--tor-control` is set and password is unset | +| `--tor-control-password PASS` | `tor_control_password=` | unset — cookie AUTH unless set | | `--milestone HEIGHT` | `milestone=` | network default (mainnet 840000) | | `--max-outbound N` | `max_outbound=` | 16 live download peers | | `--max-inbound N` | `max_inbound=` | 125 inbound sessions; **0** = no inbound slots (outbound-only) | @@ -448,6 +451,15 @@ 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. +`--tor-control [HOST:PORT]` talks to **system tor** (SAFECOOKIE/COOKIE or password). Omit +ADDR for `127.0.0.1:9051`. Failed AUTH is a start error. Unset: no control +socket. With `--electrum-listen`, the node `ADD_ONION`s that TCP port to +`127.0.0.1:` and logs `….onion:port`. The private key is +`{datadir}/onion/electrum.priv` (0600). `server.features.hosts` is +`{ ".onion": { "tcp_port": N } }` with no `ssl_port`. JSON-RPC stays off +the onion (`rpc.sock` / `--rpc-listen` only). Cookie path differs by distro; +pass `--tor-control-cookie` rather than globbing. + `--datadir` holds the node root (`store/`, `mempool/`, `peers`, `rpc.token`, `rpc.sock`). Omit `--datadir-cold` and cold files live there too. Set it to put the large rarely-read Class A **inwit** stem (`inwit.body` + `inwit.loc`, ~486 GiB + loc diff --git a/crates/rbitcoin-electrum/src/server.rs b/crates/rbitcoin-electrum/src/server.rs index afba31310..215a5ea45 100644 --- a/crates/rbitcoin-electrum/src/server.rs +++ b/crates/rbitcoin-electrum/src/server.rs @@ -14,7 +14,7 @@ use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; @@ -133,6 +133,8 @@ pub struct ElectrumConfig { /// Omit served P2TR outs with `value <=` this (sats). `0` serves all. /// Default [`crate::tweaks::DEFAULT_TWEAKS_MIN_DUST`]. pub tweaks_min_dust: u64, + /// Tor v3 hostname + TCP port for `server.features.hosts` (empty until set). + pub onion_tcp: Arc>, } impl ElectrumConfig { @@ -149,6 +151,7 @@ impl ElectrumConfig { max_broadcast_hex: DEFAULT_MAX_BROADCAST_HEX, tweaks_chunk: crate::tweaks::SUBSCRIBE_CHUNK, tweaks_min_dust: crate::tweaks::DEFAULT_TWEAKS_MIN_DUST, + onion_tcp: Arc::new(OnceLock::new()), } } @@ -1428,6 +1431,13 @@ fn sh_at_view( live_fn(query, sh_join) } +fn features_hosts_json(config: &ElectrumConfig) -> Value { + match config.onion_tcp.get() { + Some((host, port)) => json!({ host: { "tcp_port": port } }), + None => json!({}), + } +} + #[allow(clippy::too_many_arguments)] // call-site args stay unbundled fn dispatch_pinned( method: &str, @@ -1454,7 +1464,7 @@ fn dispatch_pinned( "server.donation_address" => Ok(json!(config.donation_address)), "server.features" => Ok(json!({ "genesis_hash": config.genesis_hash_hex, - "hosts": {}, + "hosts": features_hosts_json(config), "protocol_max": PROTOCOL_MAX, "protocol_min": PROTOCOL_MIN, "server_version": SERVER_VERSION, diff --git a/crates/rbitcoin-electrum/src/server_tests.rs b/crates/rbitcoin-electrum/src/server_tests.rs index 6fe2bfe80..6203928e3 100644 --- a/crates/rbitcoin-electrum/src/server_tests.rs +++ b/crates/rbitcoin-electrum/src/server_tests.rs @@ -488,6 +488,7 @@ async fn accept_client_ping_and_shutdown() { assert_eq!(features["chain_tip"], json!(true)); assert_eq!(features["asof"], json!(true)); assert_eq!(features["asof_protocol"], PROTOCOL_ASOF); + assert_eq!(features["hosts"], json!({})); let probe = electrum_tcp_rpc( &mut stream, @@ -1953,6 +1954,56 @@ fn broadcast_hex_cap_enforced() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn features_hosts_onion_tcp() { + let (dir, q) = tmp_store(); + let params = ChainParams::regtest(); + let cfg = ElectrumConfig::for_params("127.0.0.1:0".parse().unwrap(), ¶ms); + let mut header_sub = false; + let mut sh_subs = HashSet::new(); + let empty = dispatch( + "server.features", + &json!([]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!(empty["hosts"], json!({})); + cfg.onion_tcp + .set(( + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion".into(), + 50001, + )) + .unwrap(); + let got = dispatch( + "server.features", + &json!([]), + &q, + &cfg, + ¶ms, + None, + &mut header_sub, + &mut sh_subs, + ) + .unwrap(); + assert_eq!( + got["hosts"], + json!({ + "pg6mmjiyjmcrsslvykfwnntlaru7p5svn6y2ymmju6nubxndf4pscryd.onion": { "tcp_port": 50001 } + }) + ); + assert!(got["hosts"] + .as_object() + .unwrap() + .values() + .all(|v| v.get("ssl_port").is_none())); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn serve_limits_public_proxy_defaults() { let lim = ServeLimits::for_public_proxy(); diff --git a/crates/rbitcoin-node/Cargo.toml b/crates/rbitcoin-node/Cargo.toml index 9c78e92b0..77757f4b2 100644 --- a/crates/rbitcoin-node/Cargo.toml +++ b/crates/rbitcoin-node/Cargo.toml @@ -26,6 +26,9 @@ bitcoin = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } mimalloc = { workspace = true } +hmac = "0.12" +sha2 = "0.10" +getrandom = "0.3" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 0e99523cd..2b8d7a9a7 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -294,6 +294,7 @@ fn operator_usage() -> String { rbitcoin-node [--conf FILE] [--datadir PATH] [--datadir-cold PATH] [--network NET] \\\n\ [--signet-challenge HEX] [--signet-block-time SECS] \\\n\ [--listen ADDR] [--no-listen] [--connect ADDR]... [--seed-node HOST]... [--proxy HOST:PORT] [--onion HOST:PORT] [--proxy-randomize[=0|1]] [--only-net NET]... \\\n\ + [--tor-control [HOST:PORT]] [--tor-control-cookie PATH] [--tor-control-password PASS] \\\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\ @@ -321,6 +322,9 @@ Mempool: --mempool-size-mb (default ~300 MiB weight budget).\n\ Peers: --max-outbound (default 16 live download), --max-inbound (default 125).\n\ --proxy HOST:PORT SOCKS5 for all P2P outbound; --onion HOST:PORT SOCKS for onion (02).\n\ --proxy-randomize (default on) uses a fresh SOCKS username per peer (Tor circuit isolation).\n\ + --tor-control [HOST:PORT] talks to system tor (default 127.0.0.1:9051). Cookie or password AUTH;\n\ + failed AUTH is a start error. Unset: no control connection.\n\ + --tor-control-cookie PATH (default /run/tor/control.authcookie). --tor-control-password PASS.\n\ --trusted / --always-relay / --relay are inbound permission knobs.\n\ --net-permission / --net-permission-bind are CIDR or bind grants (noban, relay, …; IPv4 and IPv6).\n\ --net-permission-relay (default on) / --net-permission-force-relay (default off) are implicit bits on a bare CIDR grant.\n\ @@ -387,7 +391,10 @@ fn is_bool_key(key: &str) -> bool { } fn is_optional_addr_key(key: &str) -> bool { - matches!(key, "rpc_listen" | "electrum_listen" | "esplora_listen") + matches!( + key, + "rpc_listen" | "electrum_listen" | "esplora_listen" | "tor_control" + ) } fn looks_like_flag(s: &str) -> bool { @@ -571,6 +578,9 @@ mod tests { "--proxy-randomize", "--no-listen", "--no-discover", + "--tor-control", + "--tor-control-cookie", + "--tor-control-password", ] { assert!(h.contains(flag), "help must list {flag}"); } @@ -602,6 +612,7 @@ mod tests { "--whitelist-forcerelay", "--nolisten", "--nodiscover", + "--torcontrol", ] { assert!(!h.contains(concat), "help must not advertise {concat}"); } @@ -853,6 +864,36 @@ mod tests { ); } + #[test] + fn tor_control_cli_defaults() { + let omitted = ready_config(["rbitcoin-node", "--tor-control"]); + assert_eq!(omitted.tor.control, Some("127.0.0.1:9051".parse().unwrap())); + assert!(omitted.tor.cookie.is_none()); + assert!(omitted.tor.password.is_none()); + let explicit = ready_config(["rbitcoin-node", "--tor-control", "10.0.0.5:9151"]); + assert_eq!(explicit.tor.control, Some("10.0.0.5:9151".parse().unwrap())); + let cookie = ready_config([ + "rbitcoin-node", + "--tor-control", + "--tor-control-cookie", + "/tmp/rbtc-tor-cookie", + ]); + assert_eq!( + cookie.tor.cookie.as_deref(), + Some(std::path::Path::new("/tmp/rbtc-tor-cookie")) + ); + let mut conf = NodeConfig::default(); + conf.apply_kv("tor_control", "").unwrap(); + conf.apply_kv("tor_control_password", "pw").unwrap(); + assert_eq!(conf.tor.control, Some("127.0.0.1:9051".parse().unwrap())); + assert_eq!(conf.tor.password.as_deref(), Some("pw")); + let h = operator_usage(); + assert!(h.contains("--tor-control")); + assert!(h.contains("--tor-control-cookie")); + assert!(h.contains("--tor-control-password")); + assert!(!h.contains("--torcontrol")); + } + #[test] fn no_discover_conf() { let _g = OPERATOR_ENV_TEST_LOCK.lock().unwrap(); diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index 614f13a43..8327bfd08 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -175,6 +175,24 @@ impl Default for MempoolOpts { } } +/// System tor control port (cookie or password AUTH). +#[derive(Clone, Default, PartialEq, Eq)] +pub struct TorControlOpts { + pub control: Option, + pub cookie: Option, + pub password: Option, +} + +impl std::fmt::Debug for TorControlOpts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TorControlOpts") + .field("control", &self.control) + .field("cookie", &self.cookie) + .field("password", &self.password.as_ref().map(|_| "****")) + .finish() + } +} + /// JSON-RPC listen and auth. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RpcOpts { @@ -203,6 +221,7 @@ pub struct NodeConfig { pub listen: ListenOpts, pub mempool: MempoolOpts, pub rpc: RpcOpts, + pub tor: TorControlOpts, pub network: Network, /// Custom BIP325 challenge. `None` selects the default global Signet. pub signet_challenge: Option, @@ -286,6 +305,7 @@ impl Default for NodeConfig { listen: ListenOpts::default(), mempool: MempoolOpts::default(), rpc: RpcOpts::default(), + tor: TorControlOpts::default(), network: Network::Mainnet, signet_challenge: None, signet_block_time: None, @@ -712,6 +732,24 @@ impl NodeConfig { "onion" => { self.listen.onion = Some(parse_required_socket(val, "onion")?); } + "tor_control" => { + self.tor.control = Some(if val.is_empty() { + crate::tor_control::default_control_addr() + } else { + parse_required_socket(val, "tor_control")? + }); + } + "tor_control_cookie" => { + if val.is_empty() { + return Err(NodeError::Config( + "conf tor_control_cookie requires a path".into(), + )); + } + self.tor.cookie = Some(PathBuf::from(val)); + } + "tor_control_password" => { + self.tor.password = Some(val.to_string()); + } "proxy_randomize" => { self.listen.proxy_randomize = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf proxy_randomize: {e}")))?; diff --git a/crates/rbitcoin-node/src/lib.rs b/crates/rbitcoin-node/src/lib.rs index 0d4cf4f9f..dd24d8009 100644 --- a/crates/rbitcoin-node/src/lib.rs +++ b/crates/rbitcoin-node/src/lib.rs @@ -7,8 +7,9 @@ mod inhibit; mod lock; mod regtest_rpc; mod run; +mod tor_control; pub use cli::cli_main; -pub use config::{DatadirOpts, ListenOpts, MempoolOpts, NodeConfig, RpcOpts}; +pub use config::{DatadirOpts, ListenOpts, MempoolOpts, NodeConfig, RpcOpts, TorControlOpts}; pub use error::NodeError; pub use run::{run_node, run_p2p, NodeHandle}; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index c22661f00..16b8fa513 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -19,7 +19,7 @@ use rbitcoin_store::StoreError; use std::net::SocketAddr; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use tokio::sync::{broadcast, Notify}; @@ -355,6 +355,21 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { let shutdown = Shutdown::new(); spawn_signal_handler(shutdown.clone()); + let mut tor_ctl = crate::tor_control::TorControl::connect_if_configured( + config.tor.control, + config.tor.cookie.as_deref(), + config.tor.password.as_deref(), + ) + .await?; + if tor_ctl.is_some() { + info!( + "tor control authenticated on {}", + config + .tor + .control + .expect("control addr set when session exists") + ); + } // One Class B appender thread. Join it at shutdown so apply does not race flush. let sh_writebehind = if config.shindex { Some(spawn_sh_writebehind( @@ -600,7 +615,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { } } - let (electrum_handles, electrum_bridge) = start_electrum_if_ready( + let (electrum_handles, electrum_bridge, electrum_onion) = start_electrum_if_ready( sh_tip_ready, config.listen.electrum, config.sptweaks_dust, @@ -610,6 +625,17 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { &mempool, ) .await; + if let (Some(ctl), Some(h)) = (tor_ctl.as_mut(), electrum_handles.first()) { + let hs = ctl + .add_electrum_onion(config.datadir.path(), h.local_addr) + .await?; + info!( + "electrum onion {}.onion:{}", + hs.service_id, + h.local_addr.port() + ); + let _ = electrum_onion.set((format!("{}.onion", hs.service_id), h.local_addr.port())); + } let (esplora_handles, esplora_tip_bridge) = start_esplora_if_ready( sh_tip_ready, config.listen.esplora.clone(), @@ -1291,12 +1317,17 @@ async fn start_electrum_if_ready( hub: &ChainHub, params: &rbitcoin_consensus::ChainParams, mempool: &std::sync::Arc, -) -> (Vec, Option>) { +) -> ( + Vec, + Option>, + Arc>, +) { + let onion_tcp = Arc::new(OnceLock::new()); let Some(addr) = addr else { - return (Vec::new(), None); + return (Vec::new(), None, onion_tcp); }; if !sh_tip_ready || shutdown.requested() { - return (Vec::new(), None); + return (Vec::new(), None, onion_tcp); } let q = hub.query.clone(); let (electrum_tip_tx, _) = broadcast::channel::(64); @@ -1309,6 +1340,7 @@ async fn start_electrum_if_ready( ); let mut ecfg = ElectrumConfig::for_params(addr, params); ecfg.tweaks_min_dust = tweaks_min_dust; + ecfg.onion_tcp = Arc::clone(&onion_tcp); let max_conn = ecfg.limits.max_connections; let max_line = ecfg.limits.max_request_bytes; let idle_secs = ecfg.limits.idle_timeout.as_secs(); @@ -1326,11 +1358,11 @@ async fn start_electrum_if_ready( "electrum TCP on {} (Query + mempool; max_conn={} max_line={} idle={}s; TLS via reverse proxy if public)", h.local_addr, max_conn, max_line, idle_secs ); - (vec![h], Some(bridge)) + (vec![h], Some(bridge), onion_tcp) } Err(e) => { warn!("electrum TCP start warning: {e}"); - (Vec::new(), Some(bridge)) + (Vec::new(), Some(bridge), onion_tcp) } } } diff --git a/crates/rbitcoin-node/src/tor_control.rs b/crates/rbitcoin-node/src/tor_control.rs new file mode 100644 index 000000000..7c500354a --- /dev/null +++ b/crates/rbitcoin-node/src/tor_control.rs @@ -0,0 +1,769 @@ +//! Tor control-port AUTH (cookie or password) for hidden-service setup. + +use crate::error::NodeError; +use bitcoin::hex::DisplayHex; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::io::Write; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::net::TcpStream; + +type HmacSha256 = Hmac; + +const SAFECOOKIE_SERVER_KEY: &[u8] = b"Tor safe cookie authentication server-to-controller hash"; +const SAFECOOKIE_CLIENT_KEY: &[u8] = b"Tor safe cookie authentication controller-to-server hash"; + +pub const DEFAULT_CONTROL_PORT: u16 = 9051; +pub const DEFAULT_COOKIE_PATH: &str = "/run/tor/control.authcookie"; + +pub fn default_control_addr() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], DEFAULT_CONTROL_PORT)) +} + +#[derive(Clone, Debug)] +pub enum TorAuth { + Cookie(PathBuf), + Password(String), +} + +pub struct TorControl { + reader: BufReader, + writer: OwnedWriteHalf, +} + +impl TorControl { + pub async fn connect_and_auth(addr: SocketAddr, auth: TorAuth) -> Result { + let stream = TcpStream::connect(addr) + .await + .map_err(|e| NodeError::Init(format!("tor control connect {addr}: {e}")))?; + let (r, w) = stream.into_split(); + let mut ctl = Self { + reader: BufReader::new(r), + writer: w, + }; + ctl.authenticate(&auth).await?; + ctl.command("GETINFO version").await?; + Ok(ctl) + } + + pub async fn connect_if_configured( + addr: Option, + cookie: Option<&Path>, + password: Option<&str>, + ) -> Result, NodeError> { + let Some(addr) = addr else { + return Ok(None); + }; + let auth = match password { + Some(p) if !p.is_empty() => TorAuth::Password(p.to_string()), + _ => TorAuth::Cookie( + cookie + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(DEFAULT_COOKIE_PATH)), + ), + }; + Ok(Some(Self::connect_and_auth(addr, auth).await?)) + } + + async fn authenticate(&mut self, auth: &TorAuth) -> Result { + match auth { + TorAuth::Password(p) => { + let line = format!("AUTHENTICATE \"{}\"", escape_quoted(p)); + self.command(&line).await + } + TorAuth::Cookie(path) => { + let info = self.protocol_auth_info().await?; + if info.methods.iter().any(|m| m == "SAFECOOKIE") { + self.authenticate_safecookie(path).await + } else { + self.authenticate_cookie(path).await + } + } + } + } + + async fn authenticate_cookie(&mut self, path: &Path) -> Result { + let bytes = std::fs::read(path) + .map_err(|e| NodeError::Init(format!("tor control cookie {}: {e}", path.display())))?; + let line = format!("AUTHENTICATE {}", bytes.to_lower_hex_string()); + self.command(&line).await + } + + async fn authenticate_safecookie(&mut self, path: &Path) -> Result { + let cookie = std::fs::read(path) + .map_err(|e| NodeError::Init(format!("tor control cookie {}: {e}", path.display())))?; + if cookie.len() != 32 { + return self.authenticate_cookie(path).await; + } + let mut client_nonce = [0u8; 32]; + getrandom::fill(&mut client_nonce).map_err(|e| { + NodeError::Init(format!("tor control SAFECOOKIE client nonce rng: {e}")) + })?; + let reply = self + .command(&format!( + "AUTHCHALLENGE SAFECOOKIE {}", + client_nonce.to_lower_hex_string() + )) + .await?; + let challenge = parse_authchallenge_reply(&reply)?; + let mut mat = Vec::with_capacity(96); + mat.extend_from_slice(&cookie); + mat.extend_from_slice(&client_nonce); + mat.extend_from_slice(&challenge.server_nonce); + let want_server = hmac_sha256(SAFECOOKIE_SERVER_KEY, &mat); + if want_server != challenge.server_hash { + return Err(NodeError::Init(format!( + "tor control SAFECOOKIE server hash mismatch want={} got={}", + want_server.to_lower_hex_string(), + challenge.server_hash.to_lower_hex_string() + ))); + } + let client = hmac_sha256(SAFECOOKIE_CLIENT_KEY, &mat); + self.command(&format!("AUTHENTICATE {}", client.to_lower_hex_string())) + .await + } + + async fn protocol_auth_info(&mut self) -> Result { + let body = self.command("PROTOCOLINFO 1").await?; + parse_protocol_auth_info(&body) + } + + pub async fn command(&mut self, cmd: &str) -> Result { + self.writer + .write_all(cmd.as_bytes()) + .await + .map_err(|e| NodeError::Init(format!("tor control write: {e}")))?; + self.writer + .write_all(b"\r\n") + .await + .map_err(|e| NodeError::Init(format!("tor control write: {e}")))?; + self.writer + .flush() + .await + .map_err(|e| NodeError::Init(format!("tor control write: {e}")))?; + read_reply(&mut self.reader).await + } + + pub async fn add_onion_persistent( + &mut self, + key_path: &Path, + virt: u16, + target: SocketAddr, + ) -> Result { + let stored = match std::fs::read_to_string(key_path) { + Ok(s) => { + let s = s.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + return Err(NodeError::Init(format!( + "tor control key {}: {e}", + key_path.display() + ))); + } + }; + let spec = match stored.as_deref() { + Some(k) => k.to_string(), + None => "NEW:ED25519-V3".to_string(), + }; + let cmd = format!("ADD_ONION {spec} Port={virt},{target}"); + let reply = self.command(&cmd).await?; + let hs = parse_add_onion_reply(&reply)?; + if stored.is_none() { + let Some(ref pk) = hs.private_key else { + return Err(NodeError::Init( + "tor control ADD_ONION NEW missing PrivateKey".into(), + )); + }; + write_key_file(key_path, pk)?; + } + Ok(hs) + } + + pub async fn add_electrum_onion( + &mut self, + datadir: &Path, + bound: SocketAddr, + ) -> Result { + let key_path = datadir.join("onion").join("electrum.priv"); + let virt = bound.port(); + let target = SocketAddr::from(([127, 0, 0, 1], virt)); + self.add_onion_persistent(&key_path, virt, target).await + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HiddenService { + pub service_id: String, + pub private_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProtocolAuthInfo { + methods: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SafeCookieChallenge { + server_hash: [u8; 32], + server_nonce: [u8; 32], +} + +fn escape_quoted(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' | '"' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + out +} + +async fn read_reply(reader: &mut BufReader) -> Result { + let mut body = String::new(); + loop { + let mut line = String::new(); + let n = reader + .read_line(&mut line) + .await + .map_err(|e| NodeError::Init(format!("tor control read: {e}")))?; + if n == 0 { + return Err(NodeError::Init("tor control: connection closed".into())); + } + let line = line.trim_end_matches(['\r', '\n']); + if line.len() < 4 { + return Err(NodeError::Init(format!( + "tor control: short reply `{line}`" + ))); + } + let code = &line[..3]; + let sep = line.as_bytes()[3]; + let rest = &line[4..]; + if code.as_bytes()[0] == b'5' { + return Err(NodeError::Init(format!("tor control: {line}"))); + } + match sep { + b'-' => { + body.push_str(rest); + body.push('\n'); + } + b' ' => { + if !rest.is_empty() { + if !body.is_empty() { + body.push('\n'); + } + body.push_str(rest); + } + return Ok(body); + } + _ => { + return Err(NodeError::Init(format!( + "tor control: unexpected reply `{line}`" + ))); + } + } + } +} + +fn parse_add_onion_reply(body: &str) -> Result { + let mut service_id = None; + let mut private_key = None; + for line in body.lines() { + if let Some(id) = line.strip_prefix("ServiceID=") { + service_id = Some(id.trim().to_string()); + } else if let Some(pk) = line.strip_prefix("PrivateKey=") { + private_key = Some(pk.trim().to_string()); + } + } + let Some(service_id) = service_id else { + return Err(NodeError::Init( + "tor control ADD_ONION missing ServiceID".into(), + )); + }; + Ok(HiddenService { + service_id, + private_key, + }) +} + +fn parse_protocol_auth_info(body: &str) -> Result { + for line in body.lines() { + if !line.starts_with("AUTH ") { + continue; + } + let methods = kv_token(line, "METHODS") + .ok_or_else(|| NodeError::Init(format!("tor control AUTH METHODS missing: {line}")))? + .split(',') + .map(|s| s.trim().to_ascii_uppercase()) + .filter(|s| !s.is_empty()) + .collect::>(); + return Ok(ProtocolAuthInfo { methods }); + } + Err(NodeError::Init( + "tor control PROTOCOLINFO missing AUTH line".into(), + )) +} + +fn parse_authchallenge_reply(body: &str) -> Result { + let mut server_hash = None; + let mut server_nonce = None; + for line in body.lines() { + if let Some(v) = kv_token(line, "SERVERHASH") { + server_hash = Some(hex32(v, "SERVERHASH")?); + } + if let Some(v) = kv_token(line, "SERVERNONCE") { + server_nonce = Some(hex32(v, "SERVERNONCE")?); + } + } + let Some(server_hash) = server_hash else { + return Err(NodeError::Init( + "tor control AUTHCHALLENGE missing SERVERHASH".into(), + )); + }; + let Some(server_nonce) = server_nonce else { + return Err(NodeError::Init( + "tor control AUTHCHALLENGE missing SERVERNONCE".into(), + )); + }; + Ok(SafeCookieChallenge { + server_hash, + server_nonce, + }) +} + +fn kv_token<'a>(line: &'a str, key: &str) -> Option<&'a str> { + line.split_whitespace() + .find_map(|tok| tok.strip_prefix(&format!("{key}="))) +} + +fn hex32(s: &str, field: &str) -> Result<[u8; 32], NodeError> { + if s.len() != 64 { + return Err(NodeError::Init(format!( + "tor control {field} must be 64 hex chars" + ))); + } + let mut out = [0u8; 32]; + for (i, chunk) in s.as_bytes().chunks_exact(2).enumerate() { + let hi = hex_nybble(chunk[0]) + .ok_or_else(|| NodeError::Init(format!("tor control {field} has non-hex content")))?; + let lo = hex_nybble(chunk[1]) + .ok_or_else(|| NodeError::Init(format!("tor control {field} has non-hex content")))?; + out[i] = (hi << 4) | lo; + } + Ok(out) +} + +fn hex_nybble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] { + let mut mac = HmacSha256::new_from_slice(key).expect("hmac key"); + mac.update(data); + let out = mac.finalize().into_bytes(); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&out); + arr +} + +fn write_key_file(path: &Path, key: &str) -> Result<(), NodeError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + NodeError::Init(format!("tor control key dir {}: {e}", parent.display())) + })?; + } + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts + .open(path) + .map_err(|e| NodeError::Init(format!("tor control key {}: {e}", path.display())))?; + writeln!(f, "{key}") + .map_err(|e| NodeError::Init(format!("tor control key {}: {e}", path.display())))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hex::DisplayHex; + use std::sync::{Arc, Mutex}; + use std::time::{SystemTime, UNIX_EPOCH}; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + const FAKE_SID: &str = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd"; + const FAKE_PK: &str = "ED25519-V3:dGVzdGtleWJsb2I"; + + async fn fake_control( + cookie: Option>, + password: Option, + ) -> (SocketAddr, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let log = Arc::new(Mutex::new(Vec::new())); + let log_task = Arc::clone(&log); + tokio::spawn(async move { + let (mut s, _) = listener.accept().await.unwrap(); + let (r, mut w) = s.split(); + let mut reader = BufReader::new(r); + let mut safecookie_expected: Option<[u8; 32]> = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await.unwrap() == 0 { + break; + } + let line = line.trim_end_matches(['\r', '\n']).to_string(); + log_task.lock().unwrap().push(line.clone()); + if line.eq_ignore_ascii_case("PROTOCOLINFO 1") { + if cookie.is_some() { + w.write_all( + format!( + "250-PROTOCOLINFO 1\r\n250-AUTH METHODS=COOKIE COOKIEFILE=\"{}\"\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", + DEFAULT_COOKIE_PATH + ) + .as_bytes(), + ) + .await + .unwrap(); + } else if password.is_some() { + w.write_all( + b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=HASHEDPASSWORD\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", + ) + .await + .unwrap(); + } else { + w.write_all( + b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=NULL\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", + ) + .await + .unwrap(); + } + } else if let Some(rest) = line.strip_prefix("AUTHCHALLENGE SAFECOOKIE ") { + let Some(ref cookie_bytes) = cookie else { + w.write_all(b"515 Authentication failed\r\n").await.unwrap(); + continue; + }; + let client_nonce = match hex32(rest, "CLIENTNONCE") { + Ok(v) => v, + Err(_) => { + w.write_all(b"512 Bad client nonce\r\n").await.unwrap(); + continue; + } + }; + let server_nonce = [0x5au8; 32]; + let mut mat = Vec::with_capacity(96); + mat.extend_from_slice(cookie_bytes); + mat.extend_from_slice(&client_nonce); + mat.extend_from_slice(&server_nonce); + let server_hash = hmac_sha256(SAFECOOKIE_SERVER_KEY, &mat); + safecookie_expected = Some(hmac_sha256(SAFECOOKIE_CLIENT_KEY, &mat)); + w.write_all( + format!( + "250 AUTHCHALLENGE SERVERHASH={} SERVERNONCE={}\r\n", + server_hash.to_lower_hex_string(), + server_nonce.to_lower_hex_string() + ) + .as_bytes(), + ) + .await + .unwrap(); + } else if let Some(rest) = line.strip_prefix("AUTHENTICATE ") { + let ok = if let Some(ref want) = cookie { + rest.eq_ignore_ascii_case(&want.to_lower_hex_string()) + || safecookie_expected.as_ref().is_some_and(|v| { + rest.eq_ignore_ascii_case(&v.to_lower_hex_string()) + }) + } else if let Some(ref want) = password { + rest == format!("\"{}\"", escape_quoted(want)) + } else { + false + }; + if ok { + w.write_all(b"250 OK\r\n").await.unwrap(); + } else { + w.write_all(b"515 Authentication failed\r\n").await.unwrap(); + } + } else if line.eq_ignore_ascii_case("GETINFO version") { + w.write_all(b"250-version=0.4.8.10\r\n250 OK\r\n") + .await + .unwrap(); + } else if let Some(rest) = line.strip_prefix("ADD_ONION ") { + let spec = rest.split_once(" Port=").map(|(s, _)| s).unwrap_or(rest); + if spec == "NEW:ED25519-V3" { + w.write_all( + format!("250-ServiceID={FAKE_SID}\r\n250-PrivateKey={FAKE_PK}\r\n250 OK\r\n") + .as_bytes(), + ) + .await + .unwrap(); + } else if spec == FAKE_PK { + w.write_all(format!("250-ServiceID={FAKE_SID}\r\n250 OK\r\n").as_bytes()) + .await + .unwrap(); + } else { + w.write_all(b"512 Invalid onion key\r\n").await.unwrap(); + } + } else { + w.write_all(b"510 Unrecognized command\r\n").await.unwrap(); + } + } + }); + (addr, log) + } + + async fn fake_control_bad_safecookie() -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut s, _) = listener.accept().await.unwrap(); + let (r, mut w) = s.split(); + let mut reader = BufReader::new(r); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).await.unwrap() == 0 { + break; + } + let line = line.trim_end_matches(['\r', '\n']).to_string(); + if line.eq_ignore_ascii_case("PROTOCOLINFO 1") { + w.write_all( + b"250-PROTOCOLINFO 1\r\n250-AUTH METHODS=SAFECOOKIE\r\n250-VERSION Tor=\"0.4.8.10\"\r\n250 OK\r\n", + ) + .await + .unwrap(); + } else if line.starts_with("AUTHCHALLENGE SAFECOOKIE ") { + let bad_hash = [0u8; 32]; + let nonce = [1u8; 32]; + w.write_all( + format!( + "250 AUTHCHALLENGE SERVERHASH={} SERVERNONCE={}\r\n", + bad_hash.to_lower_hex_string(), + nonce.to_lower_hex_string() + ) + .as_bytes(), + ) + .await + .unwrap(); + } else if line.starts_with("AUTHENTICATE ") { + w.write_all(b"515 Authentication failed\r\n").await.unwrap(); + } else { + w.write_all(b"510 Unrecognized command\r\n").await.unwrap(); + } + } + }); + addr + } + + fn tmp_cookie(bytes: &[u8]) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "rbtc-tor-cookie-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&p, bytes).unwrap(); + p + } + + fn tmp_key_path() -> PathBuf { + std::env::temp_dir().join(format!( + "rbtc-tor-onion-{}-{}/electrum.priv", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[tokio::test] + async fn tor_control_auth_cookie_and_password() { + let cookie = vec![0x2a; 32]; + let (addr, log) = fake_control(Some(cookie.clone()), None).await; + let path = tmp_cookie(&cookie); + TorControl::connect_and_auth(addr, TorAuth::Cookie(path.clone())) + .await + .unwrap(); + let cmds = log.lock().unwrap().clone(); + assert!(cmds.iter().any(|c| c == "PROTOCOLINFO 1"), "{cmds:?}"); + let _ = std::fs::remove_file(&path); + + let (addr, _) = fake_control(None, Some("s3cret".into())).await; + TorControl::connect_and_auth(addr, TorAuth::Password("s3cret".into())) + .await + .unwrap(); + + let (addr, _) = fake_control(Some(cookie.clone()), None).await; + let bad = tmp_cookie(&[0x00; 32]); + let err = match TorControl::connect_and_auth(addr, TorAuth::Cookie(bad.clone())).await { + Err(e) => e, + Ok(_) => panic!("wrong cookie must not authenticate"), + }; + let msg = format!("{err}"); + assert!( + msg.contains("515") || msg.contains("Authentication failed"), + "{msg}" + ); + let _ = std::fs::remove_file(&bad); + } + + #[tokio::test] + async fn tor_add_onion_new_persists_key() { + let cookie = vec![0x11, 0x22]; + let (addr, _) = fake_control(Some(cookie.clone()), None).await; + let cookie_path = tmp_cookie(&cookie); + let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) + .await + .unwrap(); + let key_path = tmp_key_path(); + let target: SocketAddr = "127.0.0.1:50001".parse().unwrap(); + let hs = ctl + .add_onion_persistent(&key_path, 50001, target) + .await + .unwrap(); + assert_eq!(hs.service_id, FAKE_SID); + let stored = std::fs::read_to_string(&key_path).unwrap(); + assert_eq!(stored.trim(), FAKE_PK); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + let _ = std::fs::remove_file(&cookie_path); + let _ = std::fs::remove_dir_all(key_path.parent().unwrap()); + } + + #[tokio::test] + async fn tor_add_onion_reuse_key_same_id() { + let cookie = vec![0x33, 0x44]; + let (addr, log) = fake_control(Some(cookie.clone()), None).await; + let cookie_path = tmp_cookie(&cookie); + let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) + .await + .unwrap(); + let key_path = tmp_key_path(); + let target: SocketAddr = "127.0.0.1:50001".parse().unwrap(); + let first = ctl + .add_onion_persistent(&key_path, 50001, target) + .await + .unwrap(); + let second = ctl + .add_onion_persistent(&key_path, 50001, target) + .await + .unwrap(); + assert_eq!(first.service_id, second.service_id); + assert_eq!(second.service_id, FAKE_SID); + let cmds = log.lock().unwrap().clone(); + let onions: Vec<_> = cmds + .iter() + .filter(|c| c.starts_with("ADD_ONION ")) + .cloned() + .collect(); + assert_eq!(onions.len(), 2, "{onions:?}"); + assert!( + onions[0].starts_with("ADD_ONION NEW:ED25519-V3 "), + "{}", + onions[0] + ); + assert!( + onions[1].starts_with(&format!("ADD_ONION {FAKE_PK} ")), + "{}", + onions[1] + ); + let _ = std::fs::remove_file(&cookie_path); + let _ = std::fs::remove_dir_all(key_path.parent().unwrap()); + } + + #[tokio::test] + async fn tor_control_auth_fail_is_start_error() { + let cookie = vec![0xaa]; + let (addr, _) = fake_control(Some(cookie.clone()), None).await; + let bad = tmp_cookie(&[0x00]); + let err = + match TorControl::connect_if_configured(Some(addr), Some(bad.as_path()), None).await { + Err(e) => e, + Ok(_) => panic!("bad cookie must fail start"), + }; + let msg = format!("{err}"); + assert!( + msg.contains("515") + || msg.contains("Authentication failed") + || msg.contains("tor control"), + "{msg}" + ); + let none = TorControl::connect_if_configured(None, None, None) + .await + .unwrap(); + assert!(none.is_none()); + let _ = std::fs::remove_file(&bad); + } + + #[tokio::test] + async fn tor_control_safecookie_serverhash_mismatch_fails() { + let cookie = vec![0x11; 32]; + let addr = fake_control_bad_safecookie().await; + let path = tmp_cookie(&cookie); + let err = match TorControl::connect_and_auth(addr, TorAuth::Cookie(path.clone())).await { + Ok(_) => panic!("bad SAFECOOKIE server hash must fail"), + Err(e) => e, + }; + let msg = format!("{err}"); + assert!(msg.contains("server hash mismatch"), "{msg}"); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn electrum_hidden_service_add_onion_when_listening() { + let cookie = vec![0x55, 0x66]; + let (addr, log) = fake_control(Some(cookie.clone()), None).await; + let cookie_path = tmp_cookie(&cookie); + let mut ctl = TorControl::connect_and_auth(addr, TorAuth::Cookie(cookie_path.clone())) + .await + .unwrap(); + let dir = std::env::temp_dir().join(format!( + "rbtc-tor-electrum-hs-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let bound: SocketAddr = "127.0.0.1:50001".parse().unwrap(); + let hs = ctl.add_electrum_onion(&dir, bound).await.unwrap(); + assert_eq!(hs.service_id, FAKE_SID); + let cmds = log.lock().unwrap().clone(); + let onion = cmds + .iter() + .find(|c| c.starts_with("ADD_ONION ")) + .cloned() + .expect("ADD_ONION"); + assert!(onion.contains("Port=50001,127.0.0.1:50001"), "{onion}"); + assert!(std::path::Path::new(&dir.join("onion").join("electrum.priv")).is_file()); + let _ = std::fs::remove_file(&cookie_path); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/nix/modules/rbitcoin.nix b/nix/modules/rbitcoin.nix index ba42cd26e..214f0f539 100644 --- a/nix/modules/rbitcoin.nix +++ b/nix/modules/rbitcoin.nix @@ -39,6 +39,15 @@ let else "${address}:${toString port}"; + needTorControl = cfg.tor.control != null || cfg.electrum.hiddenService; + torControlAddr = + if cfg.tor.control != null then + cfg.tor.control + else if cfg.electrum.hiddenService then + "127.0.0.1:9051" + else + null; + command = [ "${cfg.package}/bin/rbitcoin-node" "--datadir" @@ -78,6 +87,10 @@ let ++ optional (cfg.onionProxy != null) cfg.onionProxy ++ optional (!cfg.proxyRandomize) "--proxy-randomize=0" ++ lib.concatMap (n: [ "--only-net" n ]) cfg.onlyNet + ++ optional (torControlAddr != null) "--tor-control" + ++ optional (torControlAddr != null) torControlAddr + ++ optional (cfg.tor.controlCookie != null) "--tor-control-cookie" + ++ optional (cfg.tor.controlCookie != null) (toString cfg.tor.controlCookie) ++ cfg.extraArgs; in { @@ -167,6 +180,22 @@ in description = "Additional command-line arguments appended after module-managed arguments."; }; + tor = { + control = mkOption { + type = types.nullOr types.str; + default = null; + example = "127.0.0.1:9051"; + description = "System tor control HOST:PORT. Unset skips the control connection."; + }; + + controlCookie = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/tor/control.authcookie"; + description = "Tor control cookie file. Default in the node is /run/tor/control.authcookie when --tor-control is set."; + }; + }; + proxy = mkOption { type = types.nullOr types.str; default = null; @@ -271,6 +300,12 @@ in default = false; description = "Open the Electrum listen port in the NixOS firewall."; }; + + hiddenService = mkOption { + type = types.bool; + default = false; + description = "ADD_ONION for Electrum when --electrum-listen is on. Implies tor.control 127.0.0.1:9051 if unset."; + }; }; esplora = { @@ -330,8 +365,8 @@ in description = "rbitcoin full node"; documentation = [ "https://github.com/reardencode/rbitcoin/blob/master/OPERATOR.md" ]; wantedBy = [ "multi-user.target" ]; - wants = [ "network-online.target" ]; - after = [ "network-online.target" ]; + wants = [ "network-online.target" ] ++ optional needTorControl "tor.service"; + after = [ "network-online.target" ] ++ optional needTorControl "tor.service"; environment = cfg.environment; serviceConfig = { @@ -347,6 +382,9 @@ in ProtectHome = true; ProtectSystem = "strict"; ReadWritePaths = [ cfg.dataDir ] ++ optional (cfg.coldDataDir != null) cfg.coldDataDir; + } + // lib.optionalAttrs (cfg.tor.controlCookie != null) { + SupplementaryGroups = [ "tor" ]; }; }; diff --git a/nix/tests/nixos-module-eval.nix b/nix/tests/nixos-module-eval.nix index 17bc64c1a..427e5d6af 100644 --- a/nix/tests/nixos-module-eval.nix +++ b/nix/tests/nixos-module-eval.nix @@ -32,6 +32,10 @@ let onionProxy = "127.0.0.1:9050"; proxyRandomize = true; onlyNet = [ "onion" ]; + tor = { + control = "127.0.0.1:9051"; + controlCookie = "/run/tor/control.authcookie"; + }; p2p = { address = "127.0.0.1"; openFirewall = true; @@ -40,6 +44,7 @@ let electrum = { enable = true; openFirewall = true; + hiddenService = true; }; esplora = { enable = true; @@ -91,6 +96,9 @@ assert defaultCfg.proxy == null; assert defaultCfg.onionProxy == null; assert defaultCfg.proxyRandomize == true; assert defaultCfg.onlyNet == [ ]; +assert defaultCfg.tor.control == null; +assert defaultCfg.tor.controlCookie == null; +assert defaultCfg.electrum.hiddenService == false; assert cfg.services.rbitcoin.p2p.port == 18444; assert cfg.services.rbitcoin.rpc.port == 18443; assert @@ -117,6 +125,10 @@ 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 ".*--only-net onion.*" execStart != null; +assert builtins.match ".*--tor-control 127.0.0.1:9051.*" execStart != null; +assert builtins.match ".*--tor-control-cookie /run/tor/control.authcookie.*" execStart != null; +assert builtins.elem "tor.service" service.after; +assert builtins.elem "tor.service" service.wants; assert builtins.match ".*--no-listen.*" listenOffExec != null; assert builtins.match ".*--listen .*" listenOffExec == null; assert builtins.match ".*--max-inbound 0.*" listenOffExec != null; diff --git a/nix/tests/nixos-module-runtime.nix b/nix/tests/nixos-module-runtime.nix index cf15944a6..614eeea32 100644 --- a/nix/tests/nixos-module-runtime.nix +++ b/nix/tests/nixos-module-runtime.nix @@ -31,11 +31,22 @@ pkgs.testers.runNixOSTest { port = 18445; }; rpc.enable = true; + tor.control = "127.0.0.1:9051"; extraArgs = [ "--max-outbound" "4" ]; }; + + systemd.services.tor = { + description = "fake tor unit for After= ordering"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${pkgs.coreutils}/bin/true"; + }; + }; }; testScript = '' @@ -49,6 +60,9 @@ pkgs.testers.runNixOSTest { machine.succeed("grep -Fx -- '127.0.0.1:18443' /var/lib/rbitcoin-test/args") machine.succeed("grep -Fx -- '--max-outbound' /var/lib/rbitcoin-test/args") machine.succeed("grep -Fx -- '4' /var/lib/rbitcoin-test/args") + machine.succeed("grep -Fx -- '--tor-control' /var/lib/rbitcoin-test/args") + machine.succeed("grep -Fx -- '127.0.0.1:9051' /var/lib/rbitcoin-test/args") + machine.succeed("systemctl show -p After rbitcoin.service | grep -F tor.service") machine.succeed("systemctl stop rbitcoin.service") machine.succeed("test -e /var/lib/rbitcoin-test/stopped") '';