diff --git a/core/archipelago/src/api/rpc/federation.rs b/core/archipelago/src/api/rpc/federation.rs index 5b646813..e3c15d76 100644 --- a/core/archipelago/src/api/rpc/federation.rs +++ b/core/archipelago/src/api/rpc/federation.rs @@ -323,4 +323,46 @@ impl RpcHandler { info!(app = %app_id, peer = %peer_did, "Deployed app to federated peer"); Ok(result) } + + /// federation.peer-address-changed — A peer notifies us that their .onion changed. + pub(super) async fn handle_federation_peer_address_changed( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let did = params + .get("did") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing did"))?; + let new_onion = params + .get("new_onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?; + + // Load existing nodes, find the peer by DID, update their onion + let mut nodes = federation::load_nodes(&self.config.data_dir).await?; + let found = nodes.iter_mut().find(|n| n.did == did); + + match found { + Some(node) => { + let old = node.onion.clone(); + node.onion = new_onion.to_string(); + federation::save_nodes(&self.config.data_dir, &nodes).await?; + info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address"); + Ok(serde_json::json!({ + "updated": true, + "did": did, + "old_onion": old, + "new_onion": new_onion, + })) + } + None => { + info!(did = %did, "Received address change from unknown peer — ignoring"); + Ok(serde_json::json!({ + "updated": false, + "reason": "Unknown peer DID", + })) + } + } + } } diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 10652ede..e1d96ce6 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -445,6 +445,7 @@ impl RpcHandler { "federation.get-state" => self.handle_federation_get_state().await, "federation.peer-joined" => self.handle_federation_peer_joined(params).await, "federation.deploy-app" => self.handle_federation_deploy_app(params).await, + "federation.peer-address-changed" => self.handle_federation_peer_address_changed(params).await, // VPN & Remote Access "vpn.status" => self.handle_vpn_status().await, diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 94e28ee8..fcccbbf6 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::{debug, info, warn}; +use crate::{federation, identity, nostr_discovery}; + 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). @@ -181,6 +183,24 @@ impl RpcHandler { // Wait up to 60s for new hostname file to appear let new_onion = wait_for_hostname(name, 60).await; + // 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(); + let nostr_relays = self.config.nostr_relays.clone(); + let tor_proxy = self.config.nostr_tor_proxy.clone(); + let new_addr_clone = new_addr.clone(); + let old_onion_clone = old_onion.clone(); + tokio::spawn(async move { + propagate_address_change( + &data_dir, + &new_addr_clone, + old_onion_clone.as_deref(), + &nostr_relays, + tor_proxy.as_deref(), + ).await; + }); + } + Ok(serde_json::json!({ "rotated": true, "name": name, @@ -408,6 +428,72 @@ async fn save_services_config(config_dir: &std::path::Path, config: &ServicesCon Ok(()) } +/// Propagate address change: publish to Nostr relays and notify federation peers. +async fn propagate_address_change( + data_dir: &std::path::Path, + new_onion: &str, + old_onion: Option<&str>, + relays: &[String], + tor_proxy: Option<&str>, +) { + // 1. Publish updated identity to Nostr relays + let identity_dir = data_dir.join("identity"); + match identity::NodeIdentity::load_or_create(&identity_dir).await { + Ok(node_id) => { + let did = node_id.did_key(); + if !relays.is_empty() { + match nostr_discovery::publish_node_identity( + &identity_dir, + &did, + new_onion, + env!("CARGO_PKG_VERSION"), + relays, + tor_proxy, + ).await { + Ok(_) => info!("Published updated .onion to Nostr relays"), + Err(e) => warn!("Failed to publish to Nostr relays: {}", e), + } + } + + // 2. Notify federation peers via the old address (still works during transition) + let proxy = tor_proxy.unwrap_or("127.0.0.1:9050"); + match federation::load_nodes(data_dir).await { + Ok(peers) => { + for peer in peers { + if peer.onion.is_empty() { + continue; + } + let target_onion = &peer.onion; + let payload = serde_json::json!({ + "method": "federation.peer-address-changed", + "params": { + "did": did, + "new_onion": new_onion, + "old_onion": old_onion, + } + }); + let url = format!("http://{}/rpc/v1", target_onion); + let client = match reqwest::Client::builder() + .proxy(reqwest::Proxy::all(format!("socks5h://{}", proxy)).unwrap_or_else(|_| reqwest::Proxy::all("socks5h://127.0.0.1:9050").expect("valid proxy"))) + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(_) => continue, + }; + match client.post(&url).json(&payload).send().await { + Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"), + Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e), + } + } + } + Err(e) => warn!("Failed to load federation peers: {}", e), + } + } + Err(e) => warn!("Failed to load node identity for propagation: {}", e), + } +} + /// 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 { diff --git a/loop/plan.md b/loop/plan.md index 1540e318..02b7c970 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -494,7 +494,7 @@ - [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. +- [x] **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. - [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.