Skip to main content

rns_ctl/cmd/
probe.rs

1//! Probe reachability to a Reticulum destination.
2//!
3//! Sends a real probe packet via RPC to a running rnsd daemon,
4//! waits for the proof (delivery receipt) to measure RTT.
5
6use 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    // Positional args: destination_hash
65    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    // Load config
86    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    // Connect to rnsd via RPC
108    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    // First, ensure we have a path
130    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    // Send probe(s)
136    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
159/// Wait for a path to the destination, requesting it if needed.
160fn 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    // Check if path already exists
168    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    // Request path
178    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
217/// Send a probe and wait for the proof.
218fn 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    // Send probe
227    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    // Poll for proof
267    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
285// --- RPC helpers ---
286
287fn 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
388/// Information about a path to a destination.
389struct PathInfo {
390    next_hop: [u8; 16],
391    hops: u8,
392    interface_name: String,
393}
394
395/// Query path information for a destination via RPC.
396fn 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    // Query interface name
427    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    // Query hop count
451    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
472/// Extract hop count for a destination from a path table RPC response.
473fn 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
492/// Parse a 32-character hex string into a 16-byte hash.
493fn 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}