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}
27
28/// Get the path hash for "/status".
29pub fn status_path_hash() -> [u8; 16] {
30    truncated_hash(b"/status")
31}
32
33/// Get the path hash for "/path".
34pub fn path_path_hash() -> [u8; 16] {
35    truncated_hash(b"/path")
36}
37
38/// Get the path hash for "/list".
39pub fn list_path_hash() -> [u8; 16] {
40    truncated_hash(b"/list")
41}
42
43/// Check if a path hash matches a known management path.
44pub fn is_management_path(path_hash: &[u8; 16]) -> bool {
45    *path_hash == status_path_hash()
46        || *path_hash == path_path_hash()
47        || *path_hash == list_path_hash()
48}
49
50/// Compute the remote management destination hash.
51///
52/// Destination: `rnstransport.remote.management` with transport identity.
53pub fn management_dest_hash(transport_identity_hash: &[u8; 16]) -> [u8; 16] {
54    destination_hash(
55        "rnstransport",
56        &["remote", "management"],
57        Some(transport_identity_hash),
58    )
59}
60
61/// Compute the blackhole info destination hash.
62///
63/// Destination: `rnstransport.info.blackhole` with transport identity.
64pub fn blackhole_dest_hash(transport_identity_hash: &[u8; 16]) -> [u8; 16] {
65    destination_hash(
66        "rnstransport",
67        &["info", "blackhole"],
68        Some(transport_identity_hash),
69    )
70}
71
72/// Compute the probe responder destination hash.
73///
74/// Destination: `rnstransport.probe` with transport identity.
75pub fn probe_dest_hash(transport_identity_hash: &[u8; 16]) -> [u8; 16] {
76    destination_hash("rnstransport", &["probe"], Some(transport_identity_hash))
77}
78
79/// Build an announce packet for the probe responder destination.
80///
81/// Returns raw packet bytes ready for `engine.handle_outbound()`.
82pub fn build_probe_announce(
83    identity: &rns_crypto::identity::Identity,
84    rng: &mut dyn rns_crypto::Rng,
85) -> Option<Vec<u8>> {
86    let identity_hash = *identity.hash();
87    let dest_hash = probe_dest_hash(&identity_hash);
88    let name_hash = rns_core::destination::name_hash("rnstransport", &["probe"]);
89    let mut random_hash = [0u8; 10];
90    rng.fill_bytes(&mut random_hash);
91
92    let (announce_data, _has_ratchet) = rns_core::announce::AnnounceData::pack(
93        identity,
94        &dest_hash,
95        &name_hash,
96        &random_hash,
97        None,
98        None,
99    )
100    .ok()?;
101
102    let flags = rns_core::packet::PacketFlags {
103        header_type: constants::HEADER_1,
104        context_flag: constants::FLAG_UNSET,
105        transport_type: constants::TRANSPORT_BROADCAST,
106        destination_type: constants::DESTINATION_SINGLE,
107        packet_type: constants::PACKET_TYPE_ANNOUNCE,
108    };
109
110    let packet = rns_core::packet::RawPacket::pack(
111        flags,
112        0,
113        &dest_hash,
114        None,
115        constants::CONTEXT_NONE,
116        &announce_data,
117    )
118    .ok()?;
119
120    Some(packet.raw)
121}
122
123/// Management configuration.
124#[derive(Debug, Clone)]
125pub struct ManagementConfig {
126    /// Enable remote management destination.
127    pub enable_remote_management: bool,
128    /// Identity hashes allowed to query management.
129    pub remote_management_allowed: Vec<[u8; 16]>,
130    /// Enable blackhole list publication.
131    pub publish_blackhole: bool,
132}
133
134impl Default for ManagementConfig {
135    fn default() -> Self {
136        ManagementConfig {
137            enable_remote_management: false,
138            remote_management_allowed: Vec::new(),
139            publish_blackhole: false,
140        }
141    }
142}
143
144/// Handle a `/status` request.
145///
146/// Request data: msgpack([include_lstats]) where include_lstats is bool.
147/// Response: msgpack([interface_stats_dict, link_count?]) matching Python format.
148pub fn handle_status_request(
149    data: &[u8],
150    engine: &TransportEngine,
151    interfaces: &[&dyn InterfaceStatusView],
152    started: f64,
153    probe_responder_hash: Option<[u8; 16]>,
154) -> Option<Vec<u8>> {
155    // Decode request data
156    let include_lstats = match msgpack::unpack_exact(data) {
157        Ok(Value::Array(arr)) if !arr.is_empty() => arr[0].as_bool().unwrap_or(false),
158        _ => false,
159    };
160
161    // Build interface stats
162    let mut iface_list = Vec::new();
163    let mut total_rxb: u64 = 0;
164    let mut total_txb: u64 = 0;
165
166    for entry in interfaces {
167        let id = entry.id();
168        let info = entry.info();
169        let stats = entry.stats();
170
171        total_rxb += stats.rxb;
172        total_txb += stats.txb;
173
174        let mut ifstats: Vec<(&str, Value)> = Vec::new();
175        ifstats.push(("name", Value::Str(info.name.clone())));
176        ifstats.push(("short_name", Value::Str(info.name.clone())));
177        ifstats.push(("status", Value::Bool(entry.online())));
178        ifstats.push(("mode", Value::UInt(info.mode as u64)));
179        ifstats.push(("rxb", Value::UInt(stats.rxb)));
180        ifstats.push(("txb", Value::UInt(stats.txb)));
181        if let Some(br) = info.bitrate {
182            ifstats.push(("bitrate", Value::UInt(br)));
183        } else {
184            ifstats.push(("bitrate", Value::Nil));
185        }
186        ifstats.push((
187            "incoming_announce_freq",
188            Value::Float(stats.incoming_announce_freq()),
189        ));
190        ifstats.push((
191            "outgoing_announce_freq",
192            Value::Float(stats.outgoing_announce_freq()),
193        ));
194        ifstats.push((
195            "held_announces",
196            Value::UInt(engine.held_announce_count(&id) as u64),
197        ));
198
199        // IFAC info
200        ifstats.push(("ifac_signature", Value::Nil));
201        ifstats.push((
202            "ifac_size",
203            if info.bitrate.is_some() {
204                Value::UInt(0)
205            } else {
206                Value::Nil
207            },
208        ));
209        ifstats.push(("ifac_netname", Value::Nil));
210
211        // Unused by Rust but expected by Python clients
212        ifstats.push(("clients", Value::Nil));
213        ifstats.push(("announce_queue", Value::Nil));
214        ifstats.push(("rxs", Value::UInt(0)));
215        ifstats.push(("txs", Value::UInt(0)));
216
217        // Build as map
218        let map = ifstats
219            .into_iter()
220            .map(|(k, v)| (Value::Str(k.into()), v))
221            .collect();
222        iface_list.push(Value::Map(map));
223    }
224
225    // Build top-level stats dict
226    let mut stats: Vec<(&str, Value)> = Vec::new();
227    stats.push(("interfaces", Value::Array(iface_list)));
228    stats.push(("rxb", Value::UInt(total_rxb)));
229    stats.push(("txb", Value::UInt(total_txb)));
230    stats.push(("rxs", Value::UInt(0)));
231    stats.push(("txs", Value::UInt(0)));
232
233    if let Some(identity_hash) = engine.config().identity_hash {
234        stats.push(("transport_id", Value::Bin(identity_hash.to_vec())));
235        stats.push(("transport_uptime", Value::Float(time::now() - started)));
236    }
237    stats.push((
238        "probe_responder",
239        match probe_responder_hash {
240            Some(hash) => Value::Bin(hash.to_vec()),
241            None => Value::Nil,
242        },
243    ));
244    stats.push(("rss", Value::Nil));
245
246    let stats_map = stats
247        .into_iter()
248        .map(|(k, v)| (Value::Str(k.into()), v))
249        .collect();
250
251    // Build response: [stats_dict] or [stats_dict, link_count]
252    let mut response = vec![Value::Map(stats_map)];
253    if include_lstats {
254        let link_count = engine.link_table_count();
255        response.push(Value::UInt(link_count as u64));
256    }
257
258    Some(msgpack::pack(&Value::Array(response)))
259}
260
261/// Handle a `/path` request.
262///
263/// Request data: msgpack([command, destination_hash?, max_hops?])
264/// - command = "table" → returns path table entries
265/// - command = "rates" → returns rate table entries
266pub fn handle_path_request(data: &[u8], engine: &TransportEngine) -> Option<Vec<u8>> {
267    let arr = match msgpack::unpack_exact(data) {
268        Ok(Value::Array(arr)) if !arr.is_empty() => arr,
269        _ => return None,
270    };
271
272    let command = match &arr[0] {
273        Value::Str(s) => s.as_str(),
274        _ => return None,
275    };
276
277    let dest_filter: Option<[u8; 16]> = if arr.len() > 1 {
278        match &arr[1] {
279            Value::Bin(b) if b.len() == 16 => {
280                let mut h = [0u8; 16];
281                h.copy_from_slice(b);
282                Some(h)
283            }
284            _ => None,
285        }
286    } else {
287        None
288    };
289
290    let max_hops: Option<u8> = if arr.len() > 2 {
291        arr[2].as_uint().map(|v| v as u8)
292    } else {
293        None
294    };
295
296    match command {
297        "table" => {
298            let paths = engine.get_path_table(max_hops);
299            let mut entries = Vec::new();
300            for p in &paths {
301                if let Some(ref filter) = dest_filter {
302                    if p.0 != *filter {
303                        continue;
304                    }
305                }
306                // p = (dest_hash, timestamp, next_hop, hops, expires, interface)
307                let entry = vec![
308                    (Value::Str("hash".into()), Value::Bin(p.0.to_vec())),
309                    (Value::Str("timestamp".into()), Value::Float(p.1)),
310                    (Value::Str("via".into()), Value::Bin(p.2.to_vec())),
311                    (Value::Str("hops".into()), Value::UInt(p.3 as u64)),
312                    (Value::Str("expires".into()), Value::Float(p.4)),
313                    (Value::Str("interface".into()), Value::Str(p.5.clone())),
314                ];
315                entries.push(Value::Map(entry));
316            }
317            Some(msgpack::pack(&Value::Array(entries)))
318        }
319        "rates" => {
320            let rates = engine.get_rate_table();
321            let mut entries = Vec::new();
322            for r in &rates {
323                if let Some(ref filter) = dest_filter {
324                    if r.0 != *filter {
325                        continue;
326                    }
327                }
328                // r = (dest_hash, last, rate_violations, blocked_until, timestamps)
329                let timestamps: Vec<Value> = r.4.iter().map(|t| Value::Float(*t)).collect();
330                let entry = vec![
331                    (Value::Str("hash".into()), Value::Bin(r.0.to_vec())),
332                    (Value::Str("last".into()), Value::Float(r.1)),
333                    (
334                        Value::Str("rate_violations".into()),
335                        Value::UInt(r.2 as u64),
336                    ),
337                    (Value::Str("blocked_until".into()), Value::Float(r.3)),
338                    (Value::Str("timestamps".into()), Value::Array(timestamps)),
339                ];
340                entries.push(Value::Map(entry));
341            }
342            Some(msgpack::pack(&Value::Array(entries)))
343        }
344        _ => None,
345    }
346}
347
348/// Handle a `/list` (blackhole list) request.
349///
350/// Returns the blackholed_identities dict as msgpack.
351pub fn handle_blackhole_list_request(engine: &TransportEngine) -> Option<Vec<u8>> {
352    let blackholed = engine.get_blackholed();
353    let mut map_entries = Vec::new();
354    for (hash, created, expires, reason) in &blackholed {
355        let mut entry = vec![
356            (Value::Str("created".into()), Value::Float(*created)),
357            (Value::Str("expires".into()), Value::Float(*expires)),
358        ];
359        if let Some(r) = reason {
360            entry.push((Value::Str("reason".into()), Value::Str(r.clone())));
361        }
362        map_entries.push((Value::Bin(hash.to_vec()), Value::Map(entry)));
363    }
364    Some(msgpack::pack(&Value::Map(map_entries)))
365}
366
367/// Build an announce packet for the management destination.
368///
369/// Returns raw packet bytes ready for `engine.handle_outbound()`.
370pub fn build_management_announce(
371    identity: &rns_crypto::identity::Identity,
372    rng: &mut dyn rns_crypto::Rng,
373) -> Option<Vec<u8>> {
374    let identity_hash = *identity.hash();
375    let dest_hash = management_dest_hash(&identity_hash);
376    let name_hash = rns_core::destination::name_hash("rnstransport", &["remote", "management"]);
377    let mut random_hash = [0u8; 10];
378    rng.fill_bytes(&mut random_hash);
379
380    let (announce_data, _has_ratchet) = rns_core::announce::AnnounceData::pack(
381        identity,
382        &dest_hash,
383        &name_hash,
384        &random_hash,
385        None, // no ratchet
386        None, // no app_data
387    )
388    .ok()?;
389
390    let flags = rns_core::packet::PacketFlags {
391        header_type: constants::HEADER_1,
392        context_flag: constants::FLAG_UNSET,
393        transport_type: constants::TRANSPORT_BROADCAST,
394        destination_type: constants::DESTINATION_SINGLE,
395        packet_type: constants::PACKET_TYPE_ANNOUNCE,
396    };
397
398    let packet = rns_core::packet::RawPacket::pack(
399        flags,
400        0,
401        &dest_hash,
402        None,
403        constants::CONTEXT_NONE,
404        &announce_data,
405    )
406    .ok()?;
407
408    Some(packet.raw)
409}
410
411/// Build an announce packet for the blackhole info destination.
412///
413/// Returns raw packet bytes ready for `engine.handle_outbound()`.
414pub fn build_blackhole_announce(
415    identity: &rns_crypto::identity::Identity,
416    rng: &mut dyn rns_crypto::Rng,
417) -> Option<Vec<u8>> {
418    let identity_hash = *identity.hash();
419    let dest_hash = blackhole_dest_hash(&identity_hash);
420    let name_hash = rns_core::destination::name_hash("rnstransport", &["info", "blackhole"]);
421    let mut random_hash = [0u8; 10];
422    rng.fill_bytes(&mut random_hash);
423
424    let (announce_data, _has_ratchet) = rns_core::announce::AnnounceData::pack(
425        identity,
426        &dest_hash,
427        &name_hash,
428        &random_hash,
429        None,
430        None,
431    )
432    .ok()?;
433
434    let flags = rns_core::packet::PacketFlags {
435        header_type: constants::HEADER_1,
436        context_flag: constants::FLAG_UNSET,
437        transport_type: constants::TRANSPORT_BROADCAST,
438        destination_type: constants::DESTINATION_SINGLE,
439        packet_type: constants::PACKET_TYPE_ANNOUNCE,
440    };
441
442    let packet = rns_core::packet::RawPacket::pack(
443        flags,
444        0,
445        &dest_hash,
446        None,
447        constants::CONTEXT_NONE,
448        &announce_data,
449    )
450    .ok()?;
451
452    Some(packet.raw)
453}