fix(tor): stop listing/pre-baking hidden services for uninstalled apps (#79)

Backend: tor.list-services now hides services whose name maps to a
known-but-uninstalled catalog app (aliases covered: bitcoin/knots/core,
electrumx/electrs, btcpay, mempool). The node's own service, the relay,
and custom user services always show.

ISO: first-boot tor setup no longer pre-creates hidden services for six
apps that may never be installed — only the node's own onion is baked.
It also only seeds services.json/torrc on FIRST boot instead of
clobbering backend-managed services on every boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-20 01:32:33 -04:00
parent 9a7331cead
commit 163bc3af01
3 changed files with 84 additions and 34 deletions

View File

@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
impl RpcHandler {
/// List all configured hidden services with their .onion addresses.
/// Services for known-but-uninstalled apps are hidden (issue #79).
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
let config_dir = self.config.data_dir.join("tor-config");
let services = list_services(&config_dir).await?;
let (data, _) = self.state_manager.get_snapshot().await;
let mut apps = AppInstallState {
known: Default::default(),
installed: Default::default(),
};
for (id, pkg) in &data.package_data {
apps.known.insert(id.clone());
if pkg.installed.is_some() {
apps.installed.insert(id.clone());
}
}
let services = list_services(&config_dir, Some(&apps)).await?;
let tor_running = check_tor_running().await;
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
}

View File

@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
// ─── Service Listing ─────────────────────────────────────────────
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
/// Which packages the node knows about and which are installed — used to
/// hide hidden services for apps that aren't installed. ISO first-boot used
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
/// never installed (issue #79).
pub(super) struct AppInstallState {
pub known: std::collections::HashSet<String>,
pub installed: std::collections::HashSet<String>,
}
/// Package ids a Tor service name may correspond to. Service names predate
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
/// can map to several package ids.
fn service_alias_candidates(name: &str) -> Vec<&str> {
match name {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
}
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx", "electrs", "mempool-electrs"]
}
"btcpay" | "btcpay-server" | "btcpayserver" => {
vec!["btcpay", "btcpay-server", "btcpayserver"]
}
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
other => vec![other],
}
}
impl AppInstallState {
/// A service is listed unless it names a known-but-uninstalled app.
/// The node's own service, the content relay, and custom user-created
/// services (names matching no catalog package) always show.
fn service_visible(&self, name: &str) -> bool {
if name == "archipelago" || name == "relay" {
return true;
}
let candidates = service_alias_candidates(name);
if !candidates.iter().any(|c| self.known.contains(*c)) {
return true; // not an app — custom hidden service
}
candidates.iter().any(|c| self.installed.contains(*c))
}
}
pub(super) async fn list_services(
config_dir: &std::path::Path,
apps: Option<&AppInstallState>,
) -> Result<Vec<TorService>> {
let base = detect_hidden_service_base();
let config = load_services_config(config_dir).await;
let mut services = Vec::new();
let mut seen = std::collections::HashSet::new();
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
for entry in &config.services {
let onion = read_onion_address(&entry.name).await;
seen.insert(entry.name.clone());
if !visible(&entry.name) {
continue;
}
let onion = read_onion_address(&entry.name).await;
services.push(TorService {
name: entry.name.clone(),
local_port: entry.local_port,
@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
if seen.contains(&service_name) {
continue;
}
seen.insert(service_name.clone());
if !visible(&service_name) {
continue;
}
let onion = read_onion_address(&service_name).await;
let port = known_service_port(&service_name);
seen.insert(service_name.clone());
let is_proto = is_protocol_service(&service_name);
services.push(TorService {
name: service_name,

View File

@ -1658,17 +1658,17 @@ LOG="/var/log/archipelago-tor.log"
mkdir -p "$ARCHY_TOR_DIR" "$TOR_CONFIG_DIR"
# Write services.json for the backend to read
# First boot only: seed services.json + torrc. The unit runs on EVERY boot
# (oneshot, multi-user.target), and rewriting these unconditionally clobbered
# hidden services the backend added after app installs. Only the node's own
# service is pre-baked — apps get their hidden service created on install
# (auto_add_tor_service / tor.create-service), never pre-created for apps
# that may never be installed (issue #79).
if [ ! -f "$TOR_CONFIG_DIR/services.json" ]; then
cat > "$ARCHY_TOR_DIR/services.json" <<TORJSON
{
"services": [
{"name": "archipelago", "local_port": 80, "enabled": true},
{"name": "bitcoin", "local_port": 8333, "enabled": true},
{"name": "electrumx", "local_port": 50001, "enabled": true},
{"name": "lnd", "local_port": 9735, "enabled": true},
{"name": "btcpay", "local_port": 23000, "enabled": true},
{"name": "mempool", "local_port": 4080, "enabled": true},
{"name": "fedimint", "local_port": 8175, "enabled": true}
{"name": "archipelago", "local_port": 80, "enabled": true}
]
}
TORJSON
@ -1688,33 +1688,16 @@ SocksPolicy reject *
HiddenServiceDir $TOR_DIR/hidden_service_archipelago
HiddenServicePort 80 127.0.0.1:80
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
HiddenServicePort 8333 127.0.0.1:8333
HiddenServicePort 8332 127.0.0.1:8332
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
HiddenServicePort 50001 127.0.0.1:50001
HiddenServiceDir $TOR_DIR/hidden_service_lnd
HiddenServicePort 9735 127.0.0.1:9735
HiddenServicePort 8080 127.0.0.1:8080
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
HiddenServicePort 23000 127.0.0.1:23000
HiddenServiceDir $TOR_DIR/hidden_service_mempool
HiddenServicePort 4080 127.0.0.1:4080
HiddenServiceDir $TOR_DIR/hidden_service_fedimint
HiddenServicePort 8175 127.0.0.1:8175
HiddenServiceDir $TOR_DIR/hidden_service_relay
HiddenServicePort 7777 127.0.0.1:7777
TORRC
else
echo "$(date): tor already initialized — leaving services.json/torrc alone" >> "$LOG"
fi
# Create hidden service dirs with correct ownership and permissions (700, not 750)
# Tor refuses to start if permissions are too permissive
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
for svc in archipelago relay; do
mkdir -p "$TOR_DIR/hidden_service_$svc"
chown debian-tor:debian-tor "$TOR_DIR/hidden_service_$svc"
chmod 700 "$TOR_DIR/hidden_service_$svc"
@ -1759,7 +1742,7 @@ done
# Sync hostnames to backend-readable directory
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
mkdir -p "$HOSTNAMES_DIR"
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
for svc in archipelago relay; do
if [ -f "$TOR_DIR/hidden_service_${svc}/hostname" ]; then
cp "$TOR_DIR/hidden_service_${svc}/hostname" "$HOSTNAMES_DIR/$svc"
echo "$(date): Synced hostname: $svc" >> "$LOG"