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 rns_core::msgpack::{self, Value};
9use rns_core::stamp::{stamp_valid, stamp_value, stamp_workblock};
10use rns_crypto::sha256::sha256;
11
12use super::time;
13
14// ============================================================================
15// Constants (matching Python Discovery.py)
16// ============================================================================
17
18/// Discovery field IDs for msgpack encoding
19pub const NAME: u8 = 0xFF;
20pub const TRANSPORT_ID: u8 = 0xFE;
21pub const INTERFACE_TYPE: u8 = 0x00;
22pub const TRANSPORT: u8 = 0x01;
23pub const REACHABLE_ON: u8 = 0x02;
24pub const LATITUDE: u8 = 0x03;
25pub const LONGITUDE: u8 = 0x04;
26pub const HEIGHT: u8 = 0x05;
27pub const PORT: u8 = 0x06;
28pub const IFAC_NETNAME: u8 = 0x07;
29pub const IFAC_NETKEY: u8 = 0x08;
30pub const FREQUENCY: u8 = 0x09;
31pub const BANDWIDTH: u8 = 0x0A;
32pub const SPREADINGFACTOR: u8 = 0x0B;
33pub const CODINGRATE: u8 = 0x0C;
34pub const MODULATION: u8 = 0x0D;
35pub const CHANNEL: u8 = 0x0E;
36
37/// App name for discovery destination
38pub const APP_NAME: &str = "rnstransport";
39
40/// Default stamp value for interface discovery
41pub const DEFAULT_STAMP_VALUE: u8 = 14;
42
43/// Workblock expand rounds for interface discovery
44pub const WORKBLOCK_EXPAND_ROUNDS: u32 = 20;
45
46/// Stamp size in bytes
47pub const STAMP_SIZE: usize = 32;
48
49/// Interface types accepted from discovery announces.
50pub const DISCOVERABLE_TYPES: [&str; 6] = [
51    "BackboneInterface",
52    "TCPServerInterface",
53    "I2PInterface",
54    "RNodeInterface",
55    "WeaveInterface",
56    "KISSInterface",
57];
58
59// Status thresholds (in seconds)
60/// 24 hours - status becomes "unknown"
61pub const THRESHOLD_UNKNOWN: f64 = 24.0 * 60.0 * 60.0;
62/// 3 days - status becomes "stale"
63pub const THRESHOLD_STALE: f64 = 3.0 * 24.0 * 60.0 * 60.0;
64/// 7 days - interface is removed
65pub const THRESHOLD_REMOVE: f64 = 7.0 * 24.0 * 60.0 * 60.0;
66
67// Status codes for sorting
68const STATUS_STALE: i32 = 0;
69const STATUS_UNKNOWN: i32 = 100;
70const STATUS_AVAILABLE: i32 = 1000;
71
72// ============================================================================
73// Per-interface discovery configuration
74// ============================================================================
75
76/// Per-interface discovery configuration parsed from config file.
77#[derive(Debug, Clone)]
78pub struct DiscoveryConfig {
79    /// Human-readable name to advertise (defaults to interface name).
80    pub discovery_name: String,
81    /// Announce interval in seconds (default 21600 = 6h, min 300 = 5min).
82    pub announce_interval: u64,
83    /// Stamp cost for discovery PoW (default 14).
84    pub stamp_value: u8,
85    /// IP/hostname this interface is reachable on.
86    pub reachable_on: Option<String>,
87    /// Interface type string (e.g. "BackboneInterface").
88    pub interface_type: String,
89    /// Listen port of the discoverable interface.
90    pub listen_port: Option<u16>,
91    /// Geographic latitude in decimal degrees.
92    pub latitude: Option<f64>,
93    /// Geographic longitude in decimal degrees.
94    pub longitude: Option<f64>,
95    /// Height/altitude in meters.
96    pub height: Option<f64>,
97}
98
99// ============================================================================
100// Data Structures
101// ============================================================================
102
103/// Status of a discovered interface
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum DiscoveredStatus {
106    Available,
107    Unknown,
108    Stale,
109}
110
111impl DiscoveredStatus {
112    /// Get numeric code for sorting (higher = better)
113    pub fn code(&self) -> i32 {
114        match self {
115            DiscoveredStatus::Available => STATUS_AVAILABLE,
116            DiscoveredStatus::Unknown => STATUS_UNKNOWN,
117            DiscoveredStatus::Stale => STATUS_STALE,
118        }
119    }
120
121    /// Convert to string
122    pub fn as_str(&self) -> &'static str {
123        match self {
124            DiscoveredStatus::Available => "available",
125            DiscoveredStatus::Unknown => "unknown",
126            DiscoveredStatus::Stale => "stale",
127        }
128    }
129}
130
131/// Information about a discovered interface
132#[derive(Debug, Clone)]
133pub struct DiscoveredInterface {
134    /// Interface type (e.g., "BackboneInterface", "TCPServerInterface", "RNodeInterface")
135    pub interface_type: String,
136    /// Whether the announcing node has transport enabled
137    pub transport: bool,
138    /// Human-readable name of the interface
139    pub name: String,
140    /// Timestamp when first discovered
141    pub discovered: f64,
142    /// Timestamp of last announcement
143    pub last_heard: f64,
144    /// Number of times heard
145    pub heard_count: u32,
146    /// Current status based on last_heard
147    pub status: DiscoveredStatus,
148    /// Raw stamp bytes
149    pub stamp: Vec<u8>,
150    /// Calculated stamp value (leading zeros)
151    pub stamp_value: u32,
152    /// Transport identity hash (truncated)
153    pub transport_id: [u8; 16],
154    /// Network identity hash (announcer)
155    pub network_id: [u8; 16],
156    /// Number of hops to reach this interface
157    pub hops: u8,
158
159    // Optional location info
160    pub latitude: Option<f64>,
161    pub longitude: Option<f64>,
162    pub height: Option<f64>,
163
164    // Connection info
165    pub reachable_on: Option<String>,
166    pub port: Option<u16>,
167
168    // RNode/RF specific
169    pub frequency: Option<u32>,
170    pub bandwidth: Option<u32>,
171    pub spreading_factor: Option<u8>,
172    pub coding_rate: Option<u8>,
173    pub modulation: Option<String>,
174    pub channel: Option<u8>,
175
176    // IFAC info
177    pub ifac_netname: Option<String>,
178    pub ifac_netkey: Option<String>,
179
180    // Auto-generated config entry
181    pub config_entry: Option<String>,
182
183    /// Hash for storage key (SHA256 of transport_id + name)
184    pub discovery_hash: [u8; 32],
185}
186
187impl DiscoveredInterface {
188    /// Compute the current status based on last_heard timestamp
189    pub fn compute_status(&self) -> DiscoveredStatus {
190        let delta = time::now() - self.last_heard;
191        if delta > THRESHOLD_STALE {
192            DiscoveredStatus::Stale
193        } else if delta > THRESHOLD_UNKNOWN {
194            DiscoveredStatus::Unknown
195        } else {
196            DiscoveredStatus::Available
197        }
198    }
199}
200
201// ============================================================================
202// Parsing and Validation
203// ============================================================================
204
205/// Parse an interface discovery announcement from app_data.
206///
207/// Returns None if:
208/// - Data is too short
209/// - Stamp is invalid
210/// - Required fields are missing
211pub fn parse_interface_announce(
212    app_data: &[u8],
213    announced_identity_hash: &[u8; 16],
214    hops: u8,
215    required_stamp_value: u8,
216) -> Option<DiscoveredInterface> {
217    // Need at least: 1 byte flags + some data + STAMP_SIZE
218    if app_data.len() <= STAMP_SIZE + 1 {
219        return None;
220    }
221
222    // Extract flags and payload
223    let flags = app_data[0];
224    let payload = &app_data[1..];
225
226    // Check encryption flag (we don't support encrypted discovery yet)
227    let encrypted = (flags & 0x02) != 0;
228    if encrypted {
229        log::debug!("Ignoring encrypted discovered interface (not supported)");
230        return None;
231    }
232
233    // Split stamp and packed info
234    let stamp = &payload[payload.len() - STAMP_SIZE..];
235    let packed = &payload[..payload.len() - STAMP_SIZE];
236
237    // Compute infohash and workblock
238    let infohash = sha256(packed);
239    let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
240
241    // Validate stamp
242    if !stamp_valid(stamp, required_stamp_value, &workblock) {
243        log::debug!("Ignoring discovered interface with invalid stamp");
244        return None;
245    }
246
247    // Calculate stamp value
248    let stamp_value = stamp_value(&workblock, stamp);
249
250    // Unpack the interface info
251    let (value, _) = msgpack::unpack(packed).ok()?;
252    let map = value.as_map()?;
253
254    // Helper to get a value from the map by integer key
255    let get_u8_val = |key: u8| -> Option<Value> {
256        for (k, v) in map {
257            if k.as_uint()? as u8 == key {
258                return Some(v.clone());
259            }
260        }
261        None
262    };
263
264    // Extract required fields
265    let interface_type = get_u8_val(INTERFACE_TYPE)?.as_str()?.to_string();
266    if !is_discoverable_type(&interface_type) {
267        log::debug!(
268            "Ignoring discovered interface with unsupported type '{}'",
269            interface_type
270        );
271        return None;
272    }
273
274    let transport = get_u8_val(TRANSPORT)?.as_bool()?;
275    let name = get_u8_val(NAME)?
276        .as_str()
277        .unwrap_or(&format!("Discovered {}", interface_type))
278        .to_string();
279
280    let transport_id_val = get_u8_val(TRANSPORT_ID)?;
281    let transport_id_bytes = transport_id_val.as_bin()?;
282    let mut transport_id = [0u8; 16];
283    if transport_id_bytes.len() >= 16 {
284        transport_id.copy_from_slice(&transport_id_bytes[..16]);
285    }
286
287    // Extract optional fields
288    let latitude = get_u8_val(LATITUDE).and_then(|v| v.as_float());
289    let longitude = get_u8_val(LONGITUDE).and_then(|v| v.as_float());
290    let height = get_u8_val(HEIGHT).and_then(|v| v.as_float());
291    let reachable_on = get_u8_val(REACHABLE_ON).and_then(|v| v.as_str().map(|s| s.to_string()));
292    if let Some(ref reachable_on) = reachable_on {
293        if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
294            log::debug!(
295                "Ignoring discovered interface with invalid reachable_on '{}'",
296                reachable_on
297            );
298            return None;
299        }
300    }
301
302    let port = get_u8_val(PORT).and_then(|v| v.as_uint().map(|n| n as u16));
303    let frequency = get_u8_val(FREQUENCY).and_then(|v| v.as_uint().map(|n| n as u32));
304    let bandwidth = get_u8_val(BANDWIDTH).and_then(|v| v.as_uint().map(|n| n as u32));
305    let spreading_factor = get_u8_val(SPREADINGFACTOR).and_then(|v| v.as_uint().map(|n| n as u8));
306    let coding_rate = get_u8_val(CODINGRATE).and_then(|v| v.as_uint().map(|n| n as u8));
307    let modulation = get_u8_val(MODULATION).and_then(|v| v.as_str().map(|s| s.to_string()));
308    let channel = get_u8_val(CHANNEL).and_then(|v| v.as_uint().map(|n| n as u8));
309    let ifac_netname = get_u8_val(IFAC_NETNAME).and_then(|v| v.as_str().map(|s| s.to_string()));
310    let ifac_netkey = get_u8_val(IFAC_NETKEY).and_then(|v| v.as_str().map(|s| s.to_string()));
311
312    // Compute discovery hash
313    let discovery_hash = compute_discovery_hash(&transport_id, &name);
314
315    // Generate config entry
316    let config_entry = generate_config_entry(
317        &interface_type,
318        &name,
319        &transport_id,
320        reachable_on.as_deref(),
321        port,
322        frequency,
323        bandwidth,
324        spreading_factor,
325        coding_rate,
326        modulation.as_deref(),
327        ifac_netname.as_deref(),
328        ifac_netkey.as_deref(),
329    );
330
331    let now = time::now();
332
333    Some(DiscoveredInterface {
334        interface_type,
335        transport,
336        name,
337        discovered: now,
338        last_heard: now,
339        heard_count: 0,
340        status: DiscoveredStatus::Available,
341        stamp: stamp.to_vec(),
342        stamp_value,
343        transport_id,
344        network_id: *announced_identity_hash,
345        hops,
346        latitude,
347        longitude,
348        height,
349        reachable_on,
350        port,
351        frequency,
352        bandwidth,
353        spreading_factor,
354        coding_rate,
355        modulation,
356        channel,
357        ifac_netname,
358        ifac_netkey,
359        config_entry,
360        discovery_hash,
361    })
362}
363
364/// Compute the discovery hash for storage
365pub fn compute_discovery_hash(transport_id: &[u8; 16], name: &str) -> [u8; 32] {
366    let mut material = Vec::with_capacity(16 + name.len());
367    material.extend_from_slice(transport_id);
368    material.extend_from_slice(name.as_bytes());
369    sha256(&material)
370}
371
372/// Generate a config entry for auto-connecting to a discovered interface
373fn generate_config_entry(
374    interface_type: &str,
375    name: &str,
376    transport_id: &[u8; 16],
377    reachable_on: Option<&str>,
378    port: Option<u16>,
379    frequency: Option<u32>,
380    bandwidth: Option<u32>,
381    spreading_factor: Option<u8>,
382    coding_rate: Option<u8>,
383    modulation: Option<&str>,
384    ifac_netname: Option<&str>,
385    ifac_netkey: Option<&str>,
386) -> Option<String> {
387    let transport_id_hex = hex_encode(transport_id);
388    let netname_str = ifac_netname
389        .map(|n| format!("\n  network_name = {}", n))
390        .unwrap_or_default();
391    let netkey_str = ifac_netkey
392        .map(|k| format!("\n  passphrase = {}", k))
393        .unwrap_or_default();
394    let identity_str = format!("\n  transport_identity = {}", transport_id_hex);
395
396    match interface_type {
397        "BackboneInterface" | "TCPServerInterface" => {
398            let reachable = reachable_on.unwrap_or("unknown");
399            let port_val = port.unwrap_or(4242);
400            Some(format!(
401                "[[{}]]\n  type = BackboneInterface\n  enabled = yes\n  remote = {}\n  target_port = {}{}{}{}",
402                name, reachable, port_val, identity_str, netname_str, netkey_str
403            ))
404        }
405        "I2PInterface" => {
406            let reachable = reachable_on.unwrap_or("unknown");
407            Some(format!(
408                "[[{}]]\n  type = I2PInterface\n  enabled = yes\n  peers = {}{}{}{}",
409                name, reachable, identity_str, netname_str, netkey_str
410            ))
411        }
412        "RNodeInterface" => {
413            let freq_str = frequency
414                .map(|f| format!("\n  frequency = {}", f))
415                .unwrap_or_default();
416            let bw_str = bandwidth
417                .map(|b| format!("\n  bandwidth = {}", b))
418                .unwrap_or_default();
419            let sf_str = spreading_factor
420                .map(|s| format!("\n  spreadingfactor = {}", s))
421                .unwrap_or_default();
422            let cr_str = coding_rate
423                .map(|c| format!("\n  codingrate = {}", c))
424                .unwrap_or_default();
425            Some(format!(
426                "[[{}]]\n  type = RNodeInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}{}",
427                name, "", freq_str, bw_str, sf_str, cr_str, identity_str, netname_str, netkey_str
428            ))
429        }
430        "KISSInterface" => {
431            let freq_str = frequency
432                .map(|f| format!("\n  # Frequency: {}", f))
433                .unwrap_or_default();
434            let bw_str = bandwidth
435                .map(|b| format!("\n  # Bandwidth: {}", b))
436                .unwrap_or_default();
437            let mod_str = modulation
438                .map(|m| format!("\n  # Modulation: {}", m))
439                .unwrap_or_default();
440            Some(format!(
441                "[[{}]]\n  type = KISSInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}",
442                name, "", freq_str, bw_str, mod_str, identity_str, netname_str, netkey_str
443            ))
444        }
445        "WeaveInterface" => Some(format!(
446            "[[{}]]\n  type = WeaveInterface\n  enabled = yes\n  port = {}{}{}{}",
447            name, "", identity_str, netname_str, netkey_str
448        )),
449        _ => None,
450    }
451}
452
453// ============================================================================
454// Helper Functions
455// ============================================================================
456
457/// Encode bytes as hex string (no delimiters)
458pub fn hex_encode(bytes: &[u8]) -> String {
459    bytes.iter().map(|b| format!("{:02x}", b)).collect()
460}
461
462/// Check if a string is a valid IP address
463pub fn is_ip_address(s: &str) -> bool {
464    s.parse::<std::net::IpAddr>().is_ok()
465}
466
467/// Check if a string is a valid hostname
468pub fn is_hostname(s: &str) -> bool {
469    let s = s.strip_suffix('.').unwrap_or(s);
470    if s.len() > 253 {
471        return false;
472    }
473    let components: Vec<&str> = s.split('.').collect();
474    if components.is_empty() {
475        return false;
476    }
477    // Last component should not be all numeric
478    if components
479        .last()
480        .map(|c| c.chars().all(|ch| ch.is_ascii_digit()))
481        .unwrap_or(false)
482    {
483        return false;
484    }
485    components.iter().all(|c| {
486        !c.is_empty()
487            && c.len() <= 63
488            && !c.starts_with('-')
489            && !c.ends_with('-')
490            && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
491    })
492}
493
494/// Check whether an interface type can be accepted from discovery.
495pub fn is_discoverable_type(interface_type: &str) -> bool {
496    DISCOVERABLE_TYPES.contains(&interface_type)
497}
498
499/// Filter and sort discovered interfaces
500pub fn filter_and_sort_interfaces(
501    interfaces: &mut Vec<DiscoveredInterface>,
502    only_available: bool,
503    only_transport: bool,
504) {
505    let now = time::now();
506
507    // Update status and filter
508    interfaces.retain(|iface| {
509        if !is_discoverable_type(&iface.interface_type) {
510            return false;
511        }
512        if let Some(ref reachable_on) = iface.reachable_on {
513            if !(is_ip_address(reachable_on) || is_hostname(reachable_on)) {
514                return false;
515            }
516        }
517
518        let delta = now - iface.last_heard;
519
520        // Check for removal threshold
521        if delta > THRESHOLD_REMOVE {
522            return false;
523        }
524
525        // Update status
526        let status = iface.compute_status();
527
528        // Apply filters
529        if only_available && status != DiscoveredStatus::Available {
530            return false;
531        }
532        if only_transport && !iface.transport {
533            return false;
534        }
535
536        true
537    });
538
539    // Sort by (status_code desc, value desc, last_heard desc)
540    interfaces.sort_by(|a, b| {
541        let status_cmp = b.compute_status().code().cmp(&a.compute_status().code());
542        if status_cmp != std::cmp::Ordering::Equal {
543            return status_cmp;
544        }
545        let value_cmp = b.stamp_value.cmp(&a.stamp_value);
546        if value_cmp != std::cmp::Ordering::Equal {
547            return value_cmp;
548        }
549        b.last_heard
550            .partial_cmp(&a.last_heard)
551            .unwrap_or(std::cmp::Ordering::Equal)
552    });
553}
554
555/// Compute the name hash for the discovery destination: `rnstransport.discovery.interface`.
556///
557/// Discovery is a SINGLE destination — its dest hash varies with the sender's identity.
558/// We match incoming announces by comparing their name_hash to this constant.
559pub fn discovery_name_hash() -> [u8; 10] {
560    rns_core::destination::name_hash(APP_NAME, &["discovery", "interface"])
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn build_discovery_app_data(interface_type: &str, reachable_on: Option<&str>) -> Vec<u8> {
568        let mut entries = vec![
569            (
570                Value::UInt(INTERFACE_TYPE as u64),
571                Value::Str(interface_type.to_string()),
572            ),
573            (Value::UInt(TRANSPORT as u64), Value::Bool(true)),
574            (
575                Value::UInt(NAME as u64),
576                Value::Str(format!("test-{interface_type}")),
577            ),
578            (Value::UInt(TRANSPORT_ID as u64), Value::Bin(vec![0x42; 16])),
579        ];
580
581        if let Some(reachable_on) = reachable_on {
582            entries.push((
583                Value::UInt(REACHABLE_ON as u64),
584                Value::Str(reachable_on.to_string()),
585            ));
586        }
587
588        let packed = msgpack::pack(&Value::Map(entries));
589        let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
590        app_data.push(0x00);
591        app_data.extend_from_slice(&packed);
592        app_data.extend_from_slice(&[0u8; STAMP_SIZE]);
593        app_data
594    }
595
596    #[test]
597    fn parse_rejects_unsupported_discovered_interface_type() {
598        let app_data = build_discovery_app_data("BogusInterface", None);
599
600        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
601
602        assert!(
603            parsed.is_none(),
604            "unsupported discovered interface types must be ignored"
605        );
606    }
607
608    #[test]
609    fn parse_rejects_invalid_reachable_on_address() {
610        let app_data = build_discovery_app_data("BackboneInterface", Some("-not a host-"));
611
612        let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
613
614        assert!(
615            parsed.is_none(),
616            "discovered interfaces with invalid reachable_on values must be ignored"
617        );
618    }
619
620    #[test]
621    fn parse_accepts_supported_discovered_interface_types() {
622        for interface_type in [
623            "BackboneInterface",
624            "TCPServerInterface",
625            "I2PInterface",
626            "RNodeInterface",
627            "WeaveInterface",
628            "KISSInterface",
629        ] {
630            let app_data = build_discovery_app_data(interface_type, Some("example.com"));
631
632            let parsed = parse_interface_announce(&app_data, &[0x11; 16], 1, 0);
633
634            assert!(
635                parsed.is_some(),
636                "{interface_type} should be accepted as a discoverable interface type"
637            );
638        }
639    }
640}