1use 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 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 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 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 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}