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::io;
10use std::path::Path;
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(kvs: &HashMap<String, String>) -> Result<ReticulumSection, ConfigError> {
366    let mut section = ReticulumSection::default();
367
368    if let Some(v) = kvs.get("enable_transport") {
369        section.enable_transport = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
370            key: "enable_transport".into(),
371            value: v.clone(),
372        })?;
373    }
374    if let Some(v) = kvs.get("share_instance") {
375        section.share_instance = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
376            key: "share_instance".into(),
377            value: v.clone(),
378        })?;
379    }
380    if let Some(v) = kvs.get("instance_name") {
381        section.instance_name = v.clone();
382    }
383    if let Some(v) = kvs.get("shared_instance_port") {
384        section.shared_instance_port = v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
385            key: "shared_instance_port".into(),
386            value: v.clone(),
387        })?;
388    }
389    if let Some(v) = kvs.get("instance_control_port") {
390        section.instance_control_port =
391            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
392                key: "instance_control_port".into(),
393                value: v.clone(),
394            })?;
395    }
396    if let Some(v) = kvs.get("panic_on_interface_error") {
397        section.panic_on_interface_error =
398            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
399                key: "panic_on_interface_error".into(),
400                value: v.clone(),
401            })?;
402    }
403    if let Some(v) = kvs.get("use_implicit_proof") {
404        section.use_implicit_proof = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
405            key: "use_implicit_proof".into(),
406            value: v.clone(),
407        })?;
408    }
409    if let Some(v) = kvs.get("network_identity") {
410        section.network_identity = Some(v.clone());
411    }
412    if let Some(v) = kvs.get("respond_to_probes") {
413        section.respond_to_probes = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
414            key: "respond_to_probes".into(),
415            value: v.clone(),
416        })?;
417    }
418    if let Some(v) = kvs.get("enable_remote_management") {
419        section.enable_remote_management =
420            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
421                key: "enable_remote_management".into(),
422                value: v.clone(),
423            })?;
424    }
425    if let Some(v) = kvs.get("remote_management_allowed") {
426        // Value is a comma-separated list of hex identity hashes
427        for item in v.split(',') {
428            let trimmed = item.trim();
429            if !trimmed.is_empty() {
430                section.remote_management_allowed.push(trimmed.to_string());
431            }
432        }
433    }
434    if let Some(v) = kvs.get("publish_blackhole") {
435        section.publish_blackhole = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
436            key: "publish_blackhole".into(),
437            value: v.clone(),
438        })?;
439    }
440    if let Some(v) = kvs.get("probe_port") {
441        section.probe_port = Some(v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
442            key: "probe_port".into(),
443            value: v.clone(),
444        })?);
445    }
446    if let Some(v) = kvs.get("probe_addr") {
447        section.probe_addr = Some(v.clone());
448    }
449    if let Some(v) = kvs.get("probe_protocol") {
450        section.probe_protocol = Some(v.clone());
451    }
452    if let Some(v) = kvs.get("device") {
453        section.device = Some(v.clone());
454    }
455    if let Some(v) = kvs.get("discover_interfaces") {
456        section.discover_interfaces = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
457            key: "discover_interfaces".into(),
458            value: v.clone(),
459        })?;
460    }
461    if let Some(v) = kvs.get("required_discovery_value") {
462        section.required_discovery_value =
463            Some(v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
464                key: "required_discovery_value".into(),
465                value: v.clone(),
466            })?);
467    }
468    if let Some(v) = kvs.get("prefer_shorter_path") {
469        section.prefer_shorter_path = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
470            key: "prefer_shorter_path".into(),
471            value: v.clone(),
472        })?;
473    }
474    if let Some(v) = kvs.get("max_paths_per_destination") {
475        let n = v.parse::<usize>().map_err(|_| ConfigError::InvalidValue {
476            key: "max_paths_per_destination".into(),
477            value: v.clone(),
478        })?;
479        section.max_paths_per_destination = n.max(1);
480    }
481
482    Ok(section)
483}
484
485fn build_logging_section(kvs: &HashMap<String, String>) -> Result<LoggingSection, ConfigError> {
486    let mut section = LoggingSection::default();
487
488    if let Some(v) = kvs.get("loglevel") {
489        section.loglevel = v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
490            key: "loglevel".into(),
491            value: v.clone(),
492        })?;
493    }
494
495    Ok(section)
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn parse_empty() {
504        let config = parse("").unwrap();
505        assert!(!config.reticulum.enable_transport);
506        assert!(config.reticulum.share_instance);
507        assert_eq!(config.reticulum.instance_name, "default");
508        assert_eq!(config.logging.loglevel, 4);
509        assert!(config.interfaces.is_empty());
510    }
511
512    #[test]
513    fn parse_default_config() {
514        // The default config from Python's __default_rns_config__
515        let input = r#"
516[reticulum]
517enable_transport = False
518share_instance = Yes
519instance_name = default
520
521[logging]
522loglevel = 4
523
524[interfaces]
525
526  [[Default Interface]]
527    type = AutoInterface
528    enabled = Yes
529"#;
530        let config = parse(input).unwrap();
531        assert!(!config.reticulum.enable_transport);
532        assert!(config.reticulum.share_instance);
533        assert_eq!(config.reticulum.instance_name, "default");
534        assert_eq!(config.logging.loglevel, 4);
535        assert_eq!(config.interfaces.len(), 1);
536        assert_eq!(config.interfaces[0].name, "Default Interface");
537        assert_eq!(config.interfaces[0].interface_type, "AutoInterface");
538        assert!(config.interfaces[0].enabled);
539    }
540
541    #[test]
542    fn parse_reticulum_section() {
543        let input = r#"
544[reticulum]
545enable_transport = True
546share_instance = No
547instance_name = mynode
548shared_instance_port = 12345
549instance_control_port = 12346
550panic_on_interface_error = Yes
551use_implicit_proof = False
552respond_to_probes = True
553network_identity = /home/user/.reticulum/identity
554"#;
555        let config = parse(input).unwrap();
556        assert!(config.reticulum.enable_transport);
557        assert!(!config.reticulum.share_instance);
558        assert_eq!(config.reticulum.instance_name, "mynode");
559        assert_eq!(config.reticulum.shared_instance_port, 12345);
560        assert_eq!(config.reticulum.instance_control_port, 12346);
561        assert!(config.reticulum.panic_on_interface_error);
562        assert!(!config.reticulum.use_implicit_proof);
563        assert!(config.reticulum.respond_to_probes);
564        assert_eq!(
565            config.reticulum.network_identity.as_deref(),
566            Some("/home/user/.reticulum/identity")
567        );
568    }
569
570    #[test]
571    fn parse_logging_section() {
572        let input = "[logging]\nloglevel = 6\n";
573        let config = parse(input).unwrap();
574        assert_eq!(config.logging.loglevel, 6);
575    }
576
577    #[test]
578    fn parse_interface_tcp_client() {
579        let input = r#"
580[interfaces]
581  [[TCP Client]]
582    type = TCPClientInterface
583    enabled = Yes
584    target_host = 87.106.8.245
585    target_port = 4242
586"#;
587        let config = parse(input).unwrap();
588        assert_eq!(config.interfaces.len(), 1);
589        let iface = &config.interfaces[0];
590        assert_eq!(iface.name, "TCP Client");
591        assert_eq!(iface.interface_type, "TCPClientInterface");
592        assert!(iface.enabled);
593        assert_eq!(iface.params.get("target_host").unwrap(), "87.106.8.245");
594        assert_eq!(iface.params.get("target_port").unwrap(), "4242");
595    }
596
597    #[test]
598    fn parse_interface_tcp_server() {
599        let input = r#"
600[interfaces]
601  [[TCP Server]]
602    type = TCPServerInterface
603    enabled = Yes
604    listen_ip = 0.0.0.0
605    listen_port = 4242
606"#;
607        let config = parse(input).unwrap();
608        assert_eq!(config.interfaces.len(), 1);
609        let iface = &config.interfaces[0];
610        assert_eq!(iface.name, "TCP Server");
611        assert_eq!(iface.interface_type, "TCPServerInterface");
612        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
613        assert_eq!(iface.params.get("listen_port").unwrap(), "4242");
614    }
615
616    #[test]
617    fn parse_interface_udp() {
618        let input = r#"
619[interfaces]
620  [[UDP Interface]]
621    type = UDPInterface
622    enabled = Yes
623    listen_ip = 0.0.0.0
624    listen_port = 4242
625    forward_ip = 255.255.255.255
626    forward_port = 4242
627"#;
628        let config = parse(input).unwrap();
629        assert_eq!(config.interfaces.len(), 1);
630        let iface = &config.interfaces[0];
631        assert_eq!(iface.name, "UDP Interface");
632        assert_eq!(iface.interface_type, "UDPInterface");
633        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
634        assert_eq!(iface.params.get("forward_ip").unwrap(), "255.255.255.255");
635    }
636
637    #[test]
638    fn parse_multiple_interfaces() {
639        let input = r#"
640[interfaces]
641  [[TCP Client]]
642    type = TCPClientInterface
643    target_host = 10.0.0.1
644    target_port = 4242
645
646  [[UDP Broadcast]]
647    type = UDPInterface
648    listen_ip = 0.0.0.0
649    listen_port = 5555
650    forward_ip = 255.255.255.255
651    forward_port = 5555
652"#;
653        let config = parse(input).unwrap();
654        assert_eq!(config.interfaces.len(), 2);
655        assert_eq!(config.interfaces[0].name, "TCP Client");
656        assert_eq!(config.interfaces[0].interface_type, "TCPClientInterface");
657        assert_eq!(config.interfaces[1].name, "UDP Broadcast");
658        assert_eq!(config.interfaces[1].interface_type, "UDPInterface");
659    }
660
661    #[test]
662    fn parse_booleans() {
663        // Test all boolean variants
664        for (input, expected) in &[
665            ("Yes", true),
666            ("No", false),
667            ("True", true),
668            ("False", false),
669            ("true", true),
670            ("false", false),
671            ("1", true),
672            ("0", false),
673            ("on", true),
674            ("off", false),
675        ] {
676            let result = parse_bool(input);
677            assert_eq!(result, Some(*expected), "parse_bool({}) failed", input);
678        }
679    }
680
681    #[test]
682    fn parse_comments() {
683        let input = r#"
684# This is a comment
685[reticulum]
686enable_transport = True  # inline comment
687# share_instance = No
688instance_name = test
689"#;
690        let config = parse(input).unwrap();
691        assert!(config.reticulum.enable_transport);
692        assert!(config.reticulum.share_instance); // commented out line should be ignored
693        assert_eq!(config.reticulum.instance_name, "test");
694    }
695
696    #[test]
697    fn parse_interface_mode_field() {
698        let input = r#"
699[interfaces]
700  [[TCP Client]]
701    type = TCPClientInterface
702    interface_mode = access_point
703    target_host = 10.0.0.1
704    target_port = 4242
705"#;
706        let config = parse(input).unwrap();
707        assert_eq!(config.interfaces[0].mode, "access_point");
708    }
709
710    #[test]
711    fn parse_mode_fallback() {
712        // Python also accepts "mode" as fallback for "interface_mode"
713        let input = r#"
714[interfaces]
715  [[TCP Client]]
716    type = TCPClientInterface
717    mode = gateway
718    target_host = 10.0.0.1
719    target_port = 4242
720"#;
721        let config = parse(input).unwrap();
722        assert_eq!(config.interfaces[0].mode, "gateway");
723    }
724
725    #[test]
726    fn parse_interface_mode_takes_precedence() {
727        // If both interface_mode and mode are set, interface_mode wins
728        let input = r#"
729[interfaces]
730  [[TCP Client]]
731    type = TCPClientInterface
732    interface_mode = roaming
733    mode = boundary
734    target_host = 10.0.0.1
735    target_port = 4242
736"#;
737        let config = parse(input).unwrap();
738        assert_eq!(config.interfaces[0].mode, "roaming");
739    }
740
741    #[test]
742    fn parse_disabled_interface() {
743        let input = r#"
744[interfaces]
745  [[Disabled TCP]]
746    type = TCPClientInterface
747    enabled = No
748    target_host = 10.0.0.1
749    target_port = 4242
750"#;
751        let config = parse(input).unwrap();
752        assert_eq!(config.interfaces.len(), 1);
753        assert!(!config.interfaces[0].enabled);
754    }
755
756    #[test]
757    fn parse_serial_interface() {
758        let input = r#"
759[interfaces]
760  [[Serial Port]]
761    type = SerialInterface
762    enabled = Yes
763    port = /dev/ttyUSB0
764    speed = 115200
765    databits = 8
766    parity = N
767    stopbits = 1
768"#;
769        let config = parse(input).unwrap();
770        assert_eq!(config.interfaces.len(), 1);
771        let iface = &config.interfaces[0];
772        assert_eq!(iface.name, "Serial Port");
773        assert_eq!(iface.interface_type, "SerialInterface");
774        assert!(iface.enabled);
775        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB0");
776        assert_eq!(iface.params.get("speed").unwrap(), "115200");
777        assert_eq!(iface.params.get("databits").unwrap(), "8");
778        assert_eq!(iface.params.get("parity").unwrap(), "N");
779        assert_eq!(iface.params.get("stopbits").unwrap(), "1");
780    }
781
782    #[test]
783    fn parse_kiss_interface() {
784        let input = r#"
785[interfaces]
786  [[KISS TNC]]
787    type = KISSInterface
788    enabled = Yes
789    port = /dev/ttyUSB1
790    speed = 9600
791    preamble = 350
792    txtail = 20
793    persistence = 64
794    slottime = 20
795    flow_control = True
796    id_interval = 600
797    id_callsign = MYCALL
798"#;
799        let config = parse(input).unwrap();
800        assert_eq!(config.interfaces.len(), 1);
801        let iface = &config.interfaces[0];
802        assert_eq!(iface.name, "KISS TNC");
803        assert_eq!(iface.interface_type, "KISSInterface");
804        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB1");
805        assert_eq!(iface.params.get("speed").unwrap(), "9600");
806        assert_eq!(iface.params.get("preamble").unwrap(), "350");
807        assert_eq!(iface.params.get("txtail").unwrap(), "20");
808        assert_eq!(iface.params.get("persistence").unwrap(), "64");
809        assert_eq!(iface.params.get("slottime").unwrap(), "20");
810        assert_eq!(iface.params.get("flow_control").unwrap(), "True");
811        assert_eq!(iface.params.get("id_interval").unwrap(), "600");
812        assert_eq!(iface.params.get("id_callsign").unwrap(), "MYCALL");
813    }
814
815    #[test]
816    fn parse_ifac_networkname() {
817        let input = r#"
818[interfaces]
819  [[TCP Client]]
820    type = TCPClientInterface
821    target_host = 10.0.0.1
822    target_port = 4242
823    networkname = testnet
824"#;
825        let config = parse(input).unwrap();
826        assert_eq!(
827            config.interfaces[0].params.get("networkname").unwrap(),
828            "testnet"
829        );
830    }
831
832    #[test]
833    fn parse_ifac_passphrase() {
834        let input = r#"
835[interfaces]
836  [[TCP Client]]
837    type = TCPClientInterface
838    target_host = 10.0.0.1
839    target_port = 4242
840    passphrase = secret123
841    ifac_size = 64
842"#;
843        let config = parse(input).unwrap();
844        assert_eq!(
845            config.interfaces[0].params.get("passphrase").unwrap(),
846            "secret123"
847        );
848        assert_eq!(config.interfaces[0].params.get("ifac_size").unwrap(), "64");
849    }
850
851    #[test]
852    fn parse_remote_management_config() {
853        let input = r#"
854[reticulum]
855enable_transport = True
856enable_remote_management = Yes
857remote_management_allowed = aabbccdd00112233aabbccdd00112233, 11223344556677881122334455667788
858publish_blackhole = Yes
859"#;
860        let config = parse(input).unwrap();
861        assert!(config.reticulum.enable_remote_management);
862        assert!(config.reticulum.publish_blackhole);
863        assert_eq!(config.reticulum.remote_management_allowed.len(), 2);
864        assert_eq!(
865            config.reticulum.remote_management_allowed[0],
866            "aabbccdd00112233aabbccdd00112233"
867        );
868        assert_eq!(
869            config.reticulum.remote_management_allowed[1],
870            "11223344556677881122334455667788"
871        );
872    }
873
874    #[test]
875    fn parse_remote_management_defaults() {
876        let input = "[reticulum]\n";
877        let config = parse(input).unwrap();
878        assert!(!config.reticulum.enable_remote_management);
879        assert!(!config.reticulum.publish_blackhole);
880        assert!(config.reticulum.remote_management_allowed.is_empty());
881    }
882
883    #[test]
884    fn parse_hooks_section() {
885        let input = r#"
886[hooks]
887  [[drop_tick]]
888    path = /tmp/drop_tick.wasm
889    attach_point = Tick
890    priority = 10
891    enabled = Yes
892
893  [[log_announce]]
894    path = /tmp/log_announce.wasm
895    attach_point = AnnounceReceived
896    priority = 5
897    enabled = No
898"#;
899        let config = parse(input).unwrap();
900        assert_eq!(config.hooks.len(), 2);
901        assert_eq!(config.hooks[0].name, "drop_tick");
902        assert_eq!(config.hooks[0].path, "/tmp/drop_tick.wasm");
903        assert_eq!(config.hooks[0].attach_point, "Tick");
904        assert_eq!(config.hooks[0].priority, 10);
905        assert!(config.hooks[0].enabled);
906        assert_eq!(config.hooks[1].name, "log_announce");
907        assert_eq!(config.hooks[1].attach_point, "AnnounceReceived");
908        assert!(!config.hooks[1].enabled);
909    }
910
911    #[test]
912    fn parse_empty_hooks() {
913        let input = "[hooks]\n";
914        let config = parse(input).unwrap();
915        assert!(config.hooks.is_empty());
916    }
917
918    #[test]
919    fn parse_hook_point_names() {
920        assert_eq!(parse_hook_point("PreIngress"), Some(0));
921        assert_eq!(parse_hook_point("PreDispatch"), Some(1));
922        assert_eq!(parse_hook_point("AnnounceReceived"), Some(2));
923        assert_eq!(parse_hook_point("PathUpdated"), Some(3));
924        assert_eq!(parse_hook_point("AnnounceRetransmit"), Some(4));
925        assert_eq!(parse_hook_point("LinkRequestReceived"), Some(5));
926        assert_eq!(parse_hook_point("LinkEstablished"), Some(6));
927        assert_eq!(parse_hook_point("LinkClosed"), Some(7));
928        assert_eq!(parse_hook_point("InterfaceUp"), Some(8));
929        assert_eq!(parse_hook_point("InterfaceDown"), Some(9));
930        assert_eq!(parse_hook_point("InterfaceConfigChanged"), Some(10));
931        assert_eq!(parse_hook_point("SendOnInterface"), Some(11));
932        assert_eq!(parse_hook_point("BroadcastOnAllInterfaces"), Some(12));
933        assert_eq!(parse_hook_point("DeliverLocal"), Some(13));
934        assert_eq!(parse_hook_point("TunnelSynthesize"), Some(14));
935        assert_eq!(parse_hook_point("Tick"), Some(15));
936        assert_eq!(parse_hook_point("Unknown"), None);
937    }
938
939    #[test]
940    fn backbone_extra_params_preserved() {
941        let config = r#"
942[reticulum]
943enable_transport = True
944
945[interfaces]
946  [[Public Entrypoint]]
947    type = BackboneInterface
948    enabled = yes
949    listen_ip = 0.0.0.0
950    listen_port = 4242
951    interface_mode = gateway
952    discoverable = Yes
953    discovery_name = PizzaSpaghettiMandolino
954    announce_interval = 600
955    discovery_stamp_value = 24
956    reachable_on = 87.106.8.245
957"#;
958        let parsed = parse(config).unwrap();
959        assert_eq!(parsed.interfaces.len(), 1);
960        let iface = &parsed.interfaces[0];
961        assert_eq!(iface.name, "Public Entrypoint");
962        assert_eq!(iface.interface_type, "BackboneInterface");
963        // After removing type, enabled, interface_mode, remaining params should include discovery keys
964        assert_eq!(
965            iface.params.get("discoverable").map(|s| s.as_str()),
966            Some("Yes")
967        );
968        assert_eq!(
969            iface.params.get("discovery_name").map(|s| s.as_str()),
970            Some("PizzaSpaghettiMandolino")
971        );
972        assert_eq!(
973            iface.params.get("announce_interval").map(|s| s.as_str()),
974            Some("600")
975        );
976        assert_eq!(
977            iface
978                .params
979                .get("discovery_stamp_value")
980                .map(|s| s.as_str()),
981            Some("24")
982        );
983        assert_eq!(
984            iface.params.get("reachable_on").map(|s| s.as_str()),
985            Some("87.106.8.245")
986        );
987        assert_eq!(
988            iface.params.get("listen_ip").map(|s| s.as_str()),
989            Some("0.0.0.0")
990        );
991        assert_eq!(
992            iface.params.get("listen_port").map(|s| s.as_str()),
993            Some("4242")
994        );
995    }
996
997    #[test]
998    fn parse_probe_protocol() {
999        let input = r#"
1000[reticulum]
1001probe_addr = 1.2.3.4:19302
1002probe_protocol = stun
1003"#;
1004        let config = parse(input).unwrap();
1005        assert_eq!(
1006            config.reticulum.probe_addr.as_deref(),
1007            Some("1.2.3.4:19302")
1008        );
1009        assert_eq!(config.reticulum.probe_protocol.as_deref(), Some("stun"));
1010    }
1011
1012    #[test]
1013    fn parse_probe_protocol_defaults_to_none() {
1014        let input = r#"
1015[reticulum]
1016probe_addr = 1.2.3.4:4343
1017"#;
1018        let config = parse(input).unwrap();
1019        assert_eq!(config.reticulum.probe_addr.as_deref(), Some("1.2.3.4:4343"));
1020        assert!(config.reticulum.probe_protocol.is_none());
1021    }
1022}