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;
7
8use crate::args::Args;
9use crate::format::{prettyfrequency, prettyhexrep, prettytime};
10use rns_net::config;
11use rns_net::pickle::PickleValue;
12use rns_net::rpc::derive_auth_key;
13use rns_net::storage;
14use rns_net::{RpcAddr, RpcClient};
15
16pub fn run(args: Args) {
17    if args.has("version") {
18        println!("rns-ctl {}", env!("FULL_VERSION"));
19        return;
20    }
21
22    if args.has("help") {
23        print_usage();
24        return;
25    }
26
27    env_logger::Builder::new()
28        .filter_level(match args.verbosity {
29            0 => log::LevelFilter::Warn,
30            1 => log::LevelFilter::Info,
31            _ => log::LevelFilter::Debug,
32        })
33        .format_timestamp_secs()
34        .init();
35
36    let config_path = args.config_path().map(|s| s.to_string());
37    let show_table = args.has("t");
38    let show_rates = args.has("r");
39    let drop_hash = args.get("d").map(|s| s.to_string());
40    let drop_via = args.get("x").map(|s| s.to_string());
41    let drop_queues = args.has("D");
42    let json_output = args.has("j");
43    let max_hops: Option<u8> = args.get("m").and_then(|s| s.parse().ok());
44    let show_blackholed = args.has("blackholed") || args.has("b");
45    let blackhole_hash = args.get("B").map(|s| s.to_string());
46    let unblackhole_hash = args.get("U").map(|s| s.to_string());
47    let duration_hours: Option<f64> = args.get("duration").and_then(|s| s.parse().ok());
48    let reason = args.get("reason").map(|s| s.to_string());
49    let remote_hash = args.get("R").map(|s| s.to_string());
50
51    // Remote management query via -R flag
52    if let Some(ref hash_str) = remote_hash {
53        remote_path(hash_str, config_path.as_deref());
54        return;
55    }
56
57    // Load config
58    let config_dir =
59        storage::resolve_config_dir(config_path.as_ref().map(|s| Path::new(s.as_str())));
60    let config_file = config_dir.join("config");
61    let rns_config = if config_file.exists() {
62        match config::parse_file(&config_file) {
63            Ok(c) => c,
64            Err(e) => {
65                eprintln!("Error reading config: {}", e);
66                process::exit(1);
67            }
68        }
69    } else {
70        match config::parse("") {
71            Ok(c) => c,
72            Err(e) => {
73                eprintln!("Error: {}", e);
74                process::exit(1);
75            }
76        }
77    };
78
79    let paths = match storage::ensure_storage_dirs(&config_dir) {
80        Ok(p) => p,
81        Err(e) => {
82            eprintln!("Error: {}", e);
83            process::exit(1);
84        }
85    };
86
87    let identity = match storage::load_or_create_identity(&paths.identities) {
88        Ok(id) => id,
89        Err(e) => {
90            eprintln!("Error loading identity: {}", e);
91            process::exit(1);
92        }
93    };
94
95    let auth_key = derive_auth_key(&identity.get_private_key().unwrap_or([0u8; 64]));
96
97    let rpc_port = rns_config.reticulum.instance_control_port;
98    let rpc_addr = RpcAddr::Tcp("127.0.0.1".into(), rpc_port);
99
100    let mut client = match RpcClient::connect(&rpc_addr, &auth_key) {
101        Ok(c) => c,
102        Err(e) => {
103            eprintln!("Could not connect to rnsd: {}", e);
104            process::exit(1);
105        }
106    };
107
108    if show_table {
109        show_path_table(&mut client, json_output, max_hops);
110    } else if show_rates {
111        show_rate_table(&mut client, json_output);
112    } else if let Some(hash_str) = blackhole_hash {
113        do_blackhole(&mut client, &hash_str, duration_hours, reason);
114    } else if let Some(hash_str) = unblackhole_hash {
115        do_unblackhole(&mut client, &hash_str);
116    } else if show_blackholed {
117        show_blackholed_list(&mut client);
118    } else if let Some(hash_str) = drop_hash {
119        drop_path(&mut client, &hash_str);
120    } else if let Some(hash_str) = drop_via {
121        drop_all_via(&mut client, &hash_str);
122    } else if drop_queues {
123        drop_announce_queues(&mut client);
124    } else if let Some(hash_str) = args.positional.first() {
125        lookup_path(&mut client, hash_str);
126    } else {
127        print_usage();
128    }
129}
130
131fn parse_hex_hash(s: &str) -> Option<Vec<u8>> {
132    let s = s.trim();
133    if s.len() % 2 != 0 {
134        return None;
135    }
136    let mut bytes = Vec::with_capacity(s.len() / 2);
137    for i in (0..s.len()).step_by(2) {
138        match u8::from_str_radix(&s[i..i + 2], 16) {
139            Ok(b) => bytes.push(b),
140            Err(_) => return None,
141        }
142    }
143    Some(bytes)
144}
145
146fn show_path_table(client: &mut RpcClient, _json_output: bool, max_hops: Option<u8>) {
147    let max_hops_val = match max_hops {
148        Some(h) => PickleValue::Int(h as i64),
149        None => PickleValue::None,
150    };
151
152    let response = match client.call(&PickleValue::Dict(vec![
153        (
154            PickleValue::String("get".into()),
155            PickleValue::String("path_table".into()),
156        ),
157        (PickleValue::String("max_hops".into()), max_hops_val),
158    ])) {
159        Ok(r) => r,
160        Err(e) => {
161            eprintln!("RPC error: {}", e);
162            process::exit(1);
163        }
164    };
165
166    if let Some(entries) = response.as_list() {
167        if entries.is_empty() {
168            println!("Path table is empty");
169            return;
170        }
171        println!(
172            "{:<34} {:>6} {:<34} {:<10} {}",
173            "Destination", "Hops", "Via", "Expires", "Interface"
174        );
175        println!("{}", "-".repeat(100));
176        for entry in entries {
177            let hash = entry
178                .get("hash")
179                .and_then(|v| v.as_bytes())
180                .map(prettyhexrep)
181                .unwrap_or_default();
182            let hops = entry.get("hops").and_then(|v| v.as_int()).unwrap_or(0);
183            let via = entry
184                .get("via")
185                .and_then(|v| v.as_bytes())
186                .map(prettyhexrep)
187                .unwrap_or_default();
188            let expires = entry
189                .get("expires")
190                .and_then(|v| v.as_float())
191                .map(|e| {
192                    let remaining = e - rns_net::time::now();
193                    if remaining > 0.0 {
194                        prettytime(remaining)
195                    } else {
196                        "expired".into()
197                    }
198                })
199                .unwrap_or_default();
200            let interface = entry
201                .get("interface")
202                .and_then(|v| v.as_str())
203                .unwrap_or("");
204
205            println!(
206                "{:<34} {:>6} {:<34} {:<10} {}",
207                &hash[..hash.len().min(32)],
208                hops,
209                &via[..via.len().min(32)],
210                expires,
211                interface,
212            );
213        }
214    } else {
215        eprintln!("Unexpected response format");
216    }
217}
218
219fn show_rate_table(client: &mut RpcClient, _json_output: bool) {
220    let response = match client.call(&PickleValue::Dict(vec![(
221        PickleValue::String("get".into()),
222        PickleValue::String("rate_table".into()),
223    )])) {
224        Ok(r) => r,
225        Err(e) => {
226            eprintln!("RPC error: {}", e);
227            process::exit(1);
228        }
229    };
230
231    if let Some(entries) = response.as_list() {
232        if entries.is_empty() {
233            println!("Rate table is empty");
234            return;
235        }
236        println!(
237            "{:<34} {:>12} {:>12} {:>16}",
238            "Destination", "Violations", "Frequency", "Blocked Until"
239        );
240        println!("{}", "-".repeat(78));
241        for entry in entries {
242            let hash = entry
243                .get("hash")
244                .and_then(|v| v.as_bytes())
245                .map(prettyhexrep)
246                .unwrap_or_default();
247            let violations = entry
248                .get("rate_violations")
249                .and_then(|v| v.as_int())
250                .unwrap_or(0);
251            let blocked = entry
252                .get("blocked_until")
253                .and_then(|v| v.as_float())
254                .map(|b| {
255                    let remaining = b - rns_net::time::now();
256                    if remaining > 0.0 {
257                        prettytime(remaining)
258                    } else {
259                        "not blocked".into()
260                    }
261                })
262                .unwrap_or_default();
263
264            // Compute hourly frequency from timestamps
265            let freq_str =
266                if let Some(timestamps) = entry.get("timestamps").and_then(|v| v.as_list()) {
267                    let ts: Vec<f64> = timestamps.iter().filter_map(|v| v.as_float()).collect();
268                    if ts.len() >= 2 {
269                        let span = ts[ts.len() - 1] - ts[0];
270                        if span > 0.0 {
271                            let freq_per_sec = (ts.len() - 1) as f64 / span;
272                            prettyfrequency(freq_per_sec)
273                        } else {
274                            "none".into()
275                        }
276                    } else {
277                        "none".into()
278                    }
279                } else {
280                    "none".into()
281                };
282
283            println!(
284                "{:<34} {:>12} {:>12} {:>16}",
285                &hash[..hash.len().min(32)],
286                violations,
287                freq_str,
288                blocked,
289            );
290        }
291    }
292}
293
294fn show_blackholed_list(client: &mut RpcClient) {
295    let response = match client.call(&PickleValue::Dict(vec![(
296        PickleValue::String("get".into()),
297        PickleValue::String("blackholed".into()),
298    )])) {
299        Ok(r) => r,
300        Err(e) => {
301            eprintln!("RPC error: {}", e);
302            process::exit(1);
303        }
304    };
305
306    if let Some(entries) = response.as_list() {
307        if entries.is_empty() {
308            println!("Blackhole list is empty");
309            return;
310        }
311        println!("{:<34} {:<16} {}", "Identity Hash", "Expires", "Reason");
312        println!("{}", "-".repeat(70));
313        for entry in entries {
314            let hash = entry
315                .get("identity_hash")
316                .and_then(|v| v.as_bytes())
317                .map(prettyhexrep)
318                .unwrap_or_default();
319            let expires = entry
320                .get("expires")
321                .and_then(|v| v.as_float())
322                .map(|e| {
323                    if e == 0.0 {
324                        "never".into()
325                    } else {
326                        let remaining = e - rns_net::time::now();
327                        if remaining > 0.0 {
328                            prettytime(remaining)
329                        } else {
330                            "expired".into()
331                        }
332                    }
333                })
334                .unwrap_or_default();
335            let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("-");
336
337            println!(
338                "{:<34} {:<16} {}",
339                &hash[..hash.len().min(32)],
340                expires,
341                reason,
342            );
343        }
344    } else {
345        eprintln!("Unexpected response format");
346    }
347}
348
349fn do_blackhole(
350    client: &mut RpcClient,
351    hash_str: &str,
352    duration_hours: Option<f64>,
353    reason: Option<String>,
354) {
355    let hash_bytes = match parse_hex_hash(hash_str) {
356        Some(b) if b.len() >= 16 => b,
357        _ => {
358            eprintln!("Invalid identity hash: {}", hash_str);
359            process::exit(1);
360        }
361    };
362
363    let mut dict = vec![(
364        PickleValue::String("blackhole".into()),
365        PickleValue::Bytes(hash_bytes[..16].to_vec()),
366    )];
367    if let Some(d) = duration_hours {
368        dict.push((
369            PickleValue::String("duration".into()),
370            PickleValue::Float(d),
371        ));
372    }
373    if let Some(r) = reason {
374        dict.push((PickleValue::String("reason".into()), PickleValue::String(r)));
375    }
376
377    match client.call(&PickleValue::Dict(dict)) {
378        Ok(r) => {
379            if r.as_bool() == Some(true) {
380                println!("Blackholed identity {}", prettyhexrep(&hash_bytes[..16]));
381            } else {
382                eprintln!("Failed to blackhole identity");
383            }
384        }
385        Err(e) => {
386            eprintln!("RPC error: {}", e);
387            process::exit(1);
388        }
389    }
390}
391
392fn do_unblackhole(client: &mut RpcClient, hash_str: &str) {
393    let hash_bytes = match parse_hex_hash(hash_str) {
394        Some(b) if b.len() >= 16 => b,
395        _ => {
396            eprintln!("Invalid identity hash: {}", hash_str);
397            process::exit(1);
398        }
399    };
400
401    match client.call(&PickleValue::Dict(vec![(
402        PickleValue::String("unblackhole".into()),
403        PickleValue::Bytes(hash_bytes[..16].to_vec()),
404    )])) {
405        Ok(r) => {
406            if r.as_bool() == Some(true) {
407                println!(
408                    "Removed {} from blackhole list",
409                    prettyhexrep(&hash_bytes[..16])
410                );
411            } else {
412                println!(
413                    "Identity {} was not blackholed",
414                    prettyhexrep(&hash_bytes[..16])
415                );
416            }
417        }
418        Err(e) => {
419            eprintln!("RPC error: {}", e);
420            process::exit(1);
421        }
422    }
423}
424
425fn lookup_path(client: &mut RpcClient, hash_str: &str) {
426    let hash_bytes = match parse_hex_hash(hash_str) {
427        Some(b) if b.len() >= 16 => b,
428        _ => {
429            eprintln!("Invalid destination hash: {}", hash_str);
430            process::exit(1);
431        }
432    };
433
434    let mut dest_hash = [0u8; 16];
435    dest_hash.copy_from_slice(&hash_bytes[..16]);
436
437    // Query next hop
438    let response = match client.call(&PickleValue::Dict(vec![
439        (
440            PickleValue::String("get".into()),
441            PickleValue::String("next_hop".into()),
442        ),
443        (
444            PickleValue::String("destination_hash".into()),
445            PickleValue::Bytes(dest_hash.to_vec()),
446        ),
447    ])) {
448        Ok(r) => r,
449        Err(e) => {
450            eprintln!("RPC error: {}", e);
451            process::exit(1);
452        }
453    };
454
455    if let Some(next_hop) = response.as_bytes() {
456        println!("Path to {} found", prettyhexrep(&dest_hash));
457        println!("  Next hop: {}", prettyhexrep(next_hop));
458    } else {
459        println!("No path found for {}", prettyhexrep(&dest_hash));
460    }
461}
462
463fn drop_path(client: &mut RpcClient, hash_str: &str) {
464    let hash_bytes = match parse_hex_hash(hash_str) {
465        Some(b) if b.len() >= 16 => b,
466        _ => {
467            eprintln!("Invalid destination hash: {}", hash_str);
468            process::exit(1);
469        }
470    };
471
472    let mut dest_hash = [0u8; 16];
473    dest_hash.copy_from_slice(&hash_bytes[..16]);
474
475    let response = match client.call(&PickleValue::Dict(vec![
476        (
477            PickleValue::String("drop".into()),
478            PickleValue::String("path".into()),
479        ),
480        (
481            PickleValue::String("destination_hash".into()),
482            PickleValue::Bytes(dest_hash.to_vec()),
483        ),
484    ])) {
485        Ok(r) => r,
486        Err(e) => {
487            eprintln!("RPC error: {}", e);
488            process::exit(1);
489        }
490    };
491
492    if response.as_bool() == Some(true) {
493        println!("Dropped path for {}", prettyhexrep(&dest_hash));
494    } else {
495        println!("No path found for {}", prettyhexrep(&dest_hash));
496    }
497}
498
499fn drop_all_via(client: &mut RpcClient, hash_str: &str) {
500    let hash_bytes = match parse_hex_hash(hash_str) {
501        Some(b) if b.len() >= 16 => b,
502        _ => {
503            eprintln!("Invalid transport hash: {}", hash_str);
504            process::exit(1);
505        }
506    };
507
508    let mut transport_hash = [0u8; 16];
509    transport_hash.copy_from_slice(&hash_bytes[..16]);
510
511    let response = match client.call(&PickleValue::Dict(vec![
512        (
513            PickleValue::String("drop".into()),
514            PickleValue::String("all_via".into()),
515        ),
516        (
517            PickleValue::String("destination_hash".into()),
518            PickleValue::Bytes(transport_hash.to_vec()),
519        ),
520    ])) {
521        Ok(r) => r,
522        Err(e) => {
523            eprintln!("RPC error: {}", e);
524            process::exit(1);
525        }
526    };
527
528    if let Some(n) = response.as_int() {
529        println!("Dropped {} paths via {}", n, prettyhexrep(&transport_hash));
530    }
531}
532
533fn drop_announce_queues(client: &mut RpcClient) {
534    match client.call(&PickleValue::Dict(vec![(
535        PickleValue::String("drop".into()),
536        PickleValue::String("announce_queues".into()),
537    )])) {
538        Ok(_) => println!("Announce queues dropped"),
539        Err(e) => {
540            eprintln!("RPC error: {}", e);
541            process::exit(1);
542        }
543    }
544}
545
546fn remote_path(hash_str: &str, config_path: Option<&str>) {
547    let dest_hash = match crate::remote::parse_hex_hash(hash_str) {
548        Some(h) => h,
549        None => {
550            eprintln!(
551                "Invalid destination hash: {} (expected 32 hex chars)",
552                hash_str
553            );
554            process::exit(1);
555        }
556    };
557
558    eprintln!(
559        "Remote management query to {} (not yet fully implemented)",
560        prettyhexrep(&dest_hash),
561    );
562    eprintln!("Requires an active link to the remote management destination.");
563    eprintln!("This feature will work once rnsd is running and the remote node is reachable.");
564
565    let _ = (dest_hash, config_path);
566}
567
568fn print_usage() {
569    println!("Usage: rns-ctl path [OPTIONS] [DESTINATION_HASH]");
570    println!();
571    println!("Options:");
572    println!("  --config PATH, -c PATH  Path to config directory");
573    println!("  -t                      Show path table");
574    println!("  -m HOPS                 Filter path table by max hops");
575    println!("  -r                      Show rate table");
576    println!("  -d HASH                 Drop path for destination");
577    println!("  -x HASH                 Drop all paths via transport");
578    println!("  -D                      Drop all announce queues");
579    println!("  -b                      Show blackholed identities");
580    println!("  -B HASH                 Blackhole an identity");
581    println!("  -U HASH                 Remove identity from blackhole list");
582    println!("  --duration HOURS        Blackhole duration (default: permanent)");
583    println!("  --reason TEXT           Reason for blackholing");
584    println!("  -R HASH                 Query remote node via management link");
585    println!("  -j                      JSON output");
586    println!("  -v                      Increase verbosity");
587    println!("  --version               Print version and exit");
588    println!("  --help, -h              Print this help");
589}