From f04941934b10af6716f0038258525454836db0b2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 2 Aug 2026 23:49:00 -0400 Subject: [PATCH] feat(tor): give archy-net containers a SOCKS path so Core can use Tor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling half of "Bitcoin Core has no Tor proxy at all", handed over from the app-UI work. Core reported `onion reachable=False, proxy=''` with all 11 peers on clearnet, and the reason was not a missing bitcoind flag: the container sits on the archy-net bridge (10.89.0.0/24 here), so 127.0.0.1:9050 inside it is its OWN loopback. The host's Tor was genuinely unreachable, and no flag on bitcoind could have fixed that alone. torrc now binds a second SOCKS listener on the archy-net gateway. The gateway is DERIVED at runtime via `podman network inspect`, never hardcoded: archy-net is created without an explicit subnet, so podman allocates one. It is 10.89.0.0/24 on this node with no guarantee of that elsewhere, and a hardcoded guess would fail silently — binding SOCKS to an address no container can reach, which looks identical to working. Two deliberate safety properties: - FAIL CLOSED. If archy-net is absent or its inspect output does not parse, no second listener is emitted and SOCKS stays loopback-only. An exposure boundary is not something to widen on a guess. - 127.0.0.1 is accepted FIRST in the SocksPolicy. SocksPolicy applies to every SocksPort, so an accept-list naming only the bridge subnet would have locked the daemon out of its own loopback SOCKS — breaking the node's Tor usage in a way that looks nothing like "we added a listener". The list is accept-loopback, accept-subnet, reject *. This widens Tor SOCKS from loopback-only to the archy-net subnet, which is a real change to the node's exposure surface and was explicitly approved by the operator rather than assumed. Inbound onion for Core remains impossible without reversing the deliberate "ControlPort disabled for security" decision — this is outbound only, and the node stays unlisted on Tor. Not yet wired: bitcoind still has no -onion flag, because the operator wants network mode to be a UI setting with Tor rather than clearnet as the default. Hardcoding the flag in the three places that currently define bitcoind's arguments would be the wrong shape for that, so it is deferred to the settings work rather than done twice. Co-Authored-By: Claude Opus 5 (1M context) --- core/archipelago/src/api/rpc/tor/mod.rs | 66 ++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) 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;