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