Skip to main content

rns_ctl/cmd/
status.rs

1//! Display Reticulum network interface status.
2//!
3//! Connects to a running rnsd via RPC and displays interface statistics.
4
5use std::path::Path;
6use std::process;
7use std::time::{Duration, Instant};
8
9use crate::args::Args;
10use crate::format::{prettyfrequency, prettyhexrep, prettytime, size_str, speed_str};
11use rns_net::config;
12use rns_net::pickle::PickleValue;
13use rns_net::rpc::derive_auth_key;
14use rns_net::storage;
15use rns_net::{RpcAddr, RpcClient};
16
17const MONITOR_MIN_SLEEP: Duration = Duration::from_millis(200);
18
19pub fn run(args: Args) {
20    if args.has("version") {
21        println!("rns-ctl {}", env!("FULL_VERSION"));
22        return;
23    }
24
25    if args.has("help") {
26        print_usage();
27        return;
28    }
29
30    env_logger::Builder::new()
31        .filter_level(match args.verbosity {
32            0 => log::LevelFilter::Warn,
33            1 => log::LevelFilter::Info,
34            2 => log::LevelFilter::Debug,
35            _ => log::LevelFilter::Trace,
36        })
37        .format_timestamp_secs()
38        .init();
39
40    let config_path = args.config_path().map(|s| s.to_string());
41    let json_output = args.has("j");
42    let show_all = args.has("a");
43    let sort_by = args.get("s").map(|s| s.to_string());
44    let reverse = args.has("r");
45    let show_totals = args.has("t");
46    let show_links = args.has("l");
47    let show_announces = args.has("A");
48    let monitor_mode = args.has("m");
49    let monitor_interval: f64 = args.get("I").and_then(|s| s.parse().ok()).unwrap_or(1.0);
50    let remote_timeout = args
51        .get("w")
52        .and_then(|s| s.parse::<f64>().ok())
53        .unwrap_or(rns_core::constants::PATH_REQUEST_TIMEOUT);
54    let management_identity = args.get("i").or_else(|| args.get("identity"));
55    let remote_hash = args.get("R").map(|s| s.to_string());
56    let filter = args.positional.first().cloned();
57
58    // Remote management query via -R flag
59    if let Some(ref hash_str) = remote_hash {
60        remote_status(
61            hash_str,
62            management_identity,
63            config_path.as_deref(),
64            remote_timeout,
65            show_links,
66            json_output,
67            monitor_mode,
68            monitor_interval,
69            show_all,
70            sort_by.as_deref(),
71            reverse,
72            filter.as_deref(),
73            show_totals,
74            show_announces,
75        );
76        return;
77    }
78
79    // Load config to get RPC address and auth key
80    let config_dir =
81        storage::resolve_config_dir(config_path.as_ref().map(|s| Path::new(s.as_str())));
82    let config_file = config_dir.join("config");
83    let rns_config = if config_file.exists() {
84        match config::parse_file(&config_file) {
85            Ok(c) => c,
86            Err(e) => {
87                eprintln!("Error reading config: {}", e);
88                process::exit(1);
89            }
90        }
91    } else {
92        match config::parse("") {
93            Ok(c) => c,
94            Err(e) => {
95                eprintln!("Error: {}", e);
96                process::exit(1);
97            }
98        }
99    };
100
101    // Load identity to derive auth key
102    let paths = match storage::ensure_storage_dirs(&config_dir) {
103        Ok(p) => p,
104        Err(e) => {
105            eprintln!("Error: {}", e);
106            process::exit(1);
107        }
108    };
109
110    let identity = match storage::load_or_create_identity(&paths.identities) {
111        Ok(id) => id,
112        Err(e) => {
113            eprintln!("Error loading identity: {}", e);
114            process::exit(1);
115        }
116    };
117
118    let auth_key = derive_auth_key(&identity.get_private_key().unwrap_or([0u8; 64]));
119
120    let rpc_port = rns_config.reticulum.instance_control_port;
121    let rpc_addr = RpcAddr::Tcp("127.0.0.1".into(), rpc_port);
122
123    loop {
124        let monitor_started = Instant::now();
125
126        // Connect to RPC server
127        let mut client = match RpcClient::connect(&rpc_addr, &auth_key) {
128            Ok(c) => c,
129            Err(e) => {
130                if monitor_mode {
131                    eprintln!("Could not connect to rnsd: {} — retrying...", e);
132                    std::thread::sleep(monitor_sleep_duration(
133                        monitor_interval,
134                        monitor_started.elapsed(),
135                    ));
136                    continue;
137                }
138                eprintln!("Could not connect to rnsd: {}", e);
139                eprintln!("Is rnsd running?");
140                process::exit(1);
141            }
142        };
143
144        // Request interface stats
145        let response = match client.call(&PickleValue::Dict(vec![(
146            PickleValue::String("get".into()),
147            PickleValue::String("interface_stats".into()),
148        )])) {
149            Ok(r) => r,
150            Err(e) => {
151                eprintln!("RPC error: {}", e);
152                if monitor_mode {
153                    std::thread::sleep(monitor_sleep_duration(
154                        monitor_interval,
155                        monitor_started.elapsed(),
156                    ));
157                    continue;
158                }
159                process::exit(1);
160            }
161        };
162
163        // Query link count if requested
164        let link_count = if show_links {
165            match client.call(&PickleValue::Dict(vec![(
166                PickleValue::String("get".into()),
167                PickleValue::String("link_count".into()),
168            )])) {
169                Ok(r) => r.as_int(),
170                Err(_) => None,
171            }
172        } else {
173            None
174        };
175
176        if monitor_mode {
177            // Clear screen
178            print!("\x1b[2J\x1b[H");
179        }
180
181        if json_output {
182            print_json(&response);
183        } else {
184            print_status(
185                &response,
186                show_all,
187                sort_by.as_deref(),
188                reverse,
189                filter.as_deref(),
190                show_totals,
191                show_announces,
192            );
193        }
194
195        if let Some(count) = link_count {
196            println!(" Active links  : {}", count);
197            println!();
198        }
199
200        if !monitor_mode {
201            break;
202        }
203
204        std::thread::sleep(monitor_sleep_duration(
205            monitor_interval,
206            monitor_started.elapsed(),
207        ));
208    }
209}
210
211fn monitor_sleep_duration(interval_secs: f64, elapsed: Duration) -> Duration {
212    let interval = Duration::from_secs_f64(interval_secs);
213    interval
214        .checked_sub(elapsed)
215        .unwrap_or(MONITOR_MIN_SLEEP)
216        .max(MONITOR_MIN_SLEEP)
217}
218
219fn print_status(
220    response: &PickleValue,
221    _show_all: bool,
222    sort_by: Option<&str>,
223    reverse: bool,
224    filter: Option<&str>,
225    show_totals: bool,
226    show_announces: bool,
227) {
228    // Print transport info
229    if let Some(PickleValue::Bool(true)) = response.get("transport_enabled").map(|v| v) {
230        print!(" Transport Instance ");
231        if let Some(tid) = response.get("transport_id").and_then(|v| v.as_bytes()) {
232            print!("{} ", prettyhexrep(&tid[..tid.len().min(8)]));
233        }
234        if let Some(PickleValue::Float(uptime)) = response.get("transport_uptime") {
235            print!("running for {}", prettytime(*uptime));
236        }
237        println!();
238        println!();
239    }
240
241    // Print interfaces
242    if let Some(interfaces) = response.get("interfaces").and_then(|v| v.as_list()) {
243        // Collect into a sortable vec of references
244        let mut iface_list: Vec<&PickleValue> = interfaces.iter().collect();
245
246        // Apply filter
247        if let Some(f) = filter {
248            iface_list.retain(|iface| {
249                let name = iface.get("name").and_then(|v| v.as_str()).unwrap_or("");
250                name.to_lowercase().contains(&f.to_lowercase())
251            });
252        }
253
254        // Sort if requested
255        if let Some(sort_key) = sort_by {
256            iface_list.sort_by(|a, b| {
257                let cmp = match sort_key {
258                    "rate" => {
259                        let ra = a.get("bitrate").and_then(|v| v.as_int()).unwrap_or(0);
260                        let rb = b.get("bitrate").and_then(|v| v.as_int()).unwrap_or(0);
261                        ra.cmp(&rb)
262                    }
263                    "traffic" => {
264                        let ta = a.get("rxb").and_then(|v| v.as_int()).unwrap_or(0)
265                            + a.get("txb").and_then(|v| v.as_int()).unwrap_or(0);
266                        let tb = b.get("rxb").and_then(|v| v.as_int()).unwrap_or(0)
267                            + b.get("txb").and_then(|v| v.as_int()).unwrap_or(0);
268                        ta.cmp(&tb)
269                    }
270                    "rx" => {
271                        let ra = a.get("rxb").and_then(|v| v.as_int()).unwrap_or(0);
272                        let rb = b.get("rxb").and_then(|v| v.as_int()).unwrap_or(0);
273                        ra.cmp(&rb)
274                    }
275                    "tx" => {
276                        let ta = a.get("txb").and_then(|v| v.as_int()).unwrap_or(0);
277                        let tb = b.get("txb").and_then(|v| v.as_int()).unwrap_or(0);
278                        ta.cmp(&tb)
279                    }
280                    _ => {
281                        let na = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
282                        let nb = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
283                        na.cmp(nb)
284                    }
285                };
286                if reverse {
287                    cmp.reverse()
288                } else {
289                    cmp
290                }
291            });
292        }
293
294        for iface in &iface_list {
295            let name = iface
296                .get("name")
297                .and_then(|v| v.as_str())
298                .unwrap_or("Unknown");
299            let status = iface
300                .get("status")
301                .and_then(|v| v.as_bool())
302                .unwrap_or(false);
303            let rxb = iface.get("rxb").and_then(|v| v.as_int()).unwrap_or(0) as u64;
304            let txb = iface.get("txb").and_then(|v| v.as_int()).unwrap_or(0) as u64;
305            let bitrate = iface
306                .get("bitrate")
307                .and_then(|v| v.as_int())
308                .map(|n| n as u64);
309            let mode = iface.get("mode").and_then(|v| v.as_int()).unwrap_or(0) as u8;
310            let started = iface
311                .get("started")
312                .and_then(|v| v.as_float())
313                .unwrap_or(0.0);
314
315            let mode_str = match mode {
316                rns_net::MODE_FULL => "Full",
317                rns_net::MODE_ACCESS_POINT => "Access Point",
318                rns_net::MODE_POINT_TO_POINT => "Point-to-Point",
319                rns_net::MODE_ROAMING => "Roaming",
320                rns_net::MODE_BOUNDARY => "Boundary",
321                rns_net::MODE_GATEWAY => "Gateway",
322                _ => "Unknown",
323            };
324
325            println!(" {}", name);
326            println!("    Status    : {}", if status { "Up" } else { "Down" });
327            println!("    Mode      : {}", mode_str);
328            if let Some(br) = bitrate {
329                println!("    Rate      : {}", speed_str(br));
330            }
331            println!(
332                "    Traffic   : {} \u{2191}  {} \u{2193}",
333                size_str(txb),
334                size_str(rxb),
335            );
336            if started > 0.0 {
337                let uptime = rns_net::time::now() - started;
338                if uptime > 0.0 {
339                    println!("    Uptime    : {}", prettytime(uptime));
340                }
341            }
342            if show_announces {
343                let ia_freq = iface
344                    .get("ia_freq")
345                    .and_then(|v| v.as_float())
346                    .unwrap_or(0.0);
347                let oa_freq = iface
348                    .get("oa_freq")
349                    .and_then(|v| v.as_float())
350                    .unwrap_or(0.0);
351                println!(
352                    "    Announces : {} in  {} out",
353                    prettyfrequency(ia_freq),
354                    prettyfrequency(oa_freq),
355                );
356            }
357            println!();
358        }
359    }
360
361    // Show traffic totals
362    if show_totals {
363        let total_rxb = response.get("rxb").and_then(|v| v.as_int()).unwrap_or(0) as u64;
364        let total_txb = response.get("txb").and_then(|v| v.as_int()).unwrap_or(0) as u64;
365        println!(
366            " Traffic totals: {} \u{2191}  {} \u{2193}",
367            size_str(total_txb),
368            size_str(total_rxb),
369        );
370        println!();
371    }
372}
373
374fn print_json(response: &PickleValue) {
375    println!("{}", pickle_to_json(response));
376}
377
378fn pickle_to_json(value: &PickleValue) -> String {
379    match value {
380        PickleValue::None => "null".into(),
381        PickleValue::Bool(b) => if *b { "true" } else { "false" }.into(),
382        PickleValue::Int(n) => format!("{}", n),
383        PickleValue::Float(f) => format!("{}", f),
384        PickleValue::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
385        PickleValue::Bytes(b) => {
386            format!("\"{}\"", prettyhexrep(b))
387        }
388        PickleValue::List(items) => {
389            let inner: Vec<String> = items.iter().map(pickle_to_json).collect();
390            format!("[{}]", inner.join(", "))
391        }
392        PickleValue::Dict(pairs) => {
393            let inner: Vec<String> = pairs
394                .iter()
395                .map(|(k, v)| format!("{}: {}", pickle_to_json(k), pickle_to_json(v)))
396                .collect();
397            format!("{{{}}}", inner.join(", "))
398        }
399    }
400}
401
402#[allow(clippy::too_many_arguments)]
403fn remote_status(
404    hash_str: &str,
405    management_identity: Option<&str>,
406    config_path: Option<&str>,
407    remote_timeout: f64,
408    show_links: bool,
409    json_output: bool,
410    monitor_mode: bool,
411    monitor_interval: f64,
412    show_all: bool,
413    sort_by: Option<&str>,
414    reverse: bool,
415    filter: Option<&str>,
416    show_totals: bool,
417    show_announces: bool,
418) {
419    let transport_hash = match rns_net::remote_management::parse_transport_identity_hash(hash_str) {
420        Ok(h) => h,
421        Err(e) => {
422            eprintln!("{e}");
423            process::exit(1);
424        }
425    };
426    let Some(identity_path) = management_identity else {
427        eprintln!(
428            "{}",
429            rns_net::remote_management::RemoteManagementError::MissingIdentity
430        );
431        process::exit(1);
432    };
433    let timeout = Duration::from_secs_f64(remote_timeout.max(0.2));
434    let mut client = match rns_net::remote_management::RemoteManagementClient::connect(
435        config_path.map(Path::new),
436        Some(Path::new(identity_path)),
437        timeout,
438    ) {
439        Ok(client) => client,
440        Err(e) => {
441            eprintln!("{e}");
442            process::exit(1);
443        }
444    };
445
446    loop {
447        let monitor_started = Instant::now();
448        match client.status(transport_hash, show_links) {
449            Ok(remote) => {
450                if monitor_mode {
451                    print!("\x1b[2J\x1b[H");
452                }
453                if json_output {
454                    print_json(&remote.stats);
455                } else {
456                    print_status(
457                        &remote.stats,
458                        show_all,
459                        sort_by,
460                        reverse,
461                        filter,
462                        show_totals,
463                        show_announces,
464                    );
465                }
466                if let Some(count) = remote.link_count {
467                    println!(" Active links  : {}", count);
468                    println!();
469                }
470            }
471            Err(e) => {
472                eprintln!("Remote status error: {e}");
473                if !monitor_mode {
474                    process::exit(1);
475                }
476            }
477        }
478
479        if !monitor_mode {
480            break;
481        }
482        std::thread::sleep(monitor_sleep_duration(
483            monitor_interval,
484            monitor_started.elapsed(),
485        ));
486    }
487}
488
489fn print_usage() {
490    println!("Usage: rns-ctl status [OPTIONS] [FILTER]");
491    println!();
492    println!("Options:");
493    println!("  --config PATH, -c PATH  Path to config directory");
494    println!("  -a                      Show all interfaces");
495    println!("  -j                      JSON output");
496    println!("  -s SORT                 Sort by: rate, traffic, rx, tx");
497    println!("  -r                      Reverse sort order");
498    println!("  -t                      Show traffic totals");
499    println!("  -l                      Show link count");
500    println!("  -A                      Show announce statistics");
501    println!("  -m                      Monitor mode (loop)");
502    println!("  -I SECONDS              Monitor interval (default: 1.0)");
503    println!("  -R HASH                 Query remote transport identity via management link");
504    println!("  -i PATH                 Identity file for remote management");
505    println!("  -w SECONDS              Timeout for remote queries");
506    println!("  -v                      Increase verbosity");
507    println!("  --version               Print version and exit");
508    println!("  --help, -h              Print this help");
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn monitor_sleep_accounts_for_elapsed_iteration_time() {
517        assert_eq!(
518            monitor_sleep_duration(1.0, Duration::from_millis(250)),
519            Duration::from_millis(750)
520        );
521        assert_eq!(
522            monitor_sleep_duration(1.0, Duration::from_millis(950)),
523            MONITOR_MIN_SLEEP
524        );
525        assert_eq!(
526            monitor_sleep_duration(1.0, Duration::from_millis(1500)),
527            MONITOR_MIN_SLEEP
528        );
529    }
530}