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 <noreply@anthropic.com>
This commit is contained in:
parent
83c0092f1b
commit
f1ca60e732
@ -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,
|
||||
|
||||
@ -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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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_<timestamp> 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<serde_json::Value> {
|
||||
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::<u64>() {
|
||||
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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user