Skip to main content

fips_core/config/
node.rs

1//! Node configuration subsections.
2//!
3//! All the `node.*` configuration parameters: resource limits, rate limiting,
4//! retry/backoff, cache sizing, discovery, spanning tree, bloom filters,
5//! session management, and internal buffers.
6
7use serde::{Deserialize, Serialize};
8
9use super::IdentityConfig;
10use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};
11
12// ============================================================================
13// Node Configuration Subsections
14// ============================================================================
15
16/// Resource limits (`node.limits.*`).
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct LimitsConfig {
19    /// Max handshake-phase connections (`node.limits.max_connections`).
20    #[serde(default = "LimitsConfig::default_max_connections")]
21    pub max_connections: usize,
22    /// Max authenticated peers (`node.limits.max_peers`).
23    #[serde(default = "LimitsConfig::default_max_peers")]
24    pub max_peers: usize,
25    /// Max active links (`node.limits.max_links`).
26    #[serde(default = "LimitsConfig::default_max_links")]
27    pub max_links: usize,
28    /// Max pending inbound handshakes (`node.limits.max_pending_inbound`).
29    #[serde(default = "LimitsConfig::default_max_pending_inbound")]
30    pub max_pending_inbound: usize,
31}
32
33impl Default for LimitsConfig {
34    fn default() -> Self {
35        Self {
36            max_connections: 256,
37            max_peers: 128,
38            max_links: 256,
39            max_pending_inbound: 1000,
40        }
41    }
42}
43
44impl LimitsConfig {
45    fn default_max_connections() -> usize {
46        256
47    }
48    fn default_max_peers() -> usize {
49        128
50    }
51    fn default_max_links() -> usize {
52        256
53    }
54    fn default_max_pending_inbound() -> usize {
55        1000
56    }
57}
58
59/// Rate limiting (`node.rate_limit.*`).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RateLimitConfig {
62    /// Token bucket burst capacity (`node.rate_limit.handshake_burst`).
63    #[serde(default = "RateLimitConfig::default_handshake_burst")]
64    pub handshake_burst: u32,
65    /// Tokens/sec refill rate (`node.rate_limit.handshake_rate`).
66    #[serde(default = "RateLimitConfig::default_handshake_rate")]
67    pub handshake_rate: f64,
68    /// Stale handshake cleanup timeout in seconds (`node.rate_limit.handshake_timeout_secs`).
69    #[serde(default = "RateLimitConfig::default_handshake_timeout_secs")]
70    pub handshake_timeout_secs: u64,
71    /// Initial handshake resend interval in ms (`node.rate_limit.handshake_resend_interval_ms`).
72    /// Handshake messages are resent with exponential backoff within the timeout window.
73    #[serde(default = "RateLimitConfig::default_handshake_resend_interval_ms")]
74    pub handshake_resend_interval_ms: u64,
75    /// Handshake resend backoff multiplier (`node.rate_limit.handshake_resend_backoff`).
76    #[serde(default = "RateLimitConfig::default_handshake_resend_backoff")]
77    pub handshake_resend_backoff: f64,
78    /// Max handshake resends per attempt (`node.rate_limit.handshake_max_resends`).
79    #[serde(default = "RateLimitConfig::default_handshake_max_resends")]
80    pub handshake_max_resends: u32,
81}
82
83impl Default for RateLimitConfig {
84    fn default() -> Self {
85        Self {
86            handshake_burst: 100,
87            handshake_rate: 10.0,
88            handshake_timeout_secs: 30,
89            handshake_resend_interval_ms: 1000,
90            handshake_resend_backoff: 2.0,
91            handshake_max_resends: 5,
92        }
93    }
94}
95
96impl RateLimitConfig {
97    fn default_handshake_burst() -> u32 {
98        100
99    }
100    fn default_handshake_rate() -> f64 {
101        10.0
102    }
103    fn default_handshake_timeout_secs() -> u64 {
104        30
105    }
106    fn default_handshake_resend_interval_ms() -> u64 {
107        1000
108    }
109    fn default_handshake_resend_backoff() -> f64 {
110        2.0
111    }
112    fn default_handshake_max_resends() -> u32 {
113        5
114    }
115}
116
117/// Retry/backoff configuration (`node.retry.*`).
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RetryConfig {
120    /// Max connection retry attempts (`node.retry.max_retries`).
121    #[serde(default = "RetryConfig::default_max_retries")]
122    pub max_retries: u32,
123    /// Base backoff interval in seconds (`node.retry.base_interval_secs`).
124    #[serde(default = "RetryConfig::default_base_interval_secs")]
125    pub base_interval_secs: u64,
126    /// Cap on exponential backoff in seconds (`node.retry.max_backoff_secs`).
127    #[serde(default = "RetryConfig::default_max_backoff_secs")]
128    pub max_backoff_secs: u64,
129}
130
131impl Default for RetryConfig {
132    fn default() -> Self {
133        Self {
134            max_retries: 5,
135            base_interval_secs: 5,
136            max_backoff_secs: 300,
137        }
138    }
139}
140
141impl RetryConfig {
142    fn default_max_retries() -> u32 {
143        5
144    }
145    fn default_base_interval_secs() -> u64 {
146        5
147    }
148    fn default_max_backoff_secs() -> u64 {
149        300
150    }
151}
152
153/// Cache parameters (`node.cache.*`).
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct CacheConfig {
156    /// Max entries in coord cache (`node.cache.coord_size`).
157    #[serde(default = "CacheConfig::default_coord_size")]
158    pub coord_size: usize,
159    /// Coord cache entry TTL in seconds (`node.cache.coord_ttl_secs`).
160    #[serde(default = "CacheConfig::default_coord_ttl_secs")]
161    pub coord_ttl_secs: u64,
162    /// Max entries in identity cache (`node.cache.identity_size`).
163    #[serde(default = "CacheConfig::default_identity_size")]
164    pub identity_size: usize,
165}
166
167impl Default for CacheConfig {
168    fn default() -> Self {
169        Self {
170            coord_size: 50_000,
171            coord_ttl_secs: 300,
172            identity_size: 10_000,
173        }
174    }
175}
176
177impl CacheConfig {
178    fn default_coord_size() -> usize {
179        50_000
180    }
181    fn default_coord_ttl_secs() -> u64 {
182        300
183    }
184    fn default_identity_size() -> usize {
185        10_000
186    }
187}
188
189/// Discovery protocol (`node.discovery.*`).
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct DiscoveryConfig {
192    /// Hop limit for LookupRequest flood (`node.discovery.ttl`).
193    #[serde(default = "DiscoveryConfig::default_ttl")]
194    pub ttl: u8,
195    /// Per-attempt timeouts in seconds (`node.discovery.attempt_timeouts_secs`).
196    /// Each entry is the time to wait for a response before sending the next
197    /// LookupRequest (with a fresh request_id). Sequence length determines the
198    /// total number of attempts before declaring the destination unreachable.
199    /// Default `[1, 2, 4, 8]` gives 4 attempts and a 15s total budget.
200    #[serde(default = "DiscoveryConfig::default_attempt_timeouts_secs")]
201    pub attempt_timeouts_secs: Vec<u64>,
202    /// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
203    #[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
204    pub recent_expiry_secs: u64,
205    /// Base backoff after lookup failure in seconds (`node.discovery.backoff_base_secs`).
206    /// Doubles per consecutive failure up to `backoff_max_secs`. Set both to
207    /// 0 to disable post-failure suppression.
208    #[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
209    pub backoff_base_secs: u64,
210    /// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
211    #[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
212    pub backoff_max_secs: u64,
213    /// Minimum interval between forwarded lookups for the same target in seconds
214    /// (`node.discovery.forward_min_interval_secs`).
215    /// Defense-in-depth against misbehaving nodes.
216    #[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
217    pub forward_min_interval_secs: u64,
218    /// Nostr-mediated overlay endpoint discovery.
219    #[serde(default = "DiscoveryConfig::default_nostr")]
220    pub nostr: NostrDiscoveryConfig,
221    /// mDNS / DNS-SD peer discovery on the local link. Identity surface
222    /// is a strict subset of what `nostr.advertise` already publishes
223    /// publicly, so there's no marginal privacy cost; the latency win
224    /// for same-LAN peers is large (sub-second pairing, no relay).
225    #[serde(default = "DiscoveryConfig::default_lan")]
226    pub lan: crate::discovery::lan::LanDiscoveryConfig,
227    /// Same-host process discovery through `~/.fips/instances/*.json`.
228    /// Embedded endpoints with a discovery scope enable this so local VMs,
229    /// browser helpers, and native app processes can find loopback-reachable
230    /// FIPS sockets without polling relays or relying on mDNS loopback.
231    #[serde(default = "DiscoveryConfig::default_local")]
232    pub local: crate::discovery::local::LocalInstanceDiscoveryConfig,
233}
234
235impl Default for DiscoveryConfig {
236    fn default() -> Self {
237        Self {
238            ttl: 64,
239            attempt_timeouts_secs: vec![1, 2, 4, 8],
240            recent_expiry_secs: 10,
241            backoff_base_secs: 30,
242            backoff_max_secs: 300,
243            forward_min_interval_secs: 2,
244            nostr: NostrDiscoveryConfig::default(),
245            lan: crate::discovery::lan::LanDiscoveryConfig::default(),
246            local: crate::discovery::local::LocalInstanceDiscoveryConfig::default(),
247        }
248    }
249}
250
251impl DiscoveryConfig {
252    fn default_ttl() -> u8 {
253        64
254    }
255    fn default_attempt_timeouts_secs() -> Vec<u64> {
256        vec![1, 2, 4, 8]
257    }
258    fn default_recent_expiry_secs() -> u64 {
259        10
260    }
261    fn default_backoff_base_secs() -> u64 {
262        30
263    }
264    fn default_backoff_max_secs() -> u64 {
265        300
266    }
267    fn default_forward_min_interval_secs() -> u64 {
268        2
269    }
270    fn default_nostr() -> NostrDiscoveryConfig {
271        NostrDiscoveryConfig::default()
272    }
273    fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
274        crate::discovery::lan::LanDiscoveryConfig::default()
275    }
276    fn default_local() -> crate::discovery::local::LocalInstanceDiscoveryConfig {
277        crate::discovery::local::LocalInstanceDiscoveryConfig::default()
278    }
279}
280
281/// Nostr advert discovery policy.
282///
283/// Controls how overlay endpoint adverts are consumed:
284/// - `disabled`: ignore advert-derived endpoints for all peers
285/// - `configured_only`: allow advert fallback for configured peers
286/// - `open`: also consider adverts for non-configured peers
287#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum NostrDiscoveryPolicy {
290    Disabled,
291    #[default]
292    ConfiguredOnly,
293    Open,
294}
295
296/// Nostr-mediated overlay endpoint discovery (`node.discovery.nostr.*`).
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(deny_unknown_fields)]
299pub struct NostrDiscoveryConfig {
300    /// Enable Nostr-signaled traversal bootstrap.
301    #[serde(default)]
302    pub enabled: bool,
303    /// Publish service advertisements so remote peers can bootstrap inbound.
304    #[serde(default = "NostrDiscoveryConfig::default_advertise")]
305    pub advertise: bool,
306    /// Relay URLs used for service advertisements.
307    #[serde(default = "NostrDiscoveryConfig::default_advert_relays")]
308    pub advert_relays: Vec<String>,
309    /// Relay URLs used for encrypted signaling events.
310    #[serde(default = "NostrDiscoveryConfig::default_dm_relays")]
311    pub dm_relays: Vec<String>,
312    /// STUN servers used for local reflexive address discovery.
313    /// Outbound observation uses only this local list; peer-advertised STUN
314    /// values are informational and are not treated as egress targets.
315    #[serde(default = "NostrDiscoveryConfig::default_stun_servers")]
316    pub stun_servers: Vec<String>,
317    /// Whether to advertise local (RFC 1918 / ULA) interface addresses as
318    /// host candidates in the traversal offer.
319    ///
320    /// Off by default: in most deployments the relevant peers are not on the
321    /// same broadcast domain, and sharing private host candidates causes
322    /// misleading punch successes when an asymmetric L3 path (corporate VPN,
323    /// Tailscale subnet route, overlapping address space, etc.) makes a
324    /// peer's private IP one-way reachable from this node. Enable only when
325    /// peers are on the same physical LAN and same-LAN punching is wanted.
326    #[serde(default)]
327    pub share_local_candidates: bool,
328    /// Traversal application namespace advertised in the Nostr protocol tag.
329    #[serde(default = "NostrDiscoveryConfig::default_app")]
330    pub app: String,
331    /// Signaling TTL in seconds.
332    #[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")]
333    pub signal_ttl_secs: u64,
334    /// Policy for advert-derived endpoint discovery.
335    #[serde(default)]
336    pub policy: NostrDiscoveryPolicy,
337    /// Max number of open-discovery peers queued for outbound retry/connection
338    /// at once. Prevents unbounded queue growth from ambient advert traffic.
339    #[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")]
340    pub open_discovery_max_pending: usize,
341    /// Max concurrent inbound traversal offers processed at once.
342    /// Acts as a rate limit against offer spam from relays.
343    #[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")]
344    pub max_concurrent_incoming_offers: usize,
345    /// Max cached overlay adverts retained from relay traffic.
346    /// Bounds memory under ambient advert volume.
347    #[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")]
348    pub advert_cache_max_entries: usize,
349    /// Max seen-session IDs retained for replay detection.
350    /// Oldest entries are evicted when the cap is exceeded.
351    #[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")]
352    pub seen_sessions_max_entries: usize,
353    /// Overall punch attempt timeout in seconds.
354    #[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")]
355    pub attempt_timeout_secs: u64,
356    /// Replay tracking retention window in seconds.
357    #[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")]
358    pub replay_window_secs: u64,
359    /// Delay before punch traffic starts.
360    #[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")]
361    pub punch_start_delay_ms: u64,
362    /// Interval between punch packets.
363    #[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")]
364    pub punch_interval_ms: u64,
365    /// How long to keep punching before failure.
366    #[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")]
367    pub punch_duration_ms: u64,
368    /// Advert TTL in seconds.
369    #[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")]
370    pub advert_ttl_secs: u64,
371    /// How often adverts are refreshed in seconds.
372    #[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")]
373    pub advert_refresh_secs: u64,
374    /// Settle delay in seconds after Nostr discovery starts before the
375    /// one-shot startup sweep of cached adverts runs. Allows the relay
376    /// subscription backlog to populate the in-memory advert cache.
377    /// Only used under `policy: open`. Default: 5.
378    #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_delay_secs")]
379    pub startup_sweep_delay_secs: u64,
380    /// Maximum age in seconds for cached adverts considered by the
381    /// one-shot startup sweep. Adverts whose `created_at` is older than
382    /// `now - startup_sweep_max_age_secs` are skipped. Only used under
383    /// `policy: open`. Default: 3600 (1 hour).
384    #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_max_age_secs")]
385    pub startup_sweep_max_age_secs: u64,
386    /// Number of consecutive NAT-traversal failures against a peer before
387    /// an extended cooldown is applied to throttle further offer publishes.
388    /// At this threshold the daemon also actively re-fetches the peer's
389    /// advert from `advert_relays` to evict cache entries for peers that
390    /// have gone away. Default: 5.
391    #[serde(default = "NostrDiscoveryConfig::default_failure_streak_threshold")]
392    pub failure_streak_threshold: u32,
393    /// Cooldown applied to a peer once `failure_streak_threshold` is hit.
394    /// Suppresses both open-discovery sweep enqueues and per-attempt
395    /// retry firings until elapsed. Default: 1800 (30 minutes).
396    #[serde(default = "NostrDiscoveryConfig::default_extended_cooldown_secs")]
397    pub extended_cooldown_secs: u64,
398    /// Minimum interval between `NAT traversal failed` WARN log lines for
399    /// the same peer. Subsequent failures inside the window log at DEBUG.
400    /// Reduces log spam on public-test nodes with many cache-learned
401    /// peers. Default: 300 (5 minutes).
402    #[serde(default = "NostrDiscoveryConfig::default_warn_log_interval_secs")]
403    pub warn_log_interval_secs: u64,
404    /// Maximum entries retained in the per-npub failure-state map.
405    /// Bounds memory under high cache turnover. Oldest entries (by last
406    /// failure time) evicted when the cap is exceeded. Default: 4096.
407    #[serde(default = "NostrDiscoveryConfig::default_failure_state_max_entries")]
408    pub failure_state_max_entries: usize,
409    /// Cooldown applied after observing a fatal protocol mismatch on a
410    /// Nostr-adopted bootstrap transport (e.g. `Unknown FMP version`
411    /// from a peer running a different FMP-protocol version). Independent
412    /// of `extended_cooldown_secs` and much longer because the mismatch
413    /// is structural — re-traversing the peer is wasted effort until one
414    /// side upgrades. Default: 86400 (24 hours).
415    #[serde(default = "NostrDiscoveryConfig::default_protocol_mismatch_cooldown_secs")]
416    pub protocol_mismatch_cooldown_secs: u64,
417}
418
419impl Default for NostrDiscoveryConfig {
420    fn default() -> Self {
421        Self {
422            enabled: false,
423            advertise: Self::default_advertise(),
424            advert_relays: Self::default_advert_relays(),
425            dm_relays: Self::default_dm_relays(),
426            stun_servers: Self::default_stun_servers(),
427            share_local_candidates: false,
428            app: Self::default_app(),
429            signal_ttl_secs: Self::default_signal_ttl_secs(),
430            policy: NostrDiscoveryPolicy::default(),
431            open_discovery_max_pending: Self::default_open_discovery_max_pending(),
432            max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(),
433            advert_cache_max_entries: Self::default_advert_cache_max_entries(),
434            seen_sessions_max_entries: Self::default_seen_sessions_max_entries(),
435            attempt_timeout_secs: Self::default_attempt_timeout_secs(),
436            replay_window_secs: Self::default_replay_window_secs(),
437            punch_start_delay_ms: Self::default_punch_start_delay_ms(),
438            punch_interval_ms: Self::default_punch_interval_ms(),
439            punch_duration_ms: Self::default_punch_duration_ms(),
440            advert_ttl_secs: Self::default_advert_ttl_secs(),
441            advert_refresh_secs: Self::default_advert_refresh_secs(),
442            startup_sweep_delay_secs: Self::default_startup_sweep_delay_secs(),
443            startup_sweep_max_age_secs: Self::default_startup_sweep_max_age_secs(),
444            failure_streak_threshold: Self::default_failure_streak_threshold(),
445            extended_cooldown_secs: Self::default_extended_cooldown_secs(),
446            warn_log_interval_secs: Self::default_warn_log_interval_secs(),
447            failure_state_max_entries: Self::default_failure_state_max_entries(),
448            protocol_mismatch_cooldown_secs: Self::default_protocol_mismatch_cooldown_secs(),
449        }
450    }
451}
452
453impl NostrDiscoveryConfig {
454    fn default_advertise() -> bool {
455        true
456    }
457
458    fn default_advert_relays() -> Vec<String> {
459        vec![
460            "wss://relay.damus.io".to_string(),
461            "wss://nos.lol".to_string(),
462            "wss://offchain.pub".to_string(),
463            "wss://temp.iris.to".to_string(),
464        ]
465    }
466
467    fn default_dm_relays() -> Vec<String> {
468        vec![
469            "wss://relay.damus.io".to_string(),
470            "wss://nos.lol".to_string(),
471            "wss://offchain.pub".to_string(),
472            "wss://temp.iris.to".to_string(),
473        ]
474    }
475
476    fn default_stun_servers() -> Vec<String> {
477        vec![
478            "stun:stun.l.google.com:19302".to_string(),
479            "stun:stun.cloudflare.com:3478".to_string(),
480            "stun:global.stun.twilio.com:3478".to_string(),
481        ]
482    }
483
484    fn default_app() -> String {
485        "fips-overlay-v1".to_string()
486    }
487
488    fn default_signal_ttl_secs() -> u64 {
489        120
490    }
491
492    fn default_open_discovery_max_pending() -> usize {
493        64
494    }
495
496    fn default_max_concurrent_incoming_offers() -> usize {
497        16
498    }
499
500    fn default_advert_cache_max_entries() -> usize {
501        2048
502    }
503
504    fn default_seen_sessions_max_entries() -> usize {
505        2048
506    }
507
508    fn default_attempt_timeout_secs() -> u64 {
509        10
510    }
511
512    fn default_replay_window_secs() -> u64 {
513        300
514    }
515
516    fn default_punch_start_delay_ms() -> u64 {
517        2_000
518    }
519
520    fn default_punch_interval_ms() -> u64 {
521        200
522    }
523
524    fn default_punch_duration_ms() -> u64 {
525        10_000
526    }
527
528    fn default_advert_ttl_secs() -> u64 {
529        3_600
530    }
531
532    fn default_advert_refresh_secs() -> u64 {
533        1_800
534    }
535
536    fn default_startup_sweep_delay_secs() -> u64 {
537        5
538    }
539
540    fn default_startup_sweep_max_age_secs() -> u64 {
541        3_600
542    }
543
544    fn default_failure_streak_threshold() -> u32 {
545        5
546    }
547
548    fn default_extended_cooldown_secs() -> u64 {
549        1_800
550    }
551
552    fn default_warn_log_interval_secs() -> u64 {
553        300
554    }
555
556    fn default_failure_state_max_entries() -> usize {
557        4_096
558    }
559
560    fn default_protocol_mismatch_cooldown_secs() -> u64 {
561        86_400
562    }
563}
564
565/// Spanning tree (`node.tree.*`).
566#[derive(Debug, Clone, Serialize, Deserialize)]
567pub struct TreeConfig {
568    /// Per-peer TreeAnnounce rate limit in ms (`node.tree.announce_min_interval_ms`).
569    #[serde(default = "TreeConfig::default_announce_min_interval_ms")]
570    pub announce_min_interval_ms: u64,
571    /// Hysteresis factor for cost-based parent re-selection (`node.tree.parent_hysteresis`).
572    ///
573    /// Only switch parents when the candidate's effective_depth is better than
574    /// `current_effective_depth * (1.0 - parent_hysteresis)`. Range: 0.0-1.0.
575    /// Set to 0.0 to disable hysteresis (switch on any improvement).
576    #[serde(default = "TreeConfig::default_parent_hysteresis")]
577    pub parent_hysteresis: f64,
578    /// Hold-down period after parent switch in seconds (`node.tree.hold_down_secs`).
579    ///
580    /// After switching parents, suppress re-evaluation for this duration to allow
581    /// MMP metrics to stabilize on the new link. Set to 0 to disable.
582    #[serde(default = "TreeConfig::default_hold_down_secs")]
583    pub hold_down_secs: u64,
584    /// Periodic parent re-evaluation interval in seconds (`node.tree.reeval_interval_secs`).
585    ///
586    /// How often to re-evaluate parent selection based on current MMP link costs,
587    /// independent of TreeAnnounce traffic. Catches link degradation after the
588    /// tree has stabilized. Set to 0 to disable.
589    #[serde(default = "TreeConfig::default_reeval_interval_secs")]
590    pub reeval_interval_secs: u64,
591    /// Flap dampening: max parent switches before extended hold-down (`node.tree.flap_threshold`).
592    #[serde(default = "TreeConfig::default_flap_threshold")]
593    pub flap_threshold: u32,
594    /// Flap dampening: window in seconds for counting switches (`node.tree.flap_window_secs`).
595    #[serde(default = "TreeConfig::default_flap_window_secs")]
596    pub flap_window_secs: u64,
597    /// Flap dampening: extended hold-down duration in seconds (`node.tree.flap_dampening_secs`).
598    #[serde(default = "TreeConfig::default_flap_dampening_secs")]
599    pub flap_dampening_secs: u64,
600}
601
602impl Default for TreeConfig {
603    fn default() -> Self {
604        Self {
605            announce_min_interval_ms: 500,
606            parent_hysteresis: 0.2,
607            hold_down_secs: 30,
608            reeval_interval_secs: 60,
609            flap_threshold: 4,
610            flap_window_secs: 60,
611            flap_dampening_secs: 120,
612        }
613    }
614}
615
616impl TreeConfig {
617    fn default_announce_min_interval_ms() -> u64 {
618        500
619    }
620    fn default_parent_hysteresis() -> f64 {
621        0.2
622    }
623    fn default_hold_down_secs() -> u64 {
624        30
625    }
626    fn default_reeval_interval_secs() -> u64 {
627        60
628    }
629    fn default_flap_threshold() -> u32 {
630        4
631    }
632    fn default_flap_window_secs() -> u64 {
633        60
634    }
635    fn default_flap_dampening_secs() -> u64 {
636        120
637    }
638}
639
640/// Routing strategy selection (`node.routing.*`).
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct RoutingConfig {
643    /// Next-hop selection mode (`node.routing.mode`).
644    #[serde(default)]
645    pub mode: RoutingMode,
646    /// TTL for learned reverse-path routes in seconds (`node.routing.learned_ttl_secs`).
647    #[serde(default = "RoutingConfig::default_learned_ttl_secs")]
648    pub learned_ttl_secs: u64,
649    /// Maximum locally observed next-hop candidates kept per destination for
650    /// reply-learned multipath/exploration
651    /// (`node.routing.max_learned_routes_per_dest`).
652    #[serde(default = "RoutingConfig::default_max_learned_routes_per_dest")]
653    pub max_learned_routes_per_dest: usize,
654    /// Every N learned-route selections, try the coordinate/bloom/tree route
655    /// instead so new paths can be discovered (`0` disables fallback exploration).
656    #[serde(default = "RoutingConfig::default_learned_fallback_explore_interval")]
657    pub learned_fallback_explore_interval: u64,
658}
659
660impl Default for RoutingConfig {
661    fn default() -> Self {
662        Self {
663            mode: RoutingMode::default(),
664            learned_ttl_secs: 300,
665            max_learned_routes_per_dest: 4,
666            learned_fallback_explore_interval: 16,
667        }
668    }
669}
670
671impl RoutingConfig {
672    fn default_learned_ttl_secs() -> u64 {
673        300
674    }
675
676    fn default_max_learned_routes_per_dest() -> usize {
677        4
678    }
679
680    fn default_learned_fallback_explore_interval() -> u64 {
681        16
682    }
683}
684
685/// Daemon routing mode.
686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
687#[serde(rename_all = "snake_case")]
688pub enum RoutingMode {
689    /// Current FIPS behavior: bloom-assisted greedy tree routing.
690    #[default]
691    Tree,
692    /// Prefer locally learned reverse paths before falling back to tree routing.
693    ///
694    /// Learned routes are populated only from local evidence: inbound
695    /// SessionDatagrams and verified LookupResponses.
696    ReplyLearned,
697}
698
699impl std::fmt::Display for RoutingMode {
700    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701        match self {
702            RoutingMode::Tree => write!(f, "tree"),
703            RoutingMode::ReplyLearned => write!(f, "reply_learned"),
704        }
705    }
706}
707
708/// Bloom filter (`node.bloom.*`).
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct BloomConfig {
711    /// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`).
712    #[serde(default = "BloomConfig::default_update_debounce_ms")]
713    pub update_debounce_ms: u64,
714    /// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
715    /// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
716    /// Default `0.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
717    /// filter. Conceptually distinct from future autoscaling hysteresis
718    /// setpoints — same unit, different knobs.
719    #[serde(default = "BloomConfig::default_max_inbound_fpr")]
720    pub max_inbound_fpr: f64,
721}
722
723impl Default for BloomConfig {
724    fn default() -> Self {
725        Self {
726            update_debounce_ms: 500,
727            max_inbound_fpr: 0.05,
728        }
729    }
730}
731
732impl BloomConfig {
733    fn default_update_debounce_ms() -> u64 {
734        500
735    }
736    fn default_max_inbound_fpr() -> f64 {
737        0.05
738    }
739}
740
741/// Session/data plane (`node.session.*`).
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct SessionConfig {
744    /// Default SessionDatagram TTL (`node.session.default_ttl`).
745    #[serde(default = "SessionConfig::default_ttl")]
746    pub default_ttl: u8,
747    /// Queue depth per dest during session establishment (`node.session.pending_packets_per_dest`).
748    #[serde(default = "SessionConfig::default_pending_packets_per_dest")]
749    pub pending_packets_per_dest: usize,
750    /// Max destinations with pending packets (`node.session.pending_max_destinations`).
751    #[serde(default = "SessionConfig::default_pending_max_destinations")]
752    pub pending_max_destinations: usize,
753    /// Idle session timeout in seconds (`node.session.idle_timeout_secs`).
754    /// Established sessions with no application data for this duration are
755    /// removed. MMP reports do not count as activity for this timer.
756    #[serde(default = "SessionConfig::default_idle_timeout_secs")]
757    pub idle_timeout_secs: u64,
758    /// Number of initial data packets per session that include COORDS_PRESENT
759    /// for transit cache warmup (`node.session.coords_warmup_packets`).
760    /// Also used as the reset count on CoordsRequired receipt.
761    #[serde(default = "SessionConfig::default_coords_warmup_packets")]
762    pub coords_warmup_packets: u8,
763    /// Minimum interval (ms) between standalone CoordsWarmup responses to
764    /// CoordsRequired/PathBroken signals, per destination
765    /// (`node.session.coords_response_interval_ms`).
766    #[serde(default = "SessionConfig::default_coords_response_interval_ms")]
767    pub coords_response_interval_ms: u64,
768}
769
770impl Default for SessionConfig {
771    fn default() -> Self {
772        Self {
773            default_ttl: 64,
774            pending_packets_per_dest: 16,
775            pending_max_destinations: 256,
776            idle_timeout_secs: 90,
777            coords_warmup_packets: 5,
778            coords_response_interval_ms: 2000,
779        }
780    }
781}
782
783impl SessionConfig {
784    fn default_ttl() -> u8 {
785        64
786    }
787    fn default_pending_packets_per_dest() -> usize {
788        16
789    }
790    fn default_pending_max_destinations() -> usize {
791        256
792    }
793    fn default_idle_timeout_secs() -> u64 {
794        90
795    }
796    fn default_coords_warmup_packets() -> u8 {
797        5
798    }
799    fn default_coords_response_interval_ms() -> u64 {
800        2000
801    }
802}
803
804/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
805///
806/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
807/// configuration per layer. Session reports consume bandwidth on every transit
808/// link, so operators may want a lighter mode (e.g., Lightweight) for sessions
809/// while running Full mode on links.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct SessionMmpConfig {
812    /// Operating mode (`node.session_mmp.mode`).
813    #[serde(default)]
814    pub mode: MmpMode,
815
816    /// Periodic operator log interval in seconds (`node.session_mmp.log_interval_secs`).
817    #[serde(default = "SessionMmpConfig::default_log_interval_secs")]
818    pub log_interval_secs: u64,
819
820    /// OWD trend ring buffer size (`node.session_mmp.owd_window_size`).
821    #[serde(default = "SessionMmpConfig::default_owd_window_size")]
822    pub owd_window_size: usize,
823}
824
825impl Default for SessionMmpConfig {
826    fn default() -> Self {
827        Self {
828            mode: MmpMode::default(),
829            log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
830            owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
831        }
832    }
833}
834
835impl SessionMmpConfig {
836    fn default_log_interval_secs() -> u64 {
837        DEFAULT_LOG_INTERVAL_SECS
838    }
839    fn default_owd_window_size() -> usize {
840        DEFAULT_OWD_WINDOW_SIZE
841    }
842}
843
844/// Control socket configuration (`node.control.*`).
845#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct ControlConfig {
847    /// Enable the control socket (`node.control.enabled`).
848    #[serde(default = "ControlConfig::default_enabled")]
849    pub enabled: bool,
850    /// Unix socket path (`node.control.socket_path`).
851    #[serde(default = "ControlConfig::default_socket_path")]
852    pub socket_path: String,
853}
854
855impl Default for ControlConfig {
856    fn default() -> Self {
857        Self {
858            enabled: true,
859            socket_path: Self::default_socket_path(),
860        }
861    }
862}
863
864impl ControlConfig {
865    fn default_enabled() -> bool {
866        true
867    }
868
869    /// Default control socket path.
870    ///
871    /// On Unix, returns the shared `/run/fips`, `XDG_RUNTIME_DIR`, then `/tmp`
872    /// fallback used by fipsctl and fipstop. On Windows, returns a TCP port
873    /// number as a string since Windows does not support Unix domain sockets;
874    /// the control socket listens on localhost at this port.
875    fn default_socket_path() -> String {
876        #[cfg(unix)]
877        {
878            super::resolve_default_socket("control.sock")
879        }
880        #[cfg(windows)]
881        {
882            "21210".to_string()
883        }
884    }
885}
886
887/// Internal buffers (`node.buffers.*`).
888#[derive(Debug, Clone, Serialize, Deserialize)]
889pub struct BuffersConfig {
890    /// Transport→Node packet channel capacity (`node.buffers.packet_channel`).
891    #[serde(default = "BuffersConfig::default_packet_channel")]
892    pub packet_channel: usize,
893    /// TUN→Node outbound channel capacity (`node.buffers.tun_channel`).
894    #[serde(default = "BuffersConfig::default_tun_channel")]
895    pub tun_channel: usize,
896    /// DNS→Node identity channel capacity (`node.buffers.dns_channel`).
897    #[serde(default = "BuffersConfig::default_dns_channel")]
898    pub dns_channel: usize,
899}
900
901impl Default for BuffersConfig {
902    fn default() -> Self {
903        Self {
904            packet_channel: 1024,
905            tun_channel: 1024,
906            dns_channel: 64,
907        }
908    }
909}
910
911impl BuffersConfig {
912    fn default_packet_channel() -> usize {
913        1024
914    }
915    fn default_tun_channel() -> usize {
916        1024
917    }
918    fn default_dns_channel() -> usize {
919        64
920    }
921}
922
923// ============================================================================
924// ECN Congestion Signaling
925// ============================================================================
926
927/// Rekey / session rekeying configuration (`node.rekey.*`).
928///
929/// Controls periodic full rekey for both FMP (link layer) and FSP
930/// (session layer) Noise sessions. Rekeying provides true forward secrecy
931/// with fresh DH randomness, nonce reset, and session index rotation.
932///
933/// Keep the packet-count default high for packet-tunnel workloads. A low value
934/// such as 65k packets can force multi-hundred-Mbit tunnels to rekey every few
935/// seconds, which creates avoidable cutover churn and can dominate throughput.
936/// Operators can still lower `node.rekey.after_messages` for CI stress tests or
937/// very conservative deployments; the time-based `after_secs` default remains
938/// the normal production rekey cadence.
939const DEFAULT_REKEY_AFTER_MESSAGES: u64 = 1 << 48;
940
941#[derive(Debug, Clone, Serialize, Deserialize)]
942pub struct RekeyConfig {
943    /// Enable periodic rekey (`node.rekey.enabled`).
944    #[serde(default = "RekeyConfig::default_enabled")]
945    pub enabled: bool,
946
947    /// Initiate rekey after this many seconds (`node.rekey.after_secs`).
948    #[serde(default = "RekeyConfig::default_after_secs")]
949    pub after_secs: u64,
950
951    /// Initiate rekey after this many messages sent (`node.rekey.after_messages`).
952    #[serde(default = "RekeyConfig::default_after_messages")]
953    pub after_messages: u64,
954}
955
956impl Default for RekeyConfig {
957    fn default() -> Self {
958        Self {
959            enabled: true,
960            after_secs: 120,
961            after_messages: DEFAULT_REKEY_AFTER_MESSAGES,
962        }
963    }
964}
965
966impl RekeyConfig {
967    fn default_enabled() -> bool {
968        true
969    }
970    fn default_after_secs() -> u64 {
971        120
972    }
973    fn default_after_messages() -> u64 {
974        DEFAULT_REKEY_AFTER_MESSAGES
975    }
976}
977
978/// ECN congestion signaling configuration (`node.ecn.*`).
979///
980/// Controls the FMP CE relay chain: transit nodes detect congestion on outgoing
981/// links and set the CE flag in forwarded datagrams. The destination marks
982/// IPv6 ECN-CE on ECN-capable packets before TUN delivery.
983#[derive(Debug, Clone, Serialize, Deserialize)]
984pub struct EcnConfig {
985    /// Enable ECN congestion signaling (`node.ecn.enabled`).
986    #[serde(default = "EcnConfig::default_enabled")]
987    pub enabled: bool,
988
989    /// Loss rate threshold for marking CE (`node.ecn.loss_threshold`).
990    /// When the outgoing link's loss rate meets or exceeds this value,
991    /// the transit node sets CE on forwarded datagrams.
992    #[serde(default = "EcnConfig::default_loss_threshold")]
993    pub loss_threshold: f64,
994
995    /// ETX threshold for marking CE (`node.ecn.etx_threshold`).
996    /// When the outgoing link's ETX meets or exceeds this value,
997    /// the transit node sets CE on forwarded datagrams.
998    #[serde(default = "EcnConfig::default_etx_threshold")]
999    pub etx_threshold: f64,
1000}
1001
1002impl Default for EcnConfig {
1003    fn default() -> Self {
1004        Self {
1005            enabled: true,
1006            loss_threshold: 0.05,
1007            etx_threshold: 3.0,
1008        }
1009    }
1010}
1011
1012impl EcnConfig {
1013    fn default_enabled() -> bool {
1014        true
1015    }
1016    fn default_loss_threshold() -> f64 {
1017        0.05
1018    }
1019    fn default_etx_threshold() -> f64 {
1020        3.0
1021    }
1022}
1023
1024// ============================================================================
1025// Node Configuration (Root)
1026// ============================================================================
1027
1028/// Node configuration (`node.*`).
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030pub struct NodeConfig {
1031    /// Identity configuration (`node.identity.*`).
1032    #[serde(default)]
1033    pub identity: IdentityConfig,
1034
1035    /// Leaf-only mode (`node.leaf_only`).
1036    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1037    pub leaf_only: bool,
1038
1039    /// RX loop maintenance tick period in seconds (`node.tick_interval_secs`).
1040    #[serde(default = "NodeConfig::default_tick_interval_secs")]
1041    pub tick_interval_secs: u64,
1042
1043    /// Initial RTT estimate for new links in ms (`node.base_rtt_ms`).
1044    #[serde(default = "NodeConfig::default_base_rtt_ms")]
1045    pub base_rtt_ms: u64,
1046
1047    /// Link heartbeat send interval in seconds (`node.heartbeat_interval_secs`).
1048    #[serde(default = "NodeConfig::default_heartbeat_interval_secs")]
1049    pub heartbeat_interval_secs: u64,
1050
1051    /// Link dead timeout in seconds (`node.link_dead_timeout_secs`).
1052    /// Peers silent for this duration are removed.
1053    #[serde(default = "NodeConfig::default_link_dead_timeout_secs")]
1054    pub link_dead_timeout_secs: u64,
1055
1056    /// Accelerated link dead timeout in seconds, used in place of
1057    /// `link_dead_timeout_secs` while a recent `transport.send` returned
1058    /// a local-side errno (`NetworkUnreachable` / `HostUnreachable` /
1059    /// `AddrNotAvailable`) — direct evidence our outbound path is broken
1060    /// right now (interface vanished, default route flapped, etc.). No
1061    /// reason to wait the full receive-silence window when the kernel
1062    /// already told us we can't send. Steady-state behavior is unchanged
1063    /// because the signal is cleared on the next successful send.
1064    /// (`node.fast_link_dead_timeout_secs`)
1065    #[serde(default = "NodeConfig::default_fast_link_dead_timeout_secs")]
1066    pub fast_link_dead_timeout_secs: u64,
1067
1068    /// Resource limits (`node.limits.*`).
1069    #[serde(default)]
1070    pub limits: LimitsConfig,
1071
1072    /// Rate limiting (`node.rate_limit.*`).
1073    #[serde(default)]
1074    pub rate_limit: RateLimitConfig,
1075
1076    /// Retry/backoff (`node.retry.*`).
1077    #[serde(default)]
1078    pub retry: RetryConfig,
1079
1080    /// Cache parameters (`node.cache.*`).
1081    #[serde(default)]
1082    pub cache: CacheConfig,
1083
1084    /// Discovery protocol (`node.discovery.*`).
1085    #[serde(default)]
1086    pub discovery: DiscoveryConfig,
1087
1088    /// Spanning tree (`node.tree.*`).
1089    #[serde(default)]
1090    pub tree: TreeConfig,
1091
1092    /// Routing strategy (`node.routing.*`).
1093    #[serde(default)]
1094    pub routing: RoutingConfig,
1095
1096    /// Bloom filter (`node.bloom.*`).
1097    #[serde(default)]
1098    pub bloom: BloomConfig,
1099
1100    /// Session/data plane (`node.session.*`).
1101    #[serde(default)]
1102    pub session: SessionConfig,
1103
1104    /// Internal buffers (`node.buffers.*`).
1105    #[serde(default)]
1106    pub buffers: BuffersConfig,
1107
1108    /// Control socket (`node.control.*`).
1109    #[serde(default)]
1110    pub control: ControlConfig,
1111
1112    /// Metrics Measurement Protocol — link layer (`node.mmp.*`).
1113    #[serde(default)]
1114    pub mmp: MmpConfig,
1115
1116    /// Metrics Measurement Protocol — session layer (`node.session_mmp.*`).
1117    #[serde(default)]
1118    pub session_mmp: SessionMmpConfig,
1119
1120    /// ECN congestion signaling (`node.ecn.*`).
1121    #[serde(default)]
1122    pub ecn: EcnConfig,
1123
1124    /// Rekey / session rekeying (`node.rekey.*`).
1125    #[serde(default)]
1126    pub rekey: RekeyConfig,
1127
1128    /// Enable daemon-oriented system files such as `/etc/fips/hosts` and
1129    /// `/etc/fips/peers.{allow,deny}`. Embedded endpoints disable this.
1130    #[serde(default = "NodeConfig::default_system_files_enabled")]
1131    pub system_files_enabled: bool,
1132
1133    /// Enable off-task Unix encrypt/decrypt worker pools (`node.worker_pools_enabled`).
1134    /// Embedded/mobile endpoints can disable this to keep crypto/send work inline
1135    /// with the rx loop when platform extension sandboxes make OS-thread pools
1136    /// unsuitable.
1137    #[serde(default = "NodeConfig::default_worker_pools_enabled")]
1138    pub worker_pools_enabled: bool,
1139
1140    /// Log level (`node.log_level`). Case-insensitive.
1141    /// Valid values: trace, debug, info, warn, error. Default: info.
1142    #[serde(default)]
1143    pub log_level: Option<String>,
1144}
1145
1146impl Default for NodeConfig {
1147    fn default() -> Self {
1148        Self {
1149            identity: IdentityConfig::default(),
1150            leaf_only: false,
1151            tick_interval_secs: 1,
1152            base_rtt_ms: 100,
1153            heartbeat_interval_secs: 10,
1154            link_dead_timeout_secs: 30,
1155            fast_link_dead_timeout_secs: 5,
1156            limits: LimitsConfig::default(),
1157            rate_limit: RateLimitConfig::default(),
1158            retry: RetryConfig::default(),
1159            cache: CacheConfig::default(),
1160            discovery: DiscoveryConfig::default(),
1161            tree: TreeConfig::default(),
1162            routing: RoutingConfig::default(),
1163            bloom: BloomConfig::default(),
1164            session: SessionConfig::default(),
1165            buffers: BuffersConfig::default(),
1166            control: ControlConfig::default(),
1167            mmp: MmpConfig::default(),
1168            session_mmp: SessionMmpConfig::default(),
1169            ecn: EcnConfig::default(),
1170            rekey: RekeyConfig::default(),
1171            system_files_enabled: true,
1172            worker_pools_enabled: true,
1173            log_level: None,
1174        }
1175    }
1176}
1177
1178impl NodeConfig {
1179    /// Get the log level as a tracing Level. Default: INFO.
1180    pub fn log_level(&self) -> tracing::Level {
1181        match self
1182            .log_level
1183            .as_deref()
1184            .map(|s| s.to_lowercase())
1185            .as_deref()
1186        {
1187            Some("trace") => tracing::Level::TRACE,
1188            Some("debug") => tracing::Level::DEBUG,
1189            Some("warn") | Some("warning") => tracing::Level::WARN,
1190            Some("error") => tracing::Level::ERROR,
1191            _ => tracing::Level::INFO,
1192        }
1193    }
1194
1195    fn default_tick_interval_secs() -> u64 {
1196        1
1197    }
1198    fn default_base_rtt_ms() -> u64 {
1199        100
1200    }
1201    fn default_heartbeat_interval_secs() -> u64 {
1202        10
1203    }
1204    fn default_link_dead_timeout_secs() -> u64 {
1205        30
1206    }
1207    fn default_fast_link_dead_timeout_secs() -> u64 {
1208        5
1209    }
1210    fn default_system_files_enabled() -> bool {
1211        true
1212    }
1213    fn default_worker_pools_enabled() -> bool {
1214        true
1215    }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use super::*;
1221
1222    #[test]
1223    fn test_ecn_config_defaults() {
1224        let c = EcnConfig::default();
1225        assert!(c.enabled);
1226        assert!((c.loss_threshold - 0.05).abs() < 1e-9);
1227        assert!((c.etx_threshold - 3.0).abs() < 1e-9);
1228    }
1229
1230    #[test]
1231    fn test_rekey_config_defaults() {
1232        let c = RekeyConfig::default();
1233        assert!(c.enabled);
1234        assert_eq!(c.after_secs, 120);
1235        assert_eq!(c.after_messages, 1 << 48);
1236    }
1237
1238    #[test]
1239    fn test_rekey_config_partial_yaml_uses_defaults() {
1240        let yaml = "after_secs: 30\n";
1241        let c: RekeyConfig = serde_yaml::from_str(yaml).unwrap();
1242        assert!(c.enabled);
1243        assert_eq!(c.after_secs, 30);
1244        assert_eq!(c.after_messages, 1 << 48);
1245    }
1246
1247    #[test]
1248    fn test_routing_config_defaults() {
1249        let c = RoutingConfig::default();
1250        assert_eq!(c.mode, RoutingMode::Tree);
1251        assert_eq!(c.learned_ttl_secs, 300);
1252        assert_eq!(c.max_learned_routes_per_dest, 4);
1253        assert_eq!(c.learned_fallback_explore_interval, 16);
1254    }
1255
1256    #[test]
1257    fn test_routing_config_yaml() {
1258        let yaml = "mode: reply_learned\nlearned_ttl_secs: 120\nmax_learned_routes_per_dest: 2\nlearned_fallback_explore_interval: 8\n";
1259        let c: RoutingConfig = serde_yaml::from_str(yaml).unwrap();
1260        assert_eq!(c.mode, RoutingMode::ReplyLearned);
1261        assert_eq!(c.learned_ttl_secs, 120);
1262        assert_eq!(c.max_learned_routes_per_dest, 2);
1263        assert_eq!(c.learned_fallback_explore_interval, 8);
1264    }
1265
1266    #[test]
1267    fn test_ecn_config_yaml_roundtrip() {
1268        let yaml = "loss_threshold: 0.10\netx_threshold: 2.5\nenabled: false\n";
1269        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
1270        assert!(!c.enabled);
1271        assert!((c.loss_threshold - 0.10).abs() < 1e-9);
1272        assert!((c.etx_threshold - 2.5).abs() < 1e-9);
1273    }
1274
1275    #[test]
1276    fn test_ecn_config_partial_yaml() {
1277        // Only specify loss_threshold — others should get defaults
1278        let yaml = "loss_threshold: 0.02\n";
1279        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
1280        assert!(c.enabled); // default
1281        assert!((c.loss_threshold - 0.02).abs() < 1e-9);
1282        assert!((c.etx_threshold - 3.0).abs() < 1e-9); // default
1283    }
1284
1285    #[test]
1286    fn test_nostr_discovery_startup_sweep_defaults() {
1287        let c = NostrDiscoveryConfig::default();
1288        assert_eq!(c.startup_sweep_delay_secs, 5);
1289        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
1290    }
1291
1292    #[test]
1293    fn test_nostr_discovery_startup_sweep_yaml_override() {
1294        let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n";
1295        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
1296        assert!(c.enabled);
1297        assert_eq!(c.policy, NostrDiscoveryPolicy::Open);
1298        assert_eq!(c.startup_sweep_delay_secs, 10);
1299        assert_eq!(c.startup_sweep_max_age_secs, 1_800);
1300    }
1301
1302    #[test]
1303    fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() {
1304        // Only override delay; max_age should fall back to default.
1305        let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n";
1306        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
1307        assert_eq!(c.startup_sweep_delay_secs, 30);
1308        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
1309    }
1310
1311    #[test]
1312    fn test_log_level_parser() {
1313        // Pin the observed behavior of NodeConfig::log_level():
1314        // - 5 explicit lowercased match arms (trace/debug/warn|warning/error)
1315        // - INFO is the default (no explicit "info" arm; falls through default)
1316        // - Case-insensitive via .to_lowercase()
1317        // - Unknown strings and None both fall through to INFO
1318        let cases: &[(Option<&str>, tracing::Level)] = &[
1319            // Explicit arms (lowercase canonical form)
1320            (Some("trace"), tracing::Level::TRACE),
1321            (Some("debug"), tracing::Level::DEBUG),
1322            (Some("warn"), tracing::Level::WARN),
1323            (Some("warning"), tracing::Level::WARN),
1324            (Some("error"), tracing::Level::ERROR),
1325            // "info" has no explicit arm — falls through default
1326            (Some("info"), tracing::Level::INFO),
1327            // None → default INFO
1328            (None, tracing::Level::INFO),
1329            // Case-insensitivity (parser lowercases via .to_lowercase())
1330            (Some("TRACE"), tracing::Level::TRACE),
1331            (Some("Debug"), tracing::Level::DEBUG),
1332            (Some("Warning"), tracing::Level::WARN),
1333            (Some("WARN"), tracing::Level::WARN),
1334            (Some("ERROR"), tracing::Level::ERROR),
1335            (Some("INFO"), tracing::Level::INFO),
1336            // Unknown strings → INFO default (no error path)
1337            (Some("verbose"), tracing::Level::INFO),
1338            (Some("nonsense"), tracing::Level::INFO),
1339            (Some(""), tracing::Level::INFO),
1340        ];
1341
1342        for (input, expected) in cases {
1343            let cfg = NodeConfig {
1344                log_level: input.map(|s| s.to_string()),
1345                ..NodeConfig::default()
1346            };
1347            assert_eq!(
1348                cfg.log_level(),
1349                *expected,
1350                "input {:?} should map to {:?}",
1351                input,
1352                expected
1353            );
1354        }
1355    }
1356
1357    #[cfg(windows)]
1358    #[test]
1359    fn test_default_socket_path_windows() {
1360        let config = ControlConfig::default();
1361        // On Windows, socket_path is a TCP port number
1362        let port: u16 = config
1363            .socket_path
1364            .parse()
1365            .expect("should be a valid port number");
1366        assert_eq!(port, 21210);
1367    }
1368}