Skip to main content

fips_core/config/node/
discovery.rs

1use super::*;
2
3/// Discovery protocol (`node.discovery.*`).
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct DiscoveryConfig {
6    /// Hop limit for LookupRequest flood (`node.discovery.ttl`).
7    #[serde(default = "DiscoveryConfig::default_ttl")]
8    pub ttl: u8,
9    /// Per-attempt timeouts in seconds (`node.discovery.attempt_timeouts_secs`).
10    /// Each entry is the time to wait for a response before sending the next
11    /// LookupRequest (with a fresh request_id). Sequence length determines the
12    /// total number of attempts before declaring the destination unreachable.
13    /// Default `[1, 2, 4, 8]` gives 4 attempts and a 15s total budget.
14    #[serde(default = "DiscoveryConfig::default_attempt_timeouts_secs")]
15    pub attempt_timeouts_secs: Vec<u64>,
16    /// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
17    #[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
18    pub recent_expiry_secs: u64,
19    /// Base backoff after lookup failure in seconds (`node.discovery.backoff_base_secs`).
20    /// Doubles per consecutive failure up to `backoff_max_secs`. Set both to
21    /// 0 to disable post-failure suppression.
22    #[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
23    pub backoff_base_secs: u64,
24    /// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
25    #[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
26    pub backoff_max_secs: u64,
27    /// Minimum interval between forwarded lookups for the same target in seconds
28    /// (`node.discovery.forward_min_interval_secs`).
29    /// Defense-in-depth against misbehaving nodes.
30    #[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
31    pub forward_min_interval_secs: u64,
32    /// Nostr-mediated overlay endpoint discovery.
33    #[serde(default = "DiscoveryConfig::default_nostr")]
34    pub nostr: NostrDiscoveryConfig,
35    /// mDNS / DNS-SD peer discovery on the local link. Identity surface
36    /// is a strict subset of what `nostr.advertise` already publishes
37    /// publicly, so there's no marginal privacy cost; the latency win
38    /// for same-LAN peers is large (sub-second pairing, no relay).
39    #[serde(default = "DiscoveryConfig::default_lan")]
40    pub lan: crate::discovery::lan::LanDiscoveryConfig,
41    /// Same-host process discovery through one fixed loopback UDP rendezvous.
42    /// The socket is only a bootstrap hint; the normal Noise handshake still
43    /// authenticates every local peer. It never changes application-owned
44    /// outbound connectivity.
45    #[serde(default = "DiscoveryConfig::default_local")]
46    pub local: crate::discovery::local::LocalInstanceDiscoveryConfig,
47}
48
49impl Default for DiscoveryConfig {
50    fn default() -> Self {
51        Self {
52            ttl: 64,
53            attempt_timeouts_secs: vec![1, 2, 4, 8],
54            recent_expiry_secs: 10,
55            backoff_base_secs: 30,
56            backoff_max_secs: 300,
57            forward_min_interval_secs: 2,
58            nostr: NostrDiscoveryConfig::default(),
59            lan: crate::discovery::lan::LanDiscoveryConfig::default(),
60            local: crate::discovery::local::LocalInstanceDiscoveryConfig::default(),
61        }
62    }
63}
64
65impl DiscoveryConfig {
66    fn default_ttl() -> u8 {
67        64
68    }
69    fn default_attempt_timeouts_secs() -> Vec<u64> {
70        vec![1, 2, 4, 8]
71    }
72    fn default_recent_expiry_secs() -> u64 {
73        10
74    }
75    fn default_backoff_base_secs() -> u64 {
76        30
77    }
78    fn default_backoff_max_secs() -> u64 {
79        300
80    }
81    fn default_forward_min_interval_secs() -> u64 {
82        2
83    }
84    fn default_nostr() -> NostrDiscoveryConfig {
85        NostrDiscoveryConfig::default()
86    }
87    fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
88        crate::discovery::lan::LanDiscoveryConfig::default()
89    }
90    fn default_local() -> crate::discovery::local::LocalInstanceDiscoveryConfig {
91        crate::discovery::local::LocalInstanceDiscoveryConfig::default()
92    }
93}
94
95/// Nostr advert discovery policy.
96///
97/// Controls how overlay endpoint adverts are consumed:
98/// - `disabled`: ignore advert-derived endpoints for all peers
99/// - `configured_only`: allow advert fallback for configured peers
100/// - `open`: also consider adverts for non-configured peers
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum NostrDiscoveryPolicy {
104    Disabled,
105    #[default]
106    ConfiguredOnly,
107    Open,
108}
109
110/// Source used to publish and receive Nostr peer adverts.
111///
112/// `relays` keeps the built-in relay client as the peerfinding provider.
113/// `external` disables built-in advert relay publication, subscriptions, and
114/// cache-miss queries so an adapter can route ordinary signed advert events
115/// through a transport-neutral pubsub provider instead. Transport negotiation
116/// is carried by authenticated FIPS sessions, not by a second relay plane.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum NostrPeerfindingSource {
120    #[default]
121    Relays,
122    External,
123}
124
125/// Nostr-mediated overlay endpoint discovery (`node.discovery.nostr.*`).
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct NostrDiscoveryConfig {
129    /// Enable signed Nostr peer adverts and FIPS-session transport upgrades.
130    #[serde(default)]
131    pub enabled: bool,
132    /// Publish service advertisements so remote peers can bootstrap inbound.
133    #[serde(default = "NostrDiscoveryConfig::default_advertise")]
134    pub advertise: bool,
135    /// Provider used for signed peer advert distribution and lookup.
136    #[serde(default)]
137    pub peerfinding_source: NostrPeerfindingSource,
138    /// Relay URLs used for service advertisements.
139    #[serde(default = "NostrDiscoveryConfig::default_advert_relays")]
140    pub advert_relays: Vec<String>,
141    /// STUN servers used for local reflexive address discovery.
142    /// Outbound observation uses only this local list; peer-advertised STUN
143    /// values are informational and are not treated as egress targets.
144    #[serde(default = "NostrDiscoveryConfig::default_stun_servers")]
145    pub stun_servers: Vec<String>,
146    /// Whether to advertise local (RFC 1918 / ULA) interface addresses as
147    /// host candidates in the traversal offer.
148    ///
149    /// Off by default: in most deployments the relevant peers are not on the
150    /// same broadcast domain, and sharing private host candidates causes
151    /// misleading punch successes when an asymmetric L3 path (corporate VPN,
152    /// Tailscale subnet route, overlapping address space, etc.) makes a
153    /// peer's private IP one-way reachable from this node. Enable only when
154    /// peers are on the same physical LAN and same-LAN punching is wanted.
155    #[serde(default)]
156    pub share_local_candidates: bool,
157    /// Traversal application namespace advertised in the Nostr protocol tag.
158    #[serde(default = "NostrDiscoveryConfig::default_app")]
159    pub app: String,
160    /// Signaling TTL in seconds.
161    #[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")]
162    pub signal_ttl_secs: u64,
163    /// Policy for advert-derived endpoint discovery.
164    #[serde(default)]
165    pub policy: NostrDiscoveryPolicy,
166    /// Max number of open-discovery peers queued for outbound retry/connection
167    /// at once. Prevents unbounded queue growth from ambient advert traffic.
168    #[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")]
169    pub open_discovery_max_pending: usize,
170    /// Sort open-discovery adverts by externally supplied peer trust scores.
171    ///
172    /// Off by default. When enabled, positive-rated peers are tried first,
173    /// unknown peers get reserved probe slots, and negatively rated peers are
174    /// deferred behind trusted and unknown peers.
175    #[serde(default)]
176    pub open_discovery_trust_ratings_enabled: bool,
177    /// Number of scarce open-discovery enqueue slots reserved for unknown
178    /// peers when trust sorting is enabled.
179    #[serde(default = "NostrDiscoveryConfig::default_open_discovery_newcomer_probe_slots")]
180    pub open_discovery_newcomer_probe_slots: usize,
181    /// Rating fact scope accepted by the open-discovery trust cache.
182    #[serde(default = "NostrDiscoveryConfig::default_open_discovery_rating_scope")]
183    pub open_discovery_rating_scope: String,
184    /// Historical rating fact lookback window for the relay subscription.
185    /// A value of 0 subscribes without a `since` bound.
186    #[serde(default = "NostrDiscoveryConfig::default_open_discovery_rating_lookback_secs")]
187    pub open_discovery_rating_lookback_secs: u64,
188    /// Signed rating fact authors accepted by the open-discovery trust cache.
189    /// The local node identity is always trusted; this list is for peers or
190    /// crawlers the operator explicitly trusts.
191    #[serde(default)]
192    pub open_discovery_trusted_rating_authors: Vec<String>,
193    /// Local JSON files containing signed rating fact events to load into the
194    /// open-discovery trust cache at startup. Files may be raw event arrays or
195    /// objects with an `events` array, such as `fipsctl ratings export --format
196    /// events` or `htree nostr-index query` output.
197    #[serde(default)]
198    pub open_discovery_rating_event_files: Vec<std::path::PathBuf>,
199    /// Max concurrent inbound traversal offers processed at once.
200    /// Acts as a rate limit against offer spam from relays.
201    #[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")]
202    pub max_concurrent_incoming_offers: usize,
203    /// Max cached overlay adverts retained from relay traffic.
204    /// Bounds memory under ambient advert volume.
205    #[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")]
206    pub advert_cache_max_entries: usize,
207    /// Max seen-session IDs retained for replay detection.
208    /// Oldest entries are evicted when the cap is exceeded.
209    #[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")]
210    pub seen_sessions_max_entries: usize,
211    /// Overall punch attempt timeout in seconds.
212    #[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")]
213    pub attempt_timeout_secs: u64,
214    /// Replay tracking retention window in seconds.
215    #[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")]
216    pub replay_window_secs: u64,
217    /// Delay before punch traffic starts.
218    #[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")]
219    pub punch_start_delay_ms: u64,
220    /// Interval between punch packets.
221    #[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")]
222    pub punch_interval_ms: u64,
223    /// How long to keep punching before failure.
224    #[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")]
225    pub punch_duration_ms: u64,
226    /// Advert TTL in seconds.
227    #[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")]
228    pub advert_ttl_secs: u64,
229    /// How often adverts are refreshed in seconds.
230    #[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")]
231    pub advert_refresh_secs: u64,
232    /// Settle delay in seconds after Nostr discovery starts before the
233    /// one-shot startup sweep of cached adverts runs. Allows the relay
234    /// subscription backlog to populate the in-memory advert cache.
235    /// Only used under `policy: open`. Default: 5.
236    #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_delay_secs")]
237    pub startup_sweep_delay_secs: u64,
238    /// Maximum age in seconds for cached adverts considered by the
239    /// one-shot startup sweep. Adverts whose `created_at` is older than
240    /// `now - startup_sweep_max_age_secs` are skipped. Only used under
241    /// `policy: open`. Default: 3600 (1 hour).
242    #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_max_age_secs")]
243    pub startup_sweep_max_age_secs: u64,
244    /// Number of consecutive NAT-traversal failures against a peer before
245    /// an extended cooldown is applied to throttle further offer publishes.
246    /// At this threshold the daemon also actively re-fetches the peer's
247    /// advert in built-in `relays` peerfinding mode to evict cache entries for
248    /// peers that have gone away. External providers own refresh in
249    /// `external` mode. Default: 5.
250    #[serde(default = "NostrDiscoveryConfig::default_failure_streak_threshold")]
251    pub failure_streak_threshold: u32,
252    /// Cooldown applied to a peer once `failure_streak_threshold` is hit.
253    /// Suppresses both open-discovery sweep enqueues and per-attempt
254    /// retry firings until elapsed. Default: 1800 (30 minutes).
255    #[serde(default = "NostrDiscoveryConfig::default_extended_cooldown_secs")]
256    pub extended_cooldown_secs: u64,
257    /// Minimum interval between `NAT traversal failed` WARN log lines for
258    /// the same peer. Subsequent failures inside the window log at DEBUG.
259    /// Reduces log spam on public-test nodes with many cache-learned
260    /// peers. Default: 300 (5 minutes).
261    #[serde(default = "NostrDiscoveryConfig::default_warn_log_interval_secs")]
262    pub warn_log_interval_secs: u64,
263    /// Maximum entries retained in the per-npub failure-state map.
264    /// Bounds memory under high cache turnover. Oldest entries (by last
265    /// failure time) evicted when the cap is exceeded. Default: 4096.
266    #[serde(default = "NostrDiscoveryConfig::default_failure_state_max_entries")]
267    pub failure_state_max_entries: usize,
268    /// Cooldown applied after observing a fatal protocol mismatch on a
269    /// Nostr-adopted bootstrap transport (e.g. `Unknown FMP version`
270    /// from a peer running a different FMP-protocol version). Independent
271    /// of `extended_cooldown_secs` and much longer because the mismatch
272    /// is structural — re-traversing the peer is wasted effort until one
273    /// side upgrades. Default: 86400 (24 hours).
274    #[serde(default = "NostrDiscoveryConfig::default_protocol_mismatch_cooldown_secs")]
275    pub protocol_mismatch_cooldown_secs: u64,
276}
277
278impl Default for NostrDiscoveryConfig {
279    fn default() -> Self {
280        let open_discovery_newcomer_probe_slots =
281            Self::default_open_discovery_newcomer_probe_slots();
282        let open_discovery_rating_lookback_secs =
283            Self::default_open_discovery_rating_lookback_secs();
284        Self {
285            enabled: false,
286            advertise: Self::default_advertise(),
287            peerfinding_source: NostrPeerfindingSource::default(),
288            advert_relays: Self::default_advert_relays(),
289            stun_servers: Self::default_stun_servers(),
290            share_local_candidates: false,
291            app: Self::default_app(),
292            signal_ttl_secs: Self::default_signal_ttl_secs(),
293            policy: NostrDiscoveryPolicy::default(),
294            open_discovery_max_pending: Self::default_open_discovery_max_pending(),
295            open_discovery_trust_ratings_enabled: false,
296            open_discovery_newcomer_probe_slots,
297            open_discovery_rating_scope: Self::default_open_discovery_rating_scope(),
298            open_discovery_rating_lookback_secs,
299            open_discovery_trusted_rating_authors: Vec::new(),
300            open_discovery_rating_event_files: Vec::new(),
301            max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(),
302            advert_cache_max_entries: Self::default_advert_cache_max_entries(),
303            seen_sessions_max_entries: Self::default_seen_sessions_max_entries(),
304            attempt_timeout_secs: Self::default_attempt_timeout_secs(),
305            replay_window_secs: Self::default_replay_window_secs(),
306            punch_start_delay_ms: Self::default_punch_start_delay_ms(),
307            punch_interval_ms: Self::default_punch_interval_ms(),
308            punch_duration_ms: Self::default_punch_duration_ms(),
309            advert_ttl_secs: Self::default_advert_ttl_secs(),
310            advert_refresh_secs: Self::default_advert_refresh_secs(),
311            startup_sweep_delay_secs: Self::default_startup_sweep_delay_secs(),
312            startup_sweep_max_age_secs: Self::default_startup_sweep_max_age_secs(),
313            failure_streak_threshold: Self::default_failure_streak_threshold(),
314            extended_cooldown_secs: Self::default_extended_cooldown_secs(),
315            warn_log_interval_secs: Self::default_warn_log_interval_secs(),
316            failure_state_max_entries: Self::default_failure_state_max_entries(),
317            protocol_mismatch_cooldown_secs: Self::default_protocol_mismatch_cooldown_secs(),
318        }
319    }
320}
321
322impl NostrDiscoveryConfig {
323    /// Whether FIPS itself should open relay paths for peer adverts.
324    #[must_use]
325    pub const fn uses_relay_peerfinding(&self) -> bool {
326        matches!(self.peerfinding_source, NostrPeerfindingSource::Relays)
327    }
328
329    fn default_advertise() -> bool {
330        true
331    }
332
333    fn default_advert_relays() -> Vec<String> {
334        vec![
335            "wss://relay.damus.io".to_string(),
336            "wss://nos.lol".to_string(),
337            "wss://offchain.pub".to_string(),
338            "wss://temp.iris.to".to_string(),
339        ]
340    }
341
342    fn default_stun_servers() -> Vec<String> {
343        vec![
344            "stun:stun.l.google.com:19302".to_string(),
345            "stun:stun.cloudflare.com:3478".to_string(),
346            "stun:global.stun.twilio.com:3478".to_string(),
347        ]
348    }
349
350    fn default_app() -> String {
351        "fips-overlay-v1".to_string()
352    }
353
354    fn default_signal_ttl_secs() -> u64 {
355        120
356    }
357
358    fn default_open_discovery_max_pending() -> usize {
359        64
360    }
361
362    fn default_open_discovery_newcomer_probe_slots() -> usize {
363        1
364    }
365
366    fn default_open_discovery_rating_scope() -> String {
367        "fips.peer".to_string()
368    }
369
370    fn default_open_discovery_rating_lookback_secs() -> u64 {
371        604_800
372    }
373
374    fn default_max_concurrent_incoming_offers() -> usize {
375        16
376    }
377
378    fn default_advert_cache_max_entries() -> usize {
379        2048
380    }
381
382    fn default_seen_sessions_max_entries() -> usize {
383        2048
384    }
385
386    fn default_attempt_timeout_secs() -> u64 {
387        10
388    }
389
390    fn default_replay_window_secs() -> u64 {
391        300
392    }
393
394    fn default_punch_start_delay_ms() -> u64 {
395        2_000
396    }
397
398    fn default_punch_interval_ms() -> u64 {
399        200
400    }
401
402    fn default_punch_duration_ms() -> u64 {
403        10_000
404    }
405
406    fn default_advert_ttl_secs() -> u64 {
407        3_600
408    }
409
410    fn default_advert_refresh_secs() -> u64 {
411        1_800
412    }
413
414    fn default_startup_sweep_delay_secs() -> u64 {
415        5
416    }
417
418    fn default_startup_sweep_max_age_secs() -> u64 {
419        3_600
420    }
421
422    fn default_failure_streak_threshold() -> u32 {
423        5
424    }
425
426    fn default_extended_cooldown_secs() -> u64 {
427        1_800
428    }
429
430    fn default_warn_log_interval_secs() -> u64 {
431        300
432    }
433
434    fn default_failure_state_max_entries() -> usize {
435        4_096
436    }
437
438    fn default_protocol_mismatch_cooldown_secs() -> u64 {
439        86_400
440    }
441}