Skip to main content

rns_net/
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}
19
20/// The `[reticulum]` section.
21#[derive(Debug, Clone)]
22pub struct ReticulumSection {
23    pub enable_transport: bool,
24    pub share_instance: bool,
25    pub instance_name: String,
26    pub shared_instance_port: u16,
27    pub instance_control_port: u16,
28    pub panic_on_interface_error: bool,
29    pub use_implicit_proof: bool,
30    pub network_identity: Option<String>,
31    pub respond_to_probes: bool,
32    pub enable_remote_management: bool,
33    pub remote_management_allowed: Vec<String>,
34    pub publish_blackhole: bool,
35    pub probe_port: Option<u16>,
36    pub probe_addr: Option<String>,
37    /// Network interface to bind outbound sockets to (e.g. "usb0").
38    pub device: Option<String>,
39}
40
41impl Default for ReticulumSection {
42    fn default() -> Self {
43        ReticulumSection {
44            enable_transport: false,
45            share_instance: true,
46            instance_name: "default".into(),
47            shared_instance_port: 37428,
48            instance_control_port: 37429,
49            panic_on_interface_error: false,
50            use_implicit_proof: true,
51            network_identity: None,
52            respond_to_probes: false,
53            enable_remote_management: false,
54            remote_management_allowed: Vec::new(),
55            publish_blackhole: false,
56            probe_port: None,
57            probe_addr: None,
58            device: None,
59        }
60    }
61}
62
63/// The `[logging]` section.
64#[derive(Debug, Clone)]
65pub struct LoggingSection {
66    pub loglevel: u8,
67}
68
69impl Default for LoggingSection {
70    fn default() -> Self {
71        LoggingSection { loglevel: 4 }
72    }
73}
74
75/// A parsed interface from `[[subsection]]` within `[interfaces]`.
76#[derive(Debug, Clone)]
77pub struct ParsedInterface {
78    pub name: String,
79    pub interface_type: String,
80    pub enabled: bool,
81    pub mode: String,
82    pub params: HashMap<String, String>,
83}
84
85/// Configuration parse error.
86#[derive(Debug, Clone)]
87pub enum ConfigError {
88    Io(String),
89    Parse(String),
90    InvalidValue { key: String, value: String },
91}
92
93impl fmt::Display for ConfigError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            ConfigError::Io(msg) => write!(f, "Config I/O error: {}", msg),
97            ConfigError::Parse(msg) => write!(f, "Config parse error: {}", msg),
98            ConfigError::InvalidValue { key, value } => {
99                write!(f, "Invalid value for '{}': '{}'", key, value)
100            }
101        }
102    }
103}
104
105impl From<io::Error> for ConfigError {
106    fn from(e: io::Error) -> Self {
107        ConfigError::Io(e.to_string())
108    }
109}
110
111/// Parse a config string into an `RnsConfig`.
112pub fn parse(input: &str) -> Result<RnsConfig, ConfigError> {
113    let mut current_section: Option<String> = None;
114    let mut current_subsection: Option<String> = None;
115
116    let mut reticulum_kvs: HashMap<String, String> = HashMap::new();
117    let mut logging_kvs: HashMap<String, String> = HashMap::new();
118    let mut interfaces: Vec<ParsedInterface> = Vec::new();
119    let mut current_iface_kvs: Option<HashMap<String, String>> = None;
120    let mut current_iface_name: Option<String> = None;
121
122    for line in input.lines() {
123        // Strip comments (# to end of line, unless inside quotes)
124        let line = strip_comment(line);
125        let trimmed = line.trim();
126
127        // Skip empty lines
128        if trimmed.is_empty() {
129            continue;
130        }
131
132        // Check for subsection [[name]]
133        if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
134            let name = trimmed[2..trimmed.len() - 2].trim().to_string();
135            // Finalize previous subsection if any
136            if let (Some(iface_name), Some(kvs)) =
137                (current_iface_name.take(), current_iface_kvs.take())
138            {
139                interfaces.push(build_parsed_interface(iface_name, kvs));
140            }
141            current_subsection = Some(name.clone());
142            current_iface_name = Some(name);
143            current_iface_kvs = Some(HashMap::new());
144            continue;
145        }
146
147        // Check for section [name]
148        if trimmed.starts_with('[') && trimmed.ends_with(']') {
149            // Finalize previous subsection if any
150            if let (Some(iface_name), Some(kvs)) =
151                (current_iface_name.take(), current_iface_kvs.take())
152            {
153                interfaces.push(build_parsed_interface(iface_name, kvs));
154            }
155            current_subsection = None;
156
157            let name = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
158            current_section = Some(name);
159            continue;
160        }
161
162        // Parse key = value
163        if let Some(eq_pos) = trimmed.find('=') {
164            let key = trimmed[..eq_pos].trim().to_string();
165            let value = trimmed[eq_pos + 1..].trim().to_string();
166
167            if current_subsection.is_some() {
168                // Inside a [[subsection]] within [interfaces]
169                if let Some(ref mut kvs) = current_iface_kvs {
170                    kvs.insert(key, value);
171                }
172            } else if let Some(ref section) = current_section {
173                match section.as_str() {
174                    "reticulum" => {
175                        reticulum_kvs.insert(key, value);
176                    }
177                    "logging" => {
178                        logging_kvs.insert(key, value);
179                    }
180                    _ => {} // ignore unknown sections
181                }
182            }
183        }
184    }
185
186    // Finalize last subsection
187    if let (Some(iface_name), Some(kvs)) = (current_iface_name.take(), current_iface_kvs.take()) {
188        interfaces.push(build_parsed_interface(iface_name, kvs));
189    }
190
191    // Build typed sections
192    let reticulum = build_reticulum_section(&reticulum_kvs)?;
193    let logging = build_logging_section(&logging_kvs)?;
194
195    Ok(RnsConfig {
196        reticulum,
197        logging,
198        interfaces,
199    })
200}
201
202/// Parse a config file from disk.
203pub fn parse_file(path: &Path) -> Result<RnsConfig, ConfigError> {
204    let content = std::fs::read_to_string(path)?;
205    parse(&content)
206}
207
208/// Strip `#` comments from a line (simple: not inside quotes).
209fn strip_comment(line: &str) -> &str {
210    // Find # that is not inside quotes
211    let mut in_quote = false;
212    let mut quote_char = '"';
213    for (i, ch) in line.char_indices() {
214        if !in_quote && (ch == '"' || ch == '\'') {
215            in_quote = true;
216            quote_char = ch;
217        } else if in_quote && ch == quote_char {
218            in_quote = false;
219        } else if !in_quote && ch == '#' {
220            return &line[..i];
221        }
222    }
223    line
224}
225
226/// Parse a string as a boolean (ConfigObj style). Public API for use by node.rs.
227pub fn parse_bool_pub(value: &str) -> Option<bool> {
228    parse_bool(value)
229}
230
231/// Parse a string as a boolean (ConfigObj style).
232fn parse_bool(value: &str) -> Option<bool> {
233    match value.to_lowercase().as_str() {
234        "yes" | "true" | "1" | "on" => Some(true),
235        "no" | "false" | "0" | "off" => Some(false),
236        _ => None,
237    }
238}
239
240fn build_parsed_interface(name: String, mut kvs: HashMap<String, String>) -> ParsedInterface {
241    let interface_type = kvs.remove("type").unwrap_or_default();
242    let enabled = kvs
243        .remove("enabled")
244        .and_then(|v| parse_bool(&v))
245        .unwrap_or(true);
246    // Python checks `interface_mode` first, then falls back to `mode`
247    let mode = kvs
248        .remove("interface_mode")
249        .or_else(|| kvs.remove("mode"))
250        .unwrap_or_else(|| "full".into());
251
252    ParsedInterface {
253        name,
254        interface_type,
255        enabled,
256        mode,
257        params: kvs,
258    }
259}
260
261fn build_reticulum_section(
262    kvs: &HashMap<String, String>,
263) -> Result<ReticulumSection, ConfigError> {
264    let mut section = ReticulumSection::default();
265
266    if let Some(v) = kvs.get("enable_transport") {
267        section.enable_transport = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
268            key: "enable_transport".into(),
269            value: v.clone(),
270        })?;
271    }
272    if let Some(v) = kvs.get("share_instance") {
273        section.share_instance = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
274            key: "share_instance".into(),
275            value: v.clone(),
276        })?;
277    }
278    if let Some(v) = kvs.get("instance_name") {
279        section.instance_name = v.clone();
280    }
281    if let Some(v) = kvs.get("shared_instance_port") {
282        section.shared_instance_port =
283            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
284                key: "shared_instance_port".into(),
285                value: v.clone(),
286            })?;
287    }
288    if let Some(v) = kvs.get("instance_control_port") {
289        section.instance_control_port =
290            v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
291                key: "instance_control_port".into(),
292                value: v.clone(),
293            })?;
294    }
295    if let Some(v) = kvs.get("panic_on_interface_error") {
296        section.panic_on_interface_error =
297            parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
298                key: "panic_on_interface_error".into(),
299                value: v.clone(),
300            })?;
301    }
302    if let Some(v) = kvs.get("use_implicit_proof") {
303        section.use_implicit_proof = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
304            key: "use_implicit_proof".into(),
305            value: v.clone(),
306        })?;
307    }
308    if let Some(v) = kvs.get("network_identity") {
309        section.network_identity = Some(v.clone());
310    }
311    if let Some(v) = kvs.get("respond_to_probes") {
312        section.respond_to_probes = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
313            key: "respond_to_probes".into(),
314            value: v.clone(),
315        })?;
316    }
317    if let Some(v) = kvs.get("enable_remote_management") {
318        section.enable_remote_management = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
319            key: "enable_remote_management".into(),
320            value: v.clone(),
321        })?;
322    }
323    if let Some(v) = kvs.get("remote_management_allowed") {
324        // Value is a comma-separated list of hex identity hashes
325        for item in v.split(',') {
326            let trimmed = item.trim();
327            if !trimmed.is_empty() {
328                section.remote_management_allowed.push(trimmed.to_string());
329            }
330        }
331    }
332    if let Some(v) = kvs.get("publish_blackhole") {
333        section.publish_blackhole = parse_bool(v).ok_or_else(|| ConfigError::InvalidValue {
334            key: "publish_blackhole".into(),
335            value: v.clone(),
336        })?;
337    }
338    if let Some(v) = kvs.get("probe_port") {
339        section.probe_port = Some(v.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
340            key: "probe_port".into(),
341            value: v.clone(),
342        })?);
343    }
344    if let Some(v) = kvs.get("probe_addr") {
345        section.probe_addr = Some(v.clone());
346    }
347    if let Some(v) = kvs.get("device") {
348        section.device = Some(v.clone());
349    }
350
351    Ok(section)
352}
353
354fn build_logging_section(kvs: &HashMap<String, String>) -> Result<LoggingSection, ConfigError> {
355    let mut section = LoggingSection::default();
356
357    if let Some(v) = kvs.get("loglevel") {
358        section.loglevel = v.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
359            key: "loglevel".into(),
360            value: v.clone(),
361        })?;
362    }
363
364    Ok(section)
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn parse_empty() {
373        let config = parse("").unwrap();
374        assert!(!config.reticulum.enable_transport);
375        assert!(config.reticulum.share_instance);
376        assert_eq!(config.reticulum.instance_name, "default");
377        assert_eq!(config.logging.loglevel, 4);
378        assert!(config.interfaces.is_empty());
379    }
380
381    #[test]
382    fn parse_default_config() {
383        // The default config from Python's __default_rns_config__
384        let input = r#"
385[reticulum]
386enable_transport = False
387share_instance = Yes
388instance_name = default
389
390[logging]
391loglevel = 4
392
393[interfaces]
394
395  [[Default Interface]]
396    type = AutoInterface
397    enabled = Yes
398"#;
399        let config = parse(input).unwrap();
400        assert!(!config.reticulum.enable_transport);
401        assert!(config.reticulum.share_instance);
402        assert_eq!(config.reticulum.instance_name, "default");
403        assert_eq!(config.logging.loglevel, 4);
404        assert_eq!(config.interfaces.len(), 1);
405        assert_eq!(config.interfaces[0].name, "Default Interface");
406        assert_eq!(config.interfaces[0].interface_type, "AutoInterface");
407        assert!(config.interfaces[0].enabled);
408    }
409
410    #[test]
411    fn parse_reticulum_section() {
412        let input = r#"
413[reticulum]
414enable_transport = True
415share_instance = No
416instance_name = mynode
417shared_instance_port = 12345
418instance_control_port = 12346
419panic_on_interface_error = Yes
420use_implicit_proof = False
421respond_to_probes = True
422network_identity = /home/user/.reticulum/identity
423"#;
424        let config = parse(input).unwrap();
425        assert!(config.reticulum.enable_transport);
426        assert!(!config.reticulum.share_instance);
427        assert_eq!(config.reticulum.instance_name, "mynode");
428        assert_eq!(config.reticulum.shared_instance_port, 12345);
429        assert_eq!(config.reticulum.instance_control_port, 12346);
430        assert!(config.reticulum.panic_on_interface_error);
431        assert!(!config.reticulum.use_implicit_proof);
432        assert!(config.reticulum.respond_to_probes);
433        assert_eq!(
434            config.reticulum.network_identity.as_deref(),
435            Some("/home/user/.reticulum/identity")
436        );
437    }
438
439    #[test]
440    fn parse_logging_section() {
441        let input = "[logging]\nloglevel = 6\n";
442        let config = parse(input).unwrap();
443        assert_eq!(config.logging.loglevel, 6);
444    }
445
446    #[test]
447    fn parse_interface_tcp_client() {
448        let input = r#"
449[interfaces]
450  [[TCP Client]]
451    type = TCPClientInterface
452    enabled = Yes
453    target_host = 87.106.8.245
454    target_port = 4242
455"#;
456        let config = parse(input).unwrap();
457        assert_eq!(config.interfaces.len(), 1);
458        let iface = &config.interfaces[0];
459        assert_eq!(iface.name, "TCP Client");
460        assert_eq!(iface.interface_type, "TCPClientInterface");
461        assert!(iface.enabled);
462        assert_eq!(iface.params.get("target_host").unwrap(), "87.106.8.245");
463        assert_eq!(iface.params.get("target_port").unwrap(), "4242");
464    }
465
466    #[test]
467    fn parse_interface_tcp_server() {
468        let input = r#"
469[interfaces]
470  [[TCP Server]]
471    type = TCPServerInterface
472    enabled = Yes
473    listen_ip = 0.0.0.0
474    listen_port = 4242
475"#;
476        let config = parse(input).unwrap();
477        assert_eq!(config.interfaces.len(), 1);
478        let iface = &config.interfaces[0];
479        assert_eq!(iface.name, "TCP Server");
480        assert_eq!(iface.interface_type, "TCPServerInterface");
481        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
482        assert_eq!(iface.params.get("listen_port").unwrap(), "4242");
483    }
484
485    #[test]
486    fn parse_interface_udp() {
487        let input = r#"
488[interfaces]
489  [[UDP Interface]]
490    type = UDPInterface
491    enabled = Yes
492    listen_ip = 0.0.0.0
493    listen_port = 4242
494    forward_ip = 255.255.255.255
495    forward_port = 4242
496"#;
497        let config = parse(input).unwrap();
498        assert_eq!(config.interfaces.len(), 1);
499        let iface = &config.interfaces[0];
500        assert_eq!(iface.name, "UDP Interface");
501        assert_eq!(iface.interface_type, "UDPInterface");
502        assert_eq!(iface.params.get("listen_ip").unwrap(), "0.0.0.0");
503        assert_eq!(iface.params.get("forward_ip").unwrap(), "255.255.255.255");
504    }
505
506    #[test]
507    fn parse_multiple_interfaces() {
508        let input = r#"
509[interfaces]
510  [[TCP Client]]
511    type = TCPClientInterface
512    target_host = 10.0.0.1
513    target_port = 4242
514
515  [[UDP Broadcast]]
516    type = UDPInterface
517    listen_ip = 0.0.0.0
518    listen_port = 5555
519    forward_ip = 255.255.255.255
520    forward_port = 5555
521"#;
522        let config = parse(input).unwrap();
523        assert_eq!(config.interfaces.len(), 2);
524        assert_eq!(config.interfaces[0].name, "TCP Client");
525        assert_eq!(config.interfaces[0].interface_type, "TCPClientInterface");
526        assert_eq!(config.interfaces[1].name, "UDP Broadcast");
527        assert_eq!(config.interfaces[1].interface_type, "UDPInterface");
528    }
529
530    #[test]
531    fn parse_booleans() {
532        // Test all boolean variants
533        for (input, expected) in &[
534            ("Yes", true),
535            ("No", false),
536            ("True", true),
537            ("False", false),
538            ("true", true),
539            ("false", false),
540            ("1", true),
541            ("0", false),
542            ("on", true),
543            ("off", false),
544        ] {
545            let result = parse_bool(input);
546            assert_eq!(result, Some(*expected), "parse_bool({}) failed", input);
547        }
548    }
549
550    #[test]
551    fn parse_comments() {
552        let input = r#"
553# This is a comment
554[reticulum]
555enable_transport = True  # inline comment
556# share_instance = No
557instance_name = test
558"#;
559        let config = parse(input).unwrap();
560        assert!(config.reticulum.enable_transport);
561        assert!(config.reticulum.share_instance); // commented out line should be ignored
562        assert_eq!(config.reticulum.instance_name, "test");
563    }
564
565    #[test]
566    fn parse_interface_mode_field() {
567        let input = r#"
568[interfaces]
569  [[TCP Client]]
570    type = TCPClientInterface
571    interface_mode = access_point
572    target_host = 10.0.0.1
573    target_port = 4242
574"#;
575        let config = parse(input).unwrap();
576        assert_eq!(config.interfaces[0].mode, "access_point");
577    }
578
579    #[test]
580    fn parse_mode_fallback() {
581        // Python also accepts "mode" as fallback for "interface_mode"
582        let input = r#"
583[interfaces]
584  [[TCP Client]]
585    type = TCPClientInterface
586    mode = gateway
587    target_host = 10.0.0.1
588    target_port = 4242
589"#;
590        let config = parse(input).unwrap();
591        assert_eq!(config.interfaces[0].mode, "gateway");
592    }
593
594    #[test]
595    fn parse_interface_mode_takes_precedence() {
596        // If both interface_mode and mode are set, interface_mode wins
597        let input = r#"
598[interfaces]
599  [[TCP Client]]
600    type = TCPClientInterface
601    interface_mode = roaming
602    mode = boundary
603    target_host = 10.0.0.1
604    target_port = 4242
605"#;
606        let config = parse(input).unwrap();
607        assert_eq!(config.interfaces[0].mode, "roaming");
608    }
609
610    #[test]
611    fn parse_disabled_interface() {
612        let input = r#"
613[interfaces]
614  [[Disabled TCP]]
615    type = TCPClientInterface
616    enabled = No
617    target_host = 10.0.0.1
618    target_port = 4242
619"#;
620        let config = parse(input).unwrap();
621        assert_eq!(config.interfaces.len(), 1);
622        assert!(!config.interfaces[0].enabled);
623    }
624
625    #[test]
626    fn parse_serial_interface() {
627        let input = r#"
628[interfaces]
629  [[Serial Port]]
630    type = SerialInterface
631    enabled = Yes
632    port = /dev/ttyUSB0
633    speed = 115200
634    databits = 8
635    parity = N
636    stopbits = 1
637"#;
638        let config = parse(input).unwrap();
639        assert_eq!(config.interfaces.len(), 1);
640        let iface = &config.interfaces[0];
641        assert_eq!(iface.name, "Serial Port");
642        assert_eq!(iface.interface_type, "SerialInterface");
643        assert!(iface.enabled);
644        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB0");
645        assert_eq!(iface.params.get("speed").unwrap(), "115200");
646        assert_eq!(iface.params.get("databits").unwrap(), "8");
647        assert_eq!(iface.params.get("parity").unwrap(), "N");
648        assert_eq!(iface.params.get("stopbits").unwrap(), "1");
649    }
650
651    #[test]
652    fn parse_kiss_interface() {
653        let input = r#"
654[interfaces]
655  [[KISS TNC]]
656    type = KISSInterface
657    enabled = Yes
658    port = /dev/ttyUSB1
659    speed = 9600
660    preamble = 350
661    txtail = 20
662    persistence = 64
663    slottime = 20
664    flow_control = True
665    id_interval = 600
666    id_callsign = MYCALL
667"#;
668        let config = parse(input).unwrap();
669        assert_eq!(config.interfaces.len(), 1);
670        let iface = &config.interfaces[0];
671        assert_eq!(iface.name, "KISS TNC");
672        assert_eq!(iface.interface_type, "KISSInterface");
673        assert_eq!(iface.params.get("port").unwrap(), "/dev/ttyUSB1");
674        assert_eq!(iface.params.get("speed").unwrap(), "9600");
675        assert_eq!(iface.params.get("preamble").unwrap(), "350");
676        assert_eq!(iface.params.get("txtail").unwrap(), "20");
677        assert_eq!(iface.params.get("persistence").unwrap(), "64");
678        assert_eq!(iface.params.get("slottime").unwrap(), "20");
679        assert_eq!(iface.params.get("flow_control").unwrap(), "True");
680        assert_eq!(iface.params.get("id_interval").unwrap(), "600");
681        assert_eq!(iface.params.get("id_callsign").unwrap(), "MYCALL");
682    }
683
684    #[test]
685    fn parse_ifac_networkname() {
686        let input = r#"
687[interfaces]
688  [[TCP Client]]
689    type = TCPClientInterface
690    target_host = 10.0.0.1
691    target_port = 4242
692    networkname = testnet
693"#;
694        let config = parse(input).unwrap();
695        assert_eq!(config.interfaces[0].params.get("networkname").unwrap(), "testnet");
696    }
697
698    #[test]
699    fn parse_ifac_passphrase() {
700        let input = r#"
701[interfaces]
702  [[TCP Client]]
703    type = TCPClientInterface
704    target_host = 10.0.0.1
705    target_port = 4242
706    passphrase = secret123
707    ifac_size = 64
708"#;
709        let config = parse(input).unwrap();
710        assert_eq!(config.interfaces[0].params.get("passphrase").unwrap(), "secret123");
711        assert_eq!(config.interfaces[0].params.get("ifac_size").unwrap(), "64");
712    }
713
714    #[test]
715    fn parse_remote_management_config() {
716        let input = r#"
717[reticulum]
718enable_transport = True
719enable_remote_management = Yes
720remote_management_allowed = aabbccdd00112233aabbccdd00112233, 11223344556677881122334455667788
721publish_blackhole = Yes
722"#;
723        let config = parse(input).unwrap();
724        assert!(config.reticulum.enable_remote_management);
725        assert!(config.reticulum.publish_blackhole);
726        assert_eq!(config.reticulum.remote_management_allowed.len(), 2);
727        assert_eq!(
728            config.reticulum.remote_management_allowed[0],
729            "aabbccdd00112233aabbccdd00112233"
730        );
731        assert_eq!(
732            config.reticulum.remote_management_allowed[1],
733            "11223344556677881122334455667788"
734        );
735    }
736
737    #[test]
738    fn parse_remote_management_defaults() {
739        let input = "[reticulum]\n";
740        let config = parse(input).unwrap();
741        assert!(!config.reticulum.enable_remote_management);
742        assert!(!config.reticulum.publish_blackhole);
743        assert!(config.reticulum.remote_management_allowed.is_empty());
744    }
745}