Skip to main content

rns_net/common/
config.rs

1//! ConfigObj parser for RNS config files.
2//!
3//! Python RNS uses ConfigObj format — NOT TOML, NOT standard INI.
4//! Key differences: nested `[[sections]]`, booleans `Yes`/`No`/`True`/`False`,
5//! comments with `#`, unquoted string values.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::path::Path;
10use std::io;
11
12/// Parsed RNS configuration.
13#[derive(Debug, Clone)]
14pub struct RnsConfig {
15    pub reticulum: ReticulumSection,
16    pub logging: LoggingSection,
17    pub interfaces: Vec<ParsedInterface>,
18    pub hooks: Vec<ParsedHook>,
19}
20
21/// A parsed hook from `[[subsection]]` within `[hooks]`.
22#[derive(Debug, Clone)]
23pub struct ParsedHook {
24    pub name: String,
25    pub path: String,
26    pub attach_point: String,
27    pub priority: i32,
28    pub enabled: bool,
29}
30
31/// The `[reticulum]` section.
32#[derive(Debug, Clone)]
33pub struct ReticulumSection {
34    pub enable_transport: bool,
35    pub share_instance: bool,
36    pub instance_name: String,
37    pub shared_instance_port: u16,
38    pub instance_control_port: u16,
39    pub panic_on_interface_error: bool,
40    pub use_implicit_proof: bool,
41    pub network_identity: Option<String>,
42    pub respond_to_probes: bool,
43    pub enable_remote_management: bool,
44    pub remote_management_allowed: Vec<String>,
45    pub publish_blackhole: bool,
46    pub probe_port: Option<u16>,
47    pub probe_addr: Option<String>,
48    /// Protocol for endpoint discovery: "rnsp" (default) or "stun".
49    pub probe_protocol: Option<String>,
50    /// Network interface to bind outbound sockets to (e.g. "usb0").
51    pub device: Option<String>,
52    /// Enable interface discovery (advertise discoverable interfaces and
53    /// listen for discovery announces from the network).
54    pub discover_interfaces: bool,
55    /// Minimum stamp value for accepting discovered interfaces.
56    pub required_discovery_value: Option<u8>,
57    /// Accept an announce with strictly fewer hops even when the random_blob
58    /// is a duplicate of the existing path entry.
59    pub prefer_shorter_path: bool,
60    /// Maximum number of alternative paths stored per destination.
61    /// Default 1 (single path, backward-compatible).
62    pub max_paths_per_destination: usize,
63}
64
65impl Default for ReticulumSection {
66    fn default() -> Self {
67        ReticulumSection {
68            enable_transport: false,
69            share_instance: true,
70            instance_name: "default".into(),
71            shared_instance_port: 37428,
72            instance_control_port: 37429,
73            panic_on_interface_error: false,
74            use_implicit_proof: true,
75            network_identity: None,
76            respond_to_probes: false,
77            enable_remote_management: false,
78            remote_management_allowed: Vec::new(),
79            publish_blackhole: false,
80            probe_port: None,
81            probe_addr: None,
82            probe_protocol: None,
83            device: None,
84            discover_interfaces: false,
85            required_discovery_value: None,
86            prefer_shorter_path: false,
87            max_paths_per_destination: 1,
88        }
89    }
90}
91
92/// The `[logging]` section.
93#[derive(Debug, Clone)]
94pub struct LoggingSection {
95    pub loglevel: u8,
96}
97
98impl Default for LoggingSection {
99    fn default() -> Self {
100        LoggingSection { loglevel: 4 }
101    }
102}
103
104/// A parsed interface from `[[subsection]]` within `[interfaces]`.
105#[derive(Debug, Clone)]
106pub struct ParsedInterface {
107    pub name: String,
108    pub interface_type: String,
109    pub enabled: bool,
110    pub mode: String,
111    pub params: HashMap<String, String>,
112}
113
114/// Configuration parse error.
115#[derive(Debug, Clone)]
116pub enum ConfigError {
117    Io(String),
118    Parse(String),
119    InvalidValue { key: String, value: String },
120}
121
122impl fmt::Display for ConfigError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            ConfigError::Io(msg) => write!(f, "Config I/O error: {}", msg),
126            ConfigError::Parse(msg) => write!(f, "Config parse error: {}", msg),
127            ConfigError::InvalidValue { key, value } => {
128                write!(f, "Invalid value for '{}': '{}'", key, value)
129            }
130        }
131    }
132}
133
134impl From<io::Error> for ConfigError {
135    fn from(e: io::Error) -> Self {
136        ConfigError::Io(e.to_string())
137    }
138}
139
140/// Parse a config string into an `RnsConfig`.
141pub fn parse(input: &str) -> Result<RnsConfig, ConfigError> {
142    let mut current_section: Option<String> = None;
143    let mut current_subsection: Option<String> = None;
144
145    let mut reticulum_kvs: HashMap<String, String> = HashMap::new();
146    let mut logging_kvs: HashMap<String, String> = HashMap::new();
147    let mut interfaces: Vec<ParsedInterface> = Vec::new();
148    let mut current_iface_kvs: Option<HashMap<String, String>> = None;
149    let mut current_iface_name: Option<String> = None;
150    let mut hooks: Vec<ParsedHook> = Vec::new();
151    let mut current_hook_kvs: Option<HashMap<String, String>> = None;
152    let mut current_hook_name: Option<String> = None;
153
154    for line in input.lines() {
155        // Strip comments (# to end of line, unless inside quotes)
156        let line = strip_comment(line);
157        let trimmed = line.trim();
158
159        // Skip empty lines
160        if trimmed.is_empty() {
161            continue;
162        }
163
164        // Check for subsection [[name]]
165        if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
166            let name = trimmed[2..trimmed.len() - 2].trim().to_string();
167            // Finalize previous interface subsection if any
168            if let (Some(iface_name), Some(kvs)) =
169                (current_iface_name.take(), current_iface_kvs.take())
170            {
171                interfaces.push(build_parsed_interface(iface_name, kvs));
172            }
173            // Finalize previous hook subsection if any
174            if let (Some(hook_name), Some(kvs)) =
175                (current_hook_name.take(), current_hook_kvs.take())
176            {
177                hooks.push(build_parsed_hook(hook_name, kvs));
178            }
179            current_subsection = Some(name.clone());
180            // Determine which section we're in to know subsection type
181            if current_section.as_deref() == Some("hooks") {
182                current_hook_name = Some(name);
183                current_hook_kvs = Some(HashMap::new());
184            } else {
185                current_iface_name = Some(name);
186                current_iface_kvs = Some(HashMap::new());
187            }
188            continue;
189        }
190
191        // Check for section [name]
192        if trimmed.starts_with('[') && trimmed.ends_with(']') {
193            // Finalize previous interface subsection if any
194            if let (Some(iface_name), Some(kvs)) =
195                (current_iface_name.take(), current_iface_kvs.take())
196            {
197                interfaces.push(build_parsed_interface(iface_name, kvs));
198            }
199            // Finalize previous hook subsection if any
200            if let (Some(hook_name), Some(kvs)) =
201                (current_hook_name.take(), current_hook_kvs.take())
202            {
203                hooks.push(build_parsed_hook(hook_name, kvs));
204            }
205            current_subsection = None;
206
207            let name = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
208            current_section = Some(name);
209            continue;
210        }
211
212        // Parse key = value
213        if let Some(eq_pos) = trimmed.find('=') {
214            let key = trimmed[..eq_pos].trim().to_string();
215            let value = trimmed[eq_pos + 1..].trim().to_string();
216
217            if current_subsection.is_some() {
218                // Inside a [[subsection]] — exactly one of these should be Some
219                debug_assert!(
220                    !(current_hook_kvs.is_some() && current_iface_kvs.is_some()),
221                    "hook and interface subsections should never be active simultaneously"
222                );
223                if let Some(ref mut kvs) = current_hook_kvs {
224                    kvs.insert(key, value);
225                } else if let Some(ref mut kvs) = current_iface_kvs {
226                    kvs.insert(key, value);
227                }
228            } else if let Some(ref section) = current_section {
229                match section.as_str() {
230                    "reticulum" => {
231                        reticulum_kvs.insert(key, value);
232                    }
233                    "logging" => {
234                        logging_kvs.insert(key, value);
235                    }
236                    _ => {} // ignore unknown sections
237                }
238            }
239        }
240    }
241
242    // Finalize last subsections
243    if let (Some(iface_name), Some(kvs)) = (current_iface_name.take(), current_iface_kvs.take()) {
244        interfaces.push(build_parsed_interface(iface_name, kvs));
245    }
246    if let (Some(hook_name), Some(kvs)) = (current_hook_name.take(), current_hook_kvs.take()) {
247        hooks.push(build_parsed_hook(hook_name, kvs));
248    }
249
250    // Build typed sections
251    let reticulum = build_reticulum_section(&reticulum_kvs)?;
252    let logging = build_logging_section(&logging_kvs)?;
253
254    Ok(RnsConfig {
255        reticulum,
256        logging,
257        interfaces,
258        hooks,
259    })
260}
261
262/// Parse a config file from disk.
263pub fn parse_file(path: &Path) -> Result<RnsConfig, ConfigError> {
264    let content = std::fs::read_to_string(path)?;
265    parse(&content)
266}
267
268/// Strip `#` comments from a line (simple: not inside quotes).
269fn strip_comment(line: &str) -> &str {
270    // Find # that is not inside quotes
271    let mut in_quote = false;
272    let mut quote_char = '"';
273    for (i, ch) in line.char_indices() {
274        if !in_quote && (ch == '"' || ch == '\'') {
275            in_quote = true;
276            quote_char = ch;
277        } else if in_quote && ch == quote_char {
278            in_quote = false;
279        } else if !in_quote && ch == '#' {
280            return &line[..i];
281        }
282    }
283    line
284}
285
286/// Parse a string as a boolean (ConfigObj style). Public API for use by node.rs.
287pub fn parse_bool_pub(value: &str) -> Option<bool> {
288    parse_bool(value)
289}
290
291/// Parse a string as a boolean (ConfigObj style).
292fn parse_bool(value: &str) -> Option<bool> {
293    match value.to_lowercase().as_str() {
294        "yes" | "true" | "1" | "on" => Some(true),
295        "no" | "false" | "0" | "off" => Some(false),
296        _ => None,
297    }
298}
299
300fn build_parsed_interface(name: String, mut kvs: HashMap<String, String>) -> ParsedInterface {
301    let interface_type = kvs.remove("type").unwrap_or_default();
302    let enabled = kvs
303        .remove("enabled")
304        .and_then(|v| parse_bool(&v))
305        .unwrap_or(true);
306    // Python checks `interface_mode` first, then falls back to `mode`
307    let mode = kvs
308        .remove("interface_mode")
309        .or_else(|| kvs.remove("mode"))
310        .unwrap_or_else(|| "full".into());
311
312    ParsedInterface {
313        name,
314        interface_type,
315        enabled,
316        mode,
317        params: kvs,
318    }
319}
320
321fn build_parsed_hook(name: String, mut kvs: HashMap<String, String>) -> ParsedHook {
322    let path = kvs.remove("path").unwrap_or_default();
323    let attach_point = kvs.remove("attach_point").unwrap_or_default();
324    let priority = kvs
325        .remove("priority")
326        .and_then(|v| v.parse::<i32>().ok())
327        .unwrap_or(0);
328    let enabled = kvs
329        .remove("enabled")
330        .and_then(|v| parse_bool(&v))
331        .unwrap_or(true);
332
333    ParsedHook {
334        name,
335        path,
336        attach_point,
337        priority,
338        enabled,
339    }
340}
341
342/// Map a hook point name string to its index. Returns None for unknown names.
343pub fn parse_hook_point(s: &str) -> Option<usize> {
344    match s {
345        "PreIngress" => Some(0),
346        "PreDispatch" => Some(1),
347        "AnnounceReceived" => Some(2),
348        "PathUpdated" => Some(3),
349        "AnnounceRetransmit" => Some(4),
350        "LinkRequestReceived" => Some(5),
351        "LinkEstablished" => Some(6),
352        "LinkClosed" => Some(7),
353        "InterfaceUp" => Some(8),
354        "InterfaceDown" => Some(9),
355        "InterfaceConfigChanged" => Some(10),
356        "SendOnInterface" => Some(11),
357        "BroadcastOnAllInterfaces" => Some(12),
358        "DeliverLocal" => Some(13),
359        "TunnelSynthesize" => Some(14),
360        "Tick" => Some(15),
361        _ => None,
362    }
363}
364
365fn build_reticulum_section(
366    kvs: &HashMap<String, String>,
367) -> Result<ReticulumSection, ConfigError> {
368    let mut section = ReticulumSection::default();
369
370    if let Some(v) = kvs.get("enable_transport") {
371        section.enable_transport = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
372            key: "enable_transport".into(),
373            value: v.clone(),
374        })?;
375    }
376    if let Some(v) = kvs.get("share_instance") {
377        section.share_instance = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
378            key: "share_instance".into(),
379            value: v.clone(),
380        })?;
381    }
382    if let Some(v) = kvs.get("instance_name") {
383        section.instance_name = v.clone();
384    }
385    if let Some(v) = kvs.get("shared_instance_port") {
386        section.shared_instance_port =
387            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
388                key: "shared_instance_port".into(),
389                value: v.clone(),
390            })?;
391    }
392    if let Some(v) = kvs.get("instance_control_port") {
393        section.instance_control_port =
394            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
395                key: "instance_control_port".into(),
396                value: v.clone(),
397            })?;
398    }
399    if let Some(v) = kvs.get("panic_on_interface_error") {
400        section.panic_on_interface_error =
401            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
402                key: "panic_on_interface_error".into(),
403                value: v.clone(),
404            })?;
405    }
406    if let Some(v) = kvs.get("use_implicit_proof") {
407        section.use_implicit_proof = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
408            key: "use_implicit_proof".into(),
409            value: v.clone(),
410        })?;
411    }
412    if let Some(v) = kvs.get("network_identity") {
413        section.network_identity = Some(v.clone());
414    }
415    if let Some(v) = kvs.get("respond_to_probes") {
416        section.respond_to_probes = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
417            key: "respond_to_probes".into(),
418            value: v.clone(),
419        })?;
420    }
421    if let Some(v) = kvs.get("enable_remote_management") {
422        section.enable_remote_management = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
423            key: "enable_remote_management".into(),
424            value: v.clone(),
425        })?;
426    }
427    if let Some(v) = kvs.get("remote_management_allowed") {
428        // Value is a comma-separated list of hex identity hashes
429        for item in v.split(',') {
430            let trimmed = item.trim();
431            if !trimmed.is_empty() {
432                section.remote_management_allowed.push(trimmed.to_string());
433            }
434        }
435    }
436    if let Some(v) = kvs.get("publish_blackhole") {
437        section.publish_blackhole = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
438            key: "publish_blackhole".into(),
439            value: v.clone(),
440        })?;
441    }
442    if let Some(v) = kvs.get("probe_port") {
443        section.probe_port = Some(v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
444            key: "probe_port".into(),
445            value: v.clone(),
446        })?);
447    }
448    if let Some(v) = kvs.get("probe_addr") {
449        section.probe_addr = Some(v.clone());
450    }
451    if let Some(v) = kvs.get("probe_protocol") {
452        section.probe_protocol = Some(v.clone());
453    }
454    if let Some(v) = kvs.get("device") {
455        section.device = Some(v.clone());
456    }
457    if let Some(v) = kvs.get("discover_interfaces") {
458        section.discover_interfaces = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
459            key: "discover_interfaces".into(),
460            value: v.clone(),
461        })?;
462    }
463    if let Some(v) = kvs.get("required_discovery_value") {
464        section.required_discovery_value = Some(v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
465            key: "required_discovery_value".into(),
466            value: v.clone(),
467        })?);
468    }
469    if let Some(v) = kvs.get("prefer_shorter_path") {
470        section.prefer_shorter_path = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
471            key: "prefer_shorter_path".into(),
472            value: v.clone(),
473        })?;
474    }
475    if let Some(v) = kvs.get("max_paths_per_destination") {
476        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
477            key: "max_paths_per_destination".into(),
478            value: v.clone(),
479        })?;
480        section.max_paths_per_destination = n.max(1);
481    }
482
483    Ok(section)
484}
485
486fn build_logging_section(kvs: &HashMap<String, String>) -> Result<LoggingSection, ConfigError> {
487    let mut section = LoggingSection::default();
488
489    if let Some(v) = kvs.get("loglevel") {
490        section.loglevel = v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
491            key: "loglevel".into(),
492            value: v.clone(),
493        })?;
494    }
495
496    Ok(section)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn parse_empty() {
505        let config = parse("").unwrap();
506        assert!(!config.reticulum.enable_transport);
507        assert!(config.reticulum.share_instance);
508        assert_eq!(config.reticulum.instance_name, "default");
509        assert_eq!(config.logging.loglevel, 4);
510        assert!(config.interfaces.is_empty());
511    }
512
513    #[test]
514    fn parse_default_config() {
515        // The default config from Python's __default_rns_config__
516        let input = r#"
517[reticulum]
518enable_transport = False
519share_instance = Yes
520instance_name = default
521
522[logging]
523loglevel = 4
524
525[interfaces]
526
527  [[Default Interface]]
528    type = AutoInterface
529    enabled = Yes
530"#;
531        let config = parse(input).unwrap();
532        assert!(!config.reticulum.enable_transport);
533        assert!(config.reticulum.share_instance);
534        assert_eq!(config.reticulum.instance_name, "default");
535        assert_eq!(config.logging.loglevel, 4);
536        assert_eq!(config.interfaces.len(), 1);
537        assert_eq!(config.interfaces[0].name, "Default Interface");
538        assert_eq!(config.interfaces[0].interface_type, "AutoInterface");
539        assert!(config.interfaces[0].enabled);
540    }
541
542    #[test]
543    fn parse_reticulum_section() {
544        let input = r#"
545[reticulum]
546enable_transport = True
547share_instance = No
548instance_name = mynode
549shared_instance_port = 12345
550instance_control_port = 12346
551panic_on_interface_error = Yes
552use_implicit_proof = False
553respond_to_probes = True
554network_identity = /home/user/.reticulum/identity
555"#;
556        let config = parse(input).unwrap();
557        assert!(config.reticulum.enable_transport);
558        assert!(!config.reticulum.share_instance);
559        assert_eq!(config.reticulum.instance_name, "mynode");
560        assert_eq!(config.reticulum.shared_instance_port, 12345);
561        assert_eq!(config.reticulum.instance_control_port, 12346);
562        assert!(config.reticulum.panic_on_interface_error);
563        assert!(!config.reticulum.use_implicit_proof);
564        assert!(config.reticulum.respond_to_probes);
565        assert_eq!(
566            config.reticulum.network_identity.as_deref(),
567            Some("/home/user/.reticulum/identity")
568        );
569    }
570
571    #[test]
572    fn parse_logging_section() {
573        let input = "[logging]\nloglevel = 6\n";
574        let config = parse(input).unwrap();
575        assert_eq!(config.logging.loglevel, 6);
576    }
577
578    #[test]
579    fn parse_interface_tcp_client() {
580        let input = r#"
581[interfaces]
582  [[TCP Client]]
583    type = TCPClientInterface
584    enabled = Yes
585    target_host = 87.106.8.245
586    target_port = 4242
587"#;
588        let config = parse(input).unwrap();
589        assert_eq!(config.interfaces.len(), 1);
590        let iface = &config.interfaces[0];
591        assert_eq!(iface.name, "TCP Client");
592        assert_eq!(iface.interface_type, "TCPClientInterface");
593        assert!(iface.enabled);
594        assert_eq!(iface.params.get("target_host").unwrap(), "87.106.8.245");
595        assert_eq!(iface.params.get("target_port").unwrap(), "4242");
596    }
597
598    #[test]
599    fn parse_interface_tcp_server() {
600        let input = r#"
601[interfaces]
602  [[TCP Server]]
603    type = TCPServerInterface
604    enabled = Yes
605    listen_ip = 0.0.0.0
606    listen_port = 4242
607"#;
608        let config = parse(input).unwrap();
609        assert_eq!(config.interfaces.len(), 1);
610        let iface = &config.interfaces[0];
611        assert_eq!(iface.name, "TCP Server");
612        assert_eq!(iface.interface_type, "TCPServerInterface");
613        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
614        assert_eq!(iface.params.get("listen_port").unwrap(), "4242");
615    }
616
617    #[test]
618    fn parse_interface_udp() {
619        let input = r#"
620[interfaces]
621  [[UDP Interface]]
622    type = UDPInterface
623    enabled = Yes
624    listen_ip = 0.0.0.0
625    listen_port = 4242
626    forward_ip = 255.255.255.255
627    forward_port = 4242
628"#;
629        let config = parse(input).unwrap();
630        assert_eq!(config.interfaces.len(), 1);
631        let iface = &config.interfaces[0];
632        assert_eq!(iface.name, "UDP Interface");
633        assert_eq!(iface.interface_type, "UDPInterface");
634        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
635        assert_eq!(iface.params.get("forward_ip").unwrap(), "255.255.255.255");
636    }
637
638    #[test]
639    fn parse_multiple_interfaces() {
640        let input = r#"
641[interfaces]
642  [[TCP Client]]
643    type = TCPClientInterface
644    target_host = 10.0.0.1
645    target_port = 4242
646
647  [[UDP Broadcast]]
648    type = UDPInterface
649    listen_ip = 0.0.0.0
650    listen_port = 5555
651    forward_ip = 255.255.255.255
652    forward_port = 5555
653"#;
654        let config = parse(input).unwrap();
655        assert_eq!(config.interfaces.len(), 2);
656        assert_eq!(config.interfaces[0].name, "TCP Client");
657        assert_eq!(config.interfaces[0].interface_type, "TCPClientInterface");
658        assert_eq!(config.interfaces[1].name, "UDP Broadcast");
659        assert_eq!(config.interfaces[1].interface_type, "UDPInterface");
660    }
661
662    #[test]
663    fn parse_booleans() {
664        // Test all boolean variants
665        for (input, expected) in &[
666            ("Yes", true),
667            ("No", false),
668            ("True", true),
669            ("False", false),
670            ("true", true),
671            ("false", false),
672            ("1", true),
673            ("0", false),
674            ("on", true),
675            ("off", false),
676        ] {
677            let result = parse_bool(input);
678            assert_eq!(result, Some(*expected), "parse_bool({}) failed", input);
679        }
680    }
681
682    #[test]
683    fn parse_comments() {
684        let input = r#"
685# This is a comment
686[reticulum]
687enable_transport = True  # inline comment
688# share_instance = No
689instance_name = test
690"#;
691        let config = parse(input).unwrap();
692        assert!(config.reticulum.enable_transport);
693        assert!(config.reticulum.share_instance); // commented out line should be ignored
694        assert_eq!(config.reticulum.instance_name, "test");
695    }
696
697    #[test]
698    fn parse_interface_mode_field() {
699        let input = r#"
700[interfaces]
701  [[TCP Client]]
702    type = TCPClientInterface
703    interface_mode = access_point
704    target_host = 10.0.0.1
705    target_port = 4242
706"#;
707        let config = parse(input).unwrap();
708        assert_eq!(config.interfaces[0].mode, "access_point");
709    }
710
711    #[test]
712    fn parse_mode_fallback() {
713        // Python also accepts "mode" as fallback for "interface_mode"
714        let input = r#"
715[interfaces]
716  [[TCP Client]]
717    type = TCPClientInterface
718    mode = gateway
719    target_host = 10.0.0.1
720    target_port = 4242
721"#;
722        let config = parse(input).unwrap();
723        assert_eq!(config.interfaces[0].mode, "gateway");
724    }
725
726    #[test]
727    fn parse_interface_mode_takes_precedence() {
728        // If both interface_mode and mode are set, interface_mode wins
729        let input = r#"
730[interfaces]
731  [[TCP Client]]
732    type = TCPClientInterface
733    interface_mode = roaming
734    mode = boundary
735    target_host = 10.0.0.1
736    target_port = 4242
737"#;
738        let config = parse(input).unwrap();
739        assert_eq!(config.interfaces[0].mode, "roaming");
740    }
741
742    #[test]
743    fn parse_disabled_interface() {
744        let input = r#"
745[interfaces]
746  [[Disabled TCP]]
747    type = TCPClientInterface
748    enabled = No
749    target_host = 10.0.0.1
750    target_port = 4242
751"#;
752        let config = parse(input).unwrap();
753        assert_eq!(config.interfaces.len(), 1);
754        assert!(!config.interfaces[0].enabled);
755    }
756
757    #[test]
758    fn parse_serial_interface() {
759        let input = r#"
760[interfaces]
761  [[Serial Port]]
762    type = SerialInterface
763    enabled = Yes
764    port = /dev/ttyUSB0
765    speed = 115200
766    databits = 8
767    parity = N
768    stopbits = 1
769"#;
770        let config = parse(input).unwrap();
771        assert_eq!(config.interfaces.len(), 1);
772        let iface = &config.interfaces[0];
773        assert_eq!(iface.name, "Serial Port");
774        assert_eq!(iface.interface_type, "SerialInterface");
775        assert!(iface.enabled);
776        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB0");
777        assert_eq!(iface.params.get("speed").unwrap(), "115200");
778        assert_eq!(iface.params.get("databits").unwrap(), "8");
779        assert_eq!(iface.params.get("parity").unwrap(), "N");
780        assert_eq!(iface.params.get("stopbits").unwrap(), "1");
781    }
782
783    #[test]
784    fn parse_kiss_interface() {
785        let input = r#"
786[interfaces]
787  [[KISS TNC]]
788    type = KISSInterface
789    enabled = Yes
790    port = /dev/ttyUSB1
791    speed = 9600
792    preamble = 350
793    txtail = 20
794    persistence = 64
795    slottime = 20
796    flow_control = True
797    id_interval = 600
798    id_callsign = MYCALL
799"#;
800        let config = parse(input).unwrap();
801        assert_eq!(config.interfaces.len(), 1);
802        let iface = &config.interfaces[0];
803        assert_eq!(iface.name, "KISS TNC");
804        assert_eq!(iface.interface_type, "KISSInterface");
805        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB1");
806        assert_eq!(iface.params.get("speed").unwrap(), "9600");
807        assert_eq!(iface.params.get("preamble").unwrap(), "350");
808        assert_eq!(iface.params.get("txtail").unwrap(), "20");
809        assert_eq!(iface.params.get("persistence").unwrap(), "64");
810        assert_eq!(iface.params.get("slottime").unwrap(), "20");
811        assert_eq!(iface.params.get("flow_control").unwrap(), "True");
812        assert_eq!(iface.params.get("id_interval").unwrap(), "600");
813        assert_eq!(iface.params.get("id_callsign").unwrap(), "MYCALL");
814    }
815
816    #[test]
817    fn parse_ifac_networkname() {
818        let input = r#"
819[interfaces]
820  [[TCP Client]]
821    type = TCPClientInterface
822    target_host = 10.0.0.1
823    target_port = 4242
824    networkname = testnet
825"#;
826        let config = parse(input).unwrap();
827        assert_eq!(config.interfaces[0].params.get("networkname").unwrap(), "testnet");
828    }
829
830    #[test]
831    fn parse_ifac_passphrase() {
832        let input = r#"
833[interfaces]
834  [[TCP Client]]
835    type = TCPClientInterface
836    target_host = 10.0.0.1
837    target_port = 4242
838    passphrase = secret123
839    ifac_size = 64
840"#;
841        let config = parse(input).unwrap();
842        assert_eq!(config.interfaces[0].params.get("passphrase").unwrap(), "secret123");
843        assert_eq!(config.interfaces[0].params.get("ifac_size").unwrap(), "64");
844    }
845
846    #[test]
847    fn parse_remote_management_config() {
848        let input = r#"
849[reticulum]
850enable_transport = True
851enable_remote_management = Yes
852remote_management_allowed = aabbccdd00112233aabbccdd00112233, 11223344556677881122334455667788
853publish_blackhole = Yes
854"#;
855        let config = parse(input).unwrap();
856        assert!(config.reticulum.enable_remote_management);
857        assert!(config.reticulum.publish_blackhole);
858        assert_eq!(config.reticulum.remote_management_allowed.len(), 2);
859        assert_eq!(
860            config.reticulum.remote_management_allowed[0],
861            "aabbccdd00112233aabbccdd00112233"
862        );
863        assert_eq!(
864            config.reticulum.remote_management_allowed[1],
865            "11223344556677881122334455667788"
866        );
867    }
868
869    #[test]
870    fn parse_remote_management_defaults() {
871        let input = "[reticulum]\n";
872        let config = parse(input).unwrap();
873        assert!(!config.reticulum.enable_remote_management);
874        assert!(!config.reticulum.publish_blackhole);
875        assert!(config.reticulum.remote_management_allowed.is_empty());
876    }
877
878    #[test]
879    fn parse_hooks_section() {
880        let input = r#"
881[hooks]
882  [[drop_tick]]
883    path = /tmp/drop_tick.wasm
884    attach_point = Tick
885    priority = 10
886    enabled = Yes
887
888  [[log_announce]]
889    path = /tmp/log_announce.wasm
890    attach_point = AnnounceReceived
891    priority = 5
892    enabled = No
893"#;
894        let config = parse(input).unwrap();
895        assert_eq!(config.hooks.len(), 2);
896        assert_eq!(config.hooks[0].name, "drop_tick");
897        assert_eq!(config.hooks[0].path, "/tmp/drop_tick.wasm");
898        assert_eq!(config.hooks[0].attach_point, "Tick");
899        assert_eq!(config.hooks[0].priority, 10);
900        assert!(config.hooks[0].enabled);
901        assert_eq!(config.hooks[1].name, "log_announce");
902        assert_eq!(config.hooks[1].attach_point, "AnnounceReceived");
903        assert!(!config.hooks[1].enabled);
904    }
905
906    #[test]
907    fn parse_empty_hooks() {
908        let input = "[hooks]\n";
909        let config = parse(input).unwrap();
910        assert!(config.hooks.is_empty());
911    }
912
913    #[test]
914    fn parse_hook_point_names() {
915        assert_eq!(parse_hook_point("PreIngress"), Some(0));
916        assert_eq!(parse_hook_point("PreDispatch"), Some(1));
917        assert_eq!(parse_hook_point("AnnounceReceived"), Some(2));
918        assert_eq!(parse_hook_point("PathUpdated"), Some(3));
919        assert_eq!(parse_hook_point("AnnounceRetransmit"), Some(4));
920        assert_eq!(parse_hook_point("LinkRequestReceived"), Some(5));
921        assert_eq!(parse_hook_point("LinkEstablished"), Some(6));
922        assert_eq!(parse_hook_point("LinkClosed"), Some(7));
923        assert_eq!(parse_hook_point("InterfaceUp"), Some(8));
924        assert_eq!(parse_hook_point("InterfaceDown"), Some(9));
925        assert_eq!(parse_hook_point("InterfaceConfigChanged"), Some(10));
926        assert_eq!(parse_hook_point("SendOnInterface"), Some(11));
927        assert_eq!(parse_hook_point("BroadcastOnAllInterfaces"), Some(12));
928        assert_eq!(parse_hook_point("DeliverLocal"), Some(13));
929        assert_eq!(parse_hook_point("TunnelSynthesize"), Some(14));
930        assert_eq!(parse_hook_point("Tick"), Some(15));
931        assert_eq!(parse_hook_point("Unknown"), None);
932    }
933
934    #[test]
935    fn backbone_extra_params_preserved() {
936        let config = r#"
937[reticulum]
938enable_transport = True
939
940[interfaces]
941  [[Public Entrypoint]]
942    type = BackboneInterface
943    enabled = yes
944    listen_ip = 0.0.0.0
945    listen_port = 4242
946    interface_mode = gateway
947    discoverable = Yes
948    discovery_name = PizzaSpaghettiMandolino
949    announce_interval = 600
950    discovery_stamp_value = 24
951    reachable_on = 87.106.8.245
952"#;
953        let parsed = parse(config).unwrap();
954        assert_eq!(parsed.interfaces.len(), 1);
955        let iface = &parsed.interfaces[0];
956        assert_eq!(iface.name, "Public Entrypoint");
957        assert_eq!(iface.interface_type, "BackboneInterface");
958        // After removing type, enabled, interface_mode, remaining params should include discovery keys
959        assert_eq!(iface.params.get("discoverable").map(|s| s.as_str()), Some("Yes"));
960        assert_eq!(iface.params.get("discovery_name").map(|s| s.as_str()), Some("PizzaSpaghettiMandolino"));
961        assert_eq!(iface.params.get("announce_interval").map(|s| s.as_str()), Some("600"));
962        assert_eq!(iface.params.get("discovery_stamp_value").map(|s| s.as_str()), Some("24"));
963        assert_eq!(iface.params.get("reachable_on").map(|s| s.as_str()), Some("87.106.8.245"));
964        assert_eq!(iface.params.get("listen_ip").map(|s| s.as_str()), Some("0.0.0.0"));
965        assert_eq!(iface.params.get("listen_port").map(|s| s.as_str()), Some("4242"));
966    }
967
968    #[test]
969    fn parse_probe_protocol() {
970        let input = r#"
971[reticulum]
972probe_addr = 1.2.3.4:19302
973probe_protocol = stun
974"#;
975        let config = parse(input).unwrap();
976        assert_eq!(config.reticulum.probe_addr.as_deref(), Some("1.2.3.4:19302"));
977        assert_eq!(config.reticulum.probe_protocol.as_deref(), Some("stun"));
978    }
979
980    #[test]
981    fn parse_probe_protocol_defaults_to_none() {
982        let input = r#"
983[reticulum]
984probe_addr = 1.2.3.4:4343
985"#;
986        let config = parse(input).unwrap();
987        assert_eq!(config.reticulum.probe_addr.as_deref(), Some("1.2.3.4:4343"));
988        assert!(config.reticulum.probe_protocol.is_none());
989    }
990}