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