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:
co-authored by
Claude Fable 5
parent
c3b532eaf4
commit
81858ab630
@@ -21,7 +21,7 @@ use anyhow::{Context, Result};
|
|||||||
use nostr_sdk::FromBech32;
|
use nostr_sdk::FromBech32;
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
/// 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
|
/// 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 {
|
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> {
|
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
|
||||||
let state = load_discovery_state(&self.config.data_dir).await;
|
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
|
/// Set the runtime discoverability flag. If turning ON, publish presence
|
||||||
@@ -101,6 +107,24 @@ impl RpcHandler {
|
|||||||
tracing::warn!("Initial presence publish failed: {}", e);
|
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 }))
|
Ok(serde_json::json!({ "enabled": enabled }))
|
||||||
|
|||||||
@@ -921,6 +921,29 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
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");
|
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||||
|
|
||||||
let data_dir = &self.config.data_dir;
|
let data_dir = &self.config.data_dir;
|
||||||
|
|||||||
@@ -35,6 +35,37 @@ use tracing::warn;
|
|||||||
|
|
||||||
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
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).
|
/// Message types exchanged inside NIP-44 encrypted DMs (kind 4).
|
||||||
///
|
///
|
||||||
/// Note: NONE of these variants carry an onion address. The onion is only
|
/// 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");
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder =
|
// NIP-40 expiration: relays that honour it garbage-collect the event if
|
||||||
EventBuilder::new(Kind::Custom(30078), content).tag(Tag::identifier("archipelago-node"));
|
// 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;
|
let _ = client.send_event_builder(builder).await;
|
||||||
client.disconnect().await;
|
client.disconnect().await;
|
||||||
|
|
||||||
@@ -176,6 +212,43 @@ pub async fn publish_presence(
|
|||||||
Ok(())
|
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).
|
/// Discover other Archipelago nodes (presence-only — no onion addresses).
|
||||||
/// Returns Nostr pubkeys and DIDs of discoverable nodes.
|
/// Returns Nostr pubkeys and DIDs of discoverable nodes.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -221,7 +294,17 @@ pub async fn discover_nodes(
|
|||||||
client.disconnect().await;
|
client.disconnect().await;
|
||||||
|
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
|
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS));
|
||||||
for event in events {
|
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) {
|
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
|
||||||
let nostr_pubkey = content
|
let nostr_pubkey = content
|
||||||
.get("nostr_pubkey")
|
.get("nostr_pubkey")
|
||||||
|
|||||||
@@ -212,7 +212,15 @@ impl Server {
|
|||||||
|
|
||||||
// Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address).
|
// Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address).
|
||||||
// Onion addresses are exchanged privately via NIP-44 encrypted DMs.
|
// 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 identity_dir = config.data_dir.join("identity");
|
||||||
let did =
|
let did =
|
||||||
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||||
@@ -221,11 +229,22 @@ impl Server {
|
|||||||
// where handshake peers actually read (2026-07-22 unification).
|
// where handshake peers actually read (2026-07-22 unification).
|
||||||
let data_dir_for_relays = config.data_dir.clone();
|
let data_dir_for_relays = config.data_dir.clone();
|
||||||
let config_relays = config.nostr_relays.clone();
|
let config_relays = config.nostr_relays.clone();
|
||||||
|
let config_flag = config.nostr_discovery_enabled;
|
||||||
let tor_proxy = config.nostr_tor_proxy.clone();
|
let tor_proxy = config.nostr_tor_proxy.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let relays =
|
const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3
|
||||||
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
|
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;
|
.await;
|
||||||
|
if !relays.is_empty() {
|
||||||
if let Err(e) = nostr_handshake::publish_presence(
|
if let Err(e) = nostr_handshake::publish_presence(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
&did,
|
&did,
|
||||||
@@ -237,6 +256,10 @@ impl Server {
|
|||||||
{
|
{
|
||||||
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
|
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(HEARTBEAT_SECS)).await;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
Reference in New Issue
Block a user