ndn_config/
config.rs

1use crate::ConfigError;
2use serde::{Deserialize, Serialize};
3
4/// Top-level forwarder configuration (loaded from TOML).
5///
6/// Example `ndn-fwd.toml`:
7///
8/// ```toml
9/// [engine]
10/// cs_capacity_mb = 64
11/// pipeline_channel_cap = 1024
12///
13/// [[face]]
14/// kind = "udp"
15/// bind = "0.0.0.0:6363"
16///
17/// [[face]]
18/// kind = "multicast"
19/// group = "224.0.23.170"
20/// port = 56363
21/// interface = "eth0"
22///
23/// [[route]]
24/// prefix = "/ndn"
25/// face = 0
26/// cost = 10
27///
28/// [security]
29/// trust_anchor = "/etc/ndn/trust-anchor.cert"
30///
31/// [[security.rule]]
32/// data = "/sensor/<node>/<type>"
33/// key  = "/sensor/<node>/KEY/<id>"
34///
35/// [logging]
36/// level = "info"
37/// file = "/var/log/ndn/router.log"
38/// ```
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct ForwarderConfig {
41    #[serde(default)]
42    pub engine: EngineConfig,
43
44    #[serde(default, rename = "face")]
45    pub faces: Vec<FaceConfig>,
46
47    #[serde(default, rename = "route")]
48    pub routes: Vec<RouteConfig>,
49
50    #[serde(default)]
51    pub management: ManagementConfig,
52
53    #[serde(default)]
54    pub security: SecurityConfig,
55
56    #[serde(default)]
57    pub cs: CsConfig,
58
59    #[serde(default)]
60    pub logging: LoggingConfig,
61
62    #[serde(default)]
63    pub discovery: DiscoveryTomlConfig,
64
65    /// Face system auto-configuration — interface enumeration and hotplug.
66    #[serde(default)]
67    pub face_system: FaceSystemConfig,
68}
69
70impl std::str::FromStr for ForwarderConfig {
71    type Err = ConfigError;
72
73    /// Parse a `ForwarderConfig` from a TOML string.
74    ///
75    /// Expands `${VAR}` environment variable references in string values before
76    /// deserializing. Unknown variables are replaced with an empty string and
77    /// a `tracing::warn!` is emitted.
78    fn from_str(s: &str) -> Result<Self, ConfigError> {
79        let expanded = expand_env_vars(s);
80        let cfg: ForwarderConfig = toml::from_str(&expanded)?;
81        cfg.validate()?;
82        Ok(cfg)
83    }
84}
85
86impl ForwarderConfig {
87    /// Load a `ForwarderConfig` from a TOML file.
88    pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
89        let s = std::fs::read_to_string(path)?;
90        s.parse()
91    }
92
93    /// Validate the parsed config for obvious errors.
94    ///
95    /// Called automatically from [`from_str`]. Returns [`ConfigError::Invalid`]
96    /// describing the first problem found.
97    pub fn validate(&self) -> Result<(), ConfigError> {
98        // Validate face URIs and addresses.
99        for face in &self.faces {
100            validate_face_config(face)?;
101        }
102
103        // Validate route costs.
104        for route in &self.routes {
105            if route.prefix.is_empty() {
106                return Err(ConfigError::Invalid(
107                    "route prefix must not be empty".into(),
108                ));
109            }
110        }
111
112        // Validate CS capacity sanity (> 0 MB when not disabled).
113        if self.engine.cs_capacity_mb > 65536 {
114            return Err(ConfigError::Invalid(format!(
115                "engine.cs_capacity_mb ({}) is unreasonably large (max 65536 MB)",
116                self.engine.cs_capacity_mb
117            )));
118        }
119
120        Ok(())
121    }
122
123    /// Serialize to a TOML string.
124    pub fn to_toml_string(&self) -> Result<String, ConfigError> {
125        toml::to_string_pretty(self).map_err(|e| ConfigError::Invalid(e.to_string()))
126    }
127}
128
129/// Expand `${VAR}` environment variable references in a TOML string.
130///
131/// Each `${VAR}` is replaced with `std::env::var(VAR)`. If the variable is
132/// not set, it is replaced with an empty string and a warning is logged.
133fn expand_env_vars(s: &str) -> String {
134    let mut result = String::with_capacity(s.len());
135    let mut chars = s.chars().peekable();
136    while let Some(ch) = chars.next() {
137        if ch == '$' && chars.peek() == Some(&'{') {
138            chars.next(); // consume '{'
139            let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
140            match std::env::var(&var_name) {
141                Ok(val) => result.push_str(&val),
142                Err(_) => {
143                    // Unknown variable — replace with empty string and warn on stderr.
144                    eprintln!(
145                        "ndn-config: unknown env var ${{{var_name}}}, replacing with empty string"
146                    );
147                }
148            }
149        } else {
150            result.push(ch);
151        }
152    }
153    result
154}
155
156/// Validate a single `FaceConfig` for obviously invalid fields.
157fn validate_face_config(face: &FaceConfig) -> Result<(), ConfigError> {
158    match face {
159        FaceConfig::Udp { bind, remote } | FaceConfig::Tcp { bind, remote } => {
160            if let Some(addr) = bind {
161                addr.parse::<std::net::SocketAddr>()
162                    .map_err(|_| ConfigError::Invalid(format!("invalid bind address: {addr}")))?;
163            }
164            if let Some(addr) = remote {
165                addr.parse::<std::net::SocketAddr>()
166                    .map_err(|_| ConfigError::Invalid(format!("invalid remote address: {addr}")))?;
167            }
168        }
169        FaceConfig::Multicast {
170            group,
171            port: _,
172            interface: _,
173        } => {
174            let ip: std::net::IpAddr = group.parse().map_err(|_| {
175                ConfigError::Invalid(format!("invalid multicast group address: {group}"))
176            })?;
177            if !ip.is_multicast() {
178                return Err(ConfigError::Invalid(format!(
179                    "multicast group address is not a multicast address: {group}"
180                )));
181            }
182        }
183        FaceConfig::WebSocket { bind, url } => {
184            if let Some(addr) = bind {
185                addr.parse::<std::net::SocketAddr>().map_err(|_| {
186                    ConfigError::Invalid(format!("invalid WebSocket bind address: {addr}"))
187                })?;
188            }
189            if let Some(u) = url
190                && !u.starts_with("ws://")
191                && !u.starts_with("wss://")
192            {
193                return Err(ConfigError::Invalid(format!(
194                    "WebSocket URL must start with ws:// or wss://: {u}"
195                )));
196            }
197        }
198        FaceConfig::Serial { path, baud } => {
199            if path.is_empty() {
200                return Err(ConfigError::Invalid(
201                    "serial face path must not be empty".into(),
202                ));
203            }
204            if *baud == 0 {
205                return Err(ConfigError::Invalid(
206                    "serial face baud rate must be > 0".into(),
207                ));
208            }
209        }
210        FaceConfig::Unix { .. } | FaceConfig::EtherMulticast { .. } => {
211            // No additional validation needed for these face types.
212        }
213    }
214    Ok(())
215}
216
217/// Content store configuration.
218///
219/// ```toml
220/// [cs]
221/// variant = "lru"           # "lru" (default), "sharded-lru", "null"
222/// capacity_mb = 64
223/// shards = 4                # only for "sharded-lru"
224/// admission_policy = "default"  # "default" or "admit-all"
225/// ```
226#[derive(Debug, Clone, Deserialize, Serialize)]
227pub struct CsConfig {
228    /// CS implementation variant.
229    #[serde(default = "default_cs_variant")]
230    pub variant: String,
231    /// Capacity in megabytes (0 = disable).
232    #[serde(default = "default_cs_capacity_mb")]
233    pub capacity_mb: usize,
234    /// Number of shards (only for "sharded-lru").
235    #[serde(default)]
236    pub shards: Option<usize>,
237    /// Admission policy: "default" or "admit-all".
238    #[serde(default = "default_admission_policy")]
239    pub admission_policy: String,
240}
241
242fn default_cs_variant() -> String {
243    "lru".to_string()
244}
245fn default_cs_capacity_mb() -> usize {
246    64
247}
248fn default_admission_policy() -> String {
249    "default".to_string()
250}
251
252impl Default for CsConfig {
253    fn default() -> Self {
254        Self {
255            variant: default_cs_variant(),
256            capacity_mb: default_cs_capacity_mb(),
257            shards: None,
258            admission_policy: default_admission_policy(),
259        }
260    }
261}
262
263/// Engine tuning parameters.
264#[derive(Debug, Clone, Deserialize, Serialize)]
265pub struct EngineConfig {
266    /// Content store capacity in megabytes (0 = disable).
267    /// Deprecated: use `[cs] capacity_mb` instead. Kept for backward compatibility.
268    pub cs_capacity_mb: usize,
269    /// Pipeline inter-task channel capacity (backpressure).
270    pub pipeline_channel_cap: usize,
271    /// Number of parallel pipeline processing threads.
272    ///
273    /// - `0` (default): auto-detect from available CPU parallelism.
274    /// - `1`: single-threaded — all pipeline processing runs inline in the
275    ///   pipeline runner task (lowest latency, no task spawn overhead).
276    /// - `N`: spawn up to N concurrent tokio tasks per batch for pipeline
277    ///   processing (highest throughput on multi-core systems).
278    #[serde(default)]
279    pub pipeline_threads: usize,
280}
281
282impl Default for EngineConfig {
283    fn default() -> Self {
284        Self {
285            cs_capacity_mb: 64,
286            pipeline_channel_cap: 4096,
287            pipeline_threads: 0,
288        }
289    }
290}
291
292/// Configuration for a single face.
293///
294/// Each variant carries only the fields relevant to that transport type,
295/// making invalid combinations unrepresentable at parse time.
296///
297/// TOML syntax is unchanged — the `kind` field selects the variant:
298///
299/// ```toml
300/// [[face]]
301/// kind = "udp"
302/// bind = "0.0.0.0:6363"
303///
304/// [[face]]
305/// kind = "serial"
306/// path = "/dev/ttyUSB0"
307/// baud = 115200
308/// ```
309#[derive(Debug, Clone, Deserialize, Serialize)]
310#[serde(tag = "kind", rename_all = "kebab-case")]
311pub enum FaceConfig {
312    Udp {
313        #[serde(default)]
314        bind: Option<String>,
315        #[serde(default)]
316        remote: Option<String>,
317    },
318    Tcp {
319        #[serde(default)]
320        bind: Option<String>,
321        #[serde(default)]
322        remote: Option<String>,
323    },
324    Multicast {
325        group: String,
326        port: u16,
327        #[serde(default)]
328        interface: Option<String>,
329    },
330    Unix {
331        #[serde(default)]
332        path: Option<String>,
333    },
334    #[serde(rename = "web-socket")]
335    WebSocket {
336        #[serde(default)]
337        bind: Option<String>,
338        #[serde(default)]
339        url: Option<String>,
340    },
341    Serial {
342        path: String,
343        #[serde(default = "default_baud")]
344        baud: u32,
345    },
346    #[serde(rename = "ether-multicast")]
347    EtherMulticast { interface: String },
348}
349
350fn default_baud() -> u32 {
351    115200
352}
353
354/// Face system auto-configuration.
355///
356/// Controls automatic creation of multicast faces on startup and dynamic
357/// interface monitoring.  When `auto_multicast` is enabled, the router
358/// enumerates all eligible network interfaces at startup and creates one
359/// multicast face per interface without requiring explicit `[[face]]` entries.
360///
361/// ```toml
362/// [face_system.ether]
363/// auto_multicast = true
364/// whitelist = ["eth*", "enp*", "en*"]
365/// blacklist = ["docker*", "virbr*", "lo"]
366///
367/// [face_system.udp]
368/// auto_multicast = true
369/// ad_hoc = false
370/// whitelist = ["*"]
371/// blacklist = ["lo"]
372///
373/// [face_system]
374/// watch_interfaces = true  # Linux only; macOS/Windows: warning logged
375/// ```
376#[derive(Debug, Clone, Default, Deserialize, Serialize)]
377pub struct FaceSystemConfig {
378    /// Ethernet (Layer-2) multicast face auto-configuration.
379    #[serde(default)]
380    pub ether: EtherFaceSystemConfig,
381    /// UDP multicast face auto-configuration.
382    #[serde(default)]
383    pub udp: UdpFaceSystemConfig,
384    /// Subscribe to OS interface add/remove events and automatically
385    /// create or destroy multicast faces as interfaces appear and disappear.
386    ///
387    /// **Linux**: uses `RTMGRP_LINK` netlink.
388    /// **macOS / Windows**: unsupported — logs a warning and ignored.
389    #[serde(default)]
390    pub watch_interfaces: bool,
391}
392
393/// Ethernet multicast face auto-configuration (`[face_system.ether]`).
394#[derive(Debug, Clone, Deserialize, Serialize)]
395pub struct EtherFaceSystemConfig {
396    /// Create a `MulticastEtherFace` for every eligible interface at startup.
397    ///
398    /// An interface is eligible when it is UP, supports multicast, is not a
399    /// loopback, and passes the `whitelist` / `blacklist` filters.
400    #[serde(default)]
401    pub auto_multicast: bool,
402    /// Interface name glob patterns to include (default: `["*"]`).
403    ///
404    /// Supports `*` (any sequence) and `?` (one character).
405    /// Examples: `"eth*"`, `"enp*"`, `"en0"`.
406    #[serde(default = "default_iface_whitelist")]
407    pub whitelist: Vec<String>,
408    /// Interface name glob patterns to exclude (default: `["lo"]`).
409    ///
410    /// Applied after the whitelist.  Examples: `"docker*"`, `"virbr*"`.
411    #[serde(default = "default_ether_iface_blacklist")]
412    pub blacklist: Vec<String>,
413}
414
415impl Default for EtherFaceSystemConfig {
416    fn default() -> Self {
417        Self {
418            auto_multicast: false,
419            whitelist: default_iface_whitelist(),
420            blacklist: default_ether_iface_blacklist(),
421        }
422    }
423}
424
425/// UDP multicast face auto-configuration (`[face_system.udp]`).
426#[derive(Debug, Clone, Deserialize, Serialize)]
427pub struct UdpFaceSystemConfig {
428    /// Create a `MulticastUdpFace` for every eligible interface at startup.
429    #[serde(default)]
430    pub auto_multicast: bool,
431    /// Advertise faces as `AdHoc` link type instead of `MultiAccess`.
432    ///
433    /// Set to `true` for Wi-Fi IBSS (ad-hoc) or MANET deployments where not
434    /// all nodes hear every multicast frame.  Strategies use this to disable
435    /// multi-access Interest suppression on partially-connected links.
436    #[serde(default)]
437    pub ad_hoc: bool,
438    /// Interface name glob patterns to include (default: `["*"]`).
439    #[serde(default = "default_iface_whitelist")]
440    pub whitelist: Vec<String>,
441    /// Interface name glob patterns to exclude (default: `["lo"]`).
442    #[serde(default = "default_udp_iface_blacklist")]
443    pub blacklist: Vec<String>,
444}
445
446impl Default for UdpFaceSystemConfig {
447    fn default() -> Self {
448        Self {
449            auto_multicast: false,
450            ad_hoc: false,
451            whitelist: default_iface_whitelist(),
452            blacklist: default_udp_iface_blacklist(),
453        }
454    }
455}
456
457fn default_iface_whitelist() -> Vec<String> {
458    vec!["*".to_owned()]
459}
460
461fn default_ether_iface_blacklist() -> Vec<String> {
462    vec![
463        "lo".to_owned(),
464        "lo0".to_owned(),
465        "docker*".to_owned(),
466        "virbr*".to_owned(),
467    ]
468}
469
470fn default_udp_iface_blacklist() -> Vec<String> {
471    vec!["lo".to_owned(), "lo0".to_owned()]
472}
473
474/// Re-export the canonical `FaceKind` from `ndn-transport` — single source of
475/// truth for all face type classification.  Serde support is enabled via the
476/// `serde` feature on `ndn-transport`.
477pub use ndn_transport::FaceKind;
478
479/// A static FIB route entry.
480#[derive(Debug, Clone, Deserialize, Serialize)]
481pub struct RouteConfig {
482    /// NDN name prefix (e.g., `"/ndn"`).
483    pub prefix: String,
484    /// Zero-based face index (matches order in `faces`).
485    pub face: usize,
486    /// Routing cost (lower is preferred).
487    #[serde(default = "default_cost")]
488    pub cost: u32,
489}
490
491fn default_cost() -> u32 {
492    10
493}
494
495/// Management interface configuration.
496#[derive(Debug, Clone, Deserialize, Serialize)]
497pub struct ManagementConfig {
498    /// Unix domain socket (or Named Pipe on Windows) that accepts NDN face
499    /// connections from apps and tools.
500    ///
501    /// `ndn-ctl` and application processes connect here to exchange NDN packets
502    /// with the forwarder.
503    ///
504    /// Default (Unix): `/run/nfd/nfd.sock`
505    /// Default (Windows): `\\.\pipe\ndn`
506    #[serde(default = "default_face_socket")]
507    pub face_socket: String,
508}
509
510impl Default for ManagementConfig {
511    fn default() -> Self {
512        Self {
513            face_socket: default_face_socket(),
514        }
515    }
516}
517
518fn default_face_socket() -> String {
519    #[cfg(unix)]
520    return "/run/nfd/nfd.sock".to_owned();
521    #[cfg(windows)]
522    return r"\\.\pipe\ndn".to_owned();
523}
524
525/// A single trust schema rule in the router configuration.
526///
527/// Rules are specified as `[[security.rule]]` entries in the TOML config:
528///
529/// ```toml
530/// [[security.rule]]
531/// data  = "/sensor/<node>/<type>"
532/// key   = "/sensor/<node>/KEY/<id>"
533///
534/// [[security.rule]]
535/// data  = "/admin/<**rest>"
536/// key   = "/admin/KEY/<id>"
537/// ```
538///
539/// Each rule consists of a data name pattern and a key name pattern. Variables
540/// (e.g. `<node>`) captured in the data pattern must bind the same component
541/// value in the key pattern — this prevents cross-identity signing.
542#[derive(Debug, Clone, Default, Deserialize, Serialize)]
543pub struct TrustRuleConfig {
544    /// Data name pattern, e.g. `/sensor/<node>/<type>`.
545    pub data: String,
546    /// Key name pattern, e.g. `/sensor/<node>/KEY/<id>`.
547    pub key: String,
548}
549
550/// Security settings.
551#[derive(Debug, Clone, Default, Deserialize, Serialize)]
552pub struct SecurityConfig {
553    /// NDN identity name for this router (e.g., `/ndn/router1`).
554    ///
555    /// The corresponding key and certificate must exist in the PIB
556    /// (unless `auto_init` is enabled).
557    #[serde(default)]
558    pub identity: Option<String>,
559
560    /// Path to the PIB directory (default: `~/.ndn/pib`).
561    ///
562    /// Create with `ndn-ctl security init` or enable `auto_init`.
563    #[serde(default)]
564    pub pib_path: Option<String>,
565
566    /// Path to a trust-anchor certificate file to load at startup.
567    ///
568    /// Takes precedence over anchors already stored in the PIB.
569    #[serde(default)]
570    pub trust_anchor: Option<String>,
571
572    /// Whether to require all Data packets to be signed and verified.
573    #[serde(default)]
574    pub require_signed: bool,
575
576    /// Automatically generate an identity and self-signed certificate
577    /// on first startup if no keys exist in the PIB.
578    ///
579    /// Requires `identity` to be set. Default: `false`.
580    #[serde(default)]
581    pub auto_init: bool,
582
583    /// Security profile: `"default"`, `"accept-signed"`, or `"disabled"`.
584    ///
585    /// - `"default"` — full chain validation with hierarchical trust schema
586    /// - `"accept-signed"` — verify signatures but skip chain walking
587    /// - `"disabled"` — no validation (benchmarking/lab only)
588    ///
589    /// Default: `"default"`.
590    #[serde(default = "default_security_profile")]
591    pub profile: String,
592
593    // ── NDNCERT CA (optional) ─────────────────────────────────────────────────
594    /// NDN name prefix for the built-in NDNCERT CA, e.g. `/ndn/edu/example/CA`.
595    ///
596    /// When set, the router registers handlers under `<ca_prefix>/CA/INFO`,
597    /// `<ca_prefix>/CA/PROBE`, `<ca_prefix>/CA/NEW`, and `<ca_prefix>/CA/CHALLENGE`.
598    ///
599    /// Leave unset to run in client-only mode (no CA hosted).
600    #[serde(default)]
601    pub ca_prefix: Option<String>,
602
603    /// Human-readable description of this CA, returned in CA INFO responses.
604    ///
605    /// Example: `"NDN Test Network CA"`.
606    #[serde(default)]
607    pub ca_info: String,
608
609    /// Maximum certificate lifetime (days) the CA will issue.
610    ///
611    /// Requests for longer validity are silently capped to this value.
612    /// Default: `365`.
613    #[serde(default = "default_ca_max_validity_days")]
614    pub ca_max_validity_days: u32,
615
616    /// Supported NDNCERT challenge types offered by the CA.
617    ///
618    /// Recognised values: `"token"`, `"pin"`, `"possession"`, `"email"`,
619    /// `"yubikey-hotp"`.  Default: `["token"]`.
620    #[serde(default = "default_ca_challenges")]
621    pub ca_challenges: Vec<String>,
622
623    /// Static trust schema rules loaded at startup.
624    ///
625    /// These are added to the active validator's schema on startup in
626    /// addition to the rules implied by the `profile` setting.
627    ///
628    /// When `profile = "default"` the hierarchical rule is pre-loaded; additional
629    /// `[[security.rule]]` entries extend it. When `profile = "accept-signed"` the
630    /// accept-all rule is pre-loaded; additional rules are appended. When
631    /// `profile = "disabled"` this field is ignored.
632    ///
633    /// Rules can also be added or removed at runtime via the management API:
634    /// `/localhost/nfd/security/schema-rule-add` and `schema-rule-remove`.
635    #[serde(default, rename = "rule")]
636    pub rules: Vec<TrustRuleConfig>,
637
638    // ── PIB backing store ─────────────────────────────────────────────────────
639    /// Key backing store type.
640    ///
641    /// - `"file"` — file-based PIB at `pib_path` (default, persisted across restarts)
642    /// - `"memory"` — ephemeral in-memory store; keys are lost on restart
643    ///
644    /// When `identity` is set and the PIB cannot be opened, the router falls
645    /// back to an ephemeral identity and logs a warning (or shows an interactive
646    /// recovery prompt when running in a terminal).
647    ///
648    /// Default: `"file"`.
649    #[serde(default = "default_pib_type")]
650    pub pib_type: String,
651
652    /// NDN name prefix for the auto-generated ephemeral identity.
653    ///
654    /// When no `identity` is configured (or the PIB fails), an in-memory key is
655    /// generated under `<ephemeral_prefix>/<hostname>`.  If not set, the router
656    /// derives the name from the system hostname (e.g. `/ndn-fwd/router-host`).
657    ///
658    /// Set this to enforce a deterministic name for ephemeral identities, e.g.
659    /// in test environments where the hostname varies.
660    #[serde(default)]
661    pub ephemeral_prefix: Option<String>,
662}
663
664fn default_pib_type() -> String {
665    "file".to_owned()
666}
667
668fn default_security_profile() -> String {
669    // Match NFD's default: forwarder does not validate Data at the network layer.
670    // Validation is a consumer-side concern in NDN; enable "default" or "accept-signed"
671    // explicitly if you want the forwarder to enforce signatures.
672    "disabled".to_owned()
673}
674
675fn default_ca_max_validity_days() -> u32 {
676    365
677}
678
679fn default_ca_challenges() -> Vec<String> {
680    vec!["token".to_owned()]
681}
682
683/// Logging configuration.
684///
685/// ```toml
686/// [logging]
687/// level = "info"                          # default tracing filter
688/// file = "/var/log/ndn/router.log"        # optional log file
689/// ```
690///
691/// **Precedence** (highest to lowest):
692/// 1. `RUST_LOG` environment variable
693/// 2. `--log-level` CLI flag
694/// 3. `level` field in this section
695///
696/// When `file` is set, logs are written to *both* stderr and the file so
697/// interactive use always shows output while the file captures a persistent
698/// record.
699#[derive(Debug, Clone, Deserialize, Serialize)]
700pub struct LoggingConfig {
701    /// Default tracing filter string (e.g. `"info"`, `"ndn_engine=debug,warn"`).
702    ///
703    /// Overridden by `--log-level` CLI flag or `RUST_LOG` env var.
704    #[serde(default = "default_log_level")]
705    pub level: String,
706
707    /// Optional file path for persistent log output.
708    ///
709    /// Parent directories are created automatically. When set, logs are
710    /// written to both stderr and this file.
711    #[serde(default)]
712    pub file: Option<String>,
713}
714
715fn default_log_level() -> String {
716    "info".to_owned()
717}
718
719impl Default for LoggingConfig {
720    fn default() -> Self {
721        Self {
722            level: default_log_level(),
723            file: None,
724        }
725    }
726}
727
728// ─── Discovery TOML config ────────────────────────────────────────────────────
729
730/// `[discovery]` section — neighbor and service discovery configuration.
731///
732/// Discovery is disabled unless `node_name` is set.
733///
734/// ```toml
735/// [discovery]
736/// profile = "lan"
737/// node_name = "/ndn/site/myrouter"
738/// served_prefixes = ["/ndn/site/sensors"]
739/// # optional per-field overrides:
740/// hello_interval_base_ms = 5000
741/// hello_interval_max_ms  = 60000
742/// liveness_miss_count    = 3
743/// gossip_fanout          = 3
744/// relay_records          = false
745/// auto_fib_cost          = 100
746/// auto_fib_ttl_multiplier = 2.0
747/// ```
748#[derive(Debug, Clone, Default, Deserialize, Serialize)]
749pub struct DiscoveryTomlConfig {
750    /// Deployment profile name: `static`, `lan`, `campus`, `mobile`,
751    /// `high-mobility`, or `asymmetric`.  Defaults to `lan`.
752    #[serde(default)]
753    pub profile: Option<String>,
754
755    /// This node's NDN name.  **Required** to enable discovery.
756    ///
757    /// If the value ends with `/`, the system hostname is appended
758    /// automatically (e.g. `"/ndn/site/"` → `"/ndn/site/router1"`).
759    #[serde(default)]
760    pub node_name: Option<String>,
761
762    /// Prefixes published as service records at startup via
763    /// `ServiceDiscoveryProtocol::publish()`.
764    #[serde(default)]
765    pub served_prefixes: Vec<String>,
766
767    // ── Per-field overrides (supplement the profile defaults) ─────────────
768    /// Override `hello_interval_base` in milliseconds.
769    #[serde(default)]
770    pub hello_interval_base_ms: Option<u64>,
771
772    /// Override `hello_interval_max` in milliseconds.
773    #[serde(default)]
774    pub hello_interval_max_ms: Option<u64>,
775
776    /// Override `liveness_miss_count`.
777    #[serde(default)]
778    pub liveness_miss_count: Option<u32>,
779
780    /// Override SWIM indirect-probe fanout K.
781    #[serde(default)]
782    pub swim_indirect_fanout: Option<u32>,
783
784    /// Override gossip broadcast fanout.
785    #[serde(default)]
786    pub gossip_fanout: Option<u32>,
787
788    /// Override `relay_records` in `ServiceDiscoveryConfig`.
789    #[serde(default)]
790    pub relay_records: Option<bool>,
791
792    /// Override auto-FIB route cost.
793    #[serde(default)]
794    pub auto_fib_cost: Option<u32>,
795
796    /// Override auto-FIB TTL multiplier.
797    #[serde(default)]
798    pub auto_fib_ttl_multiplier: Option<f32>,
799
800    /// Optional PIB (public information base) path for persistent key storage.
801    /// Defaults to `~/.ndn/pib.db`.
802    #[serde(default)]
803    pub pib_path: Option<String>,
804
805    /// Key name for signing hello packets.  If absent, a deterministic
806    /// ephemeral Ed25519 key is auto-generated from the node name.
807    #[serde(default)]
808    pub key_name: Option<String>,
809
810    /// Which link-layer transports to run discovery on.
811    ///
812    /// Accepted values:
813    /// - `"udp"` (default): UDP multicast only (`224.0.23.170:6363`).
814    /// - `"ether"`: raw Ethernet multicast only (EtherType 0x8624).
815    /// - `"both"`: UDP and Ethernet simultaneously.
816    ///
817    /// Ethernet discovery requires `CAP_NET_RAW` / root on Linux, or root on
818    /// macOS (PF_NDRV).  The `ether` and `both` options also require at least
819    /// one `[[face]]` entry with `kind = "ether-multicast"` (providing the
820    /// `FaceId` and interface name).
821    #[serde(default)]
822    pub discovery_transport: Option<String>,
823
824    /// Network interface name for Ethernet discovery (e.g. `"eth0"`, `"en0"`).
825    ///
826    /// Required when `discovery_transport` is `"ether"` or `"both"`.
827    #[serde(default)]
828    pub ether_iface: Option<String>,
829}
830
831impl DiscoveryTomlConfig {
832    /// Returns `true` if discovery is enabled (i.e. `node_name` is set).
833    pub fn enabled(&self) -> bool {
834        self.node_name.is_some()
835    }
836
837    /// Resolve the effective node name, appending the system hostname if
838    /// `node_name` ends with `/`.
839    pub fn resolved_node_name(&self) -> Option<String> {
840        let raw = self.node_name.as_deref()?;
841        if raw.ends_with('/') {
842            let host = Self::hostname();
843            Some(format!("{}{}", raw.trim_end_matches('/'), host))
844        } else {
845            Some(raw.to_owned())
846        }
847    }
848
849    fn hostname() -> String {
850        std::env::var("HOSTNAME").unwrap_or_else(|_| {
851            // Fallback: read from /etc/hostname or use "localhost".
852            std::fs::read_to_string("/etc/hostname")
853                .map(|s| s.trim().to_owned())
854                .unwrap_or_else(|_| "localhost".to_owned())
855        })
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use std::str::FromStr;
863
864    const SAMPLE_TOML: &str = r#"
865[engine]
866cs_capacity_mb = 32
867pipeline_channel_cap = 512
868
869[[face]]
870kind = "udp"
871bind = "0.0.0.0:6363"
872
873[[face]]
874kind = "multicast"
875group = "224.0.23.170"
876port = 56363
877interface = "eth0"
878
879[[route]]
880prefix = "/ndn"
881face = 0
882cost = 10
883
884[[route]]
885prefix = "/local"
886face = 1
887
888[security]
889trust_anchor = "/etc/ndn/ta.cert"
890require_signed = true
891
892[[security.rule]]
893data = "/sensor/<node>/<type>"
894key  = "/sensor/<node>/KEY/<id>"
895
896[logging]
897level = "debug"
898file = "/var/log/ndn/router.log"
899"#;
900
901    #[test]
902    fn parse_sample_config() {
903        let cfg = ForwarderConfig::from_str(SAMPLE_TOML).unwrap();
904        assert_eq!(cfg.engine.cs_capacity_mb, 32);
905        assert_eq!(cfg.engine.pipeline_channel_cap, 512);
906        assert_eq!(cfg.faces.len(), 2);
907        assert!(matches!(cfg.faces[0], FaceConfig::Udp { .. }));
908        assert!(matches!(cfg.faces[1], FaceConfig::Multicast { .. }));
909        assert_eq!(cfg.routes.len(), 2);
910        assert_eq!(cfg.routes[0].prefix, "/ndn");
911        assert_eq!(cfg.routes[0].cost, 10);
912        assert_eq!(cfg.routes[1].prefix, "/local");
913        assert_eq!(cfg.routes[1].cost, 10); // default
914        assert!(cfg.security.trust_anchor.is_some());
915        assert!(cfg.security.require_signed);
916        assert_eq!(cfg.security.rules.len(), 1);
917        assert_eq!(cfg.security.rules[0].data, "/sensor/<node>/<type>");
918        assert_eq!(cfg.security.rules[0].key, "/sensor/<node>/KEY/<id>");
919        assert_eq!(cfg.logging.level, "debug");
920        assert_eq!(cfg.logging.file.as_deref(), Some("/var/log/ndn/router.log"));
921    }
922
923    #[test]
924    fn default_config_is_valid() {
925        let cfg = ForwarderConfig::default();
926        assert_eq!(cfg.engine.cs_capacity_mb, 64);
927        assert!(cfg.faces.is_empty());
928        assert!(cfg.routes.is_empty());
929    }
930
931    #[test]
932    fn roundtrip_serialize_deserialize() {
933        let cfg = ForwarderConfig::from_str(SAMPLE_TOML).unwrap();
934        let toml_str = cfg.to_toml_string().unwrap();
935        let cfg2 = ForwarderConfig::from_str(&toml_str).unwrap();
936        assert_eq!(cfg2.engine.cs_capacity_mb, 32);
937        assert_eq!(cfg2.faces.len(), 2);
938    }
939
940    #[test]
941    fn empty_string_gives_defaults() {
942        let cfg = ForwarderConfig::from_str("").unwrap();
943        assert_eq!(cfg.engine.cs_capacity_mb, 64);
944        assert!(cfg.faces.is_empty());
945        assert_eq!(cfg.logging.level, "info");
946        assert!(cfg.logging.file.is_none());
947    }
948
949    #[test]
950    fn invalid_toml_returns_error() {
951        let result = ForwarderConfig::from_str("[[[invalid");
952        assert!(result.is_err());
953    }
954
955    #[test]
956    fn route_default_cost() {
957        let toml = "[[route]]\nprefix = \"/x\"\nface = 0\n";
958        let cfg = ForwarderConfig::from_str(toml).unwrap();
959        assert_eq!(cfg.routes[0].cost, 10);
960    }
961
962    #[test]
963    fn example_file_parses() {
964        let s = include_str!("../../../../ndn-fwd.example.toml");
965        ForwarderConfig::from_str(s).expect("example config should parse");
966    }
967}