feat: propagate Tor address rotation to Nostr relays and federation peers

After rotation, spawns background task that publishes updated .onion to
Nostr relays and sends federation.peer-address-changed RPC to all peers
over Tor. Peers update their nodes.json with the new address.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-13 00:08:16 +00:00
parent f1ca60e732
commit e74bf9bcfd
4 changed files with 130 additions and 1 deletions

View File

@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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",
}))
}
}
}
}

View File

@ -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,

View File

@ -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<String> {
for _ in 0..max_secs {

View File

@ -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.