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