From 81858ab63003a285f1ddb10dfe3c92fc549a6f52 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 10 Aug 2026 11:27:59 -0400 Subject: [PATCH] =?UTF-8?q?fix(nostr):=20discovery=20events=20expire,=20he?= =?UTF-8?q?artbeat,=20and=20tombstone=20=E2=80=94=20stale=20nodes=20age=20?= =?UTF-8?q?out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence gets a NIP-40 expiration (48h) and a 12h re-publish heartbeat that honours the runtime toggle (UI-enabled nodes previously never re-published at boot). discover() drops pre-TTL events client-side for relays that ignore NIP-40. Switching discovery off publishes an empty tombstone, and factory-reset tombstones BEFORE wiping identity — after the wipe the key is gone and the stale event could never be replaced by anyone. nostr.discovery-status now also returns the node's own npub (load-only). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/handshake.rs | 30 ++++++- .../src/api/rpc/system/handlers.rs | 23 +++++ core/archipelago/src/nostr_handshake.rs | 87 ++++++++++++++++++- core/archipelago/src/server.rs | 49 ++++++++--- 4 files changed, 171 insertions(+), 18 deletions(-) diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index cf696c60..b5499774 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -21,7 +21,7 @@ use anyhow::{Context, Result}; use nostr_sdk::FromBech32; use serde::{Deserialize, Serialize}; -const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json"; +use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE; /// Runtime override for `Config::nostr_discovery_enabled`. The OS-level /// config file is read once at boot and is OFF by default; this state file @@ -55,10 +55,16 @@ async fn save_discovery_state( } impl RpcHandler { - /// Read the current runtime discoverability flag. + /// Read the current runtime discoverability flag. Also returns the npub + /// this node publishes as (the discoverability UI shows it — that npub, + /// not the onion, is what's actually visible on the relays). Load-only: + /// null until discovery keys exist. pub(super) async fn handle_nostr_discovery_status(&self) -> Result { let state = load_discovery_state(&self.config.data_dir).await; - Ok(serde_json::json!({ "enabled": state.enabled })) + let npub = nostr_handshake::own_npub(&self.config.data_dir.join("identity")) + .await + .unwrap_or(None); + Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub })) } /// Set the runtime discoverability flag. If turning ON, publish presence @@ -101,6 +107,24 @@ impl RpcHandler { tracing::warn!("Initial presence publish failed: {}", e); } }); + } else if !enabled { + // Switching off: overwrite our presence with an empty tombstone so + // the node disappears from other nodes' discovery lists now, not + // at the next TTL expiry. + let identity_dir = self.config.data_dir.join("identity"); + let relays = self.handshake_relays().await; + let tor_proxy = self.config.nostr_tor_proxy.clone(); + tokio::spawn(async move { + if let Err(e) = nostr_handshake::publish_tombstone( + &identity_dir, + &relays, + tor_proxy.as_deref(), + ) + .await + { + tracing::warn!("Presence tombstone publish failed: {}", e); + } + }); } Ok(serde_json::json!({ "enabled": enabled })) diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index d3a11241..16c8690f 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -921,6 +921,29 @@ impl RpcHandler { return Err(anyhow::anyhow!("Password Incorrect")); } + // Overwrite our Nostr presence with a tombstone BEFORE the wipe: the + // discovery keys die with the identity dir, and once they're gone the + // stale presence event can never be replaced by anyone — it would + // list this dead install to the whole network until relays expire it. + // Best-effort with a hard cap so a dead relay can't stall the reset. + { + let identity_dir = self.config.data_dir.join("identity"); + let relays = crate::nostr_relays::merged_relay_list( + &self.config.data_dir, + &self.config.nostr_relays, + ) + .await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(15), + crate::nostr_handshake::publish_tombstone( + &identity_dir, + &relays, + self.config.nostr_tor_proxy.as_deref(), + ), + ) + .await; + } + tracing::warn!("Factory reset initiated — wiping ALL user data and containers"); let data_dir = &self.config.data_dir; diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 4d0a2edf..75375866 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -35,6 +35,37 @@ use tracing::warn; const NOSTR_SECRET_FILE: &str = "nostr_secret"; +/// Runtime discoverability override written by the `nostr.set-discovery` RPC. +/// Lives here (not api/rpc) so the server's heartbeat can honour the same +/// state the toggle writes. +pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json"; + +/// How long a presence event stays valid. Published as a NIP-40 expiration +/// tag AND enforced client-side in `discover` (relay NIP-40 support varies). +/// Must be comfortably longer than the re-publish heartbeat (12h in +/// server.rs) so a node that misses one heartbeat doesn't vanish: 48h +/// tolerates three misses. +pub const PRESENCE_TTL_SECS: u64 = 48 * 3600; + +/// Read the runtime discovery override. `None` means the toggle has never +/// been used on this node — callers fall back to the config flag. +pub async fn discovery_enabled_override(data_dir: &Path) -> Option { + let raw = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)) + .await + .ok()?; + let v: serde_json::Value = serde_json::from_str(&raw).ok()?; + v.get("enabled").and_then(|e| e.as_bool()) +} + +/// This node's own published npub (bech32), if discovery keys exist. +/// Load-only: never mints keys on a read. +pub async fn own_npub(identity_dir: &Path) -> Result> { + Ok(load_nostr_keys(identity_dir) + .await? + .map(|k| k.public_key().to_bech32().unwrap_or_default()) + .filter(|s| !s.is_empty())) +} + /// Message types exchanged inside NIP-44 encrypted DMs (kind 4). /// /// Note: NONE of these variants carry an onion address. The onion is only @@ -164,8 +195,13 @@ pub async fn publish_presence( warn!("Nostr relay connection timed out after 10s, continuing anyway"); } - let builder = - EventBuilder::new(Kind::Custom(30078), content).tag(Tag::identifier("archipelago-node")); + // NIP-40 expiration: relays that honour it garbage-collect the event if + // this node stops heartbeating (reinstall, decommission, long outage). + // `discover` enforces the same window client-side for relays that don't. + let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); + let builder = EventBuilder::new(Kind::Custom(30078), content) + .tag(Tag::identifier("archipelago-node")) + .tag(Tag::expiration(expires)); let _ = client.send_event_builder(builder).await; client.disconnect().await; @@ -176,6 +212,43 @@ pub async fn publish_presence( Ok(()) } +/// Overwrite this node's presence with an empty tombstone (NIP-33: same +/// author + kind + d-tag replaces). Called when discovery is switched off +/// and — critically — during factory-reset BEFORE the keys are wiped: once +/// the secret is gone, nothing can ever replace the stale event. +pub async fn publish_tombstone( + identity_dir: &Path, + relays: &[String], + tor_proxy: Option<&str>, +) -> Result<()> { + if relays.is_empty() { + return Ok(()); + } + let Some(keys) = load_nostr_keys(identity_dir).await? else { + return Ok(()); // never published — nothing to tombstone + }; + let client = build_client(keys, tor_proxy)?; + for url in relays { + let _ = client.add_relay(url).await; + } + if tokio::time::timeout(Duration::from_secs(10), client.connect()) + .await + .is_err() + { + warn!("Nostr relay connection timed out after 10s, continuing anyway"); + } + // Tombstone also expires: after TTL the relay may drop it entirely, + // which is the desired end state (nothing left to list). + let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); + let builder = EventBuilder::new(Kind::Custom(30078), "{}") + .tag(Tag::identifier("archipelago-node")) + .tag(Tag::expiration(expires)); + let _ = client.send_event_builder(builder).await; + client.disconnect().await; + tracing::info!("🔒 Published presence tombstone to {} relays", relays.len()); + Ok(()) +} + /// Discover other Archipelago nodes (presence-only — no onion addresses). /// Returns Nostr pubkeys and DIDs of discoverable nodes. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -221,7 +294,17 @@ pub async fn discover_nodes( client.disconnect().await; let mut nodes = Vec::new(); + let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS)); for event in events { + // Client-side staleness enforcement: pre-TTL events (and events from + // relays that ignore NIP-40) would otherwise list dead installs + // forever — every reinstall mints a new key, so the old author can + // never replace its own event. + if event.created_at < stale_cutoff { + continue; + } + // A tombstone ("{}" content) parses but yields no pubkey — the + // nostr_pubkey.is_empty() guard below already drops it. if let Ok(content) = serde_json::from_str::(&event.content) { let nostr_pubkey = content .get("nostr_pubkey") diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 7da8a379..c5d9b06f 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -212,7 +212,15 @@ impl Server { // Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address). // Onion addresses are exchanged privately via NIP-44 encrypted DMs. - if config.nostr_discovery_enabled && !config.nostr_relays.is_empty() { + // + // This is a heartbeat, not a one-shot: presence events carry a NIP-40 + // expiration of PRESENCE_TTL_SECS, so a node that stops re-publishing + // ages out of discovery instead of lingering forever. First tick runs + // immediately (preserving the old startup-publish behaviour); the + // runtime toggle (nostr.set-discovery) is re-read every tick, so a + // node switched on via the UI heartbeats too — not just ones with the + // config flag baked in. + { let identity_dir = config.data_dir.join("identity"); let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); @@ -221,21 +229,36 @@ impl Server { // where handshake peers actually read (2026-07-22 unification). let data_dir_for_relays = config.data_dir.clone(); let config_relays = config.nostr_relays.clone(); + let config_flag = config.nostr_discovery_enabled; let tor_proxy = config.nostr_tor_proxy.clone(); tokio::spawn(async move { - let relays = - crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays) + const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3 + loop { + let enabled = + nostr_handshake::discovery_enabled_override(&data_dir_for_relays) + .await + .unwrap_or(config_flag); + if enabled { + let relays = crate::nostr_relays::merged_relay_list( + &data_dir_for_relays, + &config_relays, + ) .await; - if let Err(e) = nostr_handshake::publish_presence( - &identity_dir, - &did, - &version, - &relays, - tor_proxy.as_deref(), - ) - .await - { - tracing::debug!("Nostr presence publish (non-fatal): {}", e); + if !relays.is_empty() { + if let Err(e) = nostr_handshake::publish_presence( + &identity_dir, + &did, + &version, + &relays, + tor_proxy.as_deref(), + ) + .await + { + tracing::debug!("Nostr presence publish (non-fatal): {}", e); + } + } + } + tokio::time::sleep(std::time::Duration::from_secs(HEARTBEAT_SECS)).await; } }); }