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