1use std::collections::{BTreeMap, BTreeSet, VecDeque};
9
10use rns_core::msgpack::{self, Value};
11use rns_core::stamp::{stamp_valid, stamp_value, stamp_workblock};
12use rns_crypto::sha256::sha256;
13
14use super::time;
15
16pub const NAME: u8 = 0xFF;
22pub const TRANSPORT_ID: u8 = 0xFE;
23pub const TRANSPORT_IMPL: u8 = 0xFD;
24pub const TRANSPORT_VERS: u8 = 0xFC;
25pub const INTERFACE_TYPE: u8 = 0x00;
26pub const TRANSPORT: u8 = 0x01;
27pub const REACHABLE_ON: u8 = 0x02;
28pub const LATITUDE: u8 = 0x03;
29pub const LONGITUDE: u8 = 0x04;
30pub const HEIGHT: u8 = 0x05;
31pub const PORT: u8 = 0x06;
32pub const IFAC_NETNAME: u8 = 0x07;
33pub const IFAC_NETKEY: u8 = 0x08;
34pub const FREQUENCY: u8 = 0x09;
35pub const BANDWIDTH: u8 = 0x0A;
36pub const SPREADINGFACTOR: u8 = 0x0B;
37pub const CODINGRATE: u8 = 0x0C;
38pub const MODULATION: u8 = 0x0D;
39pub const CHANNEL: u8 = 0x0E;
40pub const OP_ADDR: u8 = 0xF0;
41
42pub const TRANSPORT_IMPLEMENTATION_NAME: &str = "rns-rs";
44
45pub const TRANSPORT_IMPLEMENTATION_VERSION: &str = env!("CARGO_PKG_VERSION");
47
48pub const APP_NAME: &str = "rnstransport";
50
51pub const DEFAULT_STAMP_VALUE: u8 = 16;
53
54pub const WORKBLOCK_EXPAND_ROUNDS: u32 = 20;
56
57pub const STAMP_SIZE: usize = 32;
59
60pub const DISCOVERY_STAMP_CACHE_MAX_ENTRIES: usize = 2048;
62
63pub const DISCOVERABLE_TYPES: [&str; 6] = [
65 "BackboneInterface",
66 "TCPServerInterface",
67 "I2PInterface",
68 "RNodeInterface",
69 "WeaveInterface",
70 "KISSInterface",
71];
72
73pub const THRESHOLD_UNKNOWN: f64 = 24.0 * 60.0 * 60.0;
76pub const THRESHOLD_STALE: f64 = 3.0 * 24.0 * 60.0 * 60.0;
78pub const THRESHOLD_REMOVE: f64 = 7.0 * 24.0 * 60.0 * 60.0;
80
81const STATUS_STALE: i32 = 0;
83const STATUS_UNKNOWN: i32 = 100;
84const STATUS_AVAILABLE: i32 = 1000;
85
86#[derive(Debug, Clone)]
92pub struct DiscoveryConfig {
93 pub discovery_name: String,
95 pub announce_interval: u64,
97 pub stamp_value: u8,
99 pub reachable_on: Option<String>,
101 pub interface_type: String,
103 pub listen_port: Option<u16>,
105 pub location_cmd: Option<String>,
107 pub latitude: Option<f64>,
109 pub longitude: Option<f64>,
111 pub height: Option<f64>,
113 pub operator_lxmf_address: Option<[u8; 16]>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum DiscoveredStatus {
124 Available,
125 Unknown,
126 Stale,
127}
128
129impl DiscoveredStatus {
130 pub fn code(&self) -> i32 {
132 match self {
133 DiscoveredStatus::Available => STATUS_AVAILABLE,
134 DiscoveredStatus::Unknown => STATUS_UNKNOWN,
135 DiscoveredStatus::Stale => STATUS_STALE,
136 }
137 }
138
139 pub fn as_str(&self) -> &'static str {
141 match self {
142 DiscoveredStatus::Available => "available",
143 DiscoveredStatus::Unknown => "unknown",
144 DiscoveredStatus::Stale => "stale",
145 }
146 }
147}
148
149#[derive(Debug, Clone)]
151pub struct DiscoveredInterface {
152 pub interface_type: String,
154 pub impl_name: Option<String>,
156 pub impl_version: Option<String>,
157 pub transport: bool,
159 pub name: String,
161 pub discovered: f64,
163 pub last_heard: f64,
165 pub heard_count: u32,
167 pub status: DiscoveredStatus,
169 pub stamp: Vec<u8>,
171 pub stamp_value: u32,
173 pub transport_id: [u8; 16],
175 pub network_id: [u8; 16],
177 pub hops: u8,
179
180 pub latitude: Option<f64>,
182 pub longitude: Option<f64>,
183 pub height: Option<f64>,
184 pub operator_lxmf_address: Option<[u8; 16]>,
186
187 pub reachable_on: Option<String>,
189 pub port: Option<u16>,
190
191 pub frequency: Option<u32>,
193 pub bandwidth: Option<u32>,
194 pub spreading_factor: Option<u8>,
195 pub coding_rate: Option<u8>,
196 pub modulation: Option<String>,
197 pub channel: Option<u8>,
198
199 pub ifac_netname: Option<String>,
201 pub ifac_netkey: Option<String>,
202
203 pub config_entry: Option<String>,
205
206 pub discovery_hash: [u8; 32],
208}
209
210fn insufficient_stamp_diagnostic(value: u32) -> String {
211 format!("Ignored discovered interface with insufficient stamp value {value}")
212}
213
214#[derive(Debug, Clone)]
220pub struct DiscoveryStampCache {
221 capacity: usize,
222 valid_order: VecDeque<[u8; 32]>,
223 valid_entries: BTreeMap<[u8; 32], ValidStampEntry>,
224 invalid_order: VecDeque<[u8; 32]>,
225 invalid_entries: BTreeSet<[u8; 32]>,
226}
227
228#[derive(Debug, Clone)]
229struct ValidStampEntry {
230 stamp: Vec<u8>,
231 packed: Vec<u8>,
232 value: u32,
233}
234
235impl Default for DiscoveryStampCache {
236 fn default() -> Self {
237 Self::with_capacity(DISCOVERY_STAMP_CACHE_MAX_ENTRIES)
238 }
239}
240
241impl DiscoveryStampCache {
242 pub fn with_capacity(capacity: usize) -> Self {
243 Self {
244 capacity,
245 valid_order: VecDeque::with_capacity(capacity),
246 valid_entries: BTreeMap::new(),
247 invalid_order: VecDeque::with_capacity(capacity),
248 invalid_entries: BTreeSet::new(),
249 }
250 }
251
252 fn payload_hash(app_data: &[u8]) -> Option<[u8; 32]> {
253 app_data
254 .get(1..)
255 .filter(|payload| !payload.is_empty())
256 .map(sha256)
257 }
258
259 fn contains_invalid_hash(&self, payload_hash: &[u8; 32]) -> bool {
260 self.invalid_entries.contains(payload_hash)
261 }
262
263 fn valid_entry(&self, payload_hash: &[u8; 32]) -> Option<&ValidStampEntry> {
264 self.valid_entries.get(payload_hash)
265 }
266
267 fn insert_valid_hash(&mut self, payload_hash: [u8; 32], entry: ValidStampEntry) {
268 if self.capacity == 0 || self.valid_entries.contains_key(&payload_hash) {
269 return;
270 }
271 if self.valid_order.len() == self.capacity {
272 if let Some(evicted) = self.valid_order.pop_front() {
273 self.valid_entries.remove(&evicted);
274 }
275 }
276 self.valid_order.push_back(payload_hash);
277 self.valid_entries.insert(payload_hash, entry);
278 }
279
280 fn insert_invalid_hash(&mut self, payload_hash: [u8; 32]) {
281 if self.capacity == 0 || self.invalid_entries.contains(&payload_hash) {
282 return;
283 }
284 if self.invalid_order.len() == self.capacity {
285 if let Some(evicted) = self.invalid_order.pop_front() {
286 self.invalid_entries.remove(&evicted);
287 }
288 }
289 self.invalid_order.push_back(payload_hash);
290 self.invalid_entries.insert(payload_hash);
291 }
292
293 pub fn invalid_len(&self) -> usize {
294 self.invalid_order.len()
295 }
296
297 pub fn valid_len(&self) -> usize {
298 self.valid_order.len()
299 }
300
301 pub fn contains_invalid_app_data(&self, app_data: &[u8]) -> bool {
302 Self::payload_hash(app_data)
303 .is_some_and(|payload_hash| self.contains_invalid_hash(&payload_hash))
304 }
305
306 pub fn contains_valid_app_data(&self, app_data: &[u8]) -> bool {
307 Self::payload_hash(app_data)
308 .is_some_and(|payload_hash| self.valid_entries.contains_key(&payload_hash))
309 }
310}
311
312impl DiscoveredInterface {
313 pub fn compute_status(&self) -> DiscoveredStatus {
315 let delta = time::now() - self.last_heard;
316 if delta > THRESHOLD_STALE {
317 DiscoveredStatus::Stale
318 } else if delta > THRESHOLD_UNKNOWN {
319 DiscoveredStatus::Unknown
320 } else {
321 DiscoveredStatus::Available
322 }
323 }
324}
325
326pub fn parse_interface_announce(
337 app_data: &[u8],
338 announced_identity_hash: &[u8; 16],
339 hops: u8,
340 required_stamp_value: u8,
341) -> Option<DiscoveredInterface> {
342 let mut cache = DiscoveryStampCache::with_capacity(0);
343 parse_interface_announce_with_cache(
344 app_data,
345 announced_identity_hash,
346 hops,
347 required_stamp_value,
348 &mut cache,
349 )
350}
351
352pub fn parse_interface_announce_with_cache(
355 app_data: &[u8],
356 announced_identity_hash: &[u8; 16],
357 hops: u8,
358 required_stamp_value: u8,
359 cache: &mut DiscoveryStampCache,
360) -> Option<DiscoveredInterface> {
361 if app_data.len() <= STAMP_SIZE + 1 {
363 return None;
364 }
365
366 let flags = app_data[0];
368 let payload = &app_data[1..];
369 let payload_hash = sha256(payload);
370
371 if cache.contains_invalid_hash(&payload_hash) {
372 log::trace!(
373 target: rns_core::logging::PATHING_LOG_TARGET,
374 "Ignored previously discovered interface with insufficient stamp value"
375 );
376 return None;
377 }
378
379 let encrypted = (flags & 0x02) != 0;
381 if encrypted {
382 log::debug!("Ignoring encrypted discovered interface (not supported)");
383 return None;
384 }
385
386 let (stamp, packed, stamp_value) =
387 if let Some(entry) = cache.valid_entry(&payload_hash).cloned() {
388 log::trace!(
389 target: rns_core::logging::PATHING_LOG_TARGET,
390 "Discovery announce cache hit"
391 );
392 if entry.value < required_stamp_value as u32 {
393 log::debug!("{}", insufficient_stamp_diagnostic(entry.value));
394 return None;
395 }
396 (entry.stamp, entry.packed, entry.value)
397 } else {
398 let stamp = payload[payload.len() - STAMP_SIZE..].to_vec();
401 let packed = payload[..payload.len() - STAMP_SIZE].to_vec();
402 let infohash = sha256(&packed);
403 let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
404 let value = stamp_value(&workblock, &stamp);
405
406 if !stamp_valid(&stamp, required_stamp_value, &workblock) {
407 log::debug!("{}", insufficient_stamp_diagnostic(value));
408 cache.insert_invalid_hash(payload_hash);
409 return None;
410 }
411 cache.insert_valid_hash(
412 payload_hash,
413 ValidStampEntry {
414 stamp: stamp.clone(),
415 packed: packed.clone(),
416 value,
417 },
418 );
419 (stamp, packed, value)
420 };
421
422 let (value, _) = msgpack::unpack(&packed).ok()?;
424 let map = value.as_map()?;
425
426 let get_u8_val = |key: u8| -> Option<Value> {
428 for (k, v) in map {
429 if k.as_uint()? as u8 == key {
430 return Some(v.clone());
431 }
432 }
433 None
434 };
435
436 let interface_type = match get_u8_val(INTERFACE_TYPE)? {
438 Value::Str(value) => value,
439 _ => return None,
440 };
441 if !is_discoverable_type(&interface_type) {
442 log::debug!(
443 "Ignoring discovered interface with unsupported type '{}'",
444 interface_type
445 );
446 return None;
447 }
448
449 let transport = match get_u8_val(TRANSPORT)? {
450 Value::Bool(value) => value,
451 _ => return None,
452 };
453 let impl_name = get_u8_val(TRANSPORT_IMPL).and_then(|value| value.as_str().map(str::to_owned));
454 let impl_version =
455 get_u8_val(TRANSPORT_VERS).and_then(|value| value.as_str().map(str::to_owned));
456 let raw_name = match get_u8_val(NAME) {
457 Some(Value::Str(value)) => value,
458 Some(_) | None => String::new(),
459 };
460 let name = sanitize_discovered_name(&raw_name)
461 .unwrap_or_else(|| format!("Discovered {}", interface_type));
462
463 let transport_id_val = get_u8_val(TRANSPORT_ID)?;
464 let transport_id_bytes = transport_id_val.as_bin()?;
465 if transport_id_bytes.len() != 16 {
466 log::debug!("Ignoring discovered interface with invalid transport_id length");
467 return None;
468 }
469 let mut transport_id = [0u8; 16];
470 transport_id.copy_from_slice(transport_id_bytes);
471
472 let latitude = optional_f64_field(get_u8_val(LATITUDE))?;
474 let longitude = optional_f64_field(get_u8_val(LONGITUDE))?;
475 let height = optional_f64_field(get_u8_val(HEIGHT))?;
476 let reachable_on = match get_u8_val(REACHABLE_ON) {
477 None | Some(Value::Nil) => None,
478 Some(Value::Str(value)) => Some(value),
479 Some(_) => return None,
480 };
481 if let Some(ref reachable_on) = reachable_on {
482 if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
483 log::debug!(
484 "Ignoring discovered interface with invalid reachable_on '{}'",
485 reachable_on
486 );
487 return None;
488 }
489 }
490
491 let port = get_u8_val(PORT).and_then(|v| v.as_uint().map(|n| n as u16));
492 let frequency = get_u8_val(FREQUENCY).and_then(|v| v.as_uint().map(|n| n as u32));
493 let bandwidth = get_u8_val(BANDWIDTH).and_then(|v| v.as_uint().map(|n| n as u32));
494 let spreading_factor = get_u8_val(SPREADINGFACTOR).and_then(|v| v.as_uint().map(|n| n as u8));
495 let coding_rate = get_u8_val(CODINGRATE).and_then(|v| v.as_uint().map(|n| n as u8));
496 let modulation = get_u8_val(MODULATION).and_then(|v| v.as_str().map(|s| s.to_string()));
497 let channel = get_u8_val(CHANNEL).and_then(|v| v.as_uint().map(|n| n as u8));
498 let ifac_netname = get_u8_val(IFAC_NETNAME).and_then(sanitize_ifac_announce_string);
499 let ifac_netkey = get_u8_val(IFAC_NETKEY).and_then(sanitize_ifac_announce_string);
500 let operator_lxmf_address = match get_u8_val(OP_ADDR) {
501 None | Some(Value::Nil) => None,
502 Some(Value::Bin(value)) if value.len() == 16 => {
503 let mut address = [0u8; 16];
504 address.copy_from_slice(&value);
505 Some(address)
506 }
507 Some(Value::Bin(_)) => None,
508 Some(_) => return None,
509 };
510
511 let discovery_hash = compute_discovery_hash(&transport_id, &name);
513
514 let config_entry = generate_config_entry(ConfigEntryParams {
516 interface_type: &interface_type,
517 name: &name,
518 transport_id: &transport_id,
519 reachable_on: reachable_on.as_deref(),
520 port,
521 frequency,
522 bandwidth,
523 spreading_factor,
524 coding_rate,
525 modulation: modulation.as_deref(),
526 ifac_netname: ifac_netname.as_deref(),
527 ifac_netkey: ifac_netkey.as_deref(),
528 });
529
530 let now = time::now();
531
532 Some(DiscoveredInterface {
533 interface_type,
534 impl_name,
535 impl_version,
536 transport,
537 name,
538 discovered: now,
539 last_heard: now,
540 heard_count: 0,
541 status: DiscoveredStatus::Available,
542 stamp,
543 stamp_value,
544 transport_id,
545 network_id: *announced_identity_hash,
546 hops,
547 latitude,
548 longitude,
549 height,
550 operator_lxmf_address,
551 reachable_on,
552 port,
553 frequency,
554 bandwidth,
555 spreading_factor,
556 coding_rate,
557 modulation,
558 channel,
559 ifac_netname,
560 ifac_netkey,
561 config_entry,
562 discovery_hash,
563 })
564}
565
566pub fn compute_discovery_hash(transport_id: &[u8; 16], name: &str) -> [u8; 32] {
568 let mut material = Vec::with_capacity(16 + name.len());
569 material.extend_from_slice(transport_id);
570 material.extend_from_slice(name.as_bytes());
571 sha256(&material)
572}
573
574pub fn apply_transport_autoconnect_mode(
576 iface: &mut DiscoveredInterface,
577 local_transport_enabled: bool,
578 configured_mode: Option<u8>,
579) {
580 let mode = configured_mode.or_else(|| {
581 (local_transport_enabled && iface.transport).then_some(rns_core::constants::MODE_GATEWAY)
582 });
583 let Some(mode) = mode else {
584 return;
585 };
586 let Some(config_entry) = iface.config_entry.as_mut() else {
587 return;
588 };
589 if config_entry
590 .lines()
591 .any(|line| line.trim_start().starts_with("interface_mode"))
592 {
593 return;
594 }
595 if let Some(pos) = config_entry.find(" enabled = yes\n") {
596 let insert_at = pos + " enabled = yes\n".len();
597 config_entry.insert_str(
598 insert_at,
599 &format!(" interface_mode = {}\n", interface_mode_name(mode)),
600 );
601 } else {
602 config_entry.push_str(&format!(
603 "\n interface_mode = {}",
604 interface_mode_name(mode)
605 ));
606 }
607}
608
609fn interface_mode_name(mode: u8) -> &'static str {
610 match mode {
611 rns_core::constants::MODE_ACCESS_POINT => "access_point",
612 rns_core::constants::MODE_POINT_TO_POINT => "pointtopoint",
613 rns_core::constants::MODE_ROAMING => "roaming",
614 rns_core::constants::MODE_BOUNDARY => "boundary",
615 rns_core::constants::MODE_GATEWAY => "gateway",
616 rns_core::constants::MODE_INTERNAL => "internal",
617 _ => "full",
618 }
619}
620
621struct ConfigEntryParams<'a> {
623 interface_type: &'a str,
624 name: &'a str,
625 transport_id: &'a [u8; 16],
626 reachable_on: Option<&'a str>,
627 port: Option<u16>,
628 frequency: Option<u32>,
629 bandwidth: Option<u32>,
630 spreading_factor: Option<u8>,
631 coding_rate: Option<u8>,
632 modulation: Option<&'a str>,
633 ifac_netname: Option<&'a str>,
634 ifac_netkey: Option<&'a str>,
635}
636
637fn generate_config_entry(params: ConfigEntryParams<'_>) -> Option<String> {
638 let ConfigEntryParams {
639 interface_type,
640 name,
641 transport_id,
642 reachable_on,
643 port,
644 frequency,
645 bandwidth,
646 spreading_factor,
647 coding_rate,
648 modulation,
649 ifac_netname,
650 ifac_netkey,
651 } = params;
652 if reachable_on.is_some_and(|address| {
653 is_ygg_ipv6(address) || is_onion_address(address) || is_invalid_ip_address(address)
654 }) {
655 return None;
656 }
657
658 let transport_id_hex = hex_encode(transport_id);
659 let netname_str = ifac_netname
660 .map(|n| format!("\n network_name = {}", n))
661 .unwrap_or_default();
662 let netkey_str = ifac_netkey
663 .map(|k| format!("\n passphrase = {}", k))
664 .unwrap_or_default();
665 let identity_str = format!("\n transport_identity = {}", transport_id_hex);
666
667 match interface_type {
668 "BackboneInterface" | "TCPServerInterface" => {
669 let reachable = reachable_on.unwrap_or("unknown");
670 let port_val = port.unwrap_or(4242);
671 Some(format!(
672 "[[{}]]\n type = BackboneInterface\n enabled = yes\n remote = {}\n target_port = {}{}{}{}",
673 name, reachable, port_val, identity_str, netname_str, netkey_str
674 ))
675 }
676 "I2PInterface" => {
677 let reachable = reachable_on.unwrap_or("unknown");
678 Some(format!(
679 "[[{}]]\n type = I2PInterface\n enabled = yes\n peers = {}.b32.i2p{}{}{}",
680 name, reachable, identity_str, netname_str, netkey_str
681 ))
682 }
683 "RNodeInterface" => {
684 let freq_str = frequency
685 .map(|f| format!("\n frequency = {}", f))
686 .unwrap_or_default();
687 let bw_str = bandwidth
688 .map(|b| format!("\n bandwidth = {}", b))
689 .unwrap_or_default();
690 let sf_str = spreading_factor
691 .map(|s| format!("\n spreadingfactor = {}", s))
692 .unwrap_or_default();
693 let cr_str = coding_rate
694 .map(|c| format!("\n codingrate = {}", c))
695 .unwrap_or_default();
696 Some(format!(
697 "[[{}]]\n type = RNodeInterface\n enabled = yes\n port = {}{}{}{}{}{}{}{}",
698 name, "", freq_str, bw_str, sf_str, cr_str, identity_str, netname_str, netkey_str
699 ))
700 }
701 "KISSInterface" => {
702 let freq_str = frequency
703 .map(|f| format!("\n # Frequency: {}", f))
704 .unwrap_or_default();
705 let bw_str = bandwidth
706 .map(|b| format!("\n # Bandwidth: {}", b))
707 .unwrap_or_default();
708 let mod_str = modulation
709 .map(|m| format!("\n # Modulation: {}", m))
710 .unwrap_or_default();
711 Some(format!(
712 "[[{}]]\n type = KISSInterface\n enabled = yes\n port = {}{}{}{}{}{}{}",
713 name, "", freq_str, bw_str, mod_str, identity_str, netname_str, netkey_str
714 ))
715 }
716 "WeaveInterface" => Some(format!(
717 "[[{}]]\n type = WeaveInterface\n enabled = yes\n port = {}{}{}{}",
718 name, "", identity_str, netname_str, netkey_str
719 )),
720 _ => None,
721 }
722}
723
724fn optional_f64_field(value: Option<Value>) -> Option<Option<f64>> {
729 match value {
730 None | Some(Value::Nil) => Some(None),
731 Some(Value::Float(value)) if value.is_finite() => Some(Some(value)),
732 Some(_) => None,
733 }
734}
735
736fn sanitize_ifac_announce_string(value: Value) -> Option<String> {
742 match value {
743 Value::Str(text) if !text.is_empty() && text != "None" => Some(text),
744 _ => None,
745 }
746}
747
748pub fn sanitize_persisted_ifac(value: Option<String>) -> Option<String> {
751 value.filter(|text| !text.is_empty() && text != "None")
752}
753
754pub fn sanitize_discovered_name(name: &str) -> Option<String> {
756 let ascii: String = name.chars().filter(|ch| ch.is_ascii()).collect();
757 let mut sanitized = ascii.trim().to_string();
758 while sanitized.contains(" ") {
759 sanitized = sanitized.replace(" ", " ");
760 }
761
762 let start = sanitized
763 .char_indices()
764 .find(|(_, ch)| ch.is_ascii_alphanumeric())
765 .map(|(idx, _)| idx)?;
766 let end = sanitized
767 .char_indices()
768 .rev()
769 .find(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == ')')
770 .map(|(idx, ch)| idx + ch.len_utf8())?;
771
772 if start >= end {
773 return None;
774 }
775
776 let sanitized = sanitized[start..end].to_string();
777 (!sanitized.is_empty()).then_some(sanitized)
778}
779
780pub fn hex_encode(bytes: &[u8]) -> String {
782 bytes.iter().map(|b| format!("{:02x}", b)).collect()
783}
784
785pub fn is_ip_address(s: &str) -> bool {
787 s.parse::<std::net::IpAddr>().is_ok()
788}
789
790pub fn is_ygg_ipv6(s: &str) -> bool {
792 match s.parse::<std::net::IpAddr>() {
793 Ok(std::net::IpAddr::V6(addr)) => {
794 let segments = addr.segments();
795 (segments[0] & 0xfe00) == 0x0200
796 }
797 _ => false,
798 }
799}
800
801pub fn is_onion_address(s: &str) -> bool {
803 s.to_ascii_lowercase().ends_with(".onion")
804}
805
806pub fn is_invalid_ip_address(s: &str) -> bool {
808 matches!(s, "127.0.0.1" | "0.0.0.0")
809}
810
811pub fn is_hostname(s: &str) -> bool {
813 let s = s.strip_suffix('.').unwrap_or(s);
814 if s.len() > 253 {
815 return false;
816 }
817 let components: Vec<&str> = s.split('.').collect();
818 if components.is_empty() {
819 return false;
820 }
821 if components
823 .last()
824 .map(|c| c.chars().all(|ch| ch.is_ascii_digit()))
825 .unwrap_or(false)
826 {
827 return false;
828 }
829 components.iter().all(|c| {
830 !c.is_empty()
831 && c.len() <= 63
832 && !c.starts_with('-')
833 && !c.ends_with('-')
834 && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
835 })
836}
837
838pub fn is_discoverable_type(interface_type: &str) -> bool {
840 DISCOVERABLE_TYPES.contains(&interface_type)
841}
842
843pub fn filter_and_sort_interfaces(
845 interfaces: &mut Vec<DiscoveredInterface>,
846 only_available: bool,
847 only_transport: bool,
848) {
849 let now = time::now();
850
851 interfaces.retain(|iface| {
853 if !is_discoverable_type(&iface.interface_type) {
854 return false;
855 }
856 if let Some(ref reachable_on) = iface.reachable_on {
857 if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
858 return false;
859 }
860 }
861
862 let delta = now - iface.last_heard;
863
864 if delta > THRESHOLD_REMOVE {
866 return false;
867 }
868
869 let status = iface.compute_status();
871
872 if only_available && status != DiscoveredStatus::Available {
874 return false;
875 }
876 if only_transport && !iface.transport {
877 return false;
878 }
879
880 true
881 });
882
883 interfaces.sort_by(|a, b| {
885 let status_cmp = b.compute_status().code().cmp(&a.compute_status().code());
886 if status_cmp != std::cmp::Ordering::Equal {
887 return status_cmp;
888 }
889 let value_cmp = b.stamp_value.cmp(&a.stamp_value);
890 if value_cmp != std::cmp::Ordering::Equal {
891 return value_cmp;
892 }
893 b.last_heard
894 .partial_cmp(&a.last_heard)
895 .unwrap_or(std::cmp::Ordering::Equal)
896 });
897}
898
899pub fn discovery_name_hash() -> [u8; 10] {
904 rns_core::destination::name_hash(APP_NAME, &["discovery", "interface"])
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910
911 fn pack_discovery_entries(entries: Vec<(Value, Value)>) -> Vec<u8> {
912 let packed = msgpack::pack(&Value::Map(entries));
913 let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
914 app_data.push(0x00);
915 app_data.extend_from_slice(&packed);
916 app_data.extend_from_slice(&[0u8; STAMP_SIZE]);
917 app_data
918 }
919
920 fn discovery_entries(interface_type: &str, reachable_on: Option<&str>) -> Vec<(Value, Value)> {
921 let mut entries = vec![
922 (
923 Value::UInt(INTERFACE_TYPE as u64),
924 Value::Str(interface_type.to_string()),
925 ),
926 (Value::UInt(TRANSPORT as u64), Value::Bool(true)),
927 (
928 Value::UInt(NAME as u64),
929 Value::Str(format!("test-{interface_type}")),
930 ),
931 (Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 16])),
932 ];
933
934 if let Some(reachable_on) = reachable_on {
935 entries.push((
936 Value::UInt(REACHABLE_ON as u64),
937 Value::Str(reachable_on.to_string()),
938 ));
939 }
940
941 entries
942 }
943
944 fn build_discovery_app_data(interface_type: &str, reachable_on: Option<&str>) -> Vec<u8> {
945 pack_discovery_entries(discovery_entries(interface_type, reachable_on))
946 }
947
948 #[test]
949 fn insufficient_stamp_diagnostic_includes_calculated_value() {
950 assert_eq!(
951 insufficient_stamp_diagnostic(7),
952 "Ignored discovered interface with insufficient stamp value 7"
953 );
954 }
955
956 #[test]
957 fn default_discovery_stamp_value_matches_upstream_1_4_0() {
958 assert_eq!(DEFAULT_STAMP_VALUE, 16);
959 }
960
961 #[test]
962 fn invalid_stamp_cache_short_circuits_repeat_validation() {
963 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
964 let mut cache = DiscoveryStampCache::with_capacity(8);
965
966 assert!(parse_interface_announce_with_cache(
967 &app_data,
968 &[0x11; 16],
969 1,
970 u8::MAX,
971 &mut cache,
972 )
973 .is_none());
974 assert_eq!(cache.invalid_len(), 1);
975
976 assert!(
979 parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache,)
980 .is_none()
981 );
982 assert!(parse_interface_announce(&app_data, &[0x11; 16], 1, 0).is_some());
983 assert_eq!(
984 cache.invalid_len(),
985 1,
986 "cache hits must not duplicate entries"
987 );
988 }
989
990 #[test]
991 fn invalid_stamp_cache_is_bounded_fifo() {
992 let first = build_discovery_app_data("BackboneInterface", Some("first.example"));
993 let second = build_discovery_app_data("BackboneInterface", Some("second.example"));
994 let third = build_discovery_app_data("BackboneInterface", Some("third.example"));
995 let mut cache = DiscoveryStampCache::with_capacity(2);
996
997 for app_data in [&first, &second, &third] {
998 assert!(parse_interface_announce_with_cache(
999 app_data,
1000 &[0x11; 16],
1001 1,
1002 u8::MAX,
1003 &mut cache,
1004 )
1005 .is_none());
1006 }
1007
1008 assert_eq!(cache.invalid_len(), 2);
1009 assert!(!cache.contains_invalid_app_data(&first));
1010 assert!(cache.contains_invalid_app_data(&second));
1011 assert!(cache.contains_invalid_app_data(&third));
1012 }
1013
1014 #[test]
1015 fn valid_stamp_cache_short_circuits_repeat_work_and_preserves_dynamic_metadata() {
1016 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1017 let mut cache = DiscoveryStampCache::with_capacity(8);
1018 let first =
1019 parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache).unwrap();
1020 assert_eq!(cache.valid_len(), 1);
1021
1022 let second =
1023 parse_interface_announce_with_cache(&app_data, &[0x22; 16], 7, 0, &mut cache).unwrap();
1024 assert_eq!(cache.valid_len(), 1);
1025 assert_eq!(second.stamp, first.stamp);
1026 assert_eq!(second.stamp_value, first.stamp_value);
1027 assert_eq!(second.network_id, [0x22; 16]);
1028 assert_eq!(second.hops, 7);
1029 }
1030
1031 #[test]
1032 fn valid_stamp_cache_is_bounded_fifo_and_keys_payload_like_invalid_cache() {
1033 let first = build_discovery_app_data("BackboneInterface", Some("first.example"));
1034 let second = build_discovery_app_data("BackboneInterface", Some("second.example"));
1035 let third = build_discovery_app_data("BackboneInterface", Some("third.example"));
1036 let mut cache = DiscoveryStampCache::with_capacity(2);
1037
1038 for app_data in [&first, &second, &third] {
1039 assert!(
1040 parse_interface_announce_with_cache(app_data, &[0x11; 16], 1, 0, &mut cache,)
1041 .is_some()
1042 );
1043 }
1044 assert_eq!(cache.valid_len(), 2);
1045 assert!(!cache.contains_valid_app_data(&first));
1046 assert!(cache.contains_valid_app_data(&second));
1047 assert!(cache.contains_valid_app_data(&third));
1048
1049 let mut different_flags = second.clone();
1050 different_flags[0] ^= 0x01;
1051 assert!(cache.contains_valid_app_data(&different_flags));
1052 let mut different_stamp = second.clone();
1053 *different_stamp.last_mut().unwrap() ^= 0x01;
1054 assert!(!cache.contains_valid_app_data(&different_stamp));
1055 }
1056
1057 #[test]
1058 fn cached_stamp_value_is_rechecked_if_requirement_increases() {
1059 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1060 let mut cache = DiscoveryStampCache::with_capacity(8);
1061 assert!(
1062 parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache,)
1063 .is_some()
1064 );
1065 assert!(parse_interface_announce_with_cache(
1066 &app_data,
1067 &[0x11; 16],
1068 1,
1069 u8::MAX,
1070 &mut cache,
1071 )
1072 .is_none());
1073 assert_eq!(cache.valid_len(), 1);
1074 assert_eq!(cache.invalid_len(), 0);
1075 }
1076
1077 #[test]
1078 fn invalid_stamp_cache_keys_payload_including_stamp_but_excluding_flags() {
1079 let original = build_discovery_app_data("BackboneInterface", Some("example.com"));
1080 let mut cache = DiscoveryStampCache::with_capacity(8);
1081 assert!(parse_interface_announce_with_cache(
1082 &original,
1083 &[0x11; 16],
1084 1,
1085 u8::MAX,
1086 &mut cache,
1087 )
1088 .is_none());
1089
1090 let mut different_flags = original.clone();
1091 different_flags[0] ^= 0x01;
1092 assert!(cache.contains_invalid_app_data(&different_flags));
1093
1094 let mut different_stamp = original.clone();
1095 *different_stamp.last_mut().unwrap() ^= 0x01;
1096 assert!(!cache.contains_invalid_app_data(&different_stamp));
1097 }
1098
1099 #[test]
1100 fn malformed_discovery_payload_is_not_added_to_invalid_stamp_cache() {
1101 let mut cache = DiscoveryStampCache::with_capacity(8);
1102 assert!(parse_interface_announce_with_cache(
1103 &[0x00, 0x01, 0x02],
1104 &[0x11; 16],
1105 1,
1106 u8::MAX,
1107 &mut cache,
1108 )
1109 .is_none());
1110 assert_eq!(cache.invalid_len(), 0);
1111 }
1112
1113 #[test]
1114 fn parse_rejects_unsupported_discovered_interface_type() {
1115 let app_data = build_discovery_app_data("BogusInterface", None);
1116
1117 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1118
1119 assert!(
1120 parsed.is_none(),
1121 "unsupported discovered interface types must be ignored"
1122 );
1123 }
1124
1125 #[test]
1126 fn parse_rejects_invalid_reachable_on_address() {
1127 let app_data = build_discovery_app_data("BackboneInterface", Some("-not a host-"));
1128
1129 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1130
1131 assert!(
1132 parsed.is_none(),
1133 "discovered interfaces with invalid reachable_on values must be ignored"
1134 );
1135 }
1136
1137 #[test]
1138 fn parse_sanitizes_discovered_interface_name() {
1139 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1140 entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1141 entries.push((
1142 Value::UInt(NAME as u64),
1143 Value::Str("\t**Alpha Beta!!!\n".to_string()),
1144 ));
1145 let app_data = pack_discovery_entries(entries);
1146
1147 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1148
1149 assert_eq!(parsed.name, "Alpha Beta");
1150 assert!(parsed.config_entry.unwrap().starts_with("[[Alpha Beta]]"));
1151 }
1152
1153 #[test]
1154 fn parse_retains_optional_stack_implementation_and_version() {
1155 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1156 entries.push((
1157 Value::UInt(TRANSPORT_IMPL as u64),
1158 Value::Str("Reticulum".into()),
1159 ));
1160 entries.push((
1161 Value::UInt(TRANSPORT_VERS as u64),
1162 Value::Str("1.5.5".into()),
1163 ));
1164 let parsed =
1165 parse_interface_announce(&pack_discovery_entries(entries), &[0x11; 16], 1, 0).unwrap();
1166 assert_eq!(parsed.impl_name.as_deref(), Some("Reticulum"));
1167 assert_eq!(parsed.impl_version.as_deref(), Some("1.5.5"));
1168
1169 let legacy = parse_interface_announce(
1170 &build_discovery_app_data("BackboneInterface", Some("example.com")),
1171 &[0x11; 16],
1172 1,
1173 0,
1174 )
1175 .unwrap();
1176 assert_eq!(legacy.impl_name, None);
1177 assert_eq!(legacy.impl_version, None);
1178 }
1179
1180 #[test]
1181 fn parse_falls_back_when_discovered_interface_name_sanitizes_empty() {
1182 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1183 entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1184 entries.push((Value::UInt(NAME as u64), Value::Str("!!!".to_string())));
1185 let app_data = pack_discovery_entries(entries);
1186
1187 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1188
1189 assert_eq!(parsed.name, "Discovered BackboneInterface");
1190 }
1191
1192 #[test]
1193 fn parse_falls_back_when_discovered_interface_name_is_nil() {
1194 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1195 entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1196 entries.push((Value::UInt(NAME as u64), Value::Nil));
1197 let app_data = pack_discovery_entries(entries);
1198
1199 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1200
1201 assert_eq!(parsed.name, "Discovered BackboneInterface");
1202 }
1203
1204 #[test]
1205 fn parse_rejects_invalid_discovered_interface_field_types() {
1206 for (field, replacement) in [
1207 (TRANSPORT, Value::Str("yes".to_string())),
1208 (LATITUDE, Value::Str("45.0".to_string())),
1209 (LONGITUDE, Value::Str("9.0".to_string())),
1210 (HEIGHT, Value::Str("100".to_string())),
1211 (INTERFACE_TYPE, Value::UInt(123)),
1212 (REACHABLE_ON, Value::UInt(123)),
1213 ] {
1214 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1215 entries.retain(|(key, _)| key.as_uint() != Some(field as u64));
1216 entries.push((Value::UInt(field as u64), replacement));
1217 let app_data = pack_discovery_entries(entries);
1218
1219 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1220
1221 assert!(parsed.is_none(), "field {field} should reject invalid type");
1222 }
1223 }
1224
1225 #[test]
1226 fn parse_rejects_invalid_transport_id_length() {
1227 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1228 entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT_ID as u64));
1229 entries.push((Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 15])));
1230 let app_data = pack_discovery_entries(entries);
1231
1232 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1233
1234 assert!(parsed.is_none());
1235 }
1236
1237 #[test]
1238 fn parse_accepts_valid_string_ifac_fields() {
1239 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1240 entries.push((Value::UInt(IFAC_NETNAME as u64), Value::Str("mynet".into())));
1241 entries.push((Value::UInt(IFAC_NETKEY as u64), Value::Str("secret".into())));
1242 let app_data = pack_discovery_entries(entries);
1243
1244 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1245
1246 assert_eq!(parsed.ifac_netname.as_deref(), Some("mynet"));
1247 assert_eq!(parsed.ifac_netkey.as_deref(), Some("secret"));
1248 }
1249
1250 #[test]
1251 fn parse_drops_nonsensical_ifac_fields() {
1252 for value in [
1253 Value::UInt(123),
1254 Value::Bool(true),
1255 Value::Nil,
1256 Value::Str("None".into()),
1257 Value::Str(String::new()),
1258 ] {
1259 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1260 entries.push((Value::UInt(IFAC_NETNAME as u64), value.clone()));
1261 entries.push((Value::UInt(IFAC_NETKEY as u64), value));
1262 let app_data = pack_discovery_entries(entries);
1263
1264 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1265
1266 assert_eq!(parsed.ifac_netname, None);
1267 assert_eq!(parsed.ifac_netkey, None);
1268 }
1269 }
1270
1271 #[test]
1272 fn sanitize_persisted_ifac_drops_none_and_empty() {
1273 assert_eq!(
1274 sanitize_persisted_ifac(Some("net".into())),
1275 Some("net".into())
1276 );
1277 assert_eq!(sanitize_persisted_ifac(Some("None".into())), None);
1278 assert_eq!(sanitize_persisted_ifac(Some(String::new())), None);
1279 assert_eq!(sanitize_persisted_ifac(None), None);
1280 }
1281
1282 #[test]
1283 fn parse_accepts_exact_operator_lxmf_address() {
1284 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1285 entries.push((Value::UInt(OP_ADDR as u64), Value::Bin(vec![0x5a; 16])));
1286 let app_data = pack_discovery_entries(entries);
1287
1288 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1289
1290 assert_eq!(parsed.operator_lxmf_address, Some([0x5a; 16]));
1291 }
1292
1293 #[test]
1294 fn parse_rejects_non_binary_operator_lxmf_address() {
1295 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1296 entries.push((Value::UInt(OP_ADDR as u64), Value::Str("5a".repeat(16))));
1297 let app_data = pack_discovery_entries(entries);
1298
1299 assert!(parse_interface_announce(&app_data, &[0x11; 16], 1, 0).is_none());
1300 }
1301
1302 #[test]
1303 fn parse_ignores_operator_lxmf_address_with_wrong_length() {
1304 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1305 entries.push((Value::UInt(OP_ADDR as u64), Value::Bin(vec![0x5a; 15])));
1306 let app_data = pack_discovery_entries(entries);
1307
1308 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1309
1310 assert_eq!(parsed.operator_lxmf_address, None);
1311 }
1312
1313 #[test]
1314 fn unsafe_discovery_endpoints_are_retained_without_autoconnect_config() {
1315 for address in ["example.onion", "127.0.0.1", "0.0.0.0"] {
1316 let app_data = build_discovery_app_data("BackboneInterface", Some(address));
1317 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1318
1319 assert_eq!(parsed.reachable_on.as_deref(), Some(address));
1320 assert_eq!(parsed.config_entry, None, "{address} must not autoconnect");
1321 }
1322 }
1323
1324 #[test]
1325 fn transport_autoconnect_mode_marks_transport_discovery_config_as_gateway() {
1326 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1327 let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1328
1329 apply_transport_autoconnect_mode(&mut parsed, true, None);
1330
1331 let config_entry = parsed.config_entry.unwrap();
1332 assert!(config_entry.contains(" enabled = yes\n interface_mode = gateway\n"));
1333 }
1334
1335 #[test]
1336 fn configured_autoconnect_mode_overrides_transport_fallback() {
1337 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1338 let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1339
1340 apply_transport_autoconnect_mode(
1341 &mut parsed,
1342 true,
1343 Some(rns_core::constants::MODE_BOUNDARY),
1344 );
1345
1346 let config_entry = parsed.config_entry.unwrap();
1347 assert!(config_entry.contains(" enabled = yes\n interface_mode = boundary\n"));
1348 }
1349
1350 #[test]
1351 fn transport_autoconnect_mode_does_not_modify_non_transport_contexts() {
1352 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1353 let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1354 let original = parsed.config_entry.clone();
1355
1356 apply_transport_autoconnect_mode(&mut parsed, false, None);
1357
1358 assert_eq!(parsed.config_entry, original);
1359 }
1360
1361 #[test]
1362 fn transport_autoconnect_mode_does_not_modify_non_transport_announces() {
1363 let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1364 entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT as u64));
1365 entries.push((Value::UInt(TRANSPORT as u64), Value::Bool(false)));
1366 let app_data = pack_discovery_entries(entries);
1367 let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1368 let original = parsed.config_entry.clone();
1369
1370 apply_transport_autoconnect_mode(&mut parsed, true, None);
1371
1372 assert_eq!(parsed.config_entry, original);
1373 }
1374
1375 #[test]
1376 fn transport_autoconnect_mode_preserves_existing_interface_mode() {
1377 let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1378 let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1379 let config_entry = parsed.config_entry.as_mut().unwrap();
1380 config_entry.push_str("\n interface_mode = access_point");
1381 let original = parsed.config_entry.clone();
1382
1383 apply_transport_autoconnect_mode(&mut parsed, true, None);
1384
1385 assert_eq!(parsed.config_entry, original);
1386 }
1387
1388 #[test]
1389 fn parse_yggdrasil_reachable_on_keeps_record_without_config_entry() {
1390 let app_data = build_discovery_app_data("BackboneInterface", Some("200:1234::1"));
1391
1392 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1393
1394 assert_eq!(parsed.reachable_on.as_deref(), Some("200:1234::1"));
1395 assert!(parsed.config_entry.is_none());
1396 }
1397
1398 #[test]
1399 fn parse_accepts_supported_discovered_interface_types() {
1400 for interface_type in [
1401 "BackboneInterface",
1402 "TCPServerInterface",
1403 "I2PInterface",
1404 "RNodeInterface",
1405 "WeaveInterface",
1406 "KISSInterface",
1407 ] {
1408 let app_data = build_discovery_app_data(interface_type, Some("example.com"));
1409
1410 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1411
1412 assert!(
1413 parsed.is_some(),
1414 "{interface_type} should be accepted as a discoverable interface type"
1415 );
1416 }
1417 }
1418
1419 #[test]
1420 fn i2p_discovery_config_entry_uses_b32_hostname() {
1421 let app_data = build_discovery_app_data("I2PInterface", Some("example"));
1422 let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1423
1424 assert_eq!(parsed.reachable_on.as_deref(), Some("example"));
1425 assert!(parsed
1426 .config_entry
1427 .as_deref()
1428 .unwrap()
1429 .contains("peers = example.b32.i2p\n"));
1430 }
1431}