Skip to main content

rns_net/
discovery.rs

1//! Interface Discovery protocol implementation.
2//!
3//! Handles receiving, validating, and storing discovered interface announcements
4//! from other Reticulum nodes on the network.
5//!
6//! Python reference: RNS/Discovery.py
7
8use std::fs;
9use std::io;
10use std::path::PathBuf;
11
12use rns_core::msgpack::{self, Value};
13use rns_core::stamp::{stamp_valid, stamp_value, stamp_workblock};
14use rns_crypto::sha256::sha256;
15
16use crate::time;
17
18// ============================================================================
19// Constants (matching Python Discovery.py)
20// ============================================================================
21
22/// Discovery field IDs for msgpack encoding
23pub const NAME: u8 = 0xFF;
24pub const TRANSPORT_ID: u8 = 0xFE;
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;
40
41/// App name for discovery destination
42pub const APP_NAME: &str = "rnstransport";
43
44/// Default stamp value for interface discovery
45pub const DEFAULT_STAMP_VALUE: u8 = 14;
46
47/// Workblock expand rounds for interface discovery
48pub const WORKBLOCK_EXPAND_ROUNDS: u32 = 20;
49
50/// Stamp size in bytes
51pub const STAMP_SIZE: usize = 32;
52
53// Status thresholds (in seconds)
54/// 24 hours - status becomes "unknown"
55pub const THRESHOLD_UNKNOWN: f64 = 24.0 * 60.0 * 60.0;
56/// 3 days - status becomes "stale"
57pub const THRESHOLD_STALE: f64 = 3.0 * 24.0 * 60.0 * 60.0;
58/// 7 days - interface is removed
59pub const THRESHOLD_REMOVE: f64 = 7.0 * 24.0 * 60.0 * 60.0;
60
61// Status codes for sorting
62const STATUS_STALE: i32 = 0;
63const STATUS_UNKNOWN: i32 = 100;
64const STATUS_AVAILABLE: i32 = 1000;
65
66// ============================================================================
67// Per-interface discovery configuration
68// ============================================================================
69
70/// Per-interface discovery configuration parsed from config file.
71#[derive(Debug, Clone)]
72pub struct DiscoveryConfig {
73    /// Human-readable name to advertise (defaults to interface name).
74    pub discovery_name: String,
75    /// Announce interval in seconds (default 21600 = 6h, min 300 = 5min).
76    pub announce_interval: u64,
77    /// Stamp cost for discovery PoW (default 14).
78    pub stamp_value: u8,
79    /// IP/hostname this interface is reachable on.
80    pub reachable_on: Option<String>,
81    /// Interface type string (e.g. "BackboneInterface").
82    pub interface_type: String,
83    /// Listen port of the discoverable interface.
84    pub listen_port: Option<u16>,
85    /// Geographic latitude in decimal degrees.
86    pub latitude: Option<f64>,
87    /// Geographic longitude in decimal degrees.
88    pub longitude: Option<f64>,
89    /// Height/altitude in meters.
90    pub height: Option<f64>,
91}
92
93// ============================================================================
94// Data Structures
95// ============================================================================
96
97/// Status of a discovered interface
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum DiscoveredStatus {
100    Available,
101    Unknown,
102    Stale,
103}
104
105impl DiscoveredStatus {
106    /// Get numeric code for sorting (higher = better)
107    pub fn code(&self) -> i32 {
108        match self {
109            DiscoveredStatus::Available => STATUS_AVAILABLE,
110            DiscoveredStatus::Unknown => STATUS_UNKNOWN,
111            DiscoveredStatus::Stale => STATUS_STALE,
112        }
113    }
114
115    /// Convert to string
116    pub fn as_str(&self) -> &'static str {
117        match self {
118            DiscoveredStatus::Available => "available",
119            DiscoveredStatus::Unknown => "unknown",
120            DiscoveredStatus::Stale => "stale",
121        }
122    }
123}
124
125/// Information about a discovered interface
126#[derive(Debug, Clone)]
127pub struct DiscoveredInterface {
128    /// Interface type (e.g., "BackboneInterface", "TCPServerInterface", "RNodeInterface")
129    pub interface_type: String,
130    /// Whether the announcing node has transport enabled
131    pub transport: bool,
132    /// Human-readable name of the interface
133    pub name: String,
134    /// Timestamp when first discovered
135    pub discovered: f64,
136    /// Timestamp of last announcement
137    pub last_heard: f64,
138    /// Number of times heard
139    pub heard_count: u32,
140    /// Current status based on last_heard
141    pub status: DiscoveredStatus,
142    /// Raw stamp bytes
143    pub stamp: Vec<u8>,
144    /// Calculated stamp value (leading zeros)
145    pub stamp_value: u32,
146    /// Transport identity hash (truncated)
147    pub transport_id: [u8; 16],
148    /// Network identity hash (announcer)
149    pub network_id: [u8; 16],
150    /// Number of hops to reach this interface
151    pub hops: u8,
152
153    // Optional location info
154    pub latitude: Option<f64>,
155    pub longitude: Option<f64>,
156    pub height: Option<f64>,
157
158    // Connection info
159    pub reachable_on: Option<String>,
160    pub port: Option<u16>,
161
162    // RNode/RF specific
163    pub frequency: Option<u32>,
164    pub bandwidth: Option<u32>,
165    pub spreading_factor: Option<u8>,
166    pub coding_rate: Option<u8>,
167    pub modulation: Option<String>,
168    pub channel: Option<u8>,
169
170    // IFAC info
171    pub ifac_netname: Option<String>,
172    pub ifac_netkey: Option<String>,
173
174    // Auto-generated config entry
175    pub config_entry: Option<String>,
176
177    /// Hash for storage key (SHA256 of transport_id + name)
178    pub discovery_hash: [u8; 32],
179}
180
181impl DiscoveredInterface {
182    /// Compute the current status based on last_heard timestamp
183    pub fn compute_status(&self) -> DiscoveredStatus {
184        let delta = time::now() - self.last_heard;
185        if delta > THRESHOLD_STALE {
186            DiscoveredStatus::Stale
187        } else if delta > THRESHOLD_UNKNOWN {
188            DiscoveredStatus::Unknown
189        } else {
190            DiscoveredStatus::Available
191        }
192    }
193}
194
195// ============================================================================
196// Storage
197// ============================================================================
198
199/// Persistent storage for discovered interfaces
200pub struct DiscoveredInterfaceStorage {
201    base_path: PathBuf,
202}
203
204impl DiscoveredInterfaceStorage {
205    /// Create a new storage instance
206    pub fn new(base_path: PathBuf) -> Self {
207        Self { base_path }
208    }
209
210    /// Store a discovered interface
211    pub fn store(&self, iface: &DiscoveredInterface) -> io::Result<()> {
212        let filename = hex_encode(&iface.discovery_hash);
213        let filepath = self.base_path.join(filename);
214
215        let data = self.serialize_interface(iface)?;
216        fs::write(&filepath, &data)
217    }
218
219    /// Load a discovered interface by its discovery hash
220    pub fn load(&self, discovery_hash: &[u8; 32]) -> io::Result<Option<DiscoveredInterface>> {
221        let filename = hex_encode(discovery_hash);
222        let filepath = self.base_path.join(filename);
223
224        if !filepath.exists() {
225            return Ok(None);
226        }
227
228        let data = fs::read(&filepath)?;
229        self.deserialize_interface(&data).map(Some)
230    }
231
232    /// List all discovered interfaces
233    pub fn list(&self) -> io::Result<Vec<DiscoveredInterface>> {
234        let mut interfaces = Vec::new();
235
236        let entries = match fs::read_dir(&self.base_path) {
237            Ok(e) => e,
238            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(interfaces),
239            Err(e) => return Err(e),
240        };
241
242        for entry in entries {
243            let entry = entry?;
244            let path = entry.path();
245
246            if !path.is_file() {
247                continue;
248            }
249
250            match fs::read(&path) {
251                Ok(data) => {
252                    if let Ok(iface) = self.deserialize_interface(&data) {
253                        interfaces.push(iface);
254                    }
255                }
256                Err(_) => continue,
257            }
258        }
259
260        Ok(interfaces)
261    }
262
263    /// Remove a discovered interface by its discovery hash
264    pub fn remove(&self, discovery_hash: &[u8; 32]) -> io::Result<()> {
265        let filename = hex_encode(discovery_hash);
266        let filepath = self.base_path.join(filename);
267
268        if filepath.exists() {
269            fs::remove_file(&filepath)?;
270        }
271        Ok(())
272    }
273
274    /// Clean up stale entries (older than THRESHOLD_REMOVE)
275    /// Returns the number of entries removed
276    pub fn cleanup(&self) -> io::Result<usize> {
277        let mut removed = 0;
278        let now = time::now();
279
280        let interfaces = self.list()?;
281        for iface in interfaces {
282            if now - iface.last_heard > THRESHOLD_REMOVE {
283                self.remove(&iface.discovery_hash)?;
284                removed += 1;
285            }
286        }
287
288        Ok(removed)
289    }
290
291    /// Serialize an interface to msgpack
292    fn serialize_interface(&self, iface: &DiscoveredInterface) -> io::Result<Vec<u8>> {
293        let mut entries: Vec<(Value, Value)> = Vec::new();
294
295        entries.push((Value::Str("type".into()), Value::Str(iface.interface_type.clone())));
296        entries.push((Value::Str("transport".into()), Value::Bool(iface.transport)));
297        entries.push((Value::Str("name".into()), Value::Str(iface.name.clone())));
298        entries.push((Value::Str("discovered".into()), Value::Float(iface.discovered)));
299        entries.push((Value::Str("last_heard".into()), Value::Float(iface.last_heard)));
300        entries.push((Value::Str("heard_count".into()), Value::UInt(iface.heard_count as u64)));
301        entries.push((Value::Str("status".into()), Value::Str(iface.status.as_str().into())));
302        entries.push((Value::Str("stamp".into()), Value::Bin(iface.stamp.clone())));
303        entries.push((Value::Str("value".into()), Value::UInt(iface.stamp_value as u64)));
304        entries.push((Value::Str("transport_id".into()), Value::Bin(iface.transport_id.to_vec())));
305        entries.push((Value::Str("network_id".into()), Value::Bin(iface.network_id.to_vec())));
306        entries.push((Value::Str("hops".into()), Value::UInt(iface.hops as u64)));
307
308        if let Some(v) = iface.latitude {
309            entries.push((Value::Str("latitude".into()), Value::Float(v)));
310        }
311        if let Some(v) = iface.longitude {
312            entries.push((Value::Str("longitude".into()), Value::Float(v)));
313        }
314        if let Some(v) = iface.height {
315            entries.push((Value::Str("height".into()), Value::Float(v)));
316        }
317        if let Some(ref v) = iface.reachable_on {
318            entries.push((Value::Str("reachable_on".into()), Value::Str(v.clone())));
319        }
320        if let Some(v) = iface.port {
321            entries.push((Value::Str("port".into()), Value::UInt(v as u64)));
322        }
323        if let Some(v) = iface.frequency {
324            entries.push((Value::Str("frequency".into()), Value::UInt(v as u64)));
325        }
326        if let Some(v) = iface.bandwidth {
327            entries.push((Value::Str("bandwidth".into()), Value::UInt(v as u64)));
328        }
329        if let Some(v) = iface.spreading_factor {
330            entries.push((Value::Str("sf".into()), Value::UInt(v as u64)));
331        }
332        if let Some(v) = iface.coding_rate {
333            entries.push((Value::Str("cr".into()), Value::UInt(v as u64)));
334        }
335        if let Some(ref v) = iface.modulation {
336            entries.push((Value::Str("modulation".into()), Value::Str(v.clone())));
337        }
338        if let Some(v) = iface.channel {
339            entries.push((Value::Str("channel".into()), Value::UInt(v as u64)));
340        }
341        if let Some(ref v) = iface.ifac_netname {
342            entries.push((Value::Str("ifac_netname".into()), Value::Str(v.clone())));
343        }
344        if let Some(ref v) = iface.ifac_netkey {
345            entries.push((Value::Str("ifac_netkey".into()), Value::Str(v.clone())));
346        }
347        if let Some(ref v) = iface.config_entry {
348            entries.push((Value::Str("config_entry".into()), Value::Str(v.clone())));
349        }
350
351        entries.push((Value::Str("discovery_hash".into()), Value::Bin(iface.discovery_hash.to_vec())));
352
353        Ok(msgpack::pack(&Value::Map(entries)))
354    }
355
356    /// Deserialize an interface from msgpack
357    fn deserialize_interface(&self, data: &[u8]) -> io::Result<DiscoveredInterface> {
358        let (value, _) = msgpack::unpack(data).map_err(|e| {
359            io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
360        })?;
361
362        // Helper functions using map_get
363        let get_str = |v: &Value, key: &str| -> io::Result<String> {
364            v.map_get(key)
365                .and_then(|val| val.as_str())
366                .map(|s| s.to_string())
367                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{} not a string", key)))
368        };
369
370        let get_opt_str = |v: &Value, key: &str| -> Option<String> {
371            v.map_get(key).and_then(|val| val.as_str().map(|s| s.to_string()))
372        };
373
374        let get_bool = |v: &Value, key: &str| -> io::Result<bool> {
375            v.map_get(key)
376                .and_then(|val| val.as_bool())
377                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{} not a bool", key)))
378        };
379
380        let get_float = |v: &Value, key: &str| -> io::Result<f64> {
381            v.map_get(key)
382                .and_then(|val| val.as_float())
383                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{} not a float", key)))
384        };
385
386        let get_opt_float = |v: &Value, key: &str| -> Option<f64> {
387            v.map_get(key).and_then(|val| val.as_float())
388        };
389
390        let get_uint = |v: &Value, key: &str| -> io::Result<u64> {
391            v.map_get(key)
392                .and_then(|val| val.as_uint())
393                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{} not a uint", key)))
394        };
395
396        let get_opt_uint = |v: &Value, key: &str| -> Option<u64> {
397            v.map_get(key).and_then(|val| val.as_uint())
398        };
399
400        let get_bytes = |v: &Value, key: &str| -> io::Result<Vec<u8>> {
401            v.map_get(key)
402                .and_then(|val| val.as_bin())
403                .map(|b| b.to_vec())
404                .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{} not bytes", key)))
405        };
406
407        let transport_id_bytes = get_bytes(&value, "transport_id")?;
408        let mut transport_id = [0u8; 16];
409        if transport_id_bytes.len() == 16 {
410            transport_id.copy_from_slice(&transport_id_bytes);
411        }
412
413        let network_id_bytes = get_bytes(&value, "network_id")?;
414        let mut network_id = [0u8; 16];
415        if network_id_bytes.len() == 16 {
416            network_id.copy_from_slice(&network_id_bytes);
417        }
418
419        let discovery_hash_bytes = get_bytes(&value, "discovery_hash")?;
420        let mut discovery_hash = [0u8; 32];
421        if discovery_hash_bytes.len() == 32 {
422            discovery_hash.copy_from_slice(&discovery_hash_bytes);
423        }
424
425        let status_str = get_str(&value, "status")?;
426        let status = match status_str.as_str() {
427            "available" => DiscoveredStatus::Available,
428            "unknown" => DiscoveredStatus::Unknown,
429            "stale" => DiscoveredStatus::Stale,
430            _ => DiscoveredStatus::Unknown,
431        };
432
433        Ok(DiscoveredInterface {
434            interface_type: get_str(&value, "type")?,
435            transport: get_bool(&value, "transport")?,
436            name: get_str(&value, "name")?,
437            discovered: get_float(&value, "discovered")?,
438            last_heard: get_float(&value, "last_heard")?,
439            heard_count: get_uint(&value, "heard_count")? as u32,
440            status,
441            stamp: get_bytes(&value, "stamp")?,
442            stamp_value: get_uint(&value, "value")? as u32,
443            transport_id,
444            network_id,
445            hops: get_uint(&value, "hops")? as u8,
446            latitude: get_opt_float(&value, "latitude"),
447            longitude: get_opt_float(&value, "longitude"),
448            height: get_opt_float(&value, "height"),
449            reachable_on: get_opt_str(&value, "reachable_on"),
450            port: get_opt_uint(&value, "port").map(|v| v as u16),
451            frequency: get_opt_uint(&value, "frequency").map(|v| v as u32),
452            bandwidth: get_opt_uint(&value, "bandwidth").map(|v| v as u32),
453            spreading_factor: get_opt_uint(&value, "sf").map(|v| v as u8),
454            coding_rate: get_opt_uint(&value, "cr").map(|v| v as u8),
455            modulation: get_opt_str(&value, "modulation"),
456            channel: get_opt_uint(&value, "channel").map(|v| v as u8),
457            ifac_netname: get_opt_str(&value, "ifac_netname"),
458            ifac_netkey: get_opt_str(&value, "ifac_netkey"),
459            config_entry: get_opt_str(&value, "config_entry"),
460            discovery_hash,
461        })
462    }
463}
464
465// ============================================================================
466// Parsing and Validation
467// ============================================================================
468
469/// Parse an interface discovery announcement from app_data.
470///
471/// Returns None if:
472/// - Data is too short
473/// - Stamp is invalid
474/// - Required fields are missing
475pub fn parse_interface_announce(
476    app_data: &[u8],
477    announced_identity_hash: &[u8; 16],
478    hops: u8,
479    required_stamp_value: u8,
480) -> Option<DiscoveredInterface> {
481    // Need at least: 1 byte flags + some data + STAMP_SIZE
482    if app_data.len() <= STAMP_SIZE + 1 {
483        return None;
484    }
485
486    // Extract flags and payload
487    let flags = app_data[0];
488    let payload = &app_data[1..];
489
490    // Check encryption flag (we don't support encrypted discovery yet)
491    let encrypted = (flags & 0x02) != 0;
492    if encrypted {
493        log::debug!("Ignoring encrypted discovered interface (not supported)");
494        return None;
495    }
496
497    // Split stamp and packed info
498    let stamp = &payload[payload.len() - STAMP_SIZE..];
499    let packed = &payload[..payload.len() - STAMP_SIZE];
500
501    // Compute infohash and workblock
502    let infohash = sha256(packed);
503    let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
504
505    // Validate stamp
506    if !stamp_valid(stamp, required_stamp_value, &workblock) {
507        log::debug!("Ignoring discovered interface with invalid stamp");
508        return None;
509    }
510
511    // Calculate stamp value
512    let stamp_value = stamp_value(&workblock, stamp);
513
514    // Unpack the interface info
515    let (value, _) = msgpack::unpack(packed).ok()?;
516    let map = value.as_map()?;
517
518    // Helper to get a value from the map by integer key
519    let get_u8_val = |key: u8| -> Option<Value> {
520        for (k, v) in map {
521            if k.as_uint()? as u8 == key {
522                return Some(v.clone());
523            }
524        }
525        None
526    };
527
528    // Extract required fields
529    let interface_type = get_u8_val(INTERFACE_TYPE)?.as_str()?.to_string();
530    let transport = get_u8_val(TRANSPORT)?.as_bool()?;
531    let name = get_u8_val(NAME)?
532        .as_str()
533        .unwrap_or(&format!("Discovered {}", interface_type))
534        .to_string();
535
536    let transport_id_val = get_u8_val(TRANSPORT_ID)?;
537    let transport_id_bytes = transport_id_val.as_bin()?;
538    let mut transport_id = [0u8; 16];
539    if transport_id_bytes.len() >= 16 {
540        transport_id.copy_from_slice(&transport_id_bytes[..16]);
541    }
542
543    // Extract optional fields
544    let latitude = get_u8_val(LATITUDE).and_then(|v| v.as_float());
545    let longitude = get_u8_val(LONGITUDE).and_then(|v| v.as_float());
546    let height = get_u8_val(HEIGHT).and_then(|v| v.as_float());
547    let reachable_on = get_u8_val(REACHABLE_ON).and_then(|v| v.as_str().map(|s| s.to_string()));
548    let port = get_u8_val(PORT).and_then(|v| v.as_uint().map(|n| n as u16));
549    let frequency = get_u8_val(FREQUENCY).and_then(|v| v.as_uint().map(|n| n as u32));
550    let bandwidth = get_u8_val(BANDWIDTH).and_then(|v| v.as_uint().map(|n| n as u32));
551    let spreading_factor = get_u8_val(SPREADINGFACTOR).and_then(|v| v.as_uint().map(|n| n as u8));
552    let coding_rate = get_u8_val(CODINGRATE).and_then(|v| v.as_uint().map(|n| n as u8));
553    let modulation = get_u8_val(MODULATION).and_then(|v| v.as_str().map(|s| s.to_string()));
554    let channel = get_u8_val(CHANNEL).and_then(|v| v.as_uint().map(|n| n as u8));
555    let ifac_netname = get_u8_val(IFAC_NETNAME).and_then(|v| v.as_str().map(|s| s.to_string()));
556    let ifac_netkey = get_u8_val(IFAC_NETKEY).and_then(|v| v.as_str().map(|s| s.to_string()));
557
558    // Compute discovery hash
559    let discovery_hash = compute_discovery_hash(&transport_id, &name);
560
561    // Generate config entry
562    let config_entry = generate_config_entry(
563        &interface_type,
564        &name,
565        &transport_id,
566        reachable_on.as_deref(),
567        port,
568        frequency,
569        bandwidth,
570        spreading_factor,
571        coding_rate,
572        modulation.as_deref(),
573        ifac_netname.as_deref(),
574        ifac_netkey.as_deref(),
575    );
576
577    let now = time::now();
578
579    Some(DiscoveredInterface {
580        interface_type,
581        transport,
582        name,
583        discovered: now,
584        last_heard: now,
585        heard_count: 0,
586        status: DiscoveredStatus::Available,
587        stamp: stamp.to_vec(),
588        stamp_value,
589        transport_id,
590        network_id: *announced_identity_hash,
591        hops,
592        latitude,
593        longitude,
594        height,
595        reachable_on,
596        port,
597        frequency,
598        bandwidth,
599        spreading_factor,
600        coding_rate,
601        modulation,
602        channel,
603        ifac_netname,
604        ifac_netkey,
605        config_entry,
606        discovery_hash,
607    })
608}
609
610/// Compute the discovery hash for storage
611pub fn compute_discovery_hash(transport_id: &[u8; 16], name: &str) -> [u8; 32] {
612    let mut material = Vec::with_capacity(16 + name.len());
613    material.extend_from_slice(transport_id);
614    material.extend_from_slice(name.as_bytes());
615    sha256(&material)
616}
617
618/// Generate a config entry for auto-connecting to a discovered interface
619fn generate_config_entry(
620    interface_type: &str,
621    name: &str,
622    transport_id: &[u8; 16],
623    reachable_on: Option<&str>,
624    port: Option<u16>,
625    frequency: Option<u32>,
626    bandwidth: Option<u32>,
627    spreading_factor: Option<u8>,
628    coding_rate: Option<u8>,
629    modulation: Option<&str>,
630    ifac_netname: Option<&str>,
631    ifac_netkey: Option<&str>,
632) -> Option<String> {
633    let transport_id_hex = hex_encode(transport_id);
634    let netname_str = ifac_netname.map(|n| format!("\n  network_name = {}", n)).unwrap_or_default();
635    let netkey_str = ifac_netkey.map(|k| format!("\n  passphrase = {}", k)).unwrap_or_default();
636    let identity_str = format!("\n  transport_identity = {}", transport_id_hex);
637
638    match interface_type {
639        "BackboneInterface" | "TCPServerInterface" => {
640            let reachable = reachable_on.unwrap_or("unknown");
641            let port_val = port.unwrap_or(4242);
642            Some(format!(
643                "[[{}]]\n  type = BackboneInterface\n  enabled = yes\n  remote = {}\n  target_port = {}{}{}{}",
644                name, reachable, port_val, identity_str, netname_str, netkey_str
645            ))
646        }
647        "I2PInterface" => {
648            let reachable = reachable_on.unwrap_or("unknown");
649            Some(format!(
650                "[[{}]]\n  type = I2PInterface\n  enabled = yes\n  peers = {}{}{}{}",
651                name, reachable, identity_str, netname_str, netkey_str
652            ))
653        }
654        "RNodeInterface" => {
655            let freq_str = frequency.map(|f| format!("\n  frequency = {}", f)).unwrap_or_default();
656            let bw_str = bandwidth.map(|b| format!("\n  bandwidth = {}", b)).unwrap_or_default();
657            let sf_str = spreading_factor.map(|s| format!("\n  spreadingfactor = {}", s)).unwrap_or_default();
658            let cr_str = coding_rate.map(|c| format!("\n  codingrate = {}", c)).unwrap_or_default();
659            Some(format!(
660                "[[{}]]\n  type = RNodeInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}{}",
661                name, "", freq_str, bw_str, sf_str, cr_str, identity_str, netname_str, netkey_str
662            ))
663        }
664        "KISSInterface" => {
665            let freq_str = frequency.map(|f| format!("\n  # Frequency: {}", f)).unwrap_or_default();
666            let bw_str = bandwidth.map(|b| format!("\n  # Bandwidth: {}", b)).unwrap_or_default();
667            let mod_str = modulation.map(|m| format!("\n  # Modulation: {}", m)).unwrap_or_default();
668            Some(format!(
669                "[[{}]]\n  type = KISSInterface\n  enabled = yes\n  port = {}{}{}{}{}{}{}",
670                name, "", freq_str, bw_str, mod_str, identity_str, netname_str, netkey_str
671            ))
672        }
673        "WeaveInterface" => {
674            Some(format!(
675                "[[{}]]\n  type = WeaveInterface\n  enabled = yes\n  port = {}{}{}{}",
676                name, "", identity_str, netname_str, netkey_str
677            ))
678        }
679        _ => None,
680    }
681}
682
683// ============================================================================
684// Helper Functions
685// ============================================================================
686
687/// Encode bytes as hex string (no delimiters)
688fn hex_encode(bytes: &[u8]) -> String {
689    bytes.iter().map(|b| format!("{:02x}", b)).collect()
690}
691
692/// Check if a string is a valid IP address
693pub fn is_ip_address(s: &str) -> bool {
694    s.parse::<std::net::IpAddr>().is_ok()
695}
696
697/// Check if a string is a valid hostname
698pub fn is_hostname(s: &str) -> bool {
699    let s = s.strip_suffix('.').unwrap_or(s);
700    if s.len() > 253 {
701        return false;
702    }
703    let components: Vec<&str> = s.split('.').collect();
704    if components.is_empty() {
705        return false;
706    }
707    // Last component should not be all numeric
708    if components.last().map(|c| c.chars().all(|ch| ch.is_ascii_digit())).unwrap_or(false) {
709        return false;
710    }
711    components.iter().all(|c| {
712        !c.is_empty()
713            && c.len() <= 63
714            && !c.starts_with('-')
715            && !c.ends_with('-')
716            && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
717    })
718}
719
720/// Filter and sort discovered interfaces
721pub fn filter_and_sort_interfaces(
722    interfaces: &mut Vec<DiscoveredInterface>,
723    only_available: bool,
724    only_transport: bool,
725) {
726    let now = time::now();
727
728    // Update status and filter
729    interfaces.retain(|iface| {
730        let delta = now - iface.last_heard;
731
732        // Check for removal threshold
733        if delta > THRESHOLD_REMOVE {
734            return false;
735        }
736
737        // Update status
738        let status = iface.compute_status();
739
740        // Apply filters
741        if only_available && status != DiscoveredStatus::Available {
742            return false;
743        }
744        if only_transport && !iface.transport {
745            return false;
746        }
747
748        true
749    });
750
751    // Sort by (status_code desc, value desc, last_heard desc)
752    interfaces.sort_by(|a, b| {
753        let status_cmp = b.compute_status().code().cmp(&a.compute_status().code());
754        if status_cmp != std::cmp::Ordering::Equal {
755            return status_cmp;
756        }
757        let value_cmp = b.stamp_value.cmp(&a.stamp_value);
758        if value_cmp != std::cmp::Ordering::Equal {
759            return value_cmp;
760        }
761        b.last_heard.partial_cmp(&a.last_heard).unwrap_or(std::cmp::Ordering::Equal)
762    });
763}
764
765// ============================================================================
766// Stamp Generation (parallel PoW search)
767// ============================================================================
768
769/// Generate a discovery stamp with the given cost using rayon parallel iterators.
770///
771/// Returns `(stamp, value)` on success. This is a blocking, CPU-intensive operation.
772pub fn generate_discovery_stamp(
773    packed_data: &[u8],
774    stamp_cost: u8,
775) -> ([u8; STAMP_SIZE], u32) {
776    use std::sync::atomic::{AtomicBool, Ordering};
777    use std::sync::{Arc, Mutex};
778    use rns_crypto::{OsRng, Rng};
779
780    let infohash = sha256(packed_data);
781    let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
782
783    let found: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
784    let result: Arc<Mutex<Option<[u8; STAMP_SIZE]>>> = Arc::new(Mutex::new(None));
785
786    let num_threads = rayon::current_num_threads();
787
788    rayon::scope(|s| {
789        for _ in 0..num_threads {
790            let found = found.clone();
791            let result = result.clone();
792            let workblock = &workblock;
793            s.spawn(move |_| {
794                let mut rng = OsRng;
795                let mut nonce = [0u8; STAMP_SIZE];
796                loop {
797                    if found.load(Ordering::Relaxed) {
798                        return;
799                    }
800                    rng.fill_bytes(&mut nonce);
801                    if stamp_valid(&nonce, stamp_cost, workblock) {
802                        let mut r = result.lock().unwrap();
803                        if r.is_none() {
804                            *r = Some(nonce);
805                        }
806                        found.store(true, Ordering::Relaxed);
807                        return;
808                    }
809                }
810            });
811        }
812    });
813
814    let stamp = result.lock().unwrap().take().expect("stamp search must find result");
815    let value = stamp_value(&workblock, &stamp);
816    (stamp, value)
817}
818
819// ============================================================================
820// Interface Announcer
821// ============================================================================
822
823/// Info about a single discoverable interface, ready for announcing.
824#[derive(Debug, Clone)]
825pub struct DiscoverableInterface {
826    pub config: DiscoveryConfig,
827    /// Whether the node has transport enabled.
828    pub transport_enabled: bool,
829    /// IFAC network name, if configured.
830    pub ifac_netname: Option<String>,
831    /// IFAC passphrase, if configured.
832    pub ifac_netkey: Option<String>,
833}
834
835/// Result of a completed background stamp generation.
836pub struct StampResult {
837    /// Index of the interface this stamp is for.
838    pub index: usize,
839    /// The complete app_data: [flags][packed][stamp].
840    pub app_data: Vec<u8>,
841}
842
843/// Manages periodic announcing of discoverable interfaces.
844///
845/// Stamp generation (PoW) runs on a background thread so it never blocks the
846/// driver event loop.  The driver calls `poll_ready()` each tick to collect
847/// finished results.
848pub struct InterfaceAnnouncer {
849    /// Transport identity hash (16 bytes).
850    transport_id: [u8; 16],
851    /// Discoverable interfaces with their configs.
852    interfaces: Vec<DiscoverableInterface>,
853    /// Last announce time per interface (indexed same as `interfaces`).
854    last_announced: Vec<f64>,
855    /// Receiver for completed stamp results from background threads.
856    stamp_rx: std::sync::mpsc::Receiver<StampResult>,
857    /// Sender cloned into background threads.
858    stamp_tx: std::sync::mpsc::Sender<StampResult>,
859    /// Whether a background stamp job is currently running.
860    stamp_pending: bool,
861}
862
863impl InterfaceAnnouncer {
864    /// Create a new announcer.
865    pub fn new(transport_id: [u8; 16], interfaces: Vec<DiscoverableInterface>) -> Self {
866        let n = interfaces.len();
867        let (stamp_tx, stamp_rx) = std::sync::mpsc::channel();
868        InterfaceAnnouncer {
869            transport_id,
870            interfaces,
871            last_announced: vec![0.0; n],
872            stamp_rx,
873            stamp_tx,
874            stamp_pending: false,
875        }
876    }
877
878    /// If any interface is due for an announce and no stamp job is already
879    /// running, spawns a background thread for PoW.  The result will be
880    /// available via `poll_ready()`.
881    pub fn maybe_start(&mut self, now: f64) {
882        if self.stamp_pending {
883            return;
884        }
885        let due_index = self.interfaces.iter().enumerate().find_map(|(i, iface)| {
886            let elapsed = now - self.last_announced[i];
887            if elapsed >= iface.config.announce_interval as f64 {
888                Some(i)
889            } else {
890                None
891            }
892        });
893
894        if let Some(idx) = due_index {
895            let packed = self.pack_interface_info(idx);
896            let stamp_cost = self.interfaces[idx].config.stamp_value;
897            let name = self.interfaces[idx].config.discovery_name.clone();
898            let tx = self.stamp_tx.clone();
899
900            log::info!(
901                "Spawning discovery stamp generation (cost={}) for '{}'...",
902                stamp_cost,
903                name,
904            );
905
906            self.stamp_pending = true;
907            self.last_announced[idx] = now;
908
909            std::thread::spawn(move || {
910                let (stamp, value) = generate_discovery_stamp(&packed, stamp_cost);
911                log::info!(
912                    "Discovery stamp generated (value={}) for '{}'",
913                    value,
914                    name,
915                );
916
917                let flags: u8 = 0x00; // no encryption
918                let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
919                app_data.push(flags);
920                app_data.extend_from_slice(&packed);
921                app_data.extend_from_slice(&stamp);
922
923                let _ = tx.send(StampResult {
924                    index: idx,
925                    app_data,
926                });
927            });
928        }
929    }
930
931    /// Non-blocking poll: returns completed app_data if a background stamp
932    /// job has finished.
933    pub fn poll_ready(&mut self) -> Option<StampResult> {
934        match self.stamp_rx.try_recv() {
935            Ok(result) => {
936                self.stamp_pending = false;
937                Some(result)
938            }
939            Err(_) => None,
940        }
941    }
942
943    /// Pack interface metadata as msgpack map with integer keys.
944    fn pack_interface_info(&self, index: usize) -> Vec<u8> {
945        let iface = &self.interfaces[index];
946        let mut entries: Vec<(msgpack::Value, msgpack::Value)> = Vec::new();
947
948        entries.push((
949            msgpack::Value::UInt(INTERFACE_TYPE as u64),
950            msgpack::Value::Str(iface.config.interface_type.clone()),
951        ));
952        entries.push((
953            msgpack::Value::UInt(TRANSPORT as u64),
954            msgpack::Value::Bool(iface.transport_enabled),
955        ));
956        entries.push((
957            msgpack::Value::UInt(NAME as u64),
958            msgpack::Value::Str(iface.config.discovery_name.clone()),
959        ));
960        entries.push((
961            msgpack::Value::UInt(TRANSPORT_ID as u64),
962            msgpack::Value::Bin(self.transport_id.to_vec()),
963        ));
964        if let Some(ref reachable) = iface.config.reachable_on {
965            entries.push((
966                msgpack::Value::UInt(REACHABLE_ON as u64),
967                msgpack::Value::Str(reachable.clone()),
968            ));
969        }
970        if let Some(port) = iface.config.listen_port {
971            entries.push((
972                msgpack::Value::UInt(PORT as u64),
973                msgpack::Value::UInt(port as u64),
974            ));
975        }
976        if let Some(lat) = iface.config.latitude {
977            entries.push((
978                msgpack::Value::UInt(LATITUDE as u64),
979                msgpack::Value::Float(lat),
980            ));
981        }
982        if let Some(lon) = iface.config.longitude {
983            entries.push((
984                msgpack::Value::UInt(LONGITUDE as u64),
985                msgpack::Value::Float(lon),
986            ));
987        }
988        if let Some(h) = iface.config.height {
989            entries.push((
990                msgpack::Value::UInt(HEIGHT as u64),
991                msgpack::Value::Float(h),
992            ));
993        }
994        if let Some(ref netname) = iface.ifac_netname {
995            entries.push((
996                msgpack::Value::UInt(IFAC_NETNAME as u64),
997                msgpack::Value::Str(netname.clone()),
998            ));
999        }
1000        if let Some(ref netkey) = iface.ifac_netkey {
1001            entries.push((
1002                msgpack::Value::UInt(IFAC_NETKEY as u64),
1003                msgpack::Value::Str(netkey.clone()),
1004            ));
1005        }
1006
1007        msgpack::pack(&msgpack::Value::Map(entries))
1008    }
1009
1010}
1011
1012/// Compute the name hash for the discovery destination: `rnstransport.discovery.interface`.
1013///
1014/// Discovery is a SINGLE destination — its dest hash varies with the sender's identity.
1015/// We match incoming announces by comparing their name_hash to this constant.
1016pub fn discovery_name_hash() -> [u8; 10] {
1017    rns_core::destination::name_hash(APP_NAME, &["discovery", "interface"])
1018}
1019
1020// ============================================================================
1021// Tests
1022// ============================================================================
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027
1028    #[test]
1029    fn test_hex_encode() {
1030        assert_eq!(hex_encode(&[0x00, 0xff, 0x12]), "00ff12");
1031        assert_eq!(hex_encode(&[]), "");
1032    }
1033
1034    #[test]
1035    fn test_compute_discovery_hash() {
1036        let transport_id = [0x42u8; 16];
1037        let name = "TestInterface";
1038        let hash = compute_discovery_hash(&transport_id, name);
1039
1040        // Should be deterministic
1041        let hash2 = compute_discovery_hash(&transport_id, name);
1042        assert_eq!(hash, hash2);
1043
1044        // Different name should give different hash
1045        let hash3 = compute_discovery_hash(&transport_id, "OtherInterface");
1046        assert_ne!(hash, hash3);
1047    }
1048
1049    #[test]
1050    fn test_is_ip_address() {
1051        assert!(is_ip_address("192.168.1.1"));
1052        assert!(is_ip_address("::1"));
1053        assert!(is_ip_address("2001:db8::1"));
1054        assert!(!is_ip_address("not-an-ip"));
1055        assert!(!is_ip_address("hostname.example.com"));
1056    }
1057
1058    #[test]
1059    fn test_is_hostname() {
1060        assert!(is_hostname("example.com"));
1061        assert!(is_hostname("sub.example.com"));
1062        assert!(is_hostname("my-node"));
1063        assert!(is_hostname("my-node.example.com"));
1064        assert!(!is_hostname(""));
1065        assert!(!is_hostname("-invalid"));
1066        assert!(!is_hostname("invalid-"));
1067        assert!(!is_hostname("a".repeat(300).as_str()));
1068    }
1069
1070    #[test]
1071    fn test_discovered_status() {
1072        let now = time::now();
1073
1074        let mut iface = DiscoveredInterface {
1075            interface_type: "TestInterface".into(),
1076            transport: true,
1077            name: "Test".into(),
1078            discovered: now,
1079            last_heard: now,
1080            heard_count: 0,
1081            status: DiscoveredStatus::Available,
1082            stamp: vec![],
1083            stamp_value: 14,
1084            transport_id: [0u8; 16],
1085            network_id: [0u8; 16],
1086            hops: 0,
1087            latitude: None,
1088            longitude: None,
1089            height: None,
1090            reachable_on: None,
1091            port: None,
1092            frequency: None,
1093            bandwidth: None,
1094            spreading_factor: None,
1095            coding_rate: None,
1096            modulation: None,
1097            channel: None,
1098            ifac_netname: None,
1099            ifac_netkey: None,
1100            config_entry: None,
1101            discovery_hash: [0u8; 32],
1102        };
1103
1104        // Fresh interface should be available
1105        assert_eq!(iface.compute_status(), DiscoveredStatus::Available);
1106
1107        // 25 hours old should be unknown
1108        iface.last_heard = now - THRESHOLD_UNKNOWN - 3600.0;
1109        assert_eq!(iface.compute_status(), DiscoveredStatus::Unknown);
1110
1111        // 4 days old should be stale
1112        iface.last_heard = now - THRESHOLD_STALE - 3600.0;
1113        assert_eq!(iface.compute_status(), DiscoveredStatus::Stale);
1114    }
1115
1116    #[test]
1117    fn test_storage_roundtrip() {
1118        use std::sync::atomic::{AtomicU64, Ordering};
1119        static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
1120
1121        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
1122        let dir = std::env::temp_dir().join(format!("rns-discovery-test-{}-{}", std::process::id(), id));
1123        let _ = fs::remove_dir_all(&dir);
1124        fs::create_dir_all(&dir).unwrap();
1125
1126        let storage = DiscoveredInterfaceStorage::new(dir.clone());
1127
1128        let mut iface = DiscoveredInterface {
1129            interface_type: "BackboneInterface".into(),
1130            transport: true,
1131            name: "TestNode".into(),
1132            discovered: 1700000000.0,
1133            last_heard: 1700001000.0,
1134            heard_count: 5,
1135            status: DiscoveredStatus::Available,
1136            stamp: vec![0x42u8; 64],
1137            stamp_value: 18,
1138            transport_id: [0x01u8; 16],
1139            network_id: [0x02u8; 16],
1140            hops: 2,
1141            latitude: Some(45.0),
1142            longitude: Some(9.0),
1143            height: Some(100.0),
1144            reachable_on: Some("example.com".into()),
1145            port: Some(4242),
1146            frequency: None,
1147            bandwidth: None,
1148            spreading_factor: None,
1149            coding_rate: None,
1150            modulation: None,
1151            channel: None,
1152            ifac_netname: Some("mynetwork".into()),
1153            ifac_netkey: Some("secretkey".into()),
1154            config_entry: Some("test config".into()),
1155            discovery_hash: compute_discovery_hash(&[0x01u8; 16], "TestNode"),
1156        };
1157
1158        // Store
1159        storage.store(&iface).unwrap();
1160
1161        // Load
1162        let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
1163
1164        assert_eq!(loaded.interface_type, iface.interface_type);
1165        assert_eq!(loaded.name, iface.name);
1166        assert_eq!(loaded.stamp_value, iface.stamp_value);
1167        assert_eq!(loaded.transport_id, iface.transport_id);
1168        assert_eq!(loaded.hops, iface.hops);
1169        assert_eq!(loaded.latitude, iface.latitude);
1170        assert_eq!(loaded.reachable_on, iface.reachable_on);
1171        assert_eq!(loaded.port, iface.port);
1172
1173        // List
1174        let list = storage.list().unwrap();
1175        assert_eq!(list.len(), 1);
1176
1177        // Remove
1178        storage.remove(&iface.discovery_hash).unwrap();
1179        let list = storage.list().unwrap();
1180        assert!(list.is_empty());
1181
1182        let _ = fs::remove_dir_all(&dir);
1183    }
1184
1185    #[test]
1186    fn test_filter_and_sort() {
1187        let now = time::now();
1188
1189        let ifaces = vec![
1190            DiscoveredInterface {
1191                interface_type: "A".into(),
1192                transport: true,
1193                name: "high-value-stale".into(),
1194                discovered: now,
1195                last_heard: now - THRESHOLD_STALE - 100.0, // Stale
1196                heard_count: 0,
1197                status: DiscoveredStatus::Stale,
1198                stamp: vec![],
1199                stamp_value: 20,
1200                transport_id: [0u8; 16],
1201                network_id: [0u8; 16],
1202                hops: 0,
1203                latitude: None,
1204                longitude: None,
1205                height: None,
1206                reachable_on: None,
1207                port: None,
1208                frequency: None,
1209                bandwidth: None,
1210                spreading_factor: None,
1211                coding_rate: None,
1212                modulation: None,
1213                channel: None,
1214                ifac_netname: None,
1215                ifac_netkey: None,
1216                config_entry: None,
1217                discovery_hash: [0u8; 32],
1218            },
1219            DiscoveredInterface {
1220                interface_type: "B".into(),
1221                transport: true,
1222                name: "low-value-available".into(),
1223                discovered: now,
1224                last_heard: now,
1225                heard_count: 0,
1226                status: DiscoveredStatus::Available,
1227                stamp: vec![],
1228                stamp_value: 10,
1229                transport_id: [0u8; 16],
1230                network_id: [0u8; 16],
1231                hops: 0,
1232                latitude: None,
1233                longitude: None,
1234                height: None,
1235                reachable_on: None,
1236                port: None,
1237                frequency: None,
1238                bandwidth: None,
1239                spreading_factor: None,
1240                coding_rate: None,
1241                modulation: None,
1242                channel: None,
1243                ifac_netname: None,
1244                ifac_netkey: None,
1245                config_entry: None,
1246                discovery_hash: [0u8; 32],
1247            },
1248            DiscoveredInterface {
1249                interface_type: "C".into(),
1250                transport: false,
1251                name: "no-transport".into(),
1252                discovered: now,
1253                last_heard: now,
1254                heard_count: 0,
1255                status: DiscoveredStatus::Available,
1256                stamp: vec![],
1257                stamp_value: 15,
1258                transport_id: [0u8; 16],
1259                network_id: [0u8; 16],
1260                hops: 0,
1261                latitude: None,
1262                longitude: None,
1263                height: None,
1264                reachable_on: None,
1265                port: None,
1266                frequency: None,
1267                bandwidth: None,
1268                spreading_factor: None,
1269                coding_rate: None,
1270                modulation: None,
1271                channel: None,
1272                ifac_netname: None,
1273                ifac_netkey: None,
1274                config_entry: None,
1275                discovery_hash: [0u8; 32],
1276            },
1277        ];
1278
1279        // Test only_available filter
1280        let mut filtered = ifaces.clone();
1281        filter_and_sort_interfaces(&mut filtered, true, false);
1282        // Should exclude stale
1283        assert_eq!(filtered.len(), 2);
1284
1285        // Test only_transport filter
1286        let mut filtered = ifaces.clone();
1287        filter_and_sort_interfaces(&mut filtered, false, true);
1288        // Should exclude no-transport
1289        assert_eq!(filtered.len(), 2);
1290
1291        // Test both filters
1292        let mut filtered = ifaces.clone();
1293        filter_and_sort_interfaces(&mut filtered, true, true);
1294        // Should have only low-value-available
1295        assert_eq!(filtered.len(), 1);
1296        assert_eq!(filtered[0].name, "low-value-available");
1297    }
1298
1299    #[test]
1300    fn test_discovery_name_hash_is_deterministic() {
1301        let h1 = discovery_name_hash();
1302        let h2 = discovery_name_hash();
1303        assert_eq!(h1, h2);
1304        // Must be 10 bytes (name hash length)
1305        assert_eq!(h1.len(), 10);
1306        // Verify it matches the name hash for "rnstransport.discovery.interface"
1307        let expected = rns_core::destination::name_hash(
1308            APP_NAME,
1309            &["discovery", "interface"],
1310        );
1311        assert_eq!(h1, expected);
1312    }
1313
1314    #[test]
1315    fn test_announcer_next_due_and_maybe_start() {
1316        let transport_id = [0xABu8; 16];
1317        let iface = DiscoverableInterface {
1318            config: DiscoveryConfig {
1319                discovery_name: "TestBB".into(),
1320                announce_interval: 600, // 10 minutes
1321                stamp_value: 8,         // low cost for fast test
1322                reachable_on: Some("10.0.0.1".into()),
1323                interface_type: "BackboneInterface".into(),
1324                listen_port: Some(4242),
1325                latitude: None,
1326                longitude: None,
1327                height: None,
1328            },
1329            transport_enabled: true,
1330            ifac_netname: None,
1331            ifac_netkey: None,
1332        };
1333        let mut announcer = InterfaceAnnouncer::new(transport_id, vec![iface]);
1334
1335        // At t=0 all interfaces are due (last_announced = 0.0, interval = 600)
1336        // maybe_start should kick off a background thread
1337        announcer.maybe_start(1000.0);
1338        assert!(announcer.stamp_pending);
1339
1340        // Calling again while pending should be a no-op
1341        announcer.maybe_start(1000.0);
1342        assert!(announcer.stamp_pending);
1343
1344        // Wait for the stamp to complete (low cost = fast)
1345        let result = announcer.stamp_rx.recv_timeout(std::time::Duration::from_secs(30))
1346            .expect("stamp generation timed out");
1347        assert_eq!(result.index, 0);
1348        assert!(!result.app_data.is_empty());
1349        // First byte is flags
1350        assert_eq!(result.app_data[0], 0x00);
1351        // Last 64 bytes are stamp
1352        assert!(result.app_data.len() > STAMP_SIZE + 1);
1353
1354        // Clear the pending flag via poll_ready
1355        announcer.stamp_pending = false;
1356
1357        // After announcing at t=1000, should not be due until t=1600
1358        assert!(announcer.poll_ready().is_none()); // channel already drained
1359    }
1360
1361    #[test]
1362    fn test_pack_parse_roundtrip() {
1363        // Build announce data via the announcer, then parse it back
1364        let transport_id = [0x42u8; 16];
1365        let identity_hash = [0xFFu8; 16];
1366        let iface = DiscoverableInterface {
1367            config: DiscoveryConfig {
1368                discovery_name: "RoundtripNode".into(),
1369                announce_interval: 300,
1370                stamp_value: 8, // low for fast test
1371                reachable_on: Some("192.168.1.100".into()),
1372                interface_type: "TCPServerInterface".into(),
1373                listen_port: Some(5555),
1374                latitude: Some(45.464),
1375                longitude: Some(9.190),
1376                height: Some(120.0),
1377            },
1378            transport_enabled: true,
1379            ifac_netname: Some("testnet".into()),
1380            ifac_netkey: Some("secretkey".into()),
1381        };
1382        let mut announcer = InterfaceAnnouncer::new(transport_id, vec![iface]);
1383
1384        // Kick off stamp generation
1385        announcer.maybe_start(1000.0);
1386        let result = announcer.stamp_rx.recv_timeout(std::time::Duration::from_secs(30))
1387            .expect("stamp generation timed out");
1388
1389        // Parse the app_data back through parse_interface_announce
1390        let discovered = parse_interface_announce(
1391            &result.app_data,
1392            &identity_hash,
1393            0, // hops
1394            8, // required stamp value matches what we generated
1395        ).expect("parse_interface_announce should succeed on our own data");
1396
1397        assert_eq!(discovered.interface_type, "TCPServerInterface");
1398        assert_eq!(discovered.name, "RoundtripNode");
1399        assert_eq!(discovered.transport, true);
1400        assert_eq!(discovered.transport_id, transport_id);
1401        assert_eq!(discovered.network_id, identity_hash);
1402        assert_eq!(discovered.reachable_on.as_deref(), Some("192.168.1.100"));
1403        assert_eq!(discovered.port, Some(5555));
1404        assert_eq!(discovered.ifac_netname.as_deref(), Some("testnet"));
1405        assert_eq!(discovered.ifac_netkey.as_deref(), Some("secretkey"));
1406        assert!(discovered.stamp_value >= 8);
1407        assert_eq!(discovered.hops, 0);
1408        assert!((discovered.latitude.unwrap() - 45.464).abs() < 0.001);
1409        assert!((discovered.longitude.unwrap() - 9.190).abs() < 0.001);
1410        assert!((discovered.height.unwrap() - 120.0).abs() < 0.1);
1411    }
1412}