Skip to main content

rns_net/common/
discovery.rs

1//! Interface Discovery protocol — pure types and parsing logic.
2//!
3//! Contains constants, data structures, parsing, and validation functions
4//! for the interface discovery protocol. No filesystem or threading I/O.
5//!
6//! Python reference: RNS/Discovery.py
7
8use 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
16// ============================================================================
17// Constants (matching Python Discovery.py)
18// ============================================================================
19
20/// Discovery field IDs for msgpack encoding
21pub const NAME: u8 = 0xFF;
22pub const TRANSPORT_ID: u8 = 0xFE;
23pub const INTERFACE_TYPE: u8 = 0x00;
24pub const TRANSPORT: u8 = 0x01;
25pub const REACHABLE_ON: u8 = 0x02;
26pub const LATITUDE: u8 = 0x03;
27pub const LONGITUDE: u8 = 0x04;
28pub const HEIGHT: u8 = 0x05;
29pub const PORT: u8 = 0x06;
30pub const IFAC_NETNAME: u8 = 0x07;
31pub const IFAC_NETKEY: u8 = 0x08;
32pub const FREQUENCY: u8 = 0x09;
33pub const BANDWIDTH: u8 = 0x0A;
34pub const SPREADINGFACTOR: u8 = 0x0B;
35pub const CODINGRATE: u8 = 0x0C;
36pub const MODULATION: u8 = 0x0D;
37pub const CHANNEL: u8 = 0x0E;
38
39/// App name for discovery destination
40pub const APP_NAME: &str = "rnstransport";
41
42/// Default stamp value for interface discovery
43pub const DEFAULT_STAMP_VALUE: u8 = 16;
44
45/// Workblock expand rounds for interface discovery
46pub const WORKBLOCK_EXPAND_ROUNDS: u32 = 20;
47
48/// Stamp size in bytes
49pub const STAMP_SIZE: usize = 32;
50
51/// Maximum number of known-invalid discovery payloads retained by default.
52pub const DISCOVERY_STAMP_CACHE_MAX_ENTRIES: usize = 2048;
53
54/// Interface types accepted from discovery announces.
55pub const DISCOVERABLE_TYPES: [&str; 6] = [
56    "BackboneInterface",
57    "TCPServerInterface",
58    "I2PInterface",
59    "RNodeInterface",
60    "WeaveInterface",
61    "KISSInterface",
62];
63
64// Status thresholds (in seconds)
65/// 24 hours - status becomes "unknown"
66pub const THRESHOLD_UNKNOWN: f64 = 24.0 * 60.0 * 60.0;
67/// 3 days - status becomes "stale"
68pub const THRESHOLD_STALE: f64 = 3.0 * 24.0 * 60.0 * 60.0;
69/// 7 days - interface is removed
70pub const THRESHOLD_REMOVE: f64 = 7.0 * 24.0 * 60.0 * 60.0;
71
72// Status codes for sorting
73const STATUS_STALE: i32 = 0;
74const STATUS_UNKNOWN: i32 = 100;
75const STATUS_AVAILABLE: i32 = 1000;
76
77// ============================================================================
78// Per-interface discovery configuration
79// ============================================================================
80
81/// Per-interface discovery configuration parsed from config file.
82#[derive(Debug, Clone)]
83pub struct DiscoveryConfig {
84    /// Human-readable name to advertise (defaults to interface name).
85    pub discovery_name: String,
86    /// Announce interval in seconds (default 21600 = 6h, min 300 = 5min).
87    pub announce_interval: u64,
88    /// Stamp cost for discovery PoW (default 16).
89    pub stamp_value: u8,
90    /// IP/hostname this interface is reachable on.
91    pub reachable_on: Option<String>,
92    /// Interface type string (e.g. "BackboneInterface").
93    pub interface_type: String,
94    /// Listen port of the discoverable interface.
95    pub listen_port: Option<u16>,
96    /// Executable that resolves `latitude,longitude,height` for each announce.
97    pub location_cmd: Option<String>,
98    /// Geographic latitude in decimal degrees.
99    pub latitude: Option<f64>,
100    /// Geographic longitude in decimal degrees.
101    pub longitude: Option<f64>,
102    /// Height/altitude in meters.
103    pub height: Option<f64>,
104}
105
106// ============================================================================
107// Data Structures
108// ============================================================================
109
110/// Status of a discovered interface
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DiscoveredStatus {
113    Available,
114    Unknown,
115    Stale,
116}
117
118impl DiscoveredStatus {
119    /// Get numeric code for sorting (higher = better)
120    pub fn code(&self) -> i32 {
121        match self {
122            DiscoveredStatus::Available => STATUS_AVAILABLE,
123            DiscoveredStatus::Unknown => STATUS_UNKNOWN,
124            DiscoveredStatus::Stale => STATUS_STALE,
125        }
126    }
127
128    /// Convert to string
129    pub fn as_str(&self) -> &'static str {
130        match self {
131            DiscoveredStatus::Available => "available",
132            DiscoveredStatus::Unknown => "unknown",
133            DiscoveredStatus::Stale => "stale",
134        }
135    }
136}
137
138/// Information about a discovered interface
139#[derive(Debug, Clone)]
140pub struct DiscoveredInterface {
141    /// Interface type (e.g., "BackboneInterface", "TCPServerInterface", "RNodeInterface")
142    pub interface_type: String,
143    /// Whether the announcing node has transport enabled
144    pub transport: bool,
145    /// Human-readable name of the interface
146    pub name: String,
147    /// Timestamp when first discovered
148    pub discovered: f64,
149    /// Timestamp of last announcement
150    pub last_heard: f64,
151    /// Number of times heard
152    pub heard_count: u32,
153    /// Current status based on last_heard
154    pub status: DiscoveredStatus,
155    /// Raw stamp bytes
156    pub stamp: Vec<u8>,
157    /// Calculated stamp value (leading zeros)
158    pub stamp_value: u32,
159    /// Transport identity hash (truncated)
160    pub transport_id: [u8; 16],
161    /// Network identity hash (announcer)
162    pub network_id: [u8; 16],
163    /// Number of hops to reach this interface
164    pub hops: u8,
165
166    // Optional location info
167    pub latitude: Option<f64>,
168    pub longitude: Option<f64>,
169    pub height: Option<f64>,
170
171    // Connection info
172    pub reachable_on: Option<String>,
173    pub port: Option<u16>,
174
175    // RNode/RF specific
176    pub frequency: Option<u32>,
177    pub bandwidth: Option<u32>,
178    pub spreading_factor: Option<u8>,
179    pub coding_rate: Option<u8>,
180    pub modulation: Option<String>,
181    pub channel: Option<u8>,
182
183    // IFAC info
184    pub ifac_netname: Option<String>,
185    pub ifac_netkey: Option<String>,
186
187    // Auto-generated config entry
188    pub config_entry: Option<String>,
189
190    /// Hash for storage key (SHA256 of transport_id + name)
191    pub discovery_hash: [u8; 32],
192}
193
194fn insufficient_stamp_diagnostic(value: u32) -> String {
195    format!("Ignored discovered interface with insufficient stamp value {value}")
196}
197
198/// Bounded FIFO cache for discovery payloads with invalid proof-of-work stamps.
199///
200/// The key is the full SHA-256 hash of the discovery payload after its flags
201/// byte, matching upstream Reticulum. It therefore includes the stamp itself,
202/// while harmless changes to the flags byte do not bypass the cache.
203#[derive(Debug, Clone)]
204pub struct DiscoveryStampCache {
205    capacity: usize,
206    valid_order: VecDeque<[u8; 32]>,
207    valid_entries: BTreeMap<[u8; 32], ValidStampEntry>,
208    invalid_order: VecDeque<[u8; 32]>,
209    invalid_entries: BTreeSet<[u8; 32]>,
210}
211
212#[derive(Debug, Clone)]
213struct ValidStampEntry {
214    stamp: Vec<u8>,
215    packed: Vec<u8>,
216    value: u32,
217}
218
219impl Default for DiscoveryStampCache {
220    fn default() -> Self {
221        Self::with_capacity(DISCOVERY_STAMP_CACHE_MAX_ENTRIES)
222    }
223}
224
225impl DiscoveryStampCache {
226    pub fn with_capacity(capacity: usize) -> Self {
227        Self {
228            capacity,
229            valid_order: VecDeque::with_capacity(capacity),
230            valid_entries: BTreeMap::new(),
231            invalid_order: VecDeque::with_capacity(capacity),
232            invalid_entries: BTreeSet::new(),
233        }
234    }
235
236    fn payload_hash(app_data: &[u8]) -> Option<[u8; 32]> {
237        app_data
238            .get(1..)
239            .filter(|payload| !payload.is_empty())
240            .map(sha256)
241    }
242
243    fn contains_invalid_hash(&self, payload_hash: &[u8; 32]) -> bool {
244        self.invalid_entries.contains(payload_hash)
245    }
246
247    fn valid_entry(&self, payload_hash: &[u8; 32]) -> Option<&ValidStampEntry> {
248        self.valid_entries.get(payload_hash)
249    }
250
251    fn insert_valid_hash(&mut self, payload_hash: [u8; 32], entry: ValidStampEntry) {
252        if self.capacity == 0 || self.valid_entries.contains_key(&payload_hash) {
253            return;
254        }
255        if self.valid_order.len() == self.capacity {
256            if let Some(evicted) = self.valid_order.pop_front() {
257                self.valid_entries.remove(&evicted);
258            }
259        }
260        self.valid_order.push_back(payload_hash);
261        self.valid_entries.insert(payload_hash, entry);
262    }
263
264    fn insert_invalid_hash(&mut self, payload_hash: [u8; 32]) {
265        if self.capacity == 0 || self.invalid_entries.contains(&payload_hash) {
266            return;
267        }
268        if self.invalid_order.len() == self.capacity {
269            if let Some(evicted) = self.invalid_order.pop_front() {
270                self.invalid_entries.remove(&evicted);
271            }
272        }
273        self.invalid_order.push_back(payload_hash);
274        self.invalid_entries.insert(payload_hash);
275    }
276
277    pub fn invalid_len(&self) -> usize {
278        self.invalid_order.len()
279    }
280
281    pub fn valid_len(&self) -> usize {
282        self.valid_order.len()
283    }
284
285    pub fn contains_invalid_app_data(&self, app_data: &[u8]) -> bool {
286        Self::payload_hash(app_data)
287            .is_some_and(|payload_hash| self.contains_invalid_hash(&payload_hash))
288    }
289
290    pub fn contains_valid_app_data(&self, app_data: &[u8]) -> bool {
291        Self::payload_hash(app_data)
292            .is_some_and(|payload_hash| self.valid_entries.contains_key(&payload_hash))
293    }
294}
295
296impl DiscoveredInterface {
297    /// Compute the current status based on last_heard timestamp
298    pub fn compute_status(&self) -> DiscoveredStatus {
299        let delta = time::now() - self.last_heard;
300        if delta > THRESHOLD_STALE {
301            DiscoveredStatus::Stale
302        } else if delta > THRESHOLD_UNKNOWN {
303            DiscoveredStatus::Unknown
304        } else {
305            DiscoveredStatus::Available
306        }
307    }
308}
309
310// ============================================================================
311// Parsing and Validation
312// ============================================================================
313
314/// Parse an interface discovery announcement from app_data.
315///
316/// Returns None if:
317/// - Data is too short
318/// - Stamp is invalid
319/// - Required fields are missing
320pub fn parse_interface_announce(
321    app_data: &[u8],
322    announced_identity_hash: &[u8; 16],
323    hops: u8,
324    required_stamp_value: u8,
325) -> Option<DiscoveredInterface> {
326    let mut cache = DiscoveryStampCache::with_capacity(0);
327    parse_interface_announce_with_cache(
328        app_data,
329        announced_identity_hash,
330        hops,
331        required_stamp_value,
332        &mut cache,
333    )
334}
335
336/// Parse an interface discovery announcement while retaining invalid stamp
337/// results across calls.
338pub fn parse_interface_announce_with_cache(
339    app_data: &[u8],
340    announced_identity_hash: &[u8; 16],
341    hops: u8,
342    required_stamp_value: u8,
343    cache: &mut DiscoveryStampCache,
344) -> Option<DiscoveredInterface> {
345    // Need at least: 1 byte flags + some data + STAMP_SIZE
346    if app_data.len() <= STAMP_SIZE + 1 {
347        return None;
348    }
349
350    // Extract flags and payload
351    let flags = app_data[0];
352    let payload = &app_data[1..];
353    let payload_hash = sha256(payload);
354
355    if cache.contains_invalid_hash(&payload_hash) {
356        log::trace!(
357            target: rns_core::logging::PATHING_LOG_TARGET,
358            "Ignored previously discovered interface with insufficient stamp value"
359        );
360        return None;
361    }
362
363    // Check encryption flag (we don't support encrypted discovery yet)
364    let encrypted = (flags & 0x02) != 0;
365    if encrypted {
366        log::debug!("Ignoring encrypted discovered interface (not supported)");
367        return None;
368    }
369
370    let (stamp, packed, stamp_value) =
371        if let Some(entry) = cache.valid_entry(&payload_hash).cloned() {
372            log::trace!(
373                target: rns_core::logging::PATHING_LOG_TARGET,
374                "Discovery announce cache hit"
375            );
376            if entry.value < required_stamp_value as u32 {
377                log::debug!("{}", insufficient_stamp_diagnostic(entry.value));
378                return None;
379            }
380            (entry.stamp, entry.packed, entry.value)
381        } else {
382            // The driver owns this cache and processes events sequentially, so the
383            // upstream validation lock is implicit in Rust's ownership model.
384            let stamp = payload[payload.len() - STAMP_SIZE..].to_vec();
385            let packed = payload[..payload.len() - STAMP_SIZE].to_vec();
386            let infohash = sha256(&packed);
387            let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
388            let value = stamp_value(&workblock, &stamp);
389
390            if !stamp_valid(&stamp, required_stamp_value, &workblock) {
391                log::debug!("{}", insufficient_stamp_diagnostic(value));
392                cache.insert_invalid_hash(payload_hash);
393                return None;
394            }
395            cache.insert_valid_hash(
396                payload_hash,
397                ValidStampEntry {
398                    stamp: stamp.clone(),
399                    packed: packed.clone(),
400                    value,
401                },
402            );
403            (stamp, packed, value)
404        };
405
406    // Unpack the interface info
407    let (value, _) = msgpack::unpack(&packed).ok()?;
408    let map = value.as_map()?;
409
410    // Helper to get a value from the map by integer key
411    let get_u8_val = |key: u8| -> Option<Value> {
412        for (k, v) in map {
413            if k.as_uint()? as u8 == key {
414                return Some(v.clone());
415            }
416        }
417        None
418    };
419
420    // Extract required fields
421    let interface_type = match get_u8_val(INTERFACE_TYPE)? {
422        Value::Str(value) => value,
423        _ => return None,
424    };
425    if !is_discoverable_type(&interface_type) {
426        log::debug!(
427            "Ignoring discovered interface with unsupported type '{}'",
428            interface_type
429        );
430        return None;
431    }
432
433    let transport = match get_u8_val(TRANSPORT)? {
434        Value::Bool(value) => value,
435        _ => return None,
436    };
437    let raw_name = match get_u8_val(NAME) {
438        Some(Value::Str(value)) => value,
439        Some(_) | None => String::new(),
440    };
441    let name = sanitize_discovered_name(&raw_name)
442        .unwrap_or_else(|| format!("Discovered {}", interface_type));
443
444    let transport_id_val = get_u8_val(TRANSPORT_ID)?;
445    let transport_id_bytes = transport_id_val.as_bin()?;
446    if transport_id_bytes.len() != 16 {
447        log::debug!("Ignoring discovered interface with invalid transport_id length");
448        return None;
449    }
450    let mut transport_id = [0u8; 16];
451    transport_id.copy_from_slice(transport_id_bytes);
452
453    // Extract optional fields
454    let latitude = optional_f64_field(get_u8_val(LATITUDE))?;
455    let longitude = optional_f64_field(get_u8_val(LONGITUDE))?;
456    let height = optional_f64_field(get_u8_val(HEIGHT))?;
457    let reachable_on = match get_u8_val(REACHABLE_ON) {
458        None | Some(Value::Nil) => None,
459        Some(Value::Str(value)) => Some(value),
460        Some(_) => return None,
461    };
462    if let Some(ref reachable_on) = reachable_on {
463        if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
464            log::debug!(
465                "Ignoring discovered interface with invalid reachable_on '{}'",
466                reachable_on
467            );
468            return None;
469        }
470    }
471
472    let port = get_u8_val(PORT).and_then(|v| v.as_uint().map(|n| n as u16));
473    let frequency = get_u8_val(FREQUENCY).and_then(|v| v.as_uint().map(|n| n as u32));
474    let bandwidth = get_u8_val(BANDWIDTH).and_then(|v| v.as_uint().map(|n| n as u32));
475    let spreading_factor = get_u8_val(SPREADINGFACTOR).and_then(|v| v.as_uint().map(|n| n as u8));
476    let coding_rate = get_u8_val(CODINGRATE).and_then(|v| v.as_uint().map(|n| n as u8));
477    let modulation = get_u8_val(MODULATION).and_then(|v| v.as_str().map(|s| s.to_string()));
478    let channel = get_u8_val(CHANNEL).and_then(|v| v.as_uint().map(|n| n as u8));
479    let ifac_netname = get_u8_val(IFAC_NETNAME).map(|v| discovery_value_to_string(&v));
480    let ifac_netkey = get_u8_val(IFAC_NETKEY).map(|v| discovery_value_to_string(&v));
481
482    // Compute discovery hash
483    let discovery_hash = compute_discovery_hash(&transport_id, &name);
484
485    // Generate config entry
486    let config_entry = generate_config_entry(
487        &interface_type,
488        &name,
489        &transport_id,
490        reachable_on.as_deref(),
491        port,
492        frequency,
493        bandwidth,
494        spreading_factor,
495        coding_rate,
496        modulation.as_deref(),
497        ifac_netname.as_deref(),
498        ifac_netkey.as_deref(),
499    );
500
501    let now = time::now();
502
503    Some(DiscoveredInterface {
504        interface_type,
505        transport,
506        name,
507        discovered: now,
508        last_heard: now,
509        heard_count: 0,
510        status: DiscoveredStatus::Available,
511        stamp,
512        stamp_value,
513        transport_id,
514        network_id: *announced_identity_hash,
515        hops,
516        latitude,
517        longitude,
518        height,
519        reachable_on,
520        port,
521        frequency,
522        bandwidth,
523        spreading_factor,
524        coding_rate,
525        modulation,
526        channel,
527        ifac_netname,
528        ifac_netkey,
529        config_entry,
530        discovery_hash,
531    })
532}
533
534/// Compute the discovery hash for storage
535pub fn compute_discovery_hash(transport_id: &[u8; 16], name: &str) -> [u8; 32] {
536    let mut material = Vec::with_capacity(16 + name.len());
537    material.extend_from_slice(transport_id);
538    material.extend_from_slice(name.as_bytes());
539    sha256(&material)
540}
541
542/// Mark a discovered transport interface config entry for gateway mode.
543pub fn apply_transport_autoconnect_mode(
544    iface: &mut DiscoveredInterface,
545    local_transport_enabled: bool,
546    configured_mode: Option<u8>,
547) {
548    let mode = configured_mode.or_else(|| {
549        (local_transport_enabled && iface.transport).then_some(rns_core::constants::MODE_GATEWAY)
550    });
551    let Some(mode) = mode else {
552        return;
553    };
554    let Some(config_entry) = iface.config_entry.as_mut() else {
555        return;
556    };
557    if config_entry
558        .lines()
559        .any(|line| line.trim_start().starts_with("interface_mode"))
560    {
561        return;
562    }
563    if let Some(pos) = config_entry.find("  enabled = yes\n") {
564        let insert_at = pos + "  enabled = yes\n".len();
565        config_entry.insert_str(
566            insert_at,
567            &format!("  interface_mode = {}\n", interface_mode_name(mode)),
568        );
569    } else {
570        config_entry.push_str(&format!(
571            "\n  interface_mode = {}",
572            interface_mode_name(mode)
573        ));
574    }
575}
576
577fn interface_mode_name(mode: u8) -> &'static str {
578    match mode {
579        rns_core::constants::MODE_ACCESS_POINT => "access_point",
580        rns_core::constants::MODE_POINT_TO_POINT => "pointtopoint",
581        rns_core::constants::MODE_ROAMING => "roaming",
582        rns_core::constants::MODE_BOUNDARY => "boundary",
583        rns_core::constants::MODE_GATEWAY => "gateway",
584        rns_core::constants::MODE_INTERNAL => "internal",
585        _ => "full",
586    }
587}
588
589/// Generate a config entry for auto-connecting to a discovered interface
590fn generate_config_entry(
591    interface_type: &str,
592    name: &str,
593    transport_id: &[u8; 16],
594    reachable_on: Option<&str>,
595    port: Option<u16>,
596    frequency: Option<u32>,
597    bandwidth: Option<u32>,
598    spreading_factor: Option<u8>,
599    coding_rate: Option<u8>,
600    modulation: Option<&str>,
601    ifac_netname: Option<&str>,
602    ifac_netkey: Option<&str>,
603) -> Option<String> {
604    if reachable_on.is_some_and(is_ygg_ipv6) {
605        return None;
606    }
607
608    let transport_id_hex = hex_encode(transport_id);
609    let netname_str = ifac_netname
610        .map(|n| format!("\n  network_name = {}", n))
611        .unwrap_or_default();
612    let netkey_str = ifac_netkey
613        .map(|k| format!("\n  passphrase = {}", k))
614        .unwrap_or_default();
615    let identity_str = format!("\n  transport_identity = {}", transport_id_hex);
616
617    match interface_type {
618        "BackboneInterface" | "TCPServerInterface" => {
619            let reachable = reachable_on.unwrap_or("unknown");
620            let port_val = port.unwrap_or(4242);
621            Some(format!(
622                "[[{}]]\n  type = BackboneInterface\n  enabled = yes\n  remote = {}\n  target_port = {}{}{}{}",
623                name, reachable, port_val, identity_str, netname_str, netkey_str
624            ))
625        }
626        "I2PInterface" => {
627            let reachable = reachable_on.unwrap_or("unknown");
628            Some(format!(
629                "[[{}]]\n  type = I2PInterface\n  enabled = yes\n  peers = {}{}{}{}",
630                name, reachable, identity_str, netname_str, netkey_str
631            ))
632        }
633        "RNodeInterface" => {
634            let freq_str = frequency
635                .map(|f| format!("\n  frequency = {}", f))
636                .unwrap_or_default();
637            let bw_str = bandwidth
638                .map(|b| format!("\n  bandwidth = {}", b))
639                .unwrap_or_default();
640            let sf_str = spreading_factor
641                .map(|s| format!("\n  spreadingfactor = {}", s))
642                .unwrap_or_default();
643            let cr_str = coding_rate
644                .map(|c| format!("\n  codingrate = {}", c))
645                .unwrap_or_default();
646            Some(format!(
647                "[[{}]]\n  type = RNodeInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}{}",
648                name, "", freq_str, bw_str, sf_str, cr_str, identity_str, netname_str, netkey_str
649            ))
650        }
651        "KISSInterface" => {
652            let freq_str = frequency
653                .map(|f| format!("\n  # Frequency: {}", f))
654                .unwrap_or_default();
655            let bw_str = bandwidth
656                .map(|b| format!("\n  # Bandwidth: {}", b))
657                .unwrap_or_default();
658            let mod_str = modulation
659                .map(|m| format!("\n  # Modulation: {}", m))
660                .unwrap_or_default();
661            Some(format!(
662                "[[{}]]\n  type = KISSInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}",
663                name, "", freq_str, bw_str, mod_str, identity_str, netname_str, netkey_str
664            ))
665        }
666        "WeaveInterface" => Some(format!(
667            "[[{}]]\n  type = WeaveInterface\n  enabled = yes\n  port = {}{}{}{}",
668            name, "", identity_str, netname_str, netkey_str
669        )),
670        _ => None,
671    }
672}
673
674// ============================================================================
675// Helper Functions
676// ============================================================================
677
678fn optional_f64_field(value: Option<Value>) -> Option<Option<f64>> {
679    match value {
680        None | Some(Value::Nil) => Some(None),
681        Some(Value::Float(value)) if value.is_finite() => Some(Some(value)),
682        Some(_) => None,
683    }
684}
685
686fn discovery_value_to_string(value: &Value) -> String {
687    match value {
688        Value::Nil => "None".to_string(),
689        Value::Bool(value) => value.to_string(),
690        Value::UInt(value) => value.to_string(),
691        Value::Int(value) => value.to_string(),
692        Value::Float(value) => value.to_string(),
693        Value::Bin(value) => hex_encode(value),
694        Value::Str(value) => value.clone(),
695        Value::Array(_) => "[]".to_string(),
696        Value::Map(_) => "{}".to_string(),
697    }
698}
699
700/// Sanitize a discovered interface name like upstream Reticulum.
701pub fn sanitize_discovered_name(name: &str) -> Option<String> {
702    let ascii: String = name.chars().filter(|ch| ch.is_ascii()).collect();
703    let mut sanitized = ascii.trim().to_string();
704    while sanitized.contains("  ") {
705        sanitized = sanitized.replace("  ", " ");
706    }
707
708    let start = sanitized
709        .char_indices()
710        .find(|(_, ch)| ch.is_ascii_alphanumeric())
711        .map(|(idx, _)| idx)?;
712    let end = sanitized
713        .char_indices()
714        .rev()
715        .find(|(_, ch)| ch.is_ascii_alphanumeric() || *ch == ')')
716        .map(|(idx, ch)| idx + ch.len_utf8())?;
717
718    if start >= end {
719        return None;
720    }
721
722    let sanitized = sanitized[start..end].to_string();
723    (!sanitized.is_empty()).then_some(sanitized)
724}
725
726/// Encode bytes as hex string (no delimiters)
727pub fn hex_encode(bytes: &[u8]) -> String {
728    bytes.iter().map(|b| format!("{:02x}", b)).collect()
729}
730
731/// Check if a string is a valid IP address
732pub fn is_ip_address(s: &str) -> bool {
733    s.parse::<std::net::IpAddr>().is_ok()
734}
735
736/// Check if an address belongs to Yggdrasil's `200::/7` IPv6 range.
737pub fn is_ygg_ipv6(s: &str) -> bool {
738    match s.parse::<std::net::IpAddr>() {
739        Ok(std::net::IpAddr::V6(addr)) => {
740            let segments = addr.segments();
741            (segments[0] & 0xfe00) == 0x0200
742        }
743        _ => false,
744    }
745}
746
747/// Check if a string is a valid hostname
748pub fn is_hostname(s: &str) -> bool {
749    let s = s.strip_suffix('.').unwrap_or(s);
750    if s.len() > 253 {
751        return false;
752    }
753    let components: Vec<&str> = s.split('.').collect();
754    if components.is_empty() {
755        return false;
756    }
757    // Last component should not be all numeric
758    if components
759        .last()
760        .map(|c| c.chars().all(|ch| ch.is_ascii_digit()))
761        .unwrap_or(false)
762    {
763        return false;
764    }
765    components.iter().all(|c| {
766        !c.is_empty()
767            && c.len() <= 63
768            && !c.starts_with('-')
769            && !c.ends_with('-')
770            && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
771    })
772}
773
774/// Check whether an interface type can be accepted from discovery.
775pub fn is_discoverable_type(interface_type: &str) -> bool {
776    DISCOVERABLE_TYPES.contains(&interface_type)
777}
778
779/// Filter and sort discovered interfaces
780pub fn filter_and_sort_interfaces(
781    interfaces: &mut Vec<DiscoveredInterface>,
782    only_available: bool,
783    only_transport: bool,
784) {
785    let now = time::now();
786
787    // Update status and filter
788    interfaces.retain(|iface| {
789        if !is_discoverable_type(&iface.interface_type) {
790            return false;
791        }
792        if let Some(ref reachable_on) = iface.reachable_on {
793            if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
794                return false;
795            }
796        }
797
798        let delta = now - iface.last_heard;
799
800        // Check for removal threshold
801        if delta > THRESHOLD_REMOVE {
802            return false;
803        }
804
805        // Update status
806        let status = iface.compute_status();
807
808        // Apply filters
809        if only_available && status != DiscoveredStatus::Available {
810            return false;
811        }
812        if only_transport && !iface.transport {
813            return false;
814        }
815
816        true
817    });
818
819    // Sort by (status_code desc, value desc, last_heard desc)
820    interfaces.sort_by(|a, b| {
821        let status_cmp = b.compute_status().code().cmp(&a.compute_status().code());
822        if status_cmp != std::cmp::Ordering::Equal {
823            return status_cmp;
824        }
825        let value_cmp = b.stamp_value.cmp(&a.stamp_value);
826        if value_cmp != std::cmp::Ordering::Equal {
827            return value_cmp;
828        }
829        b.last_heard
830            .partial_cmp(&a.last_heard)
831            .unwrap_or(std::cmp::Ordering::Equal)
832    });
833}
834
835/// Compute the name hash for the discovery destination: `rnstransport.discovery.interface`.
836///
837/// Discovery is a SINGLE destination — its dest hash varies with the sender's identity.
838/// We match incoming announces by comparing their name_hash to this constant.
839pub fn discovery_name_hash() -> [u8; 10] {
840    rns_core::destination::name_hash(APP_NAME, &["discovery", "interface"])
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    fn pack_discovery_entries(entries: Vec<(Value, Value)>) -> Vec<u8> {
848        let packed = msgpack::pack(&Value::Map(entries));
849        let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
850        app_data.push(0x00);
851        app_data.extend_from_slice(&packed);
852        app_data.extend_from_slice(&[0u8; STAMP_SIZE]);
853        app_data
854    }
855
856    fn discovery_entries(interface_type: &str, reachable_on: Option<&str>) -> Vec<(Value, Value)> {
857        let mut entries = vec![
858            (
859                Value::UInt(INTERFACE_TYPE as u64),
860                Value::Str(interface_type.to_string()),
861            ),
862            (Value::UInt(TRANSPORT as u64), Value::Bool(true)),
863            (
864                Value::UInt(NAME as u64),
865                Value::Str(format!("test-{interface_type}")),
866            ),
867            (Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 16])),
868        ];
869
870        if let Some(reachable_on) = reachable_on {
871            entries.push((
872                Value::UInt(REACHABLE_ON as u64),
873                Value::Str(reachable_on.to_string()),
874            ));
875        }
876
877        entries
878    }
879
880    fn build_discovery_app_data(interface_type: &str, reachable_on: Option<&str>) -> Vec<u8> {
881        pack_discovery_entries(discovery_entries(interface_type, reachable_on))
882    }
883
884    #[test]
885    fn insufficient_stamp_diagnostic_includes_calculated_value() {
886        assert_eq!(
887            insufficient_stamp_diagnostic(7),
888            "Ignored discovered interface with insufficient stamp value 7"
889        );
890    }
891
892    #[test]
893    fn default_discovery_stamp_value_matches_upstream_1_4_0() {
894        assert_eq!(DEFAULT_STAMP_VALUE, 16);
895    }
896
897    #[test]
898    fn invalid_stamp_cache_short_circuits_repeat_validation() {
899        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
900        let mut cache = DiscoveryStampCache::with_capacity(8);
901
902        assert!(parse_interface_announce_with_cache(
903            &app_data,
904            &[0x11; 16],
905            1,
906            u8::MAX,
907            &mut cache,
908        )
909        .is_none());
910        assert_eq!(cache.invalid_len(), 1);
911
912        // A zero requirement would accept this stamp if validation ran again.
913        // The cached invalid payload must be rejected before that work occurs.
914        assert!(
915            parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache,)
916                .is_none()
917        );
918        assert!(parse_interface_announce(&app_data, &[0x11; 16], 1, 0).is_some());
919        assert_eq!(
920            cache.invalid_len(),
921            1,
922            "cache hits must not duplicate entries"
923        );
924    }
925
926    #[test]
927    fn invalid_stamp_cache_is_bounded_fifo() {
928        let first = build_discovery_app_data("BackboneInterface", Some("first.example"));
929        let second = build_discovery_app_data("BackboneInterface", Some("second.example"));
930        let third = build_discovery_app_data("BackboneInterface", Some("third.example"));
931        let mut cache = DiscoveryStampCache::with_capacity(2);
932
933        for app_data in [&first, &second, &third] {
934            assert!(parse_interface_announce_with_cache(
935                app_data,
936                &[0x11; 16],
937                1,
938                u8::MAX,
939                &mut cache,
940            )
941            .is_none());
942        }
943
944        assert_eq!(cache.invalid_len(), 2);
945        assert!(!cache.contains_invalid_app_data(&first));
946        assert!(cache.contains_invalid_app_data(&second));
947        assert!(cache.contains_invalid_app_data(&third));
948    }
949
950    #[test]
951    fn valid_stamp_cache_short_circuits_repeat_work_and_preserves_dynamic_metadata() {
952        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
953        let mut cache = DiscoveryStampCache::with_capacity(8);
954        let first =
955            parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache).unwrap();
956        assert_eq!(cache.valid_len(), 1);
957
958        let second =
959            parse_interface_announce_with_cache(&app_data, &[0x22; 16], 7, 0, &mut cache).unwrap();
960        assert_eq!(cache.valid_len(), 1);
961        assert_eq!(second.stamp, first.stamp);
962        assert_eq!(second.stamp_value, first.stamp_value);
963        assert_eq!(second.network_id, [0x22; 16]);
964        assert_eq!(second.hops, 7);
965    }
966
967    #[test]
968    fn valid_stamp_cache_is_bounded_fifo_and_keys_payload_like_invalid_cache() {
969        let first = build_discovery_app_data("BackboneInterface", Some("first.example"));
970        let second = build_discovery_app_data("BackboneInterface", Some("second.example"));
971        let third = build_discovery_app_data("BackboneInterface", Some("third.example"));
972        let mut cache = DiscoveryStampCache::with_capacity(2);
973
974        for app_data in [&first, &second, &third] {
975            assert!(
976                parse_interface_announce_with_cache(app_data, &[0x11; 16], 1, 0, &mut cache,)
977                    .is_some()
978            );
979        }
980        assert_eq!(cache.valid_len(), 2);
981        assert!(!cache.contains_valid_app_data(&first));
982        assert!(cache.contains_valid_app_data(&second));
983        assert!(cache.contains_valid_app_data(&third));
984
985        let mut different_flags = second.clone();
986        different_flags[0] ^= 0x01;
987        assert!(cache.contains_valid_app_data(&different_flags));
988        let mut different_stamp = second.clone();
989        *different_stamp.last_mut().unwrap() ^= 0x01;
990        assert!(!cache.contains_valid_app_data(&different_stamp));
991    }
992
993    #[test]
994    fn cached_stamp_value_is_rechecked_if_requirement_increases() {
995        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
996        let mut cache = DiscoveryStampCache::with_capacity(8);
997        assert!(
998            parse_interface_announce_with_cache(&app_data, &[0x11; 16], 1, 0, &mut cache,)
999                .is_some()
1000        );
1001        assert!(parse_interface_announce_with_cache(
1002            &app_data,
1003            &[0x11; 16],
1004            1,
1005            u8::MAX,
1006            &mut cache,
1007        )
1008        .is_none());
1009        assert_eq!(cache.valid_len(), 1);
1010        assert_eq!(cache.invalid_len(), 0);
1011    }
1012
1013    #[test]
1014    fn invalid_stamp_cache_keys_payload_including_stamp_but_excluding_flags() {
1015        let original = build_discovery_app_data("BackboneInterface", Some("example.com"));
1016        let mut cache = DiscoveryStampCache::with_capacity(8);
1017        assert!(parse_interface_announce_with_cache(
1018            &original,
1019            &[0x11; 16],
1020            1,
1021            u8::MAX,
1022            &mut cache,
1023        )
1024        .is_none());
1025
1026        let mut different_flags = original.clone();
1027        different_flags[0] ^= 0x01;
1028        assert!(cache.contains_invalid_app_data(&different_flags));
1029
1030        let mut different_stamp = original.clone();
1031        *different_stamp.last_mut().unwrap() ^= 0x01;
1032        assert!(!cache.contains_invalid_app_data(&different_stamp));
1033    }
1034
1035    #[test]
1036    fn malformed_discovery_payload_is_not_added_to_invalid_stamp_cache() {
1037        let mut cache = DiscoveryStampCache::with_capacity(8);
1038        assert!(parse_interface_announce_with_cache(
1039            &[0x00, 0x01, 0x02],
1040            &[0x11; 16],
1041            1,
1042            u8::MAX,
1043            &mut cache,
1044        )
1045        .is_none());
1046        assert_eq!(cache.invalid_len(), 0);
1047    }
1048
1049    #[test]
1050    fn parse_rejects_unsupported_discovered_interface_type() {
1051        let app_data = build_discovery_app_data("BogusInterface", None);
1052
1053        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1054
1055        assert!(
1056            parsed.is_none(),
1057            "unsupported discovered interface types must be ignored"
1058        );
1059    }
1060
1061    #[test]
1062    fn parse_rejects_invalid_reachable_on_address() {
1063        let app_data = build_discovery_app_data("BackboneInterface", Some("-not a host-"));
1064
1065        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1066
1067        assert!(
1068            parsed.is_none(),
1069            "discovered interfaces with invalid reachable_on values must be ignored"
1070        );
1071    }
1072
1073    #[test]
1074    fn parse_sanitizes_discovered_interface_name() {
1075        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1076        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1077        entries.push((
1078            Value::UInt(NAME as u64),
1079            Value::Str("\t**Alpha     Beta!!!\n".to_string()),
1080        ));
1081        let app_data = pack_discovery_entries(entries);
1082
1083        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1084
1085        assert_eq!(parsed.name, "Alpha Beta");
1086        assert!(parsed.config_entry.unwrap().starts_with("[[Alpha Beta]]"));
1087    }
1088
1089    #[test]
1090    fn parse_falls_back_when_discovered_interface_name_sanitizes_empty() {
1091        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1092        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1093        entries.push((Value::UInt(NAME as u64), Value::Str("!!!".to_string())));
1094        let app_data = pack_discovery_entries(entries);
1095
1096        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1097
1098        assert_eq!(parsed.name, "Discovered BackboneInterface");
1099    }
1100
1101    #[test]
1102    fn parse_falls_back_when_discovered_interface_name_is_nil() {
1103        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1104        entries.retain(|(key, _)| key.as_uint() != Some(NAME as u64));
1105        entries.push((Value::UInt(NAME as u64), Value::Nil));
1106        let app_data = pack_discovery_entries(entries);
1107
1108        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1109
1110        assert_eq!(parsed.name, "Discovered BackboneInterface");
1111    }
1112
1113    #[test]
1114    fn parse_rejects_invalid_discovered_interface_field_types() {
1115        for (field, replacement) in [
1116            (TRANSPORT, Value::Str("yes".to_string())),
1117            (LATITUDE, Value::Str("45.0".to_string())),
1118            (LONGITUDE, Value::Str("9.0".to_string())),
1119            (HEIGHT, Value::Str("100".to_string())),
1120            (INTERFACE_TYPE, Value::UInt(123)),
1121            (REACHABLE_ON, Value::UInt(123)),
1122        ] {
1123            let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1124            entries.retain(|(key, _)| key.as_uint() != Some(field as u64));
1125            entries.push((Value::UInt(field as u64), replacement));
1126            let app_data = pack_discovery_entries(entries);
1127
1128            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1129
1130            assert!(parsed.is_none(), "field {field} should reject invalid type");
1131        }
1132    }
1133
1134    #[test]
1135    fn parse_rejects_invalid_transport_id_length() {
1136        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1137        entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT_ID as u64));
1138        entries.push((Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 15])));
1139        let app_data = pack_discovery_entries(entries);
1140
1141        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1142
1143        assert!(parsed.is_none());
1144    }
1145
1146    #[test]
1147    fn parse_converts_ifac_fields_to_strings() {
1148        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1149        entries.push((Value::UInt(IFAC_NETNAME as u64), Value::UInt(123)));
1150        entries.push((Value::UInt(IFAC_NETKEY as u64), Value::Bool(true)));
1151        let app_data = pack_discovery_entries(entries);
1152
1153        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1154
1155        assert_eq!(parsed.ifac_netname.as_deref(), Some("123"));
1156        assert_eq!(parsed.ifac_netkey.as_deref(), Some("true"));
1157    }
1158
1159    #[test]
1160    fn transport_autoconnect_mode_marks_transport_discovery_config_as_gateway() {
1161        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1162        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1163
1164        apply_transport_autoconnect_mode(&mut parsed, true, None);
1165
1166        let config_entry = parsed.config_entry.unwrap();
1167        assert!(config_entry.contains("  enabled = yes\n  interface_mode = gateway\n"));
1168    }
1169
1170    #[test]
1171    fn configured_autoconnect_mode_overrides_transport_fallback() {
1172        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1173        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1174
1175        apply_transport_autoconnect_mode(
1176            &mut parsed,
1177            true,
1178            Some(rns_core::constants::MODE_BOUNDARY),
1179        );
1180
1181        let config_entry = parsed.config_entry.unwrap();
1182        assert!(config_entry.contains("  enabled = yes\n  interface_mode = boundary\n"));
1183    }
1184
1185    #[test]
1186    fn transport_autoconnect_mode_does_not_modify_non_transport_contexts() {
1187        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1188        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1189        let original = parsed.config_entry.clone();
1190
1191        apply_transport_autoconnect_mode(&mut parsed, false, None);
1192
1193        assert_eq!(parsed.config_entry, original);
1194    }
1195
1196    #[test]
1197    fn transport_autoconnect_mode_does_not_modify_non_transport_announces() {
1198        let mut entries = discovery_entries("BackboneInterface", Some("example.com"));
1199        entries.retain(|(key, _)| key.as_uint() != Some(TRANSPORT as u64));
1200        entries.push((Value::UInt(TRANSPORT as u64), Value::Bool(false)));
1201        let app_data = pack_discovery_entries(entries);
1202        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1203        let original = parsed.config_entry.clone();
1204
1205        apply_transport_autoconnect_mode(&mut parsed, true, None);
1206
1207        assert_eq!(parsed.config_entry, original);
1208    }
1209
1210    #[test]
1211    fn transport_autoconnect_mode_preserves_existing_interface_mode() {
1212        let app_data = build_discovery_app_data("BackboneInterface", Some("example.com"));
1213        let mut parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1214        let config_entry = parsed.config_entry.as_mut().unwrap();
1215        config_entry.push_str("\n  interface_mode = access_point");
1216        let original = parsed.config_entry.clone();
1217
1218        apply_transport_autoconnect_mode(&mut parsed, true, None);
1219
1220        assert_eq!(parsed.config_entry, original);
1221    }
1222
1223    #[test]
1224    fn parse_yggdrasil_reachable_on_keeps_record_without_config_entry() {
1225        let app_data = build_discovery_app_data("BackboneInterface", Some("200:1234::1"));
1226
1227        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0).unwrap();
1228
1229        assert_eq!(parsed.reachable_on.as_deref(), Some("200:1234::1"));
1230        assert!(parsed.config_entry.is_none());
1231    }
1232
1233    #[test]
1234    fn parse_accepts_supported_discovered_interface_types() {
1235        for interface_type in [
1236            "BackboneInterface",
1237            "TCPServerInterface",
1238            "I2PInterface",
1239            "RNodeInterface",
1240            "WeaveInterface",
1241            "KISSInterface",
1242        ] {
1243            let app_data = build_discovery_app_data(interface_type, Some("example.com"));
1244
1245            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
1246
1247            assert!(
1248                parsed.is_some(),
1249                "{interface_type} should be accepted as a discoverable interface type"
1250            );
1251        }
1252    }
1253}