use std::path::Path;
use std::process;
use std::time::Duration;
use crate::args::Args;
use crate::format::{prettyfrequency, prettyhexrep, prettytime};
use rns_net::config;
use rns_net::pickle::PickleValue;
use rns_net::rpc::derive_auth_key;
use rns_net::storage;
use rns_net::{RpcAddr, RpcClient};
pub fn run(args: Args) {
if args.has("version") {
println!("rns-ctl {}", env!("FULL_VERSION"));
return;
}
if args.has("help") {
print_usage();
return;
}
env_logger::Builder::new()
.filter_level(match args.verbosity {
0 => log::LevelFilter::Warn,
1 => log::LevelFilter::Info,
_ => log::LevelFilter::Debug,
})
.format_timestamp_secs()
.init();
let config_path = args.config_path().map(|s| s.to_string());
let show_table = args.has("t");
let show_rates = args.has("r");
let drop_hash = args.get("d").map(|s| s.to_string());
let drop_via = args.get("x").map(|s| s.to_string());
let drop_queues = args.has("D");
let json_output = args.has("j");
let max_hops: Option<u8> = args.get("m").and_then(|s| s.parse().ok());
let show_blackholed = args.has("blackholed") || args.has("b");
let blackhole_hash = args.get("B").map(|s| s.to_string());
let unblackhole_hash = args.get("U").map(|s| s.to_string());
let duration_hours: Option<f64> = args.get("duration").and_then(|s| s.parse().ok());
let reason = args.get("reason").map(|s| s.to_string());
let remote_blackholed = args.has("p") || args.has("blackholed-list");
let remote_timeout = args
.get("W")
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(rns_core::constants::PATH_REQUEST_TIMEOUT);
let management_identity = args.get("i").or_else(|| args.get("identity"));
let remote_hash = args.get("R").map(|s| s.to_string());
if let Some(ref hash_str) = remote_hash {
remote_path(
hash_str,
management_identity,
config_path.as_deref(),
remote_timeout,
show_table,
show_rates,
remote_blackholed,
max_hops,
args.positional.first().map(String::as_str),
drop_hash.as_deref(),
drop_via.as_deref(),
drop_queues,
blackhole_hash.as_deref(),
unblackhole_hash.as_deref(),
);
return;
}
let config_dir =
storage::resolve_config_dir(config_path.as_ref().map(|s| Path::new(s.as_str())));
let config_file = config_dir.join("config");
let rns_config = if config_file.exists() {
match config::parse_file(&config_file) {
Ok(c) => c,
Err(e) => {
eprintln!("Error reading config: {}", e);
process::exit(1);
}
}
} else {
match config::parse("") {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
}
};
let paths = match storage::ensure_storage_dirs(&config_dir) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
};
let identity = match storage::load_or_create_identity(&paths.identities) {
Ok(id) => id,
Err(e) => {
eprintln!("Error loading identity: {}", e);
process::exit(1);
}
};
let auth_key = derive_auth_key(&identity.get_private_key().unwrap_or([0u8; 64]));
let rpc_port = rns_config.reticulum.instance_control_port;
let rpc_addr = RpcAddr::Tcp("127.0.0.1".into(), rpc_port);
let mut client = match RpcClient::connect(&rpc_addr, &auth_key) {
Ok(c) => c,
Err(e) => {
eprintln!("Could not connect to rnsd: {}", e);
process::exit(1);
}
};
if show_table {
show_path_table(&mut client, json_output, max_hops);
} else if show_rates {
show_rate_table(&mut client, json_output);
} else if let Some(hash_str) = blackhole_hash {
do_blackhole(&mut client, &hash_str, duration_hours, reason);
} else if let Some(hash_str) = unblackhole_hash {
do_unblackhole(&mut client, &hash_str);
} else if show_blackholed {
show_blackholed_list(&mut client);
} else if let Some(hash_str) = drop_hash {
drop_path(&mut client, &hash_str);
} else if let Some(hash_str) = drop_via {
drop_all_via(&mut client, &hash_str);
} else if drop_queues {
drop_announce_queues(&mut client);
} else if let Some(hash_str) = args.positional.first() {
lookup_path(&mut client, hash_str);
} else {
print_usage();
}
}
fn parse_hex_hash(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if s.len() % 2 != 0 {
return None;
}
let mut bytes = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
match u8::from_str_radix(&s[i..i + 2], 16) {
Ok(b) => bytes.push(b),
Err(_) => return None,
}
}
Some(bytes)
}
fn show_path_table(client: &mut RpcClient, _json_output: bool, max_hops: Option<u8>) {
let max_hops_val = match max_hops {
Some(h) => PickleValue::Int(h as i64),
None => PickleValue::None,
};
let response = match client.call(&PickleValue::Dict(vec![
(
PickleValue::String("get".into()),
PickleValue::String("path_table".into()),
),
(PickleValue::String("max_hops".into()), max_hops_val),
])) {
Ok(r) => r,
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
};
render_path_table(&response);
}
fn render_path_table(response: &PickleValue) {
if let Some(entries) = response.as_list() {
if entries.is_empty() {
println!("Path table is empty");
return;
}
println!(
"{:<34} {:>6} {:<34} {:<10} {}",
"Destination", "Hops", "Via", "Expires", "Interface"
);
println!("{}", "-".repeat(100));
for entry in entries {
let hash = entry
.get("hash")
.and_then(|v| v.as_bytes())
.map(prettyhexrep)
.unwrap_or_default();
let hops = entry.get("hops").and_then(|v| v.as_int()).unwrap_or(0);
let via = entry
.get("via")
.and_then(|v| v.as_bytes())
.map(prettyhexrep)
.unwrap_or_default();
let expires = entry
.get("expires")
.and_then(|v| v.as_float())
.map(|e| {
let remaining = e - rns_net::time::now();
if remaining > 0.0 {
prettytime(remaining)
} else {
"expired".into()
}
})
.unwrap_or_default();
let interface = entry
.get("interface")
.and_then(|v| v.as_str())
.unwrap_or("");
println!(
"{:<34} {:>6} {:<34} {:<10} {}",
&hash[..hash.len().min(32)],
hops,
&via[..via.len().min(32)],
expires,
interface,
);
}
} else {
eprintln!("Unexpected response format");
}
}
fn show_rate_table(client: &mut RpcClient, _json_output: bool) {
let response = match client.call(&PickleValue::Dict(vec![(
PickleValue::String("get".into()),
PickleValue::String("rate_table".into()),
)])) {
Ok(r) => r,
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
};
render_rate_table(&response);
}
fn render_rate_table(response: &PickleValue) {
if let Some(entries) = response.as_list() {
if entries.is_empty() {
println!("Rate table is empty");
return;
}
println!(
"{:<34} {:>12} {:>12} {:>16}",
"Destination", "Violations", "Frequency", "Blocked Until"
);
println!("{}", "-".repeat(78));
for entry in entries {
let hash = entry
.get("hash")
.and_then(|v| v.as_bytes())
.map(prettyhexrep)
.unwrap_or_default();
let violations = entry
.get("rate_violations")
.and_then(|v| v.as_int())
.unwrap_or(0);
let blocked = entry
.get("blocked_until")
.and_then(|v| v.as_float())
.map(|b| {
let remaining = b - rns_net::time::now();
if remaining > 0.0 {
prettytime(remaining)
} else {
"not blocked".into()
}
})
.unwrap_or_default();
let freq_str =
if let Some(timestamps) = entry.get("timestamps").and_then(|v| v.as_list()) {
let ts: Vec<f64> = timestamps.iter().filter_map(|v| v.as_float()).collect();
if ts.len() >= 2 {
let span = ts[ts.len() - 1] - ts[0];
if span > 0.0 {
let freq_per_sec = (ts.len() - 1) as f64 / span;
prettyfrequency(freq_per_sec)
} else {
"none".into()
}
} else {
"none".into()
}
} else {
"none".into()
};
println!(
"{:<34} {:>12} {:>12} {:>16}",
&hash[..hash.len().min(32)],
violations,
freq_str,
blocked,
);
}
}
}
fn show_blackholed_list(client: &mut RpcClient) {
let response = match client.call(&PickleValue::Dict(vec![(
PickleValue::String("get".into()),
PickleValue::String("blackholed".into()),
)])) {
Ok(r) => r,
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
};
render_blackholed_list(&response);
}
fn render_blackholed_list(response: &PickleValue) {
if let Some(entries) = response.as_list() {
if entries.is_empty() {
println!("Blackhole list is empty");
return;
}
println!("{:<34} {:<16} {}", "Identity Hash", "Expires", "Reason");
println!("{}", "-".repeat(70));
for entry in entries {
let hash = entry
.get("identity_hash")
.and_then(|v| v.as_bytes())
.map(prettyhexrep)
.unwrap_or_default();
let expires = entry
.get("expires")
.and_then(|v| v.as_float())
.map(|e| {
if e == 0.0 {
"never".into()
} else {
let remaining = e - rns_net::time::now();
if remaining > 0.0 {
prettytime(remaining)
} else {
"expired".into()
}
}
})
.unwrap_or_default();
let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("-");
println!(
"{:<34} {:<16} {}",
&hash[..hash.len().min(32)],
expires,
reason,
);
}
} else {
eprintln!("Unexpected response format");
}
}
fn do_blackhole(
client: &mut RpcClient,
hash_str: &str,
duration_hours: Option<f64>,
reason: Option<String>,
) {
let hash_bytes = match parse_hex_hash(hash_str) {
Some(b) if b.len() >= 16 => b,
_ => {
eprintln!("Invalid identity hash: {}", hash_str);
process::exit(1);
}
};
let mut dict = vec![(
PickleValue::String("blackhole".into()),
PickleValue::Bytes(hash_bytes[..16].to_vec()),
)];
if let Some(d) = duration_hours {
dict.push((
PickleValue::String("duration".into()),
PickleValue::Float(d),
));
}
if let Some(r) = reason {
dict.push((PickleValue::String("reason".into()), PickleValue::String(r)));
}
match client.call(&PickleValue::Dict(dict)) {
Ok(r) => {
if r.as_bool() == Some(true) {
println!("Blackholed identity {}", prettyhexrep(&hash_bytes[..16]));
} else {
eprintln!("Failed to blackhole identity");
}
}
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
}
}
fn do_unblackhole(client: &mut RpcClient, hash_str: &str) {
let hash_bytes = match parse_hex_hash(hash_str) {
Some(b) if b.len() >= 16 => b,
_ => {
eprintln!("Invalid identity hash: {}", hash_str);
process::exit(1);
}
};
match client.call(&PickleValue::Dict(vec![(
PickleValue::String("unblackhole".into()),
PickleValue::Bytes(hash_bytes[..16].to_vec()),
)])) {
Ok(r) => {
if r.as_bool() == Some(true) {
println!(
"Removed {} from blackhole list",
prettyhexrep(&hash_bytes[..16])
);
} else {
println!(
"Identity {} was not blackholed",
prettyhexrep(&hash_bytes[..16])
);
}
}
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
}
}
fn lookup_path(client: &mut RpcClient, hash_str: &str) {
let hash_bytes = match parse_hex_hash(hash_str) {
Some(b) if b.len() >= 16 => b,
_ => {
eprintln!("Invalid destination hash: {}", hash_str);
process::exit(1);
}
};
let mut dest_hash = [0u8; 16];
dest_hash.copy_from_slice(&hash_bytes[..16]);
let response = match client.call(&PickleValue::Dict(vec![
(
PickleValue::String("get".into()),
PickleValue::String("next_hop".into()),
),
(
PickleValue::String("destination_hash".into()),
PickleValue::Bytes(dest_hash.to_vec()),
),
])) {
Ok(r) => r,
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
};
if let Some(next_hop) = response.as_bytes() {
println!("Path to {} found", prettyhexrep(&dest_hash));
println!(" Next hop: {}", prettyhexrep(next_hop));
} else {
println!("No path found for {}", prettyhexrep(&dest_hash));
}
}
fn drop_path(client: &mut RpcClient, hash_str: &str) {
let hash_bytes = match parse_hex_hash(hash_str) {
Some(b) if b.len() >= 16 => b,
_ => {
eprintln!("Invalid destination hash: {}", hash_str);
process::exit(1);
}
};
let mut dest_hash = [0u8; 16];
dest_hash.copy_from_slice(&hash_bytes[..16]);
let response = match client.call(&PickleValue::Dict(vec![
(
PickleValue::String("drop".into()),
PickleValue::String("path".into()),
),
(
PickleValue::String("destination_hash".into()),
PickleValue::Bytes(dest_hash.to_vec()),
),
])) {
Ok(r) => r,
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
};
if response.as_bool() == Some(true) {
println!("Dropped path for {}", prettyhexrep(&dest_hash));
} else {
println!("No path found for {}", prettyhexrep(&dest_hash));
}
}
fn drop_all_via(client: &mut RpcClient, hash_str: &str) {
let hash_bytes = match parse_hex_hash(hash_str) {
Some(b) if b.len() >= 16 => b,
_ => {
eprintln!("Invalid transport hash: {}", hash_str);
process::exit(1);
}
};
let mut transport_hash = [0u8; 16];
transport_hash.copy_from_slice(&hash_bytes[..16]);
let response = match client.call(&PickleValue::Dict(vec![
(
PickleValue::String("drop".into()),
PickleValue::String("all_via".into()),
),
(
PickleValue::String("destination_hash".into()),
PickleValue::Bytes(transport_hash.to_vec()),
),
])) {
Ok(r) => r,
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
};
if let Some(n) = response.as_int() {
println!("Dropped {} paths via {}", n, prettyhexrep(&transport_hash));
}
}
fn drop_announce_queues(client: &mut RpcClient) {
match client.call(&PickleValue::Dict(vec![(
PickleValue::String("drop".into()),
PickleValue::String("announce_queues".into()),
)])) {
Ok(_) => println!("Announce queues dropped"),
Err(e) => {
eprintln!("RPC error: {}", e);
process::exit(1);
}
}
}
#[allow(clippy::too_many_arguments)]
fn remote_path(
hash_str: &str,
management_identity: Option<&str>,
config_path: Option<&str>,
remote_timeout: f64,
show_table: bool,
show_rates: bool,
remote_blackholed: bool,
max_hops: Option<u8>,
destination_filter: Option<&str>,
drop_hash: Option<&str>,
drop_via: Option<&str>,
drop_queues: bool,
blackhole_hash: Option<&str>,
unblackhole_hash: Option<&str>,
) {
if drop_hash.is_some()
|| drop_via.is_some()
|| drop_queues
|| blackhole_hash.is_some()
|| unblackhole_hash.is_some()
{
eprintln!(
"{}",
rns_net::remote_management::RemoteManagementError::Unsupported(
"remote path mutations are not implemented upstream in Reticulum 1.2.7".into(),
)
);
process::exit(1);
}
let transport_hash = match rns_net::remote_management::parse_transport_identity_hash(hash_str) {
Ok(h) => h,
Err(e) => {
eprintln!("{e}");
process::exit(1);
}
};
let management_identity_path = match management_identity {
Some(path) => Some(Path::new(path)),
None if remote_blackholed => None,
None => {
eprintln!(
"{}",
rns_net::remote_management::RemoteManagementError::MissingIdentity
);
process::exit(1);
}
};
let destination_filter = match destination_filter {
Some(hash) => Some(parse_fixed_hash(hash, "destination").unwrap_or_else(|e| {
eprintln!("{e}");
process::exit(1);
})),
None => None,
};
let timeout = Duration::from_secs_f64(remote_timeout.max(0.2));
let mut client = match rns_net::remote_management::RemoteManagementClient::connect(
config_path.map(Path::new),
management_identity_path,
timeout,
) {
Ok(client) => client,
Err(e) => {
eprintln!("{e}");
process::exit(1);
}
};
let result = if show_rates {
client.rate_table(transport_hash, destination_filter)
} else if remote_blackholed {
client.published_blackhole_list(transport_hash)
} else if show_table || destination_filter.is_some() || max_hops.is_some() {
client.path_table(transport_hash, destination_filter, max_hops)
} else {
eprintln!("Remote path mode requires -t, -r, or -p/--blackholed-list");
process::exit(1);
};
match result {
Ok(response) if show_rates => render_rate_table(&response),
Ok(response) if remote_blackholed => render_blackholed_list(&response),
Ok(response) => render_path_table(&response),
Err(e) => {
eprintln!("Remote path error: {e}");
process::exit(1);
}
}
}
fn parse_fixed_hash(s: &str, label: &str) -> Result<[u8; 16], String> {
let bytes = parse_hex_hash(s).ok_or_else(|| format!("Invalid {label} hash: {s}"))?;
if bytes.len() < 16 {
return Err(format!("Invalid {label} hash: {s}"));
}
let mut out = [0u8; 16];
out.copy_from_slice(&bytes[..16]);
Ok(out)
}
fn print_usage() {
println!("Usage: rns-ctl path [OPTIONS] [DESTINATION_HASH]");
println!();
println!("Options:");
println!(" --config PATH, -c PATH Path to config directory");
println!(" -t Show path table");
println!(" -m HOPS Filter path table by max hops");
println!(" -r Show rate table");
println!(" -d HASH Drop path for destination");
println!(" -x HASH Drop all paths via transport");
println!(" -D Drop all announce queues");
println!(" -b Show blackholed identities");
println!(" -p, --blackholed-list View published remote blackhole list with -R");
println!(" -B HASH Blackhole an identity");
println!(" -U HASH Remove identity from blackhole list");
println!(" --duration HOURS Blackhole duration (default: permanent)");
println!(" --reason TEXT Reason for blackholing");
println!(" -R HASH Query remote transport identity via management link");
println!(" -i PATH Identity file for remote management");
println!(" -W SECONDS Timeout for remote path queries");
println!(" -j JSON output");
println!(" -v Increase verbosity");
println!(" --version Print version and exit");
println!(" --help, -h Print this help");
}