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