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