feat(tor): give archy-net containers a SOCKS path so Core can use Tor

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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 23:49:00 -04:00
co-authored by Claude Opus 5
parent fbec70069f
commit f04941934b
+64 -2
View File
@@ -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;