1use std::path::Path;
7use std::process;
8use std::time::{Duration, Instant};
9
10use crate::args::Args;
11use crate::format::prettyhexrep;
12use rns_net::config;
13use rns_net::pickle::PickleValue;
14use rns_net::rpc::derive_auth_key;
15use rns_net::storage;
16use rns_net::{RpcAddr, RpcClient};
17
18const DEFAULT_TIMEOUT: f64 = 15.0;
19const DEFAULT_PAYLOAD_SIZE: usize = 16;
20
21pub fn run(args: Args) {
22 if args.has("version") {
23 println!("rns-ctl {}", env!("FULL_VERSION"));
24 return;
25 }
26
27 if args.has("help") {
28 print_usage();
29 return;
30 }
31
32 env_logger::Builder::new()
33 .filter_level(match args.verbosity {
34 0 => log::LevelFilter::Warn,
35 1 => log::LevelFilter::Info,
36 _ => log::LevelFilter::Debug,
37 })
38 .format_timestamp_secs()
39 .init();
40
41 let config_path = args.config_path().map(|s| s.to_string());
42 let timeout: f64 = args
43 .get("t")
44 .or_else(|| args.get("timeout"))
45 .and_then(|s| s.parse().ok())
46 .unwrap_or(DEFAULT_TIMEOUT);
47 let payload_size: usize = args
48 .get("s")
49 .or_else(|| args.get("size"))
50 .and_then(|s| s.parse().ok())
51 .unwrap_or(DEFAULT_PAYLOAD_SIZE);
52 let count: usize = args
53 .get("n")
54 .or_else(|| args.get("count"))
55 .and_then(|s| s.parse().ok())
56 .unwrap_or(1);
57 let wait: f64 = args
58 .get("w")
59 .or_else(|| args.get("wait"))
60 .and_then(|s| s.parse().ok())
61 .unwrap_or(0.0);
62 let verbosity = args.verbosity;
63
64 let dest_hash_hex = match args.positional.first() {
66 Some(h) => h.clone(),
67 None => {
68 eprintln!("No destination hash specified.");
69 print_usage();
70 process::exit(1);
71 }
72 };
73
74 let dest_hash = match parse_dest_hash(&dest_hash_hex) {
75 Some(h) => h,
76 None => {
77 eprintln!(
78 "Invalid destination hash: {} (expected 32 hex chars)",
79 dest_hash_hex,
80 );
81 process::exit(1);
82 }
83 };
84
85 let config_dir =
87 storage::resolve_config_dir(config_path.as_ref().map(|s| Path::new(s.as_str())));
88 let config_file = config_dir.join("config");
89 let rns_config = if config_file.exists() {
90 match config::parse_file(&config_file) {
91 Ok(c) => c,
92 Err(e) => {
93 eprintln!("Config parse error: {}", e);
94 process::exit(1);
95 }
96 }
97 } else {
98 match config::parse("") {
99 Ok(c) => c,
100 Err(e) => {
101 eprintln!("Config parse error: {}", e);
102 process::exit(1);
103 }
104 }
105 };
106
107 let rpc_port = rns_config.reticulum.instance_control_port;
109 let identity_path = config_dir.join("storage").join("identity");
110 let identity = match storage::load_identity(&identity_path) {
111 Ok(id) => id,
112 Err(e) => {
113 eprintln!("Failed to load identity (is rnsd running?): {}", e);
114 process::exit(1);
115 }
116 };
117
118 let prv_key = match identity.get_private_key() {
119 Some(k) => k,
120 None => {
121 eprintln!("Identity has no private key");
122 process::exit(1);
123 }
124 };
125
126 let auth_key = derive_auth_key(&prv_key);
127 let rpc_addr = RpcAddr::Tcp("127.0.0.1".into(), rpc_port);
128
129 let timeout_dur = Duration::from_secs_f64(timeout);
131 if !wait_for_path(&rpc_addr, &auth_key, &dest_hash, timeout_dur, verbosity) {
132 process::exit(1);
133 }
134
135 let mut any_failed = false;
137 for i in 0..count {
138 if i > 0 && wait > 0.0 {
139 std::thread::sleep(Duration::from_secs_f64(wait));
140 }
141
142 if !send_and_wait_probe(
143 &rpc_addr,
144 &auth_key,
145 &dest_hash,
146 payload_size,
147 timeout_dur,
148 verbosity,
149 ) {
150 any_failed = true;
151 }
152 }
153
154 if any_failed {
155 process::exit(1);
156 }
157}
158
159fn wait_for_path(
161 addr: &RpcAddr,
162 auth_key: &[u8; 32],
163 dest_hash: &[u8; 16],
164 timeout: Duration,
165 verbosity: u8,
166) -> bool {
167 match query_has_path(addr, auth_key, dest_hash) {
169 Ok(true) => return true,
170 Ok(false) => {}
171 Err(e) => {
172 eprintln!("RPC error: {}", e);
173 return false;
174 }
175 }
176
177 if let Err(e) = request_path(addr, auth_key, dest_hash) {
179 eprintln!("RPC error requesting path: {}", e);
180 return false;
181 }
182
183 eprint!("Waiting for path to {}... ", prettyhexrep(dest_hash));
184
185 let start = Instant::now();
186 while start.elapsed() < timeout {
187 std::thread::sleep(Duration::from_millis(250));
188 match query_has_path(addr, auth_key, dest_hash) {
189 Ok(true) => {
190 eprintln!("found!");
191 if verbosity > 0 {
192 if let Ok(Some(info)) = query_path_info(addr, auth_key, dest_hash) {
193 eprintln!(
194 " via {} on {}, {} hops",
195 prettyhexrep(&info.next_hop),
196 info.interface_name,
197 info.hops,
198 );
199 }
200 }
201 return true;
202 }
203 Ok(false) => continue,
204 Err(_) => continue,
205 }
206 }
207
208 eprintln!("timeout!");
209 eprintln!(
210 "Path to {} not found within {:.1}s",
211 prettyhexrep(dest_hash),
212 timeout.as_secs_f64(),
213 );
214 false
215}
216
217fn send_and_wait_probe(
219 addr: &RpcAddr,
220 auth_key: &[u8; 32],
221 dest_hash: &[u8; 16],
222 payload_size: usize,
223 timeout: Duration,
224 verbosity: u8,
225) -> bool {
226 let (packet_hash, hops) = match send_probe_rpc(addr, auth_key, dest_hash, payload_size) {
228 Ok(Some(result)) => result,
229 Ok(None) => {
230 eprintln!(
231 "Could not send probe to {} (identity not known)",
232 prettyhexrep(dest_hash),
233 );
234 return false;
235 }
236 Err(e) => {
237 eprintln!("RPC error sending probe: {}", e);
238 return false;
239 }
240 };
241
242 if verbosity > 0 {
243 if let Ok(Some(info)) = query_path_info(addr, auth_key, dest_hash) {
244 println!(
245 "Sent probe ({} bytes) to {} via {} on {}",
246 payload_size,
247 prettyhexrep(dest_hash),
248 prettyhexrep(&info.next_hop),
249 info.interface_name,
250 );
251 } else {
252 println!(
253 "Sent probe ({} bytes) to {}",
254 payload_size,
255 prettyhexrep(dest_hash),
256 );
257 }
258 } else {
259 println!(
260 "Sent probe ({} bytes) to {}",
261 payload_size,
262 prettyhexrep(dest_hash),
263 );
264 }
265
266 let start = Instant::now();
268 while start.elapsed() < timeout {
269 std::thread::sleep(Duration::from_millis(100));
270 match check_proof_rpc(addr, auth_key, &packet_hash) {
271 Ok(Some(rtt)) => {
272 let rtt_ms = rtt * 1000.0;
273 println!("Probe reply received in {:.0}ms, {} hops", rtt_ms, hops,);
274 return true;
275 }
276 Ok(None) => continue,
277 Err(_) => continue,
278 }
279 }
280
281 println!("Probe timed out after {:.1}s", timeout.as_secs_f64());
282 false
283}
284
285fn query_has_path(
288 addr: &RpcAddr,
289 auth_key: &[u8; 32],
290 dest_hash: &[u8; 16],
291) -> Result<bool, String> {
292 let mut client =
293 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
294 let response = client
295 .call(&PickleValue::Dict(vec![
296 (
297 PickleValue::String("get".into()),
298 PickleValue::String("next_hop".into()),
299 ),
300 (
301 PickleValue::String("destination_hash".into()),
302 PickleValue::Bytes(dest_hash.to_vec()),
303 ),
304 ]))
305 .map_err(|e| format!("RPC call: {}", e))?;
306 Ok(response.as_bytes().map_or(false, |b| b.len() == 16))
307}
308
309fn request_path(addr: &RpcAddr, auth_key: &[u8; 32], dest_hash: &[u8; 16]) -> Result<(), String> {
310 let mut client =
311 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
312 let _ = client
313 .call(&PickleValue::Dict(vec![(
314 PickleValue::String("request_path".into()),
315 PickleValue::Bytes(dest_hash.to_vec()),
316 )]))
317 .map_err(|e| format!("RPC call: {}", e))?;
318 Ok(())
319}
320
321fn send_probe_rpc(
322 addr: &RpcAddr,
323 auth_key: &[u8; 32],
324 dest_hash: &[u8; 16],
325 payload_size: usize,
326) -> Result<Option<([u8; 32], u8)>, String> {
327 let mut client =
328 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
329 let response = client
330 .call(&PickleValue::Dict(vec![
331 (
332 PickleValue::String("send_probe".into()),
333 PickleValue::Bytes(dest_hash.to_vec()),
334 ),
335 (
336 PickleValue::String("size".into()),
337 PickleValue::Int(payload_size as i64),
338 ),
339 ]))
340 .map_err(|e| format!("RPC call: {}", e))?;
341
342 match &response {
343 PickleValue::Dict(entries) => {
344 let packet_hash = entries
345 .iter()
346 .find(|(k, _)| *k == PickleValue::String("packet_hash".into()))
347 .and_then(|(_, v)| v.as_bytes());
348 let hops = entries
349 .iter()
350 .find(|(k, _)| *k == PickleValue::String("hops".into()))
351 .and_then(|(_, v)| v.as_int());
352 if let (Some(ph), Some(h)) = (packet_hash, hops) {
353 if ph.len() >= 32 {
354 let mut hash = [0u8; 32];
355 hash.copy_from_slice(&ph[..32]);
356 Ok(Some((hash, h as u8)))
357 } else {
358 Ok(None)
359 }
360 } else {
361 Ok(None)
362 }
363 }
364 _ => Ok(None),
365 }
366}
367
368fn check_proof_rpc(
369 addr: &RpcAddr,
370 auth_key: &[u8; 32],
371 packet_hash: &[u8; 32],
372) -> Result<Option<f64>, String> {
373 let mut client =
374 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
375 let response = client
376 .call(&PickleValue::Dict(vec![(
377 PickleValue::String("check_proof".into()),
378 PickleValue::Bytes(packet_hash.to_vec()),
379 )]))
380 .map_err(|e| format!("RPC call: {}", e))?;
381
382 match &response {
383 PickleValue::Float(rtt) => Ok(Some(*rtt)),
384 _ => Ok(None),
385 }
386}
387
388struct PathInfo {
390 next_hop: [u8; 16],
391 hops: u8,
392 interface_name: String,
393}
394
395fn query_path_info(
397 addr: &RpcAddr,
398 auth_key: &[u8; 32],
399 dest_hash: &[u8; 16],
400) -> Result<Option<PathInfo>, String> {
401 let mut client =
402 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
403
404 let response = client
405 .call(&PickleValue::Dict(vec![
406 (
407 PickleValue::String("get".into()),
408 PickleValue::String("next_hop".into()),
409 ),
410 (
411 PickleValue::String("destination_hash".into()),
412 PickleValue::Bytes(dest_hash.to_vec()),
413 ),
414 ]))
415 .map_err(|e| format!("RPC call: {}", e))?;
416
417 let next_hop = match response.as_bytes() {
418 Some(b) if b.len() == 16 => {
419 let mut h = [0u8; 16];
420 h.copy_from_slice(b);
421 h
422 }
423 _ => return Ok(None),
424 };
425
426 let if_name = {
428 let mut client2 =
429 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
430
431 let resp = client2
432 .call(&PickleValue::Dict(vec![
433 (
434 PickleValue::String("get".into()),
435 PickleValue::String("next_hop_if_name".into()),
436 ),
437 (
438 PickleValue::String("destination_hash".into()),
439 PickleValue::Bytes(dest_hash.to_vec()),
440 ),
441 ]))
442 .map_err(|e| format!("RPC call: {}", e))?;
443
444 match resp {
445 PickleValue::String(s) => s,
446 _ => "unknown".into(),
447 }
448 };
449
450 let hops = {
452 let mut client3 =
453 RpcClient::connect(addr, auth_key).map_err(|e| format!("RPC connect: {}", e))?;
454
455 let resp = client3
456 .call(&PickleValue::Dict(vec![(
457 PickleValue::String("get".into()),
458 PickleValue::String("path_table".into()),
459 )]))
460 .map_err(|e| format!("RPC call: {}", e))?;
461
462 extract_hops_from_path_table(&resp, dest_hash)
463 };
464
465 Ok(Some(PathInfo {
466 next_hop,
467 hops,
468 interface_name: if_name,
469 }))
470}
471
472fn extract_hops_from_path_table(response: &PickleValue, dest_hash: &[u8; 16]) -> u8 {
474 if let PickleValue::List(entries) = response {
475 for entry in entries {
476 if let PickleValue::List(fields) = entry {
477 if fields.len() >= 4 {
478 if let Some(hash_bytes) = fields[0].as_bytes() {
479 if hash_bytes == dest_hash {
480 if let PickleValue::Int(h) = &fields[3] {
481 return *h as u8;
482 }
483 }
484 }
485 }
486 }
487 }
488 }
489 0
490}
491
492fn parse_dest_hash(hex: &str) -> Option<[u8; 16]> {
494 if hex.len() != 32 {
495 return None;
496 }
497 let bytes: Vec<u8> = (0..hex.len())
498 .step_by(2)
499 .filter_map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
500 .collect();
501 if bytes.len() != 16 {
502 return None;
503 }
504 let mut result = [0u8; 16];
505 result.copy_from_slice(&bytes);
506 Some(result)
507}
508
509fn print_usage() {
510 println!("Usage: rns-ctl probe [OPTIONS] <destination_hash>");
511 println!();
512 println!("Send a probe packet to a Reticulum destination and measure RTT.");
513 println!();
514 println!("Arguments:");
515 println!(" <destination_hash> Hex hash of the destination (32 chars)");
516 println!();
517 println!("Options:");
518 println!(" -c, --config PATH Config directory path");
519 println!(" -t, --timeout SECS Timeout in seconds (default: 15)");
520 println!(" -s, --size BYTES Probe payload size (default: 16)");
521 println!(" -n, --count N Number of probes to send (default: 1)");
522 println!(" -w, --wait SECS Seconds between probes (default: 0)");
523 println!(" -v, --verbose Increase verbosity");
524 println!(" --version Show version");
525 println!(" -h, --help Show this help");
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use rns_net::pickle::PickleValue;
532
533 #[test]
534 fn parse_valid_hash() {
535 let hex = "0123456789abcdef0123456789abcdef";
536 let hash = parse_dest_hash(hex).unwrap();
537 assert_eq!(hash[0], 0x01);
538 assert_eq!(hash[1], 0x23);
539 assert_eq!(hash[15], 0xef);
540 }
541
542 #[test]
543 fn parse_invalid_hash_short() {
544 assert!(parse_dest_hash("0123").is_none());
545 }
546
547 #[test]
548 fn parse_invalid_hash_long() {
549 assert!(parse_dest_hash("0123456789abcdef0123456789abcdef00").is_none());
550 }
551
552 #[test]
553 fn parse_invalid_hash_bad_hex() {
554 assert!(parse_dest_hash("xyz3456789abcdef0123456789abcdef").is_none());
555 }
556
557 #[test]
558 fn parse_uppercase_hash() {
559 let hex = "0123456789ABCDEF0123456789ABCDEF";
560 let hash = parse_dest_hash(hex).unwrap();
561 assert_eq!(hash[0], 0x01);
562 assert_eq!(hash[15], 0xEF);
563 }
564
565 #[test]
566 fn default_timeout() {
567 assert!((DEFAULT_TIMEOUT - 15.0).abs() < f64::EPSILON);
568 }
569
570 #[test]
571 fn prettyhexrep_format() {
572 let hash = [
573 0xAA, 0xBB, 0xCC, 0xDD, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99,
574 0xAA, 0xBB,
575 ];
576 let hex = prettyhexrep(&hash);
577 assert_eq!(hex, "aabbccdd00112233445566778899aabb");
578 }
579
580 #[test]
581 fn extract_hops_empty_table() {
582 let table = PickleValue::List(vec![]);
583 let hash = [0u8; 16];
584 assert_eq!(extract_hops_from_path_table(&table, &hash), 0);
585 }
586
587 #[test]
588 fn extract_hops_found() {
589 let dest = vec![0xAA; 16];
590 let entry = PickleValue::List(vec![
591 PickleValue::Bytes(dest.clone()),
592 PickleValue::Float(1000.0),
593 PickleValue::Bytes(vec![0xBB; 16]),
594 PickleValue::Int(3),
595 PickleValue::Float(2000.0),
596 PickleValue::String("TCPInterface".into()),
597 ]);
598 let table = PickleValue::List(vec![entry]);
599 let mut hash = [0u8; 16];
600 hash.copy_from_slice(&dest);
601 assert_eq!(extract_hops_from_path_table(&table, &hash), 3);
602 }
603
604 #[test]
605 fn extract_hops_not_found() {
606 let entry = PickleValue::List(vec![
607 PickleValue::Bytes(vec![0xCC; 16]),
608 PickleValue::Float(1000.0),
609 PickleValue::Bytes(vec![0xBB; 16]),
610 PickleValue::Int(5),
611 PickleValue::Float(2000.0),
612 PickleValue::String("TCPInterface".into()),
613 ]);
614 let table = PickleValue::List(vec![entry]);
615 let hash = [0xAA; 16];
616 assert_eq!(extract_hops_from_path_table(&table, &hash), 0);
617 }
618}