diff --git a/core/archipelago/src/api/rpc/tor/mod.rs b/core/archipelago/src/api/rpc/tor/mod.rs index aad7dcec..7b3cc5af 100644 --- a/core/archipelago/src/api/rpc/tor/mod.rs +++ b/core/archipelago/src/api/rpc/tor/mod.rs @@ -222,6 +222,18 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re lines.push("# ControlPort disabled for security".to_string()); lines.push(String::new()); + // Ports whose manifests declare `auth: gated` forward to the gate's own + // loopback (127.0.0.2, where the app-gate listener binds — see + // `appgate::listener::GATE_TOR_UPSTREAM`) instead of the app's 127.0.0.1. + // Tor carries no session cookie, so an onion pointed at the app is an + // unauthenticated bypass of the gate. Declared-gated ports only: an + // undeclared port keeps today's target, because absence of the field is + // not an instruction (the v1.7.121 incident rule). + let gated_ports: std::collections::HashSet = crate::appgate::identity::build_port_map() + .gated_ports() + .map(|g| g.port) + .collect(); + for svc in &config.services { if !svc.enabled { continue; @@ -240,7 +252,7 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string()); } } else { - lines.push(format!("HiddenServicePort 80 127.0.0.1:{}", svc.local_port)); + lines.push(app_hidden_service_port_line(svc.local_port, &gated_ports)); } lines.push(String::new()); @@ -248,6 +260,24 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re let content = lines.join("\n"); let staging = "/var/lib/archipelago/tor-config/torrc.staged"; + write_staged_torrc(&content, staging).await +} + +/// The `HiddenServicePort` line for an HTTP app onion. Gated ports forward to +/// the gate's Tor upstream; everything else to the app itself. +fn app_hidden_service_port_line( + local_port: u16, + gated_ports: &std::collections::HashSet, +) -> String { + let upstream = if gated_ports.contains(&local_port) { + crate::appgate::listener::GATE_TOR_UPSTREAM.to_string() + } else { + "127.0.0.1".to_string() + }; + format!("HiddenServicePort 80 {}:{}", upstream, local_port) +} + +async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> { let config_dir = Path::new(staging) .parent() .unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config")); @@ -256,14 +286,37 @@ pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Re .await .context("Failed to write staged torrc")?; - debug!( - "Staged torrc with {} enabled services", - config.services.iter().filter(|s| s.enabled).count() - ); + debug!("Staged torrc ({} bytes)", content.len()); Ok(()) } +#[cfg(test)] +mod torrc_tests { + use super::app_hidden_service_port_line; + use std::collections::HashSet; + + #[test] + fn gated_port_forwards_to_the_gate_not_the_app() { + let gated: HashSet = [8082u16].into_iter().collect(); + assert_eq!( + app_hidden_service_port_line(8082, &gated), + "HiddenServicePort 80 127.0.0.2:8082" + ); + } + + #[test] + fn undeclared_port_keeps_the_app_loopback_target() { + // Absence of `auth: gated` is not an instruction — the onion keeps + // pointing at the app, exactly as before this change. + let gated: HashSet = [8082u16].into_iter().collect(); + assert_eq!( + app_hidden_service_port_line(9100, &gated), + "HiddenServicePort 80 127.0.0.1:9100" + ); + } +} + // ─── Hostname Sync ─────────────────────────────────────────────── pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) { diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 10af2946..4e1a198d 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -44,6 +44,15 @@ use tracing::{debug, info, warn}; /// apps are installed while the daemon runs. const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); +/// The gate's own loopback address, distinct from the app's `127.0.0.1`. +/// +/// Tor cannot present a session cookie, so `HiddenServicePort → 127.0.0.1` +/// reaches the app around the gate. Instead torrc forwards gated ports to +/// this address (`api/rpc/tor`), where the gate — not the app — listens. A +/// second loopback address rather than a second port number, so no app needs +/// a port it did not declare. +pub const GATE_TOR_UPSTREAM: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 2)); + /// A port the gate should own but could not claim, and why. #[derive(Debug, Clone, serde::Serialize)] pub struct UnprotectedPort { @@ -192,6 +201,10 @@ async fn sweep( let mut claimed_any = false; let mut blocked = false; + // External addresses first, then the gate's Tor upstream. 127.0.0.2 + // deliberately does NOT count toward `claimed_any`: the warning below + // is about external exposure, and a port whose only claim is the Tor + // loopback is still wide open on the LAN. for &addr in &addresses { let key = (app.port, addr); if held.contains_key(&key) { @@ -214,6 +227,23 @@ async fn sweep( Err(_) => blocked = true, } } + let tor_key = (app.port, GATE_TOR_UPSTREAM); + if held.contains_key(&tor_key) { + claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); + } else { + match TcpListener::bind(SocketAddr::new(GATE_TOR_UPSTREAM, app.port)).await { + Ok(listener) => { + held.insert(tor_key, ()); + claimed.push((app.port, GATE_TOR_UPSTREAM.to_string())); + info!( + port = app.port, app = %app.app_id, + "app gate claimed the Tor upstream (127.0.0.2)" + ); + spawn_accept_loop(listener, gate.clone(), app.clone(), shutdown_rx.clone()); + } + Err(_) => blocked = true, + } + } if blocked && !claimed_any { warn!(