Skip to main content

rns_net/common/
management.rs

1//! Remote management destinations for the Reticulum transport node.
2//!
3//! Implements the server-side handlers for:
4//! - `/status` on `rnstransport.remote.management` destination
5//! - `/path` on `rnstransport.remote.management` destination
6//! - `/list` on `rnstransport.info.blackhole` destination
7//!
8//! Python reference: Transport.py:220-241, 2591-2643, 3243-3249
9
10use rns_core::constants;
11use rns_core::destination::destination_hash;
12use rns_core::hash::truncated_hash;
13use rns_core::msgpack::{self, Value};
14use rns_core::transport::types::{InterfaceId, InterfaceInfo};
15use rns_core::transport::TransportEngine;
16
17use super::interface_stats::InterfaceStats;
18use super::time;
19
20/// View trait for interface status, decoupling management from InterfaceEntry.
21pub trait InterfaceStatusView {
22    fn id(&self) -> InterfaceId;
23    fn info(&self) -> &InterfaceInfo;
24    fn online(&self) -> bool;
25    fn stats(&self) -> &InterfaceStats;
26    fn tx_drops(&self) -> u64 {
27        0
28    }
29    fn tx_dropped_bytes(&self) -> u64 {
30        0
31    }
32    fn tx_stalled(&self) -> bool {
33        false
34    }
35    fn tx_buffered(&self) -> usize {
36        0
37    }
38}
39
40/// Get the path hash for "/status".
41pub fn status_path_hash() -> [u8; 16] {
42    truncated_hash(b"/status")
43}
44
45/// Get the path hash for "/path".
46pub fn path_path_hash() -> [u8; 16] {
47    truncated_hash(b"/path")
48}
49
50/// Get the path hash for "/list".
51pub fn list_path_hash() -> [u8; 16] {
52    truncated_hash(b"/list")
53}
54
55/// Check if a path hash matches a known management path.
56pub fn is_management_path(path_hash: &[u8; 16]) -> bool {
57    *path_hash == status_path_hash()
58        || *path_hash == path_path_hash()
59        || *path_hash == list_path_hash()
60}
61
62/// Compute the remote management destination hash.
63///
64/// Destination: `rnstransport.remote.management` with transport identity.
65pub fn management_dest_hash(transport_identity_hash: &[u8; 16]) -> [u8; 16] {
66    destination_hash(
67        "rnstransport",
68        &["remote", "management"],
69        Some(transport_identity_hash),
70    )
71}
72
73/// Compute the blackhole info destination hash.
74///
75/// Destination: `rnstransport.info.blackhole` with transport identity.
76pub fn blackhole_dest_hash(transport_identity_hash: &[u8; 16]) -> [u8; 16] {
77    destination_hash(
78        "rnstransport",
79        &["info", "blackhole"],
80        Some(transport_identity_hash),
81    )
82}
83
84/// Compute the probe responder destination hash.
85///
86/// Destination: `rnstransport.probe` with transport identity.
87pub fn probe_dest_hash(transport_identity_hash: &[u8; 16]) -> [u8; 16] {
88    destination_hash("rnstransport", &["probe"], Some(transport_identity_hash))
89}
90
91/// Build the 10-byte random blob carried by an announce.
92///
93/// Matches Python `Destination.announce()`: 5 random bytes followed by the
94/// emission time in whole seconds, big-endian, truncated to 5 bytes. Path
95/// update rules compare this emission time, so it must be a real timestamp.
96pub fn announce_random_blob(rng: &mut dyn rns_crypto::Rng, now: f64) -> [u8; 10] {
97    let mut random_hash = [0u8; 10];
98    rng.fill_bytes(&mut random_hash[..5]);
99    let now_secs = if now.is_finite() && now > 0.0 {
100        now as u64
101    } else {
102        0
103    };
104    random_hash[5..10].copy_from_slice(&now_secs.to_be_bytes()[3..8]);
105    random_hash
106}
107
108/// Packet context for a built-in announce: `PATH_RESPONSE` when it answers
109/// a path request, as Python `Destination.announce(path_response=True)`.
110fn announce_context(path_response: bool) -> u8 {
111    if path_response {
112        constants::CONTEXT_PATH_RESPONSE
113    } else {
114        constants::CONTEXT_NONE
115    }
116}
117
118/// Build an announce packet for the probe responder destination.
119///
120/// Returns raw packet bytes ready for `engine.handle_outbound()`.
121pub fn build_probe_announce(
122    identity: &rns_crypto::identity::Identity,
123    rng: &mut dyn rns_crypto::Rng,
124    now: f64,
125    path_response: bool,
126) -> Option<Vec<u8>> {
127    let identity_hash = *identity.hash();
128    let dest_hash = probe_dest_hash(&identity_hash);
129    let name_hash = rns_core::destination::name_hash("rnstransport", &["probe"]);
130    let random_hash = announce_random_blob(rng, now);
131
132    let (announce_data, _has_ratchet) = rns_core::announce::AnnounceData::pack(
133        identity,
134        &dest_hash,
135        &name_hash,
136        &random_hash,
137        None,
138        None,
139    )
140    .ok()?;
141
142    let flags = rns_core::packet::PacketFlags {
143        header_type: constants::HEADER_1,
144        context_flag: constants::FLAG_UNSET,
145        transport_type: constants::TRANSPORT_BROADCAST,
146        destination_type: constants::DESTINATION_SINGLE,
147        packet_type: constants::PACKET_TYPE_ANNOUNCE,
148    };
149
150    let packet = rns_core::packet::RawPacket::pack(
151        flags,
152        0,
153        &dest_hash,
154        None,
155        announce_context(path_response),
156        &announce_data,
157    )
158    .ok()?;
159
160    Some(packet.raw)
161}
162
163/// Management configuration.
164#[derive(Debug, Clone, Default)]
165pub struct ManagementConfig {
166    /// Enable remote management destination.
167    pub enable_remote_management: bool,
168    /// Identity hashes allowed to query management.
169    pub remote_management_allowed: Vec<[u8; 16]>,
170    /// Enable blackhole list publication.
171    pub publish_blackhole: bool,
172}
173
174/// Handle a `/status` request.
175///
176/// Request data: msgpack([include_lstats, include_profiling]) where both are bool.
177/// Response: msgpack([interface_stats_dict, link_count?, profiling_results?])
178/// matching Python format.
179pub fn handle_status_request(
180    data: &[u8],
181    engine: &TransportEngine,
182    interfaces: &[&dyn InterfaceStatusView],
183    started: f64,
184    probe_responder_hash: Option<[u8; 16]>,
185) -> Option<Vec<u8>> {
186    // Decode request data
187    let (include_lstats, include_profiling) = match msgpack::unpack_exact(data) {
188        Ok(Value::Array(arr)) if !arr.is_empty() => (
189            arr[0].as_bool().unwrap_or(false),
190            arr.get(1).and_then(Value::as_bool).unwrap_or(false),
191        ),
192        _ => (false, false),
193    };
194
195    // Build interface stats
196    let mut iface_list = Vec::new();
197    let mut total_rxb: u64 = 0;
198    let mut total_txb: u64 = 0;
199    let mut total_rxpps = 0.0;
200    let mut total_txpps = 0.0;
201    let mut total_arxb: u64 = 0;
202    let mut total_atxb: u64 = 0;
203    let mut total_arxc: u64 = 0;
204    let mut total_atxc: u64 = 0;
205    let mut total_prxb: u64 = 0;
206    let mut total_ptxb: u64 = 0;
207    let mut total_prxc: u64 = 0;
208    let mut total_ptxc: u64 = 0;
209    let mut total_rxs = 0.0;
210    let mut total_txs = 0.0;
211    let mut total_arxs = 0.0;
212    let mut total_atxs = 0.0;
213    let mut total_prxs = 0.0;
214    let mut total_ptxs = 0.0;
215    let mut total_arxf = 0.0;
216    let mut total_atxf = 0.0;
217    let mut total_prxf = 0.0;
218    let mut total_ptxf = 0.0;
219
220    for entry in interfaces {
221        let id = entry.id();
222        let info = entry.info();
223        let stats = entry.stats();
224
225        if !info.is_local_client {
226            total_rxb += stats.rxb;
227            total_txb += stats.txb;
228            total_rxpps += stats.traffic_rates.rxpps;
229            total_txpps += stats.traffic_rates.txpps;
230            total_arxb += stats.arxb;
231            total_atxb += stats.atxb;
232            total_arxc += stats.arxc;
233            total_atxc += stats.atxc;
234            total_prxb += stats.prxb;
235            total_ptxb += stats.ptxb;
236            total_prxc += stats.prxc;
237            total_ptxc += stats.ptxc;
238            total_rxs += stats.traffic_rates.rxs;
239            total_txs += stats.traffic_rates.txs;
240            total_arxs += stats.traffic_rates.arxs;
241            total_atxs += stats.traffic_rates.atxs;
242            total_prxs += stats.traffic_rates.prxs;
243            total_ptxs += stats.traffic_rates.ptxs;
244            total_arxf += stats.incoming_announce_freq();
245            total_atxf += stats.outgoing_announce_freq();
246            total_prxf += stats.incoming_path_request_freq();
247            total_ptxf += stats.outgoing_path_request_freq();
248        }
249
250        let mut ifstats: Vec<(&str, Value)> = vec![
251            ("name", Value::Str(info.name.clone())),
252            ("short_name", Value::Str(info.name.clone())),
253            ("status", Value::Bool(entry.online())),
254            ("mode", Value::UInt(info.mode as u64)),
255            ("rxb", Value::UInt(stats.rxb)),
256            ("txb", Value::UInt(stats.txb)),
257            ("txdrp", Value::UInt(entry.tx_drops())),
258            ("txdrb", Value::UInt(entry.tx_dropped_bytes())),
259            ("txstalled", Value::Bool(entry.tx_stalled())),
260            ("txbuffered", Value::UInt(entry.tx_buffered() as u64)),
261            ("rxs", Value::Float(stats.traffic_rates.rxs)),
262            ("txs", Value::Float(stats.traffic_rates.txs)),
263            ("arxb", Value::UInt(stats.arxb)),
264            ("atxb", Value::UInt(stats.atxb)),
265            ("arxc", Value::UInt(stats.arxc)),
266            ("atxc", Value::UInt(stats.atxc)),
267            ("arxs", Value::Float(stats.traffic_rates.arxs)),
268            ("atxs", Value::Float(stats.traffic_rates.atxs)),
269            ("prxb", Value::UInt(stats.prxb)),
270            ("ptxb", Value::UInt(stats.ptxb)),
271            ("prxc", Value::UInt(stats.prxc)),
272            ("ptxc", Value::UInt(stats.ptxc)),
273            ("prxs", Value::Float(stats.traffic_rates.prxs)),
274            ("ptxs", Value::Float(stats.traffic_rates.ptxs)),
275            (
276                "protocol_violations",
277                Value::UInt(stats.protocol_violations),
278            ),
279            ("ifac_violations", Value::UInt(stats.ifac_violations)),
280            ("packet_filter_hits", Value::UInt(stats.packet_filter_hits)),
281        ];
282        if let Some(br) = info.bitrate {
283            ifstats.push(("bitrate", Value::UInt(br)));
284        } else {
285            ifstats.push(("bitrate", Value::Nil));
286        }
287        ifstats.push((
288            "incoming_announce_freq",
289            Value::Float(stats.incoming_announce_freq()),
290        ));
291        ifstats.push((
292            "outgoing_announce_freq",
293            Value::Float(stats.outgoing_announce_freq()),
294        ));
295        ifstats.push((
296            "held_announces",
297            Value::UInt(engine.held_announce_count(&id) as u64),
298        ));
299
300        // IFAC info
301        ifstats.push(("ifac_signature", Value::Nil));
302        ifstats.push((
303            "ifac_size",
304            if info.bitrate.is_some() {
305                Value::UInt(0)
306            } else {
307                Value::Nil
308            },
309        ));
310        ifstats.push(("ifac_netname", Value::Nil));
311
312        // Unused by Rust but expected by Python clients
313        ifstats.push(("clients", Value::Nil));
314        ifstats.push(("announce_queue", Value::Nil));
315
316        // Build as map
317        let map = ifstats
318            .into_iter()
319            .map(|(k, v)| (Value::Str(k.into()), v))
320            .collect();
321        iface_list.push(Value::Map(map));
322    }
323
324    // Build top-level stats dict
325    let mut stats: Vec<(&str, Value)> = vec![
326        ("interfaces", Value::Array(iface_list)),
327        ("rxb", Value::UInt(total_rxb)),
328        ("txb", Value::UInt(total_txb)),
329        ("rxpps", Value::Float(total_rxpps)),
330        ("txpps", Value::Float(total_txpps)),
331        ("rxs", Value::Float(total_rxs)),
332        ("txs", Value::Float(total_txs)),
333        ("arxb", Value::UInt(total_arxb)),
334        ("atxb", Value::UInt(total_atxb)),
335        ("arxc", Value::UInt(total_arxc)),
336        ("atxc", Value::UInt(total_atxc)),
337        ("arxs", Value::Float(total_arxs)),
338        ("atxs", Value::Float(total_atxs)),
339        ("prxb", Value::UInt(total_prxb)),
340        ("ptxb", Value::UInt(total_ptxb)),
341        ("prxc", Value::UInt(total_prxc)),
342        ("ptxc", Value::UInt(total_ptxc)),
343        ("prxs", Value::Float(total_prxs)),
344        ("ptxs", Value::Float(total_ptxs)),
345        ("arxf", Value::Float(total_arxf)),
346        ("atxf", Value::Float(total_atxf)),
347        ("prxf", Value::Float(total_prxf)),
348        ("ptxf", Value::Float(total_ptxf)),
349    ];
350
351    if let Some(identity_hash) = engine.config().identity_hash {
352        stats.push(("transport_id", Value::Bin(identity_hash.to_vec())));
353        stats.push(("transport_uptime", Value::Float(time::now() - started)));
354    }
355    stats.push((
356        "probe_responder",
357        match probe_responder_hash {
358            Some(hash) => Value::Bin(hash.to_vec()),
359            None => Value::Nil,
360        },
361    ));
362    stats.push(("rss", Value::Nil));
363
364    let stats_map = stats
365        .into_iter()
366        .map(|(k, v)| (Value::Str(k.into()), v))
367        .collect();
368
369    // Build response: [stats_dict] or [stats_dict, link_count]
370    let mut response = vec![Value::Map(stats_map)];
371    if include_lstats {
372        let link_count = engine.link_table_count();
373        response.push(Value::UInt(link_count as u64));
374    }
375    if include_profiling {
376        response.push(crate::profiling::results_msgpack());
377    }
378
379    Some(msgpack::pack(&Value::Array(response)))
380}
381
382/// Handle a `/path` request.
383///
384/// Request data: msgpack([command, destination_hash?, max_hops?])
385/// - command = "table" → returns path table entries
386/// - command = "rates" → returns rate table entries
387pub fn handle_path_request(data: &[u8], engine: &TransportEngine) -> Option<Vec<u8>> {
388    let arr = match msgpack::unpack_exact(data) {
389        Ok(Value::Array(arr)) if !arr.is_empty() => arr,
390        _ => return None,
391    };
392
393    let command = match &arr[0] {
394        Value::Str(s) => s.as_str(),
395        _ => return None,
396    };
397
398    let dest_filter: Option<[u8; 16]> = if arr.len() > 1 {
399        match &arr[1] {
400            Value::Bin(b) if b.len() == 16 => {
401                let mut h = [0u8; 16];
402                h.copy_from_slice(b);
403                Some(h)
404            }
405            _ => None,
406        }
407    } else {
408        None
409    };
410
411    let max_hops: Option<u8> = if arr.len() > 2 {
412        arr[2].as_uint().map(|v| v as u8)
413    } else {
414        None
415    };
416
417    match command {
418        "table" => {
419            let paths = engine.get_path_table(max_hops);
420            let mut entries = Vec::new();
421            for p in &paths {
422                if let Some(ref filter) = dest_filter {
423                    if p.0 != *filter {
424                        continue;
425                    }
426                }
427                // p = (dest_hash, timestamp, next_hop, hops, expires, interface)
428                let entry = vec![
429                    (Value::Str("hash".into()), Value::Bin(p.0.to_vec())),
430                    (Value::Str("timestamp".into()), Value::Float(p.1)),
431                    (Value::Str("via".into()), Value::Bin(p.2.to_vec())),
432                    (Value::Str("hops".into()), Value::UInt(p.3 as u64)),
433                    (Value::Str("expires".into()), Value::Float(p.4)),
434                    (Value::Str("interface".into()), Value::Str(p.5.clone())),
435                ];
436                entries.push(Value::Map(entry));
437            }
438            Some(msgpack::pack(&Value::Array(entries)))
439        }
440        "rates" => {
441            let rates = engine.get_rate_table();
442            let mut entries = Vec::new();
443            for r in &rates {
444                if let Some(ref filter) = dest_filter {
445                    if r.0 != *filter {
446                        continue;
447                    }
448                }
449                // r = (dest_hash, last, rate_violations, blocked_until, timestamps)
450                let timestamps: Vec<Value> = r.4.iter().map(|t| Value::Float(*t)).collect();
451                let entry = vec![
452                    (Value::Str("hash".into()), Value::Bin(r.0.to_vec())),
453                    (Value::Str("last".into()), Value::Float(r.1)),
454                    (
455                        Value::Str("rate_violations".into()),
456                        Value::UInt(r.2 as u64),
457                    ),
458                    (Value::Str("blocked_until".into()), Value::Float(r.3)),
459                    (Value::Str("timestamps".into()), Value::Array(timestamps)),
460                ];
461                entries.push(Value::Map(entry));
462            }
463            Some(msgpack::pack(&Value::Array(entries)))
464        }
465        _ => None,
466    }
467}
468
469/// Handle a `/list` (blackhole list) request.
470///
471/// Returns the blackholed_identities dict as msgpack.
472pub fn handle_blackhole_list_request(engine: &TransportEngine) -> Option<Vec<u8>> {
473    let blackholed = engine.get_blackholed();
474    let mut map_entries = Vec::new();
475    for (hash, created, expires, reason) in &blackholed {
476        let mut entry = vec![
477            (Value::Str("created".into()), Value::Float(*created)),
478            (Value::Str("expires".into()), Value::Float(*expires)),
479        ];
480        if let Some(r) = reason {
481            entry.push((Value::Str("reason".into()), Value::Str(r.clone())));
482        }
483        map_entries.push((Value::Bin(hash.to_vec()), Value::Map(entry)));
484    }
485    Some(msgpack::pack(&Value::Map(map_entries)))
486}
487
488/// Build an announce packet for the management destination.
489///
490/// Returns raw packet bytes ready for `engine.handle_outbound()`.
491pub fn build_management_announce(
492    identity: &rns_crypto::identity::Identity,
493    rng: &mut dyn rns_crypto::Rng,
494    now: f64,
495    path_response: bool,
496) -> Option<Vec<u8>> {
497    let identity_hash = *identity.hash();
498    let dest_hash = management_dest_hash(&identity_hash);
499    let name_hash = rns_core::destination::name_hash("rnstransport", &["remote", "management"]);
500    let random_hash = announce_random_blob(rng, now);
501
502    let (announce_data, _has_ratchet) = rns_core::announce::AnnounceData::pack(
503        identity,
504        &dest_hash,
505        &name_hash,
506        &random_hash,
507        None, // no ratchet
508        None, // no app_data
509    )
510    .ok()?;
511
512    let flags = rns_core::packet::PacketFlags {
513        header_type: constants::HEADER_1,
514        context_flag: constants::FLAG_UNSET,
515        transport_type: constants::TRANSPORT_BROADCAST,
516        destination_type: constants::DESTINATION_SINGLE,
517        packet_type: constants::PACKET_TYPE_ANNOUNCE,
518    };
519
520    let packet = rns_core::packet::RawPacket::pack(
521        flags,
522        0,
523        &dest_hash,
524        None,
525        announce_context(path_response),
526        &announce_data,
527    )
528    .ok()?;
529
530    Some(packet.raw)
531}
532
533/// Build an announce packet for the blackhole info destination.
534///
535/// Returns raw packet bytes ready for `engine.handle_outbound()`.
536pub fn build_blackhole_announce(
537    identity: &rns_crypto::identity::Identity,
538    rng: &mut dyn rns_crypto::Rng,
539    now: f64,
540    path_response: bool,
541) -> Option<Vec<u8>> {
542    let identity_hash = *identity.hash();
543    let dest_hash = blackhole_dest_hash(&identity_hash);
544    let name_hash = rns_core::destination::name_hash("rnstransport", &["info", "blackhole"]);
545    let random_hash = announce_random_blob(rng, now);
546
547    let (announce_data, _has_ratchet) = rns_core::announce::AnnounceData::pack(
548        identity,
549        &dest_hash,
550        &name_hash,
551        &random_hash,
552        None,
553        None,
554    )
555    .ok()?;
556
557    let flags = rns_core::packet::PacketFlags {
558        header_type: constants::HEADER_1,
559        context_flag: constants::FLAG_UNSET,
560        transport_type: constants::TRANSPORT_BROADCAST,
561        destination_type: constants::DESTINATION_SINGLE,
562        packet_type: constants::PACKET_TYPE_ANNOUNCE,
563    };
564
565    let packet = rns_core::packet::RawPacket::pack(
566        flags,
567        0,
568        &dest_hash,
569        None,
570        announce_context(path_response),
571        &announce_data,
572    )
573    .ok()?;
574
575    Some(packet.raw)
576}