feat: fix Tor rotation to handle system Tor and hostname caching

read_onion_address() now checks tor-hostnames readable cache first,
clears cache before wait_for_hostname, updates it after rotation.
Rotation restarts system Tor (not just archy-tor container). Created
test-tor-rotation.sh with 10 automated checks (INSTALL-03).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 03:32:21 +00:00
parent 1ac6034457
commit a98529868e
3 changed files with 187 additions and 15 deletions

View File

@@ -161,28 +161,58 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Failed to rename hidden service directory for rotation"));
}
info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting container");
// Clear the readable tor-hostnames cache so wait_for_hostname reads the new key
let hostnames_dir = std::path::Path::new(&base)
.parent()
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
.join("tor-hostnames");
let _ = tokio::fs::remove_file(hostnames_dir.join(name)).await;
// Restart archy-tor container so Tor generates new keys
let restart_status = tokio::process::Command::new("sudo")
.args(["podman", "restart", "archy-tor"])
info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting Tor");
// Try system Tor first (hidden services may be in /etc/tor/torrc), then container
let system_ok = tokio::process::Command::new("sudo")
.args(["systemctl", "restart", "tor"])
.status()
.await
.context("Failed to restart archy-tor container")?;
.map(|s| s.success())
.unwrap_or(false);
if !restart_status.success() {
warn!("Failed to restart archy-tor container after rotation");
// Try to restore old directory
let _ = tokio::process::Command::new("sudo")
.args(["mv", &old_dir, &service_dir])
if !system_ok {
// Fall back to container restart
let container_ok = tokio::process::Command::new("sudo")
.args(["podman", "restart", "archy-tor"])
.status()
.await;
return Err(anyhow::anyhow!("Failed to restart Tor — rotation rolled back"));
.await
.map(|s| s.success())
.unwrap_or(false);
if !container_ok {
warn!("Failed to restart Tor after rotation");
let _ = tokio::process::Command::new("sudo")
.args(["mv", &old_dir, &service_dir])
.status()
.await;
return Err(anyhow::anyhow!("Failed to restart Tor — rotation rolled back"));
}
}
// Wait up to 60s for new hostname file to appear
let new_onion = wait_for_hostname(name, 60).await;
// Update the readable tor-hostnames copy
if let Some(ref new_addr) = new_onion {
let hostnames_dir = std::path::Path::new(&base)
.parent()
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
.join("tor-hostnames");
if let Err(e) = tokio::fs::create_dir_all(&hostnames_dir).await {
warn!("Failed to create tor-hostnames dir: {}", e);
}
if let Err(e) = tokio::fs::write(hostnames_dir.join(name), new_addr).await {
warn!("Failed to update tor-hostnames copy: {}", e);
}
}
// Propagate address change to Nostr relays and federation peers (fire-and-forget)
if let Some(ref new_addr) = new_onion {
let data_dir = self.config.data_dir.clone();
@@ -386,12 +416,39 @@ async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>>
}
/// Read .onion address from hostname file.
/// Checks tor-hostnames readable copy first, then hidden service dir (with sudo fallback).
fn read_onion_address(service_name: &str) -> Option<String> {
let path = std::path::Path::new(&tor_data_dir())
let base = tor_data_dir();
let base_path = std::path::Path::new(&base);
// Try readable hostname copy first (system Tor owns hidden_service dirs at 0700)
let hostnames_dir = base_path
.parent()
.unwrap_or(std::path::Path::new("/var/lib/archipelago"))
.join("tor-hostnames")
.join(service_name);
if let Some(addr) = std::fs::read_to_string(&hostnames_dir)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
{
return Some(addr);
}
// Fall back to hidden service directory (direct read, then sudo)
let path = base_path
.join(format!("hidden_service_{}", service_name))
.join("hostname");
std::fs::read_to_string(path)
std::fs::read_to_string(&path)
.ok()
.or_else(|| {
std::process::Command::new("sudo")
.args(["cat", &path.to_string_lossy()])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
})
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
}