1use 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 if let Some(ref hash_str) = remote_hash {
53 remote_path(hash_str, config_path.as_deref());
54 return;
55 }
56
57 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 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 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}