1use std::collections::HashMap;
8use std::fmt;
9use std::io;
10use std::path::Path;
11
12#[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#[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#[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 pub probe_protocol: Option<String>,
50 pub device: Option<String>,
52 pub discover_interfaces: bool,
55 pub required_discovery_value: Option<u8>,
57 pub prefer_shorter_path: bool,
60 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#[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#[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#[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
140pub 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 let line = strip_comment(line);
157 let trimmed = line.trim();
158
159 if trimmed.is_empty() {
161 continue;
162 }
163
164 if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
166 let name = trimmed[2..trimmed.len() - 2].trim().to_string();
167 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 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 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 if trimmed.starts_with('[') && trimmed.ends_with(']') {
193 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 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 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 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 _ => {} }
238 }
239 }
240 }
241
242 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 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
262pub fn parse_file(path: &Path) -> Result<RnsConfig, ConfigError> {
264 let content = std::fs::read_to_string(path)?;
265 parse(&content)
266}
267
268fn strip_comment(line: &str) -> &str {
270 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
286pub fn parse_bool_pub(value: &str) -> Option<bool> {
288 parse_bool(value)
289}
290
291fn 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 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
342pub 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 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 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 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); 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 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 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 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}