Skip to main content

rns_ctl/cmd/
path.rs

1//! Display and manage Reticulum path table.
2//!
3//! Connects to a running rnsd via RPC to query/modify the path table.
4
5use std::path::Path;
6use std::process;
7use std::time::Duration;
8
9use crate::args::Args;
10use crate::format::{prettyfrequency, prettyhexrep, prettytime};
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
17pub fn run(args: Args) {
18    if args.has("version") {
19        println!("rns-ctl {}", env!("FULL_VERSION"));
20        return;
21    }
22
23    if args.has("help") {
24        print_usage();
25        return;
26    }
27
28    env_logger::Builder::new()
29        .filter_level(match args.verbosity {
30            0 => log::LevelFilter::Warn,
31            1 => log::LevelFilter::Info,
32            _ => log::LevelFilter::Debug,
33        })
34        .format_timestamp_secs()
35        .init();
36
37    let config_path = args.config_path().map(|s| s.to_string());
38    let show_table = args.has("t");
39    let show_rates = args.has("r");
40    let drop_hash = args.get("d").map(|s| s.to_string());
41    let drop_via = args.get("x").map(|s| s.to_string());
42    let drop_queues = args.has("D");
43    let json_output = args.has("j");
44    let max_hops: Option<u8> = args.get("m").and_then(|s| s.parse().ok());
45    let show_blackholed = args.has("blackholed") || args.has("b");
46    let blackhole_hash = args.get("B").map(|s| s.to_string());
47    let unblackhole_hash = args.get("U").map(|s| s.to_string());
48    let duration_hours: Option<f64> = args.get("duration").and_then(|s| s.parse().ok());
49    let reason = args.get("reason").map(|s| s.to_string());
50    let remote_blackholed = args.has("p") || args.has("blackholed-list");
51    let remote_timeout = args
52        .get("W")
53        .and_then(|s| s.parse::<f64>().ok())
54        .unwrap_or(rns_core::constants::PATH_REQUEST_TIMEOUT);
55    let management_identity = args.get("i").or_else(|| args.get("identity"));
56    let remote_hash = args.get("R").map(|s| s.to_string());
57
58    // Remote management query via -R flag
59    if let Some(ref hash_str) = remote_hash {
60        remote_path(
61            hash_str,
62            management_identity,
63            config_path.as_deref(),
64            remote_timeout,
65            show_table,
66            show_rates,
67            remote_blackholed,
68            max_hops,
69            args.positional.first().map(String::as_str),
70            drop_hash.as_deref(),
71            drop_via.as_deref(),
72            drop_queues,
73            blackhole_hash.as_deref(),
74            unblackhole_hash.as_deref(),
75        );
76        return;
77    }
78
79    // Load config
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    let paths = match storage::ensure_storage_dirs(&config_dir) {
102        Ok(p) => p,
103        Err(e) => {
104            eprintln!("Error: {}", e);
105            process::exit(1);
106        }
107    };
108
109    let identity = match storage::load_or_create_identity(&paths.identities) {
110        Ok(id) => id,
111        Err(e) => {
112            eprintln!("Error loading identity: {}", e);
113            process::exit(1);
114        }
115    };
116
117    let auth_key = derive_auth_key(&identity.get_private_key().unwrap_or([0u8; 64]));
118
119    let rpc_port = rns_config.reticulum.instance_control_port;
120    let rpc_addr = RpcAddr::Tcp("127.0.0.1".into(), rpc_port);
121
122    let mut client = match RpcClient::connect(&rpc_addr, &auth_key) {
123        Ok(c) => c,
124        Err(e) => {
125            eprintln!("Could not connect to rnsd: {}", e);
126            process::exit(1);
127        }
128    };
129
130    if show_table {
131        show_path_table(&mut client, json_output, max_hops);
132    } else if show_rates {
133        show_rate_table(&mut client, json_output);
134    } else if let Some(hash_str) = blackhole_hash {
135        do_blackhole(&mut client, &hash_str, duration_hours, reason);
136    } else if let Some(hash_str) = unblackhole_hash {
137        do_unblackhole(&mut client, &hash_str);
138    } else if show_blackholed {
139        show_blackholed_list(&mut client);
140    } else if let Some(hash_str) = drop_hash {
141        drop_path(&mut client, &hash_str);
142    } else if let Some(hash_str) = drop_via {
143        drop_all_via(&mut client, &hash_str);
144    } else if drop_queues {
145        drop_announce_queues(&mut client);
146    } else if let Some(hash_str) = args.positional.first() {
147        lookup_path(&mut client, hash_str);
148    } else {
149        print_usage();
150    }
151}
152
153fn parse_hex_hash(s: &str) -> Option<Vec<u8>> {
154    let s = s.trim();
155    if s.len() % 2 != 0 {
156        return None;
157    }
158    let mut bytes = Vec::with_capacity(s.len() / 2);
159    for i in (0..s.len()).step_by(2) {
160        match u8::from_str_radix(&s[i..i + 2], 16) {
161            Ok(b) => bytes.push(b),
162            Err(_) => return None,
163        }
164    }
165    Some(bytes)
166}
167
168fn show_path_table(client: &mut RpcClient, _json_output: bool, max_hops: Option<u8>) {
169    let max_hops_val = match max_hops {
170        Some(h) => PickleValue::Int(h as i64),
171        None => PickleValue::None,
172    };
173
174    let response = match client.call(&PickleValue::Dict(vec![
175        (
176            PickleValue::String("get".into()),
177            PickleValue::String("path_table".into()),
178        ),
179        (PickleValue::String("max_hops".into()), max_hops_val),
180    ])) {
181        Ok(r) => r,
182        Err(e) => {
183            eprintln!("RPC error: {}", e);
184            process::exit(1);
185        }
186    };
187
188    render_path_table(&response);
189}
190
191fn render_path_table(response: &PickleValue) {
192    if let Some(entries) = response.as_list() {
193        if entries.is_empty() {
194            println!("Path table is empty");
195            return;
196        }
197        println!(
198            "{:<34} {:>6} {:<34} {:<10} {}",
199            "Destination", "Hops", "Via", "Expires", "Interface"
200        );
201        println!("{}", "-".repeat(100));
202        for entry in entries {
203            let hash = entry
204                .get("hash")
205                .and_then(|v| v.as_bytes())
206                .map(prettyhexrep)
207                .unwrap_or_default();
208            let hops = entry.get("hops").and_then(|v| v.as_int()).unwrap_or(0);
209            let via = entry
210                .get("via")
211                .and_then(|v| v.as_bytes())
212                .map(prettyhexrep)
213                .unwrap_or_default();
214            let expires = entry
215                .get("expires")
216                .and_then(|v| v.as_float())
217                .map(|e| {
218                    let remaining = e - rns_net::time::now();
219                    if remaining > 0.0 {
220                        prettytime(remaining)
221                    } else {
222                        "expired".into()
223                    }
224                })
225                .unwrap_or_default();
226            let interface = entry
227                .get("interface")
228                .and_then(|v| v.as_str())
229                .unwrap_or("");
230
231            println!(
232                "{:<34} {:>6} {:<34} {:<10} {}",
233                &hash[..hash.len().min(32)],
234                hops,
235                &via[..via.len().min(32)],
236                expires,
237                interface,
238            );
239        }
240    } else {
241        eprintln!("Unexpected response format");
242    }
243}
244
245fn show_rate_table(client: &mut RpcClient, _json_output: bool) {
246    let response = match client.call(&PickleValue::Dict(vec![(
247        PickleValue::String("get".into()),
248        PickleValue::String("rate_table".into()),
249    )])) {
250        Ok(r) => r,
251        Err(e) => {
252            eprintln!("RPC error: {}", e);
253            process::exit(1);
254        }
255    };
256
257    render_rate_table(&response);
258}
259
260fn render_rate_table(response: &PickleValue) {
261    if let Some(entries) = response.as_list() {
262        if entries.is_empty() {
263            println!("Rate table is empty");
264            return;
265        }
266        println!(
267            "{:<34} {:>12} {:>12} {:>16}",
268            "Destination", "Violations", "Frequency", "Blocked Until"
269        );
270        println!("{}", "-".repeat(78));
271        for entry in entries {
272            let hash = entry
273                .get("hash")
274                .and_then(|v| v.as_bytes())
275                .map(prettyhexrep)
276                .unwrap_or_default();
277            let violations = entry
278                .get("rate_violations")
279                .and_then(|v| v.as_int())
280                .unwrap_or(0);
281            let blocked = entry
282                .get("blocked_until")
283                .and_then(|v| v.as_float())
284                .map(|b| {
285                    let remaining = b - rns_net::time::now();
286                    if remaining > 0.0 {
287                        prettytime(remaining)
288                    } else {
289                        "not blocked".into()
290                    }
291                })
292                .unwrap_or_default();
293
294            // Compute hourly frequency from timestamps
295            let freq_str =
296                if let Some(timestamps) = entry.get("timestamps").and_then(|v| v.as_list()) {
297                    let ts: Vec<f64> = timestamps.iter().filter_map(|v| v.as_float()).collect();
298                    if ts.len() >= 2 {
299                        let span = ts[ts.len() - 1] - ts[0];
300                        if span > 0.0 {
301                            let freq_per_sec = (ts.len() - 1) as f64 / span;
302                            prettyfrequency(freq_per_sec)
303                        } else {
304                            "none".into()
305                        }
306                    } else {
307                        "none".into()
308                    }
309                } else {
310                    "none".into()
311                };
312
313            println!(
314                "{:<34} {:>12} {:>12} {:>16}",
315                &hash[..hash.len().min(32)],
316                violations,
317                freq_str,
318                blocked,
319            );
320        }
321    }
322}
323
324fn show_blackholed_list(client: &mut RpcClient) {
325    let response = match client.call(&PickleValue::Dict(vec![(
326        PickleValue::String("get".into()),
327        PickleValue::String("blackholed".into()),
328    )])) {
329        Ok(r) => r,
330        Err(e) => {
331            eprintln!("RPC error: {}", e);
332            process::exit(1);
333        }
334    };
335
336    render_blackholed_list(&response);
337}
338
339fn render_blackholed_list(response: &PickleValue) {
340    if let Some(entries) = response.as_list() {
341        if entries.is_empty() {
342            println!("Blackhole list is empty");
343            return;
344        }
345        println!("{:<34} {:<16} {}", "Identity Hash", "Expires", "Reason");
346        println!("{}", "-".repeat(70));
347        for entry in entries {
348            let hash = entry
349                .get("identity_hash")
350                .and_then(|v| v.as_bytes())
351                .map(prettyhexrep)
352                .unwrap_or_default();
353            let expires = entry
354                .get("expires")
355                .and_then(|v| v.as_float())
356                .map(|e| {
357                    if e == 0.0 {
358                        "never".into()
359                    } else {
360                        let remaining = e - rns_net::time::now();
361                        if remaining > 0.0 {
362                            prettytime(remaining)
363                        } else {
364                            "expired".into()
365                        }
366                    }
367                })
368                .unwrap_or_default();
369            let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("-");
370
371            println!(
372                "{:<34} {:<16} {}",
373                &hash[..hash.len().min(32)],
374                expires,
375                reason,
376            );
377        }
378    } else {
379        eprintln!("Unexpected response format");
380    }
381}
382
383fn do_blackhole(
384    client: &mut RpcClient,
385    hash_str: &str,
386    duration_hours: Option<f64>,
387    reason: Option<String>,
388) {
389    let hash_bytes = match parse_hex_hash(hash_str) {
390        Some(b) if b.len() >= 16 => b,
391        _ => {
392            eprintln!("Invalid identity hash: {}", hash_str);
393            process::exit(1);
394        }
395    };
396
397    let mut dict = vec![(
398        PickleValue::String("blackhole".into()),
399        PickleValue::Bytes(hash_bytes[..16].to_vec()),
400    )];
401    if let Some(d) = duration_hours {
402        dict.push((
403            PickleValue::String("duration".into()),
404            PickleValue::Float(d),
405        ));
406    }
407    if let Some(r) = reason {
408        dict.push((PickleValue::String("reason".into()), PickleValue::String(r)));
409    }
410
411    match client.call(&PickleValue::Dict(dict)) {
412        Ok(r) => {
413            if r.as_bool() == Some(true) {
414                println!("Blackholed identity {}", prettyhexrep(&hash_bytes[..16]));
415            } else {
416                eprintln!("Failed to blackhole identity");
417            }
418        }
419        Err(e) => {
420            eprintln!("RPC error: {}", e);
421            process::exit(1);
422        }
423    }
424}
425
426fn do_unblackhole(client: &mut RpcClient, hash_str: &str) {
427    let hash_bytes = match parse_hex_hash(hash_str) {
428        Some(b) if b.len() >= 16 => b,
429        _ => {
430            eprintln!("Invalid identity hash: {}", hash_str);
431            process::exit(1);
432        }
433    };
434
435    match client.call(&PickleValue::Dict(vec![(
436        PickleValue::String("unblackhole".into()),
437        PickleValue::Bytes(hash_bytes[..16].to_vec()),
438    )])) {
439        Ok(r) => {
440            if r.as_bool() == Some(true) {
441                println!(
442                    "Removed {} from blackhole list",
443                    prettyhexrep(&hash_bytes[..16])
444                );
445            } else {
446                println!(
447                    "Identity {} was not blackholed",
448                    prettyhexrep(&hash_bytes[..16])
449                );
450            }
451        }
452        Err(e) => {
453            eprintln!("RPC error: {}", e);
454            process::exit(1);
455        }
456    }
457}
458
459fn lookup_path(client: &mut RpcClient, hash_str: &str) {
460    let hash_bytes = match parse_hex_hash(hash_str) {
461        Some(b) if b.len() >= 16 => b,
462        _ => {
463            eprintln!("Invalid destination hash: {}", hash_str);
464            process::exit(1);
465        }
466    };
467
468    let mut dest_hash = [0u8; 16];
469    dest_hash.copy_from_slice(&hash_bytes[..16]);
470
471    // Query next hop
472    let response = match client.call(&PickleValue::Dict(vec![
473        (
474            PickleValue::String("get".into()),
475            PickleValue::String("next_hop".into()),
476        ),
477        (
478            PickleValue::String("destination_hash".into()),
479            PickleValue::Bytes(dest_hash.to_vec()),
480        ),
481    ])) {
482        Ok(r) => r,
483        Err(e) => {
484            eprintln!("RPC error: {}", e);
485            process::exit(1);
486        }
487    };
488
489    if let Some(next_hop) = response.as_bytes() {
490        println!("Path to {} found", prettyhexrep(&dest_hash));
491        println!("  Next hop: {}", prettyhexrep(next_hop));
492    } else {
493        println!("No path found for {}", prettyhexrep(&dest_hash));
494    }
495}
496
497fn drop_path(client: &mut RpcClient, hash_str: &str) {
498    let hash_bytes = match parse_hex_hash(hash_str) {
499        Some(b) if b.len() >= 16 => b,
500        _ => {
501            eprintln!("Invalid destination hash: {}", hash_str);
502            process::exit(1);
503        }
504    };
505
506    let mut dest_hash = [0u8; 16];
507    dest_hash.copy_from_slice(&hash_bytes[..16]);
508
509    let response = match client.call(&PickleValue::Dict(vec![
510        (
511            PickleValue::String("drop".into()),
512            PickleValue::String("path".into()),
513        ),
514        (
515            PickleValue::String("destination_hash".into()),
516            PickleValue::Bytes(dest_hash.to_vec()),
517        ),
518    ])) {
519        Ok(r) => r,
520        Err(e) => {
521            eprintln!("RPC error: {}", e);
522            process::exit(1);
523        }
524    };
525
526    if response.as_bool() == Some(true) {
527        println!("Dropped path for {}", prettyhexrep(&dest_hash));
528    } else {
529        println!("No path found for {}", prettyhexrep(&dest_hash));
530    }
531}
532
533fn drop_all_via(client: &mut RpcClient, hash_str: &str) {
534    let hash_bytes = match parse_hex_hash(hash_str) {
535        Some(b) if b.len() >= 16 => b,
536        _ => {
537            eprintln!("Invalid transport hash: {}", hash_str);
538            process::exit(1);
539        }
540    };
541
542    let mut transport_hash = [0u8; 16];
543    transport_hash.copy_from_slice(&hash_bytes[..16]);
544
545    let response = match client.call(&PickleValue::Dict(vec![
546        (
547            PickleValue::String("drop".into()),
548            PickleValue::String("all_via".into()),
549        ),
550        (
551            PickleValue::String("destination_hash".into()),
552            PickleValue::Bytes(transport_hash.to_vec()),
553        ),
554    ])) {
555        Ok(r) => r,
556        Err(e) => {
557            eprintln!("RPC error: {}", e);
558            process::exit(1);
559        }
560    };
561
562    if let Some(n) = response.as_int() {
563        println!("Dropped {} paths via {}", n, prettyhexrep(&transport_hash));
564    }
565}
566
567fn drop_announce_queues(client: &mut RpcClient) {
568    match client.call(&PickleValue::Dict(vec![(
569        PickleValue::String("drop".into()),
570        PickleValue::String("announce_queues".into()),
571    )])) {
572        Ok(_) => println!("Announce queues dropped"),
573        Err(e) => {
574            eprintln!("RPC error: {}", e);
575            process::exit(1);
576        }
577    }
578}
579
580#[allow(clippy::too_many_arguments)]
581fn remote_path(
582    hash_str: &str,
583    management_identity: Option<&str>,
584    config_path: Option<&str>,
585    remote_timeout: f64,
586    show_table: bool,
587    show_rates: bool,
588    remote_blackholed: bool,
589    max_hops: Option<u8>,
590    destination_filter: Option<&str>,
591    drop_hash: Option<&str>,
592    drop_via: Option<&str>,
593    drop_queues: bool,
594    blackhole_hash: Option<&str>,
595    unblackhole_hash: Option<&str>,
596) {
597    if drop_hash.is_some()
598        || drop_via.is_some()
599        || drop_queues
600        || blackhole_hash.is_some()
601        || unblackhole_hash.is_some()
602    {
603        eprintln!(
604            "{}",
605            rns_net::remote_management::RemoteManagementError::Unsupported(
606                "remote path mutations are not implemented upstream in Reticulum 1.2.7".into(),
607            )
608        );
609        process::exit(1);
610    }
611
612    let transport_hash = match rns_net::remote_management::parse_transport_identity_hash(hash_str) {
613        Ok(h) => h,
614        Err(e) => {
615            eprintln!("{e}");
616            process::exit(1);
617        }
618    };
619    let management_identity_path = match management_identity {
620        Some(path) => Some(Path::new(path)),
621        None if remote_blackholed => None,
622        None => {
623            eprintln!(
624                "{}",
625                rns_net::remote_management::RemoteManagementError::MissingIdentity
626            );
627            process::exit(1);
628        }
629    };
630    let destination_filter = match destination_filter {
631        Some(hash) => Some(parse_fixed_hash(hash, "destination").unwrap_or_else(|e| {
632            eprintln!("{e}");
633            process::exit(1);
634        })),
635        None => None,
636    };
637    let timeout = Duration::from_secs_f64(remote_timeout.max(0.2));
638    let mut client = match rns_net::remote_management::RemoteManagementClient::connect(
639        config_path.map(Path::new),
640        management_identity_path,
641        timeout,
642    ) {
643        Ok(client) => client,
644        Err(e) => {
645            eprintln!("{e}");
646            process::exit(1);
647        }
648    };
649
650    let result = if show_rates {
651        client.rate_table(transport_hash, destination_filter)
652    } else if remote_blackholed {
653        client.published_blackhole_list(transport_hash)
654    } else if show_table || destination_filter.is_some() || max_hops.is_some() {
655        client.path_table(transport_hash, destination_filter, max_hops)
656    } else {
657        eprintln!("Remote path mode requires -t, -r, or -p/--blackholed-list");
658        process::exit(1);
659    };
660
661    match result {
662        Ok(response) if show_rates => render_rate_table(&response),
663        Ok(response) if remote_blackholed => render_blackholed_list(&response),
664        Ok(response) => render_path_table(&response),
665        Err(e) => {
666            eprintln!("Remote path error: {e}");
667            process::exit(1);
668        }
669    }
670}
671
672fn parse_fixed_hash(s: &str, label: &str) -> Result<[u8; 16], String> {
673    let bytes = parse_hex_hash(s).ok_or_else(|| format!("Invalid {label} hash: {s}"))?;
674    if bytes.len() < 16 {
675        return Err(format!("Invalid {label} hash: {s}"));
676    }
677    let mut out = [0u8; 16];
678    out.copy_from_slice(&bytes[..16]);
679    Ok(out)
680}
681
682fn print_usage() {
683    println!("Usage: rns-ctl path [OPTIONS] [DESTINATION_HASH]");
684    println!();
685    println!("Options:");
686    println!("  --config PATH, -c PATH  Path to config directory");
687    println!("  -t                      Show path table");
688    println!("  -m HOPS                 Filter path table by max hops");
689    println!("  -r                      Show rate table");
690    println!("  -d HASH                 Drop path for destination");
691    println!("  -x HASH                 Drop all paths via transport");
692    println!("  -D                      Drop all announce queues");
693    println!("  -b                      Show blackholed identities");
694    println!("  -p, --blackholed-list   View published remote blackhole list with -R");
695    println!("  -B HASH                 Blackhole an identity");
696    println!("  -U HASH                 Remove identity from blackhole list");
697    println!("  --duration HOURS        Blackhole duration (default: permanent)");
698    println!("  --reason TEXT           Reason for blackholing");
699    println!("  -R HASH                 Query remote transport identity via management link");
700    println!("  -i PATH                 Identity file for remote management");
701    println!("  -W SECONDS              Timeout for remote path queries");
702    println!("  -j                      JSON output");
703    println!("  -v                      Increase verbosity");
704    println!("  --version               Print version and exit");
705    println!("  --help, -h              Print this help");
706}