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 a Unix domain socket path (XDG_RUNTIME_DIR, /run/fips,
861    /// or /tmp fallback). On Windows, returns a TCP port number as a string
862    /// since Windows does not support Unix domain sockets; the control socket
863    /// listens on localhost at this port.
864    fn default_socket_path() -> String {
865        #[cfg(unix)]
866        {
867            if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
868                format!("{runtime_dir}/fips/control.sock")
869            } else if std::fs::create_dir_all("/run/fips").is_ok() {
870                "/run/fips/control.sock".to_string()
871            } else {
872                "/tmp/fips-control.sock".to_string()
873            }
874        }
875        #[cfg(windows)]
876        {
877            "21210".to_string()
878        }
879    }
880}
881
882/// Internal buffers (`node.buffers.*`).
883#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct BuffersConfig {
885    /// Transport→Node packet channel capacity (`node.buffers.packet_channel`).
886    #[serde(default = "BuffersConfig::default_packet_channel")]
887    pub packet_channel: usize,
888    /// TUN→Node outbound channel capacity (`node.buffers.tun_channel`).
889    #[serde(default = "BuffersConfig::default_tun_channel")]
890    pub tun_channel: usize,
891    /// DNS→Node identity channel capacity (`node.buffers.dns_channel`).
892    #[serde(default = "BuffersConfig::default_dns_channel")]
893    pub dns_channel: usize,
894}
895
896impl Default for BuffersConfig {
897    fn default() -> Self {
898        Self {
899            packet_channel: 1024,
900            tun_channel: 1024,
901            dns_channel: 64,
902        }
903    }
904}
905
906impl BuffersConfig {
907    fn default_packet_channel() -> usize {
908        1024
909    }
910    fn default_tun_channel() -> usize {
911        1024
912    }
913    fn default_dns_channel() -> usize {
914        64
915    }
916}
917
918// ============================================================================
919// ECN Congestion Signaling
920// ============================================================================
921
922/// Rekey / session rekeying configuration (`node.rekey.*`).
923///
924/// Controls periodic full rekey for both FMP (link layer) and FSP
925/// (session layer) Noise sessions. Rekeying provides true forward secrecy
926/// with fresh DH randomness, nonce reset, and session index rotation.
927///
928/// Keep the packet-count default high for packet-tunnel workloads. A low value
929/// such as 65k packets can force multi-hundred-Mbit tunnels to rekey every few
930/// seconds, which creates avoidable cutover churn and can dominate throughput.
931/// Operators can still lower `node.rekey.after_messages` for CI stress tests or
932/// very conservative deployments; the time-based `after_secs` default remains
933/// the normal production rekey cadence.
934const DEFAULT_REKEY_AFTER_MESSAGES: u64 = 1 << 48;
935
936#[derive(Debug, Clone, Serialize, Deserialize)]
937pub struct RekeyConfig {
938    /// Enable periodic rekey (`node.rekey.enabled`).
939    #[serde(default = "RekeyConfig::default_enabled")]
940    pub enabled: bool,
941
942    /// Initiate rekey after this many seconds (`node.rekey.after_secs`).
943    #[serde(default = "RekeyConfig::default_after_secs")]
944    pub after_secs: u64,
945
946    /// Initiate rekey after this many messages sent (`node.rekey.after_messages`).
947    #[serde(default = "RekeyConfig::default_after_messages")]
948    pub after_messages: u64,
949}
950
951impl Default for RekeyConfig {
952    fn default() -> Self {
953        Self {
954            enabled: true,
955            after_secs: 120,
956            after_messages: DEFAULT_REKEY_AFTER_MESSAGES,
957        }
958    }
959}
960
961impl RekeyConfig {
962    fn default_enabled() -> bool {
963        true
964    }
965    fn default_after_secs() -> u64 {
966        120
967    }
968    fn default_after_messages() -> u64 {
969        DEFAULT_REKEY_AFTER_MESSAGES
970    }
971}
972
973/// ECN congestion signaling configuration (`node.ecn.*`).
974///
975/// Controls the FMP CE relay chain: transit nodes detect congestion on outgoing
976/// links and set the CE flag in forwarded datagrams. The destination marks
977/// IPv6 ECN-CE on ECN-capable packets before TUN delivery.
978#[derive(Debug, Clone, Serialize, Deserialize)]
979pub struct EcnConfig {
980    /// Enable ECN congestion signaling (`node.ecn.enabled`).
981    #[serde(default = "EcnConfig::default_enabled")]
982    pub enabled: bool,
983
984    /// Loss rate threshold for marking CE (`node.ecn.loss_threshold`).
985    /// When the outgoing link's loss rate meets or exceeds this value,
986    /// the transit node sets CE on forwarded datagrams.
987    #[serde(default = "EcnConfig::default_loss_threshold")]
988    pub loss_threshold: f64,
989
990    /// ETX threshold for marking CE (`node.ecn.etx_threshold`).
991    /// When the outgoing link's ETX meets or exceeds this value,
992    /// the transit node sets CE on forwarded datagrams.
993    #[serde(default = "EcnConfig::default_etx_threshold")]
994    pub etx_threshold: f64,
995}
996
997impl Default for EcnConfig {
998    fn default() -> Self {
999        Self {
1000            enabled: true,
1001            loss_threshold: 0.05,
1002            etx_threshold: 3.0,
1003        }
1004    }
1005}
1006
1007impl EcnConfig {
1008    fn default_enabled() -> bool {
1009        true
1010    }
1011    fn default_loss_threshold() -> f64 {
1012        0.05
1013    }
1014    fn default_etx_threshold() -> f64 {
1015        3.0
1016    }
1017}
1018
1019// ============================================================================
1020// Node Configuration (Root)
1021// ============================================================================
1022
1023/// Node configuration (`node.*`).
1024#[derive(Debug, Clone, Serialize, Deserialize)]
1025pub struct NodeConfig {
1026    /// Identity configuration (`node.identity.*`).
1027    #[serde(default)]
1028    pub identity: IdentityConfig,
1029
1030    /// Leaf-only mode (`node.leaf_only`).
1031    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1032    pub leaf_only: bool,
1033
1034    /// RX loop maintenance tick period in seconds (`node.tick_interval_secs`).
1035    #[serde(default = "NodeConfig::default_tick_interval_secs")]
1036    pub tick_interval_secs: u64,
1037
1038    /// Initial RTT estimate for new links in ms (`node.base_rtt_ms`).
1039    #[serde(default = "NodeConfig::default_base_rtt_ms")]
1040    pub base_rtt_ms: u64,
1041
1042    /// Link heartbeat send interval in seconds (`node.heartbeat_interval_secs`).
1043    #[serde(default = "NodeConfig::default_heartbeat_interval_secs")]
1044    pub heartbeat_interval_secs: u64,
1045
1046    /// Link dead timeout in seconds (`node.link_dead_timeout_secs`).
1047    /// Peers silent for this duration are removed.
1048    #[serde(default = "NodeConfig::default_link_dead_timeout_secs")]
1049    pub link_dead_timeout_secs: u64,
1050
1051    /// Accelerated link dead timeout in seconds, used in place of
1052    /// `link_dead_timeout_secs` while a recent `transport.send` returned
1053    /// a local-side errno (`NetworkUnreachable` / `HostUnreachable` /
1054    /// `AddrNotAvailable`) — direct evidence our outbound path is broken
1055    /// right now (interface vanished, default route flapped, etc.). No
1056    /// reason to wait the full receive-silence window when the kernel
1057    /// already told us we can't send. Steady-state behavior is unchanged
1058    /// because the signal is cleared on the next successful send.
1059    /// (`node.fast_link_dead_timeout_secs`)
1060    #[serde(default = "NodeConfig::default_fast_link_dead_timeout_secs")]
1061    pub fast_link_dead_timeout_secs: u64,
1062
1063    /// Resource limits (`node.limits.*`).
1064    #[serde(default)]
1065    pub limits: LimitsConfig,
1066
1067    /// Rate limiting (`node.rate_limit.*`).
1068    #[serde(default)]
1069    pub rate_limit: RateLimitConfig,
1070
1071    /// Retry/backoff (`node.retry.*`).
1072    #[serde(default)]
1073    pub retry: RetryConfig,
1074
1075    /// Cache parameters (`node.cache.*`).
1076    #[serde(default)]
1077    pub cache: CacheConfig,
1078
1079    /// Discovery protocol (`node.discovery.*`).
1080    #[serde(default)]
1081    pub discovery: DiscoveryConfig,
1082
1083    /// Spanning tree (`node.tree.*`).
1084    #[serde(default)]
1085    pub tree: TreeConfig,
1086
1087    /// Routing strategy (`node.routing.*`).
1088    #[serde(default)]
1089    pub routing: RoutingConfig,
1090
1091    /// Bloom filter (`node.bloom.*`).
1092    #[serde(default)]
1093    pub bloom: BloomConfig,
1094
1095    /// Session/data plane (`node.session.*`).
1096    #[serde(default)]
1097    pub session: SessionConfig,
1098
1099    /// Internal buffers (`node.buffers.*`).
1100    #[serde(default)]
1101    pub buffers: BuffersConfig,
1102
1103    /// Control socket (`node.control.*`).
1104    #[serde(default)]
1105    pub control: ControlConfig,
1106
1107    /// Metrics Measurement Protocol — link layer (`node.mmp.*`).
1108    #[serde(default)]
1109    pub mmp: MmpConfig,
1110
1111    /// Metrics Measurement Protocol — session layer (`node.session_mmp.*`).
1112    #[serde(default)]
1113    pub session_mmp: SessionMmpConfig,
1114
1115    /// ECN congestion signaling (`node.ecn.*`).
1116    #[serde(default)]
1117    pub ecn: EcnConfig,
1118
1119    /// Rekey / session rekeying (`node.rekey.*`).
1120    #[serde(default)]
1121    pub rekey: RekeyConfig,
1122
1123    /// Enable daemon-oriented system files such as `/etc/fips/hosts` and
1124    /// `/etc/fips/peers.{allow,deny}`. Embedded endpoints disable this.
1125    #[serde(default = "NodeConfig::default_system_files_enabled")]
1126    pub system_files_enabled: bool,
1127
1128    /// Log level (`node.log_level`). Case-insensitive.
1129    /// Valid values: trace, debug, info, warn, error. Default: info.
1130    #[serde(default)]
1131    pub log_level: Option<String>,
1132}
1133
1134impl Default for NodeConfig {
1135    fn default() -> Self {
1136        Self {
1137            identity: IdentityConfig::default(),
1138            leaf_only: false,
1139            tick_interval_secs: 1,
1140            base_rtt_ms: 100,
1141            heartbeat_interval_secs: 10,
1142            link_dead_timeout_secs: 30,
1143            fast_link_dead_timeout_secs: 5,
1144            limits: LimitsConfig::default(),
1145            rate_limit: RateLimitConfig::default(),
1146            retry: RetryConfig::default(),
1147            cache: CacheConfig::default(),
1148            discovery: DiscoveryConfig::default(),
1149            tree: TreeConfig::default(),
1150            routing: RoutingConfig::default(),
1151            bloom: BloomConfig::default(),
1152            session: SessionConfig::default(),
1153            buffers: BuffersConfig::default(),
1154            control: ControlConfig::default(),
1155            mmp: MmpConfig::default(),
1156            session_mmp: SessionMmpConfig::default(),
1157            ecn: EcnConfig::default(),
1158            rekey: RekeyConfig::default(),
1159            system_files_enabled: true,
1160            log_level: None,
1161        }
1162    }
1163}
1164
1165impl NodeConfig {
1166    /// Get the log level as a tracing Level. Default: INFO.
1167    pub fn log_level(&self) -> tracing::Level {
1168        match self
1169            .log_level
1170            .as_deref()
1171            .map(|s| s.to_lowercase())
1172            .as_deref()
1173        {
1174            Some("trace") => tracing::Level::TRACE,
1175            Some("debug") => tracing::Level::DEBUG,
1176            Some("warn") | Some("warning") => tracing::Level::WARN,
1177            Some("error") => tracing::Level::ERROR,
1178            _ => tracing::Level::INFO,
1179        }
1180    }
1181
1182    fn default_tick_interval_secs() -> u64 {
1183        1
1184    }
1185    fn default_base_rtt_ms() -> u64 {
1186        100
1187    }
1188    fn default_heartbeat_interval_secs() -> u64 {
1189        10
1190    }
1191    fn default_link_dead_timeout_secs() -> u64 {
1192        30
1193    }
1194    fn default_fast_link_dead_timeout_secs() -> u64 {
1195        5
1196    }
1197    fn default_system_files_enabled() -> bool {
1198        true
1199    }
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204    use super::*;
1205
1206    #[test]
1207    fn test_ecn_config_defaults() {
1208        let c = EcnConfig::default();
1209        assert!(c.enabled);
1210        assert!((c.loss_threshold - 0.05).abs() < 1e-9);
1211        assert!((c.etx_threshold - 3.0).abs() < 1e-9);
1212    }
1213
1214    #[test]
1215    fn test_rekey_config_defaults() {
1216        let c = RekeyConfig::default();
1217        assert!(c.enabled);
1218        assert_eq!(c.after_secs, 120);
1219        assert_eq!(c.after_messages, 1 << 48);
1220    }
1221
1222    #[test]
1223    fn test_rekey_config_partial_yaml_uses_defaults() {
1224        let yaml = "after_secs: 30\n";
1225        let c: RekeyConfig = serde_yaml::from_str(yaml).unwrap();
1226        assert!(c.enabled);
1227        assert_eq!(c.after_secs, 30);
1228        assert_eq!(c.after_messages, 1 << 48);
1229    }
1230
1231    #[test]
1232    fn test_routing_config_defaults() {
1233        let c = RoutingConfig::default();
1234        assert_eq!(c.mode, RoutingMode::Tree);
1235        assert_eq!(c.learned_ttl_secs, 300);
1236        assert_eq!(c.max_learned_routes_per_dest, 4);
1237        assert_eq!(c.learned_fallback_explore_interval, 16);
1238    }
1239
1240    #[test]
1241    fn test_routing_config_yaml() {
1242        let yaml = "mode: reply_learned\nlearned_ttl_secs: 120\nmax_learned_routes_per_dest: 2\nlearned_fallback_explore_interval: 8\n";
1243        let c: RoutingConfig = serde_yaml::from_str(yaml).unwrap();
1244        assert_eq!(c.mode, RoutingMode::ReplyLearned);
1245        assert_eq!(c.learned_ttl_secs, 120);
1246        assert_eq!(c.max_learned_routes_per_dest, 2);
1247        assert_eq!(c.learned_fallback_explore_interval, 8);
1248    }
1249
1250    #[test]
1251    fn test_ecn_config_yaml_roundtrip() {
1252        let yaml = "loss_threshold: 0.10\netx_threshold: 2.5\nenabled: false\n";
1253        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
1254        assert!(!c.enabled);
1255        assert!((c.loss_threshold - 0.10).abs() < 1e-9);
1256        assert!((c.etx_threshold - 2.5).abs() < 1e-9);
1257    }
1258
1259    #[test]
1260    fn test_ecn_config_partial_yaml() {
1261        // Only specify loss_threshold — others should get defaults
1262        let yaml = "loss_threshold: 0.02\n";
1263        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
1264        assert!(c.enabled); // default
1265        assert!((c.loss_threshold - 0.02).abs() < 1e-9);
1266        assert!((c.etx_threshold - 3.0).abs() < 1e-9); // default
1267    }
1268
1269    #[test]
1270    fn test_nostr_discovery_startup_sweep_defaults() {
1271        let c = NostrDiscoveryConfig::default();
1272        assert_eq!(c.startup_sweep_delay_secs, 5);
1273        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
1274    }
1275
1276    #[test]
1277    fn test_nostr_discovery_startup_sweep_yaml_override() {
1278        let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n";
1279        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
1280        assert!(c.enabled);
1281        assert_eq!(c.policy, NostrDiscoveryPolicy::Open);
1282        assert_eq!(c.startup_sweep_delay_secs, 10);
1283        assert_eq!(c.startup_sweep_max_age_secs, 1_800);
1284    }
1285
1286    #[test]
1287    fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() {
1288        // Only override delay; max_age should fall back to default.
1289        let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n";
1290        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
1291        assert_eq!(c.startup_sweep_delay_secs, 30);
1292        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
1293    }
1294
1295    #[test]
1296    fn test_log_level_parser() {
1297        // Pin the observed behavior of NodeConfig::log_level():
1298        // - 5 explicit lowercased match arms (trace/debug/warn|warning/error)
1299        // - INFO is the default (no explicit "info" arm; falls through default)
1300        // - Case-insensitive via .to_lowercase()
1301        // - Unknown strings and None both fall through to INFO
1302        let cases: &[(Option<&str>, tracing::Level)] = &[
1303            // Explicit arms (lowercase canonical form)
1304            (Some("trace"), tracing::Level::TRACE),
1305            (Some("debug"), tracing::Level::DEBUG),
1306            (Some("warn"), tracing::Level::WARN),
1307            (Some("warning"), tracing::Level::WARN),
1308            (Some("error"), tracing::Level::ERROR),
1309            // "info" has no explicit arm — falls through default
1310            (Some("info"), tracing::Level::INFO),
1311            // None → default INFO
1312            (None, tracing::Level::INFO),
1313            // Case-insensitivity (parser lowercases via .to_lowercase())
1314            (Some("TRACE"), tracing::Level::TRACE),
1315            (Some("Debug"), tracing::Level::DEBUG),
1316            (Some("Warning"), tracing::Level::WARN),
1317            (Some("WARN"), tracing::Level::WARN),
1318            (Some("ERROR"), tracing::Level::ERROR),
1319            (Some("INFO"), tracing::Level::INFO),
1320            // Unknown strings → INFO default (no error path)
1321            (Some("verbose"), tracing::Level::INFO),
1322            (Some("nonsense"), tracing::Level::INFO),
1323            (Some(""), tracing::Level::INFO),
1324        ];
1325
1326        for (input, expected) in cases {
1327            let cfg = NodeConfig {
1328                log_level: input.map(|s| s.to_string()),
1329                ..NodeConfig::default()
1330            };
1331            assert_eq!(
1332                cfg.log_level(),
1333                *expected,
1334                "input {:?} should map to {:?}",
1335                input,
1336                expected
1337            );
1338        }
1339    }
1340
1341    #[cfg(windows)]
1342    #[test]
1343    fn test_default_socket_path_windows() {
1344        let config = ControlConfig::default();
1345        // On Windows, socket_path is a TCP port number
1346        let port: u16 = config
1347            .socket_path
1348            .parse()
1349            .expect("should be a valid port number");
1350        assert_eq!(port, 21210);
1351    }
1352}