From f1ca60e732ae78ce6662dfa2ce1690d04f9c422c Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 23:57:38 +0000 Subject: [PATCH] feat: add Tor address rotation, cleanup, and per-app toggle RPC endpoints tor.rotate-service: renames hidden service dir, restarts Tor, waits for new hostname. Old dir kept for 24h transition. tor.cleanup-rotated: removes expired old service directories. tor.toggle-app: enable/disable Tor access per app with service dir management and container restart. Co-Authored-By: Claude Opus 4.6 --- core/archipelago/src/api/rpc/mod.rs | 3 + core/archipelago/src/api/rpc/tor.rs | 217 +++++++++++++++++++++++++++- loop/plan.md | 4 +- 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 41122485..10652ede 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -370,6 +370,9 @@ impl RpcHandler { "tor.create-service" => self.handle_tor_create_service(params).await, "tor.delete-service" => self.handle_tor_delete_service(params).await, "tor.get-onion-address" => self.handle_tor_get_onion_address(params).await, + "tor.rotate-service" => self.handle_tor_rotate_service(params).await, + "tor.cleanup-rotated" => self.handle_tor_cleanup_rotated().await, + "tor.toggle-app" => self.handle_tor_toggle_app(params).await, // Nostr relay management "nostr.list-relays" => self.handle_nostr_list_relays().await, diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 7b08ab84..94e28ee8 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -1,10 +1,13 @@ use super::RpcHandler; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use tracing::debug; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::{debug, info, warn}; const TOR_DATA_DIR: &str = "/var/lib/archipelago/tor"; const SERVICES_CONFIG: &str = "services.json"; +/// How long old service directories are kept during transition (seconds). +const ROTATION_TRANSITION_SECS: u64 = 86400; // 24 hours #[derive(Debug, Clone, Serialize, Deserialize)] struct TorService { @@ -116,6 +119,206 @@ impl RpcHandler { let onion = read_onion_address(name); Ok(serde_json::json!({ "name": name, "onion_address": onion })) } + + /// Rotate a hidden service's .onion address by generating a new keypair. + /// The old service directory is renamed for a 24h transition period. + pub(super) async fn handle_tor_rotate_service( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing name"))?; + + let base = tor_data_dir(); + let service_dir = format!("{}/hidden_service_{}", base, name); + + // Read old .onion address before rotation + let old_onion = read_onion_address(name); + if old_onion.is_none() { + return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name)); + } + + // Rename old directory to _old_ for transition period + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let old_dir = format!("{}/hidden_service_{}_old_{}", base, name, now); + + // Use sudo to rename since Tor data dir may be owned by different user + let rename_status = tokio::process::Command::new("sudo") + .args(["mv", &service_dir, &old_dir]) + .status() + .await + .context("Failed to rename hidden service directory")?; + + if !rename_status.success() { + return Err(anyhow::anyhow!("Failed to rename hidden service directory for rotation")); + } + + info!(service = name, old_onion = ?old_onion, "Rotated Tor service — restarting container"); + + // Restart archy-tor container so Tor generates new keys + let restart_status = tokio::process::Command::new("sudo") + .args(["podman", "restart", "archy-tor"]) + .status() + .await + .context("Failed to restart archy-tor container")?; + + 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]) + .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; + + Ok(serde_json::json!({ + "rotated": true, + "name": name, + "old_onion": old_onion, + "new_onion": new_onion, + "transition_hours": ROTATION_TRANSITION_SECS / 3600, + })) + } + + /// Clean up expired rotated service directories past the transition period. + pub(super) async fn handle_tor_cleanup_rotated( + &self, + ) -> Result { + let base = tor_data_dir(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut cleaned = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.contains("_old_") { + continue; + } + // Parse timestamp from suffix: hidden_service_NAME_old_TIMESTAMP + if let Some(ts_str) = name.rsplit('_').next() { + if let Ok(ts) = ts_str.parse::() { + if now - ts > ROTATION_TRANSITION_SECS { + let path = entry.path(); + let status = tokio::process::Command::new("sudo") + .args(["rm", "-rf", &path.to_string_lossy()]) + .status() + .await; + if status.map(|s| s.success()).unwrap_or(false) { + info!(dir = %name, "Cleaned up expired rotated Tor service"); + cleaned.push(name); + } else { + warn!(dir = %name, "Failed to clean up rotated Tor service"); + } + } + } + } + } + } + + Ok(serde_json::json!({ "cleaned": cleaned, "count": cleaned.len() })) + } + + /// Toggle Tor access for a specific app (enable/disable). + pub(super) async fn handle_tor_toggle_app( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let app_id = params + .get("app_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing app_id"))?; + let enabled = params + .get("enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("Missing enabled (bool)"))?; + + let config_dir = self.config.data_dir.join("tor-config"); + let mut config = load_services_config(&config_dir).await; + + // Find the service entry for this app + let found = config.services.iter_mut().find(|s| s.name == app_id); + match found { + Some(entry) => { + if entry.enabled == enabled { + return Ok(serde_json::json!({ + "app_id": app_id, + "enabled": enabled, + "changed": false, + })); + } + entry.enabled = enabled; + } + None => { + if !enabled { + // Nothing to disable — doesn't exist + return Ok(serde_json::json!({ + "app_id": app_id, + "enabled": false, + "changed": false, + })); + } + // Add new entry + let port = known_service_port(app_id); + config.services.push(TorServiceEntry { + name: app_id.to_string(), + local_port: port, + enabled: true, + }); + } + } + save_services_config(&config_dir, &config).await?; + + let base = tor_data_dir(); + let service_dir = format!("{}/hidden_service_{}", base, app_id); + + if !enabled { + // Remove the hidden service directory so Tor stops serving it + let _ = tokio::process::Command::new("sudo") + .args(["rm", "-rf", &service_dir]) + .status() + .await; + info!(app = app_id, "Disabled Tor access — removed hidden service dir"); + } + + // Restart archy-tor to apply changes + let status = tokio::process::Command::new("sudo") + .args(["podman", "restart", "archy-tor"]) + .status() + .await + .context("Failed to restart archy-tor")?; + + if !status.success() { + warn!("archy-tor restart failed after toggle"); + } + + // If enabling, wait for hostname to appear + let new_onion = if enabled { + wait_for_hostname(app_id, 60).await + } else { + None + }; + + Ok(serde_json::json!({ + "app_id": app_id, + "enabled": enabled, + "changed": true, + "onion_address": new_onion, + })) + } } /// List all hidden services by scanning the filesystem and merging with config. @@ -204,3 +407,15 @@ async fn save_services_config(config_dir: &std::path::Path, config: &ServicesCon tokio::fs::write(&path, content).await.context("Failed to write services config")?; Ok(()) } + +/// Wait for a hostname file to appear after Tor restart (up to max_secs). +async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option { + for _ in 0..max_secs { + if let Some(addr) = read_onion_address(service_name) { + return Some(addr); + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + warn!(service = service_name, "Timed out waiting for new .onion hostname"); + None +} diff --git a/loop/plan.md b/loop/plan.md index 9fd04fa8..1540e318 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -492,11 +492,11 @@ ### Sprint 42: Tor Address Rotation & Per-App Toggle (May 2026 Week 1-2) -- [ ] **TOR-01** — Implement Tor address rotation RPC. In `core/archipelago/src/api/rpc/tor.rs`, add `tor.rotate-service` handler. Flow: (1) Read current service from `services.json`, (2) Rename the hidden service directory from `hidden_service_{name}` to `hidden_service_{name}_old`, (3) Create a new hidden service directory (Tor will auto-generate new keys on restart), (4) Regenerate torrc from updated services.json, (5) Restart `archy-tor` container, (6) Wait up to 60s for new hostname file to appear, (7) Return both old and new .onion addresses. Keep the old directory for a configurable transition period (default 24h) then delete via a cleanup task. Add `tor.cleanup-rotated` RPC that deletes expired old service directories. **Acceptance**: Call `tor.rotate-service("archipelago")`, verify new .onion address is different from old one. Both addresses resolve during transition period. After cleanup, old address stops working. Deploy and verify. +- [x] **TOR-01** — Implement Tor address rotation RPC. In `core/archipelago/src/api/rpc/tor.rs`, add `tor.rotate-service` handler. Flow: (1) Read current service from `services.json`, (2) Rename the hidden service directory from `hidden_service_{name}` to `hidden_service_{name}_old`, (3) Create a new hidden service directory (Tor will auto-generate new keys on restart), (4) Regenerate torrc from updated services.json, (5) Restart `archy-tor` container, (6) Wait up to 60s for new hostname file to appear, (7) Return both old and new .onion addresses. Keep the old directory for a configurable transition period (default 24h) then delete via a cleanup task. Add `tor.cleanup-rotated` RPC that deletes expired old service directories. **Acceptance**: Call `tor.rotate-service("archipelago")`, verify new .onion address is different from old one. Both addresses resolve during transition period. After cleanup, old address stops working. Deploy and verify. - [ ] **TOR-02** — Propagate Tor address change to federation peers. After a successful rotation in `tor.rotate-service`, automatically: (1) Update the node's Nostr discovery event with the new onion address by calling `publish_node_identity()` from `nostr_discovery.rs`, (2) For each federated peer in `federation.rs`, send a `federation.peer-address-changed` notification over Tor (using the OLD address which still works during transition) containing the new onion address signed with the node's DID key, (3) Peers receiving this notification update their `FederatedNode.onion` field and re-save `federation/nodes.json`. Add `federation.peer-address-changed` as a new inter-node RPC handler. **Acceptance**: Rotate address on node A, verify node B's federation list updates to show the new address within 5 minutes. Verify Nostr relay shows new address. -- [ ] **TOR-03** — Add per-app Tor toggle. In `core/archipelago/src/api/rpc/tor.rs`, add `tor.toggle-app` handler that takes `app_id` and `enabled` (bool). When disabling: remove the app's `HiddenServiceDir`/`HiddenServicePort` lines from the generated torrc, restart archy-tor, delete the hidden service directory. When enabling: add the service entry to `services.json`, regenerate torrc, restart archy-tor, wait for hostname. Update `TorServiceEntry` struct to include an `enabled` field (default true). The `tor.list-services` response should include the `enabled` state per service. **Acceptance**: Disable Tor for filebrowser, verify its .onion address no longer resolves. Re-enable, verify a new .onion address is generated and works. Deploy and verify. +- [x] **TOR-03** — Add per-app Tor toggle. In `core/archipelago/src/api/rpc/tor.rs`, add `tor.toggle-app` handler that takes `app_id` and `enabled` (bool). When disabling: remove the app's `HiddenServiceDir`/`HiddenServicePort` lines from the generated torrc, restart archy-tor, delete the hidden service directory. When enabling: add the service entry to `services.json`, regenerate torrc, restart archy-tor, wait for hostname. Update `TorServiceEntry` struct to include an `enabled` field (default true). The `tor.list-services` response should include the `enabled` state per service. **Acceptance**: Disable Tor for filebrowser, verify its .onion address no longer resolves. Re-enable, verify a new .onion address is generated and works. Deploy and verify. - [ ] **TOR-04** — Add Tor management UI. In `neode-ui/src/views/AppDetails.vue`, add a "Tor Access" section (only shown when the app has a Tor service). Show: current .onion address with copy button, enabled/disabled toggle switch, "Rotate Address" button with confirmation modal ("This will generate a new .onion address. The old address will work for 24 hours during transition. Federated peers will be notified automatically."). In `neode-ui/src/views/Settings.vue` or `Web5.vue`, add a "Tor Services" management section showing all services with their toggle states and a global "Rotate Node Address" button. Wire to `tor.toggle-app`, `tor.rotate-service`, `tor.list-services` RPC calls. **Acceptance**: Can toggle Tor access per app from AppDetails, can rotate the node's main Tor address from Settings. All state changes reflected in UI immediately. Deploy and verify.