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        ]
454    }
455
456    fn default_dm_relays() -> Vec<String> {
457        vec![
458            "wss://relay.damus.io".to_string(),
459            "wss://nos.lol".to_string(),
460            "wss://offchain.pub".to_string(),
461        ]
462    }
463
464    fn default_stun_servers() -> Vec<String> {
465        vec![
466            "stun:stun.l.google.com:19302".to_string(),
467            "stun:stun.cloudflare.com:3478".to_string(),
468            "stun:global.stun.twilio.com:3478".to_string(),
469        ]
470    }
471
472    fn default_app() -> String {
473        "fips-overlay-v1".to_string()
474    }
475
476    fn default_signal_ttl_secs() -> u64 {
477        120
478    }
479
480    fn default_open_discovery_max_pending() -> usize {
481        64
482    }
483
484    fn default_max_concurrent_incoming_offers() -> usize {
485        16
486    }
487
488    fn default_advert_cache_max_entries() -> usize {
489        2048
490    }
491
492    fn default_seen_sessions_max_entries() -> usize {
493        2048
494    }
495
496    fn default_attempt_timeout_secs() -> u64 {
497        10
498    }
499
500    fn default_replay_window_secs() -> u64 {
501        300
502    }
503
504    fn default_punch_start_delay_ms() -> u64 {
505        2_000
506    }
507
508    fn default_punch_interval_ms() -> u64 {
509        200
510    }
511
512    fn default_punch_duration_ms() -> u64 {
513        10_000
514    }
515
516    fn default_advert_ttl_secs() -> u64 {
517        3_600
518    }
519
520    fn default_advert_refresh_secs() -> u64 {
521        1_800
522    }
523
524    fn default_startup_sweep_delay_secs() -> u64 {
525        5
526    }
527
528    fn default_startup_sweep_max_age_secs() -> u64 {
529        3_600
530    }
531
532    fn default_failure_streak_threshold() -> u32 {
533        5
534    }
535
536    fn default_extended_cooldown_secs() -> u64 {
537        1_800
538    }
539
540    fn default_warn_log_interval_secs() -> u64 {
541        300
542    }
543
544    fn default_failure_state_max_entries() -> usize {
545        4_096
546    }
547
548    fn default_protocol_mismatch_cooldown_secs() -> u64 {
549        86_400
550    }
551}
552
553/// Spanning tree (`node.tree.*`).
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct TreeConfig {
556    /// Per-peer TreeAnnounce rate limit in ms (`node.tree.announce_min_interval_ms`).
557    #[serde(default = "TreeConfig::default_announce_min_interval_ms")]
558    pub announce_min_interval_ms: u64,
559    /// Hysteresis factor for cost-based parent re-selection (`node.tree.parent_hysteresis`).
560    ///
561    /// Only switch parents when the candidate's effective_depth is better than
562    /// `current_effective_depth * (1.0 - parent_hysteresis)`. Range: 0.0-1.0.
563    /// Set to 0.0 to disable hysteresis (switch on any improvement).
564    #[serde(default = "TreeConfig::default_parent_hysteresis")]
565    pub parent_hysteresis: f64,
566    /// Hold-down period after parent switch in seconds (`node.tree.hold_down_secs`).
567    ///
568    /// After switching parents, suppress re-evaluation for this duration to allow
569    /// MMP metrics to stabilize on the new link. Set to 0 to disable.
570    #[serde(default = "TreeConfig::default_hold_down_secs")]
571    pub hold_down_secs: u64,
572    /// Periodic parent re-evaluation interval in seconds (`node.tree.reeval_interval_secs`).
573    ///
574    /// How often to re-evaluate parent selection based on current MMP link costs,
575    /// independent of TreeAnnounce traffic. Catches link degradation after the
576    /// tree has stabilized. Set to 0 to disable.
577    #[serde(default = "TreeConfig::default_reeval_interval_secs")]
578    pub reeval_interval_secs: u64,
579    /// Flap dampening: max parent switches before extended hold-down (`node.tree.flap_threshold`).
580    #[serde(default = "TreeConfig::default_flap_threshold")]
581    pub flap_threshold: u32,
582    /// Flap dampening: window in seconds for counting switches (`node.tree.flap_window_secs`).
583    #[serde(default = "TreeConfig::default_flap_window_secs")]
584    pub flap_window_secs: u64,
585    /// Flap dampening: extended hold-down duration in seconds (`node.tree.flap_dampening_secs`).
586    #[serde(default = "TreeConfig::default_flap_dampening_secs")]
587    pub flap_dampening_secs: u64,
588}
589
590impl Default for TreeConfig {
591    fn default() -> Self {
592        Self {
593            announce_min_interval_ms: 500,
594            parent_hysteresis: 0.2,
595            hold_down_secs: 30,
596            reeval_interval_secs: 60,
597            flap_threshold: 4,
598            flap_window_secs: 60,
599            flap_dampening_secs: 120,
600        }
601    }
602}
603
604impl TreeConfig {
605    fn default_announce_min_interval_ms() -> u64 {
606        500
607    }
608    fn default_parent_hysteresis() -> f64 {
609        0.2
610    }
611    fn default_hold_down_secs() -> u64 {
612        30
613    }
614    fn default_reeval_interval_secs() -> u64 {
615        60
616    }
617    fn default_flap_threshold() -> u32 {
618        4
619    }
620    fn default_flap_window_secs() -> u64 {
621        60
622    }
623    fn default_flap_dampening_secs() -> u64 {
624        120
625    }
626}
627
628/// Routing strategy selection (`node.routing.*`).
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct RoutingConfig {
631    /// Next-hop selection mode (`node.routing.mode`).
632    #[serde(default)]
633    pub mode: RoutingMode,
634    /// TTL for learned reverse-path routes in seconds (`node.routing.learned_ttl_secs`).
635    #[serde(default = "RoutingConfig::default_learned_ttl_secs")]
636    pub learned_ttl_secs: u64,
637    /// Maximum locally observed next-hop candidates kept per destination for
638    /// reply-learned multipath/exploration
639    /// (`node.routing.max_learned_routes_per_dest`).
640    #[serde(default = "RoutingConfig::default_max_learned_routes_per_dest")]
641    pub max_learned_routes_per_dest: usize,
642    /// Every N learned-route selections, try the coordinate/bloom/tree route
643    /// instead so new paths can be discovered (`0` disables fallback exploration).
644    #[serde(default = "RoutingConfig::default_learned_fallback_explore_interval")]
645    pub learned_fallback_explore_interval: u64,
646}
647
648impl Default for RoutingConfig {
649    fn default() -> Self {
650        Self {
651            mode: RoutingMode::default(),
652            learned_ttl_secs: 300,
653            max_learned_routes_per_dest: 4,
654            learned_fallback_explore_interval: 16,
655        }
656    }
657}
658
659impl RoutingConfig {
660    fn default_learned_ttl_secs() -> u64 {
661        300
662    }
663
664    fn default_max_learned_routes_per_dest() -> usize {
665        4
666    }
667
668    fn default_learned_fallback_explore_interval() -> u64 {
669        16
670    }
671}
672
673/// Daemon routing mode.
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
675#[serde(rename_all = "snake_case")]
676pub enum RoutingMode {
677    /// Current FIPS behavior: bloom-assisted greedy tree routing.
678    #[default]
679    Tree,
680    /// Prefer locally learned reverse paths before falling back to tree routing.
681    ///
682    /// Learned routes are populated only from local evidence: inbound
683    /// SessionDatagrams and verified LookupResponses.
684    ReplyLearned,
685}
686
687impl std::fmt::Display for RoutingMode {
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        match self {
690            RoutingMode::Tree => write!(f, "tree"),
691            RoutingMode::ReplyLearned => write!(f, "reply_learned"),
692        }
693    }
694}
695
696/// Bloom filter (`node.bloom.*`).
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct BloomConfig {
699    /// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`).
700    #[serde(default = "BloomConfig::default_update_debounce_ms")]
701    pub update_debounce_ms: u64,
702    /// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
703    /// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
704    /// Default `0.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
705    /// filter. Conceptually distinct from future autoscaling hysteresis
706    /// setpoints — same unit, different knobs.
707    #[serde(default = "BloomConfig::default_max_inbound_fpr")]
708    pub max_inbound_fpr: f64,
709}
710
711impl Default for BloomConfig {
712    fn default() -> Self {
713        Self {
714            update_debounce_ms: 500,
715            max_inbound_fpr: 0.05,
716        }
717    }
718}
719
720impl BloomConfig {
721    fn default_update_debounce_ms() -> u64 {
722        500
723    }
724    fn default_max_inbound_fpr() -> f64 {
725        0.05
726    }
727}
728
729/// Session/data plane (`node.session.*`).
730#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct SessionConfig {
732    /// Default SessionDatagram TTL (`node.session.default_ttl`).
733    #[serde(default = "SessionConfig::default_ttl")]
734    pub default_ttl: u8,
735    /// Queue depth per dest during session establishment (`node.session.pending_packets_per_dest`).
736    #[serde(default = "SessionConfig::default_pending_packets_per_dest")]
737    pub pending_packets_per_dest: usize,
738    /// Max destinations with pending packets (`node.session.pending_max_destinations`).
739    #[serde(default = "SessionConfig::default_pending_max_destinations")]
740    pub pending_max_destinations: usize,
741    /// Idle session timeout in seconds (`node.session.idle_timeout_secs`).
742    /// Established sessions with no application data for this duration are
743    /// removed. MMP reports do not count as activity for this timer.
744    #[serde(default = "SessionConfig::default_idle_timeout_secs")]
745    pub idle_timeout_secs: u64,
746    /// Number of initial data packets per session that include COORDS_PRESENT
747    /// for transit cache warmup (`node.session.coords_warmup_packets`).
748    /// Also used as the reset count on CoordsRequired receipt.
749    #[serde(default = "SessionConfig::default_coords_warmup_packets")]
750    pub coords_warmup_packets: u8,
751    /// Minimum interval (ms) between standalone CoordsWarmup responses to
752    /// CoordsRequired/PathBroken signals, per destination
753    /// (`node.session.coords_response_interval_ms`).
754    #[serde(default = "SessionConfig::default_coords_response_interval_ms")]
755    pub coords_response_interval_ms: u64,
756}
757
758impl Default for SessionConfig {
759    fn default() -> Self {
760        Self {
761            default_ttl: 64,
762            pending_packets_per_dest: 16,
763            pending_max_destinations: 256,
764            idle_timeout_secs: 90,
765            coords_warmup_packets: 5,
766            coords_response_interval_ms: 2000,
767        }
768    }
769}
770
771impl SessionConfig {
772    fn default_ttl() -> u8 {
773        64
774    }
775    fn default_pending_packets_per_dest() -> usize {
776        16
777    }
778    fn default_pending_max_destinations() -> usize {
779        256
780    }
781    fn default_idle_timeout_secs() -> u64 {
782        90
783    }
784    fn default_coords_warmup_packets() -> u8 {
785        5
786    }
787    fn default_coords_response_interval_ms() -> u64 {
788        2000
789    }
790}
791
792/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
793///
794/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
795/// configuration per layer. Session reports consume bandwidth on every transit
796/// link, so operators may want a lighter mode (e.g., Lightweight) for sessions
797/// while running Full mode on links.
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct SessionMmpConfig {
800    /// Operating mode (`node.session_mmp.mode`).
801    #[serde(default)]
802    pub mode: MmpMode,
803
804    /// Periodic operator log interval in seconds (`node.session_mmp.log_interval_secs`).
805    #[serde(default = "SessionMmpConfig::default_log_interval_secs")]
806    pub log_interval_secs: u64,
807
808    /// OWD trend ring buffer size (`node.session_mmp.owd_window_size`).
809    #[serde(default = "SessionMmpConfig::default_owd_window_size")]
810    pub owd_window_size: usize,
811}
812
813impl Default for SessionMmpConfig {
814    fn default() -> Self {
815        Self {
816            mode: MmpMode::default(),
817            log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
818            owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
819        }
820    }
821}
822
823impl SessionMmpConfig {
824    fn default_log_interval_secs() -> u64 {
825        DEFAULT_LOG_INTERVAL_SECS
826    }
827    fn default_owd_window_size() -> usize {
828        DEFAULT_OWD_WINDOW_SIZE
829    }
830}
831
832/// Control socket configuration (`node.control.*`).
833#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct ControlConfig {
835    /// Enable the control socket (`node.control.enabled`).
836    #[serde(default = "ControlConfig::default_enabled")]
837    pub enabled: bool,
838    /// Unix socket path (`node.control.socket_path`).
839    #[serde(default = "ControlConfig::default_socket_path")]
840    pub socket_path: String,
841}
842
843impl Default for ControlConfig {
844    fn default() -> Self {
845        Self {
846            enabled: true,
847            socket_path: Self::default_socket_path(),
848        }
849    }
850}
851
852impl ControlConfig {
853    fn default_enabled() -> bool {
854        true
855    }
856
857    /// Default control socket path.
858    ///
859    /// On Unix, returns the shared `/run/fips`, `XDG_RUNTIME_DIR`, then `/tmp`
860    /// fallback used by fipsctl and fipstop. On Windows, returns a TCP port
861    /// number as a string since Windows does not support Unix domain sockets;
862    /// the control socket listens on localhost at this port.
863    fn default_socket_path() -> String {
864        #[cfg(unix)]
865        {
866            super::resolve_default_socket("control.sock")
867        }
868        #[cfg(windows)]
869        {
870            "21210".to_string()
871        }
872    }
873}
874
875/// Internal buffers (`node.buffers.*`).
876#[derive(Debug, Clone, Serialize, Deserialize)]
877pub struct BuffersConfig {
878    /// Transport→Node packet channel capacity (`node.buffers.packet_channel`).
879    #[serde(default = "BuffersConfig::default_packet_channel")]
880    pub packet_channel: usize,
881    /// TUN→Node outbound channel capacity (`node.buffers.tun_channel`).
882    #[serde(default = "BuffersConfig::default_tun_channel")]
883    pub tun_channel: usize,
884    /// DNS→Node identity channel capacity (`node.buffers.dns_channel`).
885    #[serde(default = "BuffersConfig::default_dns_channel")]
886    pub dns_channel: usize,
887}
888
889impl Default for BuffersConfig {
890    fn default() -> Self {
891        Self {
892            packet_channel: 1024,
893            tun_channel: 1024,
894            dns_channel: 64,
895        }
896    }
897}
898
899impl BuffersConfig {
900    fn default_packet_channel() -> usize {
901        1024
902    }
903    fn default_tun_channel() -> usize {
904        1024
905    }
906    fn default_dns_channel() -> usize {
907        64
908    }
909}
910
911// ============================================================================
912// ECN Congestion Signaling
913// ============================================================================
914
915/// Rekey / session rekeying configuration (`node.rekey.*`).
916///
917/// Controls periodic full rekey for both FMP (link layer) and FSP
918/// (session layer) Noise sessions. Rekeying provides true forward secrecy
919/// with fresh DH randomness, nonce reset, and session index rotation.
920///
921/// Keep the packet-count default high for packet-tunnel workloads. A low value
922/// such as 65k packets can force multi-hundred-Mbit tunnels to rekey every few
923/// seconds, which creates avoidable cutover churn and can dominate throughput.
924/// Operators can still lower `node.rekey.after_messages` for CI stress tests or
925/// very conservative deployments; the time-based `after_secs` default remains
926/// the normal production rekey cadence.
927const DEFAULT_REKEY_AFTER_MESSAGES: u64 = 1 << 48;
928
929#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct RekeyConfig {
931    /// Enable periodic rekey (`node.rekey.enabled`).
932    #[serde(default = "RekeyConfig::default_enabled")]
933    pub enabled: bool,
934
935    /// Initiate rekey after this many seconds (`node.rekey.after_secs`).
936    #[serde(default = "RekeyConfig::default_after_secs")]
937    pub after_secs: u64,
938
939    /// Initiate rekey after this many messages sent (`node.rekey.after_messages`).
940    #[serde(default = "RekeyConfig::default_after_messages")]
941    pub after_messages: u64,
942}
943
944impl Default for RekeyConfig {
945    fn default() -> Self {
946        Self {
947            enabled: true,
948            after_secs: 120,
949            after_messages: DEFAULT_REKEY_AFTER_MESSAGES,
950        }
951    }
952}
953
954impl RekeyConfig {
955    fn default_enabled() -> bool {
956        true
957    }
958    fn default_after_secs() -> u64 {
959        120
960    }
961    fn default_after_messages() -> u64 {
962        DEFAULT_REKEY_AFTER_MESSAGES
963    }
964}
965
966/// ECN congestion signaling configuration (`node.ecn.*`).
967///
968/// Controls the FMP CE relay chain: transit nodes detect congestion on outgoing
969/// links and set the CE flag in forwarded datagrams. The destination marks
970/// IPv6 ECN-CE on ECN-capable packets before TUN delivery.
971#[derive(Debug, Clone, Serialize, Deserialize)]
972pub struct EcnConfig {
973    /// Enable ECN congestion signaling (`node.ecn.enabled`).
974    #[serde(default = "EcnConfig::default_enabled")]
975    pub enabled: bool,
976
977    /// Loss rate threshold for marking CE (`node.ecn.loss_threshold`).
978    /// When the outgoing link's loss rate meets or exceeds this value,
979    /// the transit node sets CE on forwarded datagrams.
980    #[serde(default = "EcnConfig::default_loss_threshold")]
981    pub loss_threshold: f64,
982
983    /// ETX threshold for marking CE (`node.ecn.etx_threshold`).
984    /// When the outgoing link's ETX meets or exceeds this value,
985    /// the transit node sets CE on forwarded datagrams.
986    #[serde(default = "EcnConfig::default_etx_threshold")]
987    pub etx_threshold: f64,
988}
989
990impl Default for EcnConfig {
991    fn default() -> Self {
992        Self {
993            enabled: true,
994            loss_threshold: 0.05,
995            etx_threshold: 3.0,
996        }
997    }
998}
999
1000impl EcnConfig {
1001    fn default_enabled() -> bool {
1002        true
1003    }
1004    fn default_loss_threshold() -> f64 {
1005        0.05
1006    }
1007    fn default_etx_threshold() -> f64 {
1008        3.0
1009    }
1010}
1011
1012// ============================================================================
1013// Node Configuration (Root)
1014// ============================================================================
1015
1016/// Node configuration (`node.*`).
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct NodeConfig {
1019    /// Identity configuration (`node.identity.*`).
1020    #[serde(default)]
1021    pub identity: IdentityConfig,
1022
1023    /// Leaf-only mode (`node.leaf_only`).
1024    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1025    pub leaf_only: bool,
1026
1027    /// RX loop maintenance tick period in seconds (`node.tick_interval_secs`).
1028    #[serde(default = "NodeConfig::default_tick_interval_secs")]
1029    pub tick_interval_secs: u64,
1030
1031    /// Initial RTT estimate for new links in ms (`node.base_rtt_ms`).
1032    #[serde(default = "NodeConfig::default_base_rtt_ms")]
1033    pub base_rtt_ms: u64,
1034
1035    /// Link heartbeat send interval in seconds (`node.heartbeat_interval_secs`).
1036    #[serde(default = "NodeConfig::default_heartbeat_interval_secs")]
1037    pub heartbeat_interval_secs: u64,
1038
1039    /// Link dead timeout in seconds (`node.link_dead_timeout_secs`).
1040    /// Peers silent for this duration are removed.
1041    #[serde(default = "NodeConfig::default_link_dead_timeout_secs")]
1042    pub link_dead_timeout_secs: u64,
1043
1044    /// Accelerated link dead timeout in seconds, used in place of
1045    /// `link_dead_timeout_secs` while a recent `transport.send` returned
1046    /// a local-side errno (`NetworkUnreachable` / `HostUnreachable` /
1047    /// `AddrNotAvailable`) — direct evidence our outbound path is broken
1048    /// right now (interface vanished, default route flapped, etc.). No
1049    /// reason to wait the full receive-silence window when the kernel
1050    /// already told us we can't send. Steady-state behavior is unchanged
1051    /// because the signal is cleared on the next successful send.
1052    /// (`node.fast_link_dead_timeout_secs`)
1053    #[serde(default = "NodeConfig::default_fast_link_dead_timeout_secs")]
1054    pub fast_link_dead_timeout_secs: u64,
1055
1056    /// Resource limits (`node.limits.*`).
1057    #[serde(default)]
1058    pub limits: LimitsConfig,
1059
1060    /// Rate limiting (`node.rate_limit.*`).
1061    #[serde(default)]
1062    pub rate_limit: RateLimitConfig,
1063
1064    /// Retry/backoff (`node.retry.*`).
1065    #[serde(default)]
1066    pub retry: RetryConfig,
1067
1068    /// Cache parameters (`node.cache.*`).
1069    #[serde(default)]
1070    pub cache: CacheConfig,
1071
1072    /// Discovery protocol (`node.discovery.*`).
1073    #[serde(default)]
1074    pub discovery: DiscoveryConfig,
1075
1076    /// Spanning tree (`node.tree.*`).
1077    #[serde(default)]
1078    pub tree: TreeConfig,
1079
1080    /// Routing strategy (`node.routing.*`).
1081    #[serde(default)]
1082    pub routing: RoutingConfig,
1083
1084    /// Bloom filter (`node.bloom.*`).
1085    #[serde(default)]
1086    pub bloom: BloomConfig,
1087
1088    /// Session/data plane (`node.session.*`).
1089    #[serde(default)]
1090    pub session: SessionConfig,
1091
1092    /// Internal buffers (`node.buffers.*`).
1093    #[serde(default)]
1094    pub buffers: BuffersConfig,
1095
1096    /// Control socket (`node.control.*`).
1097    #[serde(default)]
1098    pub control: ControlConfig,
1099
1100    /// Metrics Measurement Protocol — link layer (`node.mmp.*`).
1101    #[serde(default)]
1102    pub mmp: MmpConfig,
1103
1104    /// Metrics Measurement Protocol — session layer (`node.session_mmp.*`).
1105    #[serde(default)]
1106    pub session_mmp: SessionMmpConfig,
1107
1108    /// ECN congestion signaling (`node.ecn.*`).
1109    #[serde(default)]
1110    pub ecn: EcnConfig,
1111
1112    /// Rekey / session rekeying (`node.rekey.*`).
1113    #[serde(default)]
1114    pub rekey: RekeyConfig,
1115
1116    /// Enable daemon-oriented system files such as `/etc/fips/hosts` and
1117    /// `/etc/fips/peers.{allow,deny}`. Embedded endpoints disable this.
1118    #[serde(default = "NodeConfig::default_system_files_enabled")]
1119    pub system_files_enabled: bool,
1120
1121    /// Enable off-task Unix encrypt/decrypt worker pools (`node.worker_pools_enabled`).
1122    /// Embedded/mobile endpoints can disable this to keep crypto/send work inline
1123    /// with the rx loop when platform extension sandboxes make OS-thread pools
1124    /// unsuitable.
1125    #[serde(default = "NodeConfig::default_worker_pools_enabled")]
1126    pub worker_pools_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            worker_pools_enabled: true,
1161            log_level: None,
1162        }
1163    }
1164}
1165
1166impl NodeConfig {
1167    /// Get the log level as a tracing Level. Default: INFO.
1168    pub fn log_level(&self) -> tracing::Level {
1169        match self
1170            .log_level
1171            .as_deref()
1172            .map(|s| s.to_lowercase())
1173            .as_deref()
1174        {
1175            Some("trace") => tracing::Level::TRACE,
1176            Some("debug") => tracing::Level::DEBUG,
1177            Some("warn") | Some("warning") => tracing::Level::WARN,
1178            Some("error") => tracing::Level::ERROR,
1179            _ => tracing::Level::INFO,
1180        }
1181    }
1182
1183    fn default_tick_interval_secs() -> u64 {
1184        1
1185    }
1186    fn default_base_rtt_ms() -> u64 {
1187        100
1188    }
1189    fn default_heartbeat_interval_secs() -> u64 {
1190        10
1191    }
1192    fn default_link_dead_timeout_secs() -> u64 {
1193        30
1194    }
1195    fn default_fast_link_dead_timeout_secs() -> u64 {
1196        5
1197    }
1198    fn default_system_files_enabled() -> bool {
1199        true
1200    }
1201    fn default_worker_pools_enabled() -> bool {
1202        true
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    #[test]
1211    fn test_ecn_config_defaults() {
1212        let c = EcnConfig::default();
1213        assert!(c.enabled);
1214        assert!((c.loss_threshold - 0.05).abs() < 1e-9);
1215        assert!((c.etx_threshold - 3.0).abs() < 1e-9);
1216    }
1217
1218    #[test]
1219    fn test_rekey_config_defaults() {
1220        let c = RekeyConfig::default();
1221        assert!(c.enabled);
1222        assert_eq!(c.after_secs, 120);
1223        assert_eq!(c.after_messages, 1 << 48);
1224    }
1225
1226    #[test]
1227    fn test_rekey_config_partial_yaml_uses_defaults() {
1228        let yaml = "after_secs: 30\n";
1229        let c: RekeyConfig = serde_yaml::from_str(yaml).unwrap();
1230        assert!(c.enabled);
1231        assert_eq!(c.after_secs, 30);
1232        assert_eq!(c.after_messages, 1 << 48);
1233    }
1234
1235    #[test]
1236    fn test_routing_config_defaults() {
1237        let c = RoutingConfig::default();
1238        assert_eq!(c.mode, RoutingMode::Tree);
1239        assert_eq!(c.learned_ttl_secs, 300);
1240        assert_eq!(c.max_learned_routes_per_dest, 4);
1241        assert_eq!(c.learned_fallback_explore_interval, 16);
1242    }
1243
1244    #[test]
1245    fn test_routing_config_yaml() {
1246        let yaml = "mode: reply_learned\nlearned_ttl_secs: 120\nmax_learned_routes_per_dest: 2\nlearned_fallback_explore_interval: 8\n";
1247        let c: RoutingConfig = serde_yaml::from_str(yaml).unwrap();
1248        assert_eq!(c.mode, RoutingMode::ReplyLearned);
1249        assert_eq!(c.learned_ttl_secs, 120);
1250        assert_eq!(c.max_learned_routes_per_dest, 2);
1251        assert_eq!(c.learned_fallback_explore_interval, 8);
1252    }
1253
1254    #[test]
1255    fn test_ecn_config_yaml_roundtrip() {
1256        let yaml = "loss_threshold: 0.10\netx_threshold: 2.5\nenabled: false\n";
1257        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
1258        assert!(!c.enabled);
1259        assert!((c.loss_threshold - 0.10).abs() < 1e-9);
1260        assert!((c.etx_threshold - 2.5).abs() < 1e-9);
1261    }
1262
1263    #[test]
1264    fn test_ecn_config_partial_yaml() {
1265        // Only specify loss_threshold — others should get defaults
1266        let yaml = "loss_threshold: 0.02\n";
1267        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
1268        assert!(c.enabled); // default
1269        assert!((c.loss_threshold - 0.02).abs() < 1e-9);
1270        assert!((c.etx_threshold - 3.0).abs() < 1e-9); // default
1271    }
1272
1273    #[test]
1274    fn test_nostr_discovery_startup_sweep_defaults() {
1275        let c = NostrDiscoveryConfig::default();
1276        assert_eq!(c.startup_sweep_delay_secs, 5);
1277        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
1278    }
1279
1280    #[test]
1281    fn test_nostr_discovery_startup_sweep_yaml_override() {
1282        let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n";
1283        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
1284        assert!(c.enabled);
1285        assert_eq!(c.policy, NostrDiscoveryPolicy::Open);
1286        assert_eq!(c.startup_sweep_delay_secs, 10);
1287        assert_eq!(c.startup_sweep_max_age_secs, 1_800);
1288    }
1289
1290    #[test]
1291    fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() {
1292        // Only override delay; max_age should fall back to default.
1293        let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n";
1294        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
1295        assert_eq!(c.startup_sweep_delay_secs, 30);
1296        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
1297    }
1298
1299    #[test]
1300    fn test_log_level_parser() {
1301        // Pin the observed behavior of NodeConfig::log_level():
1302        // - 5 explicit lowercased match arms (trace/debug/warn|warning/error)
1303        // - INFO is the default (no explicit "info" arm; falls through default)
1304        // - Case-insensitive via .to_lowercase()
1305        // - Unknown strings and None both fall through to INFO
1306        let cases: &[(Option<&str>, tracing::Level)] = &[
1307            // Explicit arms (lowercase canonical form)
1308            (Some("trace"), tracing::Level::TRACE),
1309            (Some("debug"), tracing::Level::DEBUG),
1310            (Some("warn"), tracing::Level::WARN),
1311            (Some("warning"), tracing::Level::WARN),
1312            (Some("error"), tracing::Level::ERROR),
1313            // "info" has no explicit arm — falls through default
1314            (Some("info"), tracing::Level::INFO),
1315            // None → default INFO
1316            (None, tracing::Level::INFO),
1317            // Case-insensitivity (parser lowercases via .to_lowercase())
1318            (Some("TRACE"), tracing::Level::TRACE),
1319            (Some("Debug"), tracing::Level::DEBUG),
1320            (Some("Warning"), tracing::Level::WARN),
1321            (Some("WARN"), tracing::Level::WARN),
1322            (Some("ERROR"), tracing::Level::ERROR),
1323            (Some("INFO"), tracing::Level::INFO),
1324            // Unknown strings → INFO default (no error path)
1325            (Some("verbose"), tracing::Level::INFO),
1326            (Some("nonsense"), tracing::Level::INFO),
1327            (Some(""), tracing::Level::INFO),
1328        ];
1329
1330        for (input, expected) in cases {
1331            let cfg = NodeConfig {
1332                log_level: input.map(|s| s.to_string()),
1333                ..NodeConfig::default()
1334            };
1335            assert_eq!(
1336                cfg.log_level(),
1337                *expected,
1338                "input {:?} should map to {:?}",
1339                input,
1340                expected
1341            );
1342        }
1343    }
1344
1345    #[cfg(windows)]
1346    #[test]
1347    fn test_default_socket_path_windows() {
1348        let config = ControlConfig::default();
1349        // On Windows, socket_path is a TCP port number
1350        let port: u16 = config
1351            .socket_path
1352            .parse()
1353            .expect("should be a valid port number");
1354        assert_eq!(port, 21210);
1355    }
1356}