Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions OPERATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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:<bound>` and logs `….onion:port`. The private key is
`{datadir}/onion/electrum.priv` (0600). `server.features.hosts` is
`{ "<id>.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
Expand Down
14 changes: 12 additions & 2 deletions crates/rbitcoin-electrum/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<OnceLock<(String, u16)>>,
}

impl ElectrumConfig {
Expand All @@ -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()),
}
}

Expand Down Expand Up @@ -1428,6 +1431,13 @@ fn sh_at_view<T>(
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,
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions crates/rbitcoin-electrum/src/server_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(), &params);
let mut header_sub = false;
let mut sh_subs = HashSet::new();
let empty = dispatch(
"server.features",
&json!([]),
&q,
&cfg,
&params,
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,
&params,
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();
Expand Down
3 changes: 3 additions & 0 deletions crates/rbitcoin-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 42 additions & 1 deletion crates/rbitcoin-node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\
Expand Down Expand Up @@ -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\
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -602,6 +612,7 @@ mod tests {
"--whitelist-forcerelay",
"--nolisten",
"--nodiscover",
"--torcontrol",
] {
assert!(!h.contains(concat), "help must not advertise {concat}");
}
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 38 additions & 0 deletions crates/rbitcoin-node/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SocketAddr>,
pub cookie: Option<PathBuf>,
pub password: Option<String>,
}

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 {
Expand Down Expand Up @@ -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<ScriptBuf>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}")))?;
Expand Down
3 changes: 2 additions & 1 deletion crates/rbitcoin-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Loading
Loading