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