fix(nostr): discovery events expire, heartbeat, and tombstone — stale nodes age out

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-10 11:27:59 -04:00
co-authored by Claude Fable 5
parent c3b532eaf4
commit 81858ab630
4 changed files with 171 additions and 18 deletions
+27 -3
View File
@@ -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<serde_json::Value> {
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 }))
@@ -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;
+85 -2
View File
@@ -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<bool> {
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<Option<String>> {
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::<serde_json::Value>(&event.content) {
let nostr_pubkey = content
.get("nostr_pubkey")
+36 -13
View File
@@ -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;
}
});
}