diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index 00a01447..aad7dcec 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -151,15 +151,77 @@ pub(super) fn detect_hidden_service_base() -> String { "/var/lib/tor".to_string() } +/// The `archy-net` bridge's gateway address and subnet, if the network exists. +/// +/// Derived at runtime, never hardcoded: `archy-net` is created without an +/// explicit subnet (see docker-compose.yml), so podman allocates one — it is +/// 10.89.0.0/24 on archi-dev-box but there is no guarantee of that on another +/// node, and a hardcoded guess would fail silently by binding SOCKS to an +/// address no container can reach. +/// +/// Returns `None` when the network is absent or unparseable, which callers must +/// treat as "do not widen SOCKS" — failing closed keeps Tor loopback-only +/// rather than guessing at an exposure boundary. +async fn archy_net_gateway_and_subnet() -> Option<(String, String)> { + let out = tokio::process::Command::new("podman") + .args([ + "network", + "inspect", + "archy-net", + "--format", + "{{range .Subnets}}{{.Gateway}} {{.Subnet}}{{end}}", + ]) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut parts = text.split_whitespace(); + let gateway = parts.next()?.to_string(); + let subnet = parts.next()?.to_string(); + if gateway.is_empty() || subnet.is_empty() { + return None; + } + Some((gateway, subnet)) +} + pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> { let base = detect_hidden_service_base(); let mut lines = vec![ "# Auto-generated by Archipelago — do not edit manually".to_string(), "SocksPort 9050".to_string(), - "# ControlPort disabled for security".to_string(), - String::new(), ]; + // Containerised apps (Bitcoin Core/Knots) sit on the archy-net bridge, so + // 127.0.0.1:9050 inside them is their OWN loopback — the host's Tor is + // genuinely unreachable, which is why Core reported `onion reachable=False, + // proxy=''` with every peer on clearnet. Bind a second SOCKS listener on the + // bridge gateway so those containers have a Tor path at all. + // + // SocksPolicy is applied as an explicit accept-list terminated by a reject. + // 127.0.0.1 MUST be accepted first: SocksPolicy applies to every SocksPort, + // so an accept-list naming only the bridge subnet would lock the daemon out + // of its own loopback SOCKS — the node's Tor usage would break in a way that + // looks nothing like "we added a listener". + match archy_net_gateway_and_subnet().await { + Some((gateway, subnet)) => { + lines.push(format!("SocksPort {gateway}:9050")); + lines.push("SocksPolicy accept 127.0.0.1/32".to_string()); + lines.push(format!("SocksPolicy accept {subnet}")); + lines.push("SocksPolicy reject *".to_string()); + } + None => { + lines.push( + "# archy-net not found — SOCKS stays loopback-only (fail closed)".to_string(), + ); + } + } + + lines.push("# ControlPort disabled for security".to_string()); + lines.push(String::new()); + for svc in &config.services { if !svc.enabled { continue;