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}