//! fipsctl — FIPS control client //! //! Connects to the FIPS daemon's control socket, sends commands, and //! pretty-prints the JSON response. //! //! On Unix, uses a Unix domain socket for local IPC. //! On Windows, uses a TCP connection to localhost. use clap::{Parser, Subcommand}; use fips::config::{write_key_file, write_pub_file}; use fips::upper::hosts::HostMap; use fips::version; use fips::{Identity, encode_nsec}; use std::io::{BufRead, BufReader, Write}; use std::net::{Ipv6Addr, SocketAddrV6}; use std::path::{Path, PathBuf}; use std::time::Duration; /// FIPS control client #[derive(Parser, Debug)] #[command( name = "fipsctl", version = version::short_version(), long_version = version::long_version(), about = "Control a running FIPS daemon" )] struct Cli { /// Control socket path override #[arg(short = 's', long)] socket: Option, #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum Commands { /// Show node information Show { #[command(subcommand)] what: ShowCommands, }, /// Show peer ACL information Acl { #[command(subcommand)] what: AclCommands, }, /// Generate a new FIPS identity keypair Keygen { /// Output directory for fips.key and fips.pub #[arg(short = 'd', long = "dir", default_value_os_t = default_key_dir())] dir: PathBuf, /// Overwrite existing key files #[arg(short = 'f', long = "force")] force: bool, /// Print nsec and npub to stdout instead of writing files #[arg(short = 's', long = "stdout")] stdout: bool, }, /// Connect to a peer Connect { /// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts peer: String, /// Transport address (e.g., "192.168.1.1:2121") address: String, /// Transport type: udp, tcp, tor, ethernet transport: String, }, /// Disconnect a peer Disconnect { /// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts peer: String, }, /// Query historical node statistics Stats { #[command(subcommand)] what: StatsCommands, }, /// Control the built-in profiler (requires a `--features profiling` build) #[cfg(feature = "profiling")] Profile { #[command(subcommand)] what: ProfileCommands, }, } #[cfg(feature = "profiling")] #[derive(Subcommand, Debug)] enum ProfileCommands { /// Profile the rx-loop tick body Tick { #[command(subcommand)] action: ProfileTickAction, }, } #[cfg(feature = "profiling")] #[derive(Subcommand, Debug)] enum ProfileTickAction { /// Start a capture On { /// Directory for the capture file (default /var/log/fips) #[arg(long)] dir: Option, }, /// Stop the running capture Off, /// Report capture state Status, } #[derive(Subcommand, Debug)] enum StatsCommands { /// List available history metrics List, /// Dump current counter values for every protocol metric family Metrics, /// List peers tracked in the stats history Peers, /// Fetch a time-series window for a metric History { /// Metric name (see `fipsctl stats list`). Node-level metrics /// need no `--peer`; per-peer metrics require it. metric: String, /// Peer npub (bech32) or hostname from /etc/fips/hosts for /// per-peer metrics #[arg(long)] peer: Option, /// Window duration — `s`, `m`, `h` #[arg(long, default_value = "10m")] window: String, /// Sample resolution — `1s` (fast ring) or `1m` (slow ring) #[arg(long, default_value = "1s")] granularity: String, /// Render a Unicode block sparkline instead of JSON #[arg(long)] plot: bool, }, } #[derive(Subcommand, Debug)] enum ShowCommands { /// Node status overview Status, /// Authenticated peers Peers, /// Active links Links, /// Spanning tree state Tree, /// End-to-end sessions Sessions, /// Bloom filter state Bloom, /// MMP metrics summary Mmp, /// Coordinate cache stats Cache, /// Pending handshake connections Connections, /// Transport instances Transports, /// Routing table summary Routing, /// Identity cache entries (known node pubkeys) IdentityCache, } #[derive(Subcommand, Debug)] enum AclCommands { /// Loaded peer ACL state Show, } impl ShowCommands { fn command_name(&self) -> &'static str { match self { ShowCommands::Status => "show_status", ShowCommands::Peers => "show_peers", ShowCommands::Links => "show_links", ShowCommands::Tree => "show_tree", ShowCommands::Sessions => "show_sessions", ShowCommands::Bloom => "show_bloom", ShowCommands::Mmp => "show_mmp", ShowCommands::Cache => "show_cache", ShowCommands::Connections => "show_connections", ShowCommands::Transports => "show_transports", ShowCommands::Routing => "show_routing", ShowCommands::IdentityCache => "show_identity_cache", } } } impl AclCommands { fn command_name(&self) -> &'static str { match self { AclCommands::Show => "show_acl", } } } fn default_socket_path() -> PathBuf { fips::config::default_control_path() } /// Send a JSON request to the control socket and return the response. /// /// On Unix, connects via Unix domain socket. /// On Windows, connects via TCP to localhost. #[cfg(unix)] fn send_request(socket_path: &Path, request_json: &str) -> Result { use std::os::unix::net::UnixStream; let mut stream = UnixStream::connect(socket_path).map_err(|e| { if e.kind() == std::io::ErrorKind::PermissionDenied { format!( "cannot connect to {}: {}\n\ Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER\n\ Then log out and back in for the change to take effect.", socket_path.display(), e ) } else { format!( "cannot connect to {}: {}\nIs the FIPS daemon running?", socket_path.display(), e ) } })?; let timeout = Duration::from_secs(5); let _ = stream.set_read_timeout(Some(timeout)); let _ = stream.set_write_timeout(Some(timeout)); stream .write_all(request_json.as_bytes()) .map_err(|e| format!("failed to send request: {e}"))?; let _ = stream.shutdown(std::net::Shutdown::Write); let reader = BufReader::new(&stream); let line = reader .lines() .next() .ok_or("no response from daemon")? .map_err(|e| format!("failed to read response: {e}"))?; serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}")) } #[cfg(windows)] fn send_request(socket_path: &Path, request_json: &str) -> Result { use std::net::TcpStream; let port_str = socket_path.to_string_lossy(); let port: u16 = match port_str.parse() { Ok(p) => p, Err(_) => { eprintln!("warning: invalid port '{}', using default 21210", port_str); 21210 } }; let addr = format!("127.0.0.1:{port}"); let mut stream = TcpStream::connect(&addr).map_err(|e| { format!( "cannot connect to {}: {}\nIs the FIPS daemon running?", addr, e ) })?; let timeout = Duration::from_secs(5); let _ = stream.set_read_timeout(Some(timeout)); let _ = stream.set_write_timeout(Some(timeout)); stream .write_all(request_json.as_bytes()) .map_err(|e| format!("failed to send request: {e}"))?; let _ = stream.shutdown(std::net::Shutdown::Write); let reader = BufReader::new(&stream); let line = reader .lines() .next() .ok_or("no response from daemon")? .map_err(|e| format!("failed to read response: {e}"))?; serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}")) } /// Build a request JSON string for a simple command (no params). fn build_query(command: &str) -> String { format!("{{\"command\":\"{command}\"}}\n") } /// Build a request JSON string for a command with params. fn build_command(command: &str, params: serde_json::Value) -> String { let req = serde_json::json!({"command": command, "params": params}); format!("{}\n", serde_json::to_string(&req).unwrap()) } /// Print a control socket response, handling error status. fn print_response(value: &serde_json::Value) { let status = value .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); if status == "error" { let msg = value .get("message") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); eprintln!("error: {msg}"); std::process::exit(1); } let output = if let Some(data) = value.get("data") { serde_json::to_string_pretty(data) } else { serde_json::to_string_pretty(value) }; println!("{}", output.unwrap_or_else(|_| format!("{value}"))); } /// Default directory for keygen output. fn default_key_dir() -> PathBuf { #[cfg(unix)] { PathBuf::from("/etc/fips") } #[cfg(windows)] { dirs::config_dir() .map(|d| d.join("fips")) .unwrap_or_else(|| PathBuf::from("C:\\ProgramData\\fips")) } } /// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range). /// /// Handles three common syntaxes: /// - bare IPv6: `fd9d:...` /// - bracketed + port: `[fd9d:...]:2121` /// - bare IPv6 + port: `fd9d:...:2121` (ambiguous; accepted if tail is numeric) fn is_fips_mesh_address(address: &str) -> bool { let is_ula = |a: &Ipv6Addr| a.octets()[0] == 0xfd; if let Ok(a) = address.parse::() { return is_ula(&a); } if let Ok(sa) = address.parse::() { return is_ula(sa.ip()); } if let Some((host, port)) = address.rsplit_once(':') && port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() { let host = host.trim_start_matches('[').trim_end_matches(']'); if let Ok(a) = host.parse::() { return is_ula(&a); } } false } /// Reject `fd00::/8` addresses for transports that expect a reachable network endpoint. /// /// FIPS mesh ULAs are derived from npubs and only make sense as destinations /// inside an already-established mesh — they are not valid udp/tcp/ethernet /// transport endpoints. Without this check the CLI echoes success while the /// daemon rejects the bind with EAFNOSUPPORT (issue #61). fn validate_connect_address(address: &str, transport: &str) -> Result<(), String> { let checked = matches!(transport, "udp" | "tcp" | "ethernet"); if checked && is_fips_mesh_address(address) { return Err(format!( "'{address}' is a FIPS mesh address (fd00::/8), not a reachable {transport} endpoint.\n\ Provide the peer's routable IP/hostname and port (e.g., '192.0.2.1:2121' or 'peer.example.com:2121')." )); } Ok(()) } /// Resolve a peer identifier to an npub. /// /// If the identifier starts with "npub1", it's returned as-is. /// Otherwise, it's looked up as a hostname in the hosts file. fn resolve_peer(peer: &str) -> String { if peer.starts_with("npub1") { return peer.to_string(); } let hosts = HostMap::load_hosts_file(Path::new(fips::upper::hosts::DEFAULT_HOSTS_PATH)); match hosts.lookup_npub(peer) { Some(npub) => npub.to_string(), None => { eprintln!("error: unknown host '{peer}'"); eprintln!( "Not found in {} and not an npub.", fips::upper::hosts::DEFAULT_HOSTS_PATH ); std::process::exit(1); } } } fn main() { let cli = Cli::parse(); // Commands that don't require a running daemon if let Commands::Keygen { dir, force, stdout } = &cli.command { let identity = Identity::generate(); let nsec = encode_nsec(&identity.keypair().secret_key()); let npub = identity.npub(); if *stdout { println!("{nsec}"); println!("{npub}"); return; } let key_path = dir.join("fips.key"); let pub_path = dir.join("fips.pub"); if key_path.exists() && !force { eprintln!("error: key file already exists: {}", key_path.display()); eprintln!("Use --force to overwrite."); std::process::exit(1); } if let Err(e) = std::fs::create_dir_all(dir) { eprintln!("error: cannot create directory {}: {e}", dir.display()); std::process::exit(1); } if let Err(e) = write_key_file(&key_path, &nsec) { eprintln!("error: failed to write key file: {e}"); std::process::exit(1); } if let Err(e) = write_pub_file(&pub_path, &npub) { eprintln!("error: failed to write pub file: {e}"); std::process::exit(1); } eprintln!("{npub}"); eprintln!("Key files written to: {}/", dir.display()); eprintln!(); eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml"); eprintln!(" or these keys will be overwritten on next daemon start."); return; } let socket_path = cli.socket.unwrap_or_else(default_socket_path); let request = match &cli.command { Commands::Show { what } => build_query(what.command_name()), Commands::Acl { what } => build_query(what.command_name()), Commands::Connect { peer, address, transport, } => { if let Err(e) = validate_connect_address(address, transport) { eprintln!("error: {e}"); std::process::exit(1); } let npub = resolve_peer(peer); build_command( "connect", serde_json::json!({ "npub": npub, "address": address, "transport": transport, }), ) } Commands::Disconnect { peer } => { let npub = resolve_peer(peer); build_command("disconnect", serde_json::json!({"npub": npub})) } Commands::Stats { what } => match what { StatsCommands::List => build_query("show_stats_list"), StatsCommands::Metrics => build_query("show_metrics"), StatsCommands::Peers => build_query("show_stats_peers"), StatsCommands::History { metric, peer, window, granularity, .. } => { let mut params = serde_json::json!({ "metric": metric, "window": window, "granularity": granularity, }); if let Some(p) = peer { let resolved = resolve_peer(p); params["peer"] = serde_json::json!(resolved); } build_command("show_stats_history", params) } }, #[cfg(feature = "profiling")] Commands::Profile { what } => match what { ProfileCommands::Tick { action } => match action { ProfileTickAction::On { dir } => match dir { Some(dir) => build_command( "profile_tick_on", serde_json::json!({"dir": dir.display().to_string()}), ), None => build_query("profile_tick_on"), }, ProfileTickAction::Off => build_query("profile_tick_off"), ProfileTickAction::Status => build_query("profile_tick_status"), }, }, Commands::Keygen { .. } => unreachable!(), }; // For plot output we need to post-process the JSON response rather // than pretty-print it. if let Commands::Stats { what: StatsCommands::History { plot: true, metric, .. }, } = &cli.command { match send_request(&socket_path, &request) { Ok(value) => print_plot(&value, metric), Err(e) => { eprintln!("error: {e}"); std::process::exit(1); } } return; } match send_request(&socket_path, &request) { Ok(value) => print_response(&value), Err(e) => { eprintln!("error: {e}"); std::process::exit(1); } } } /// Render the response as a Unicode block sparkline plot. fn print_plot(value: &serde_json::Value, metric: &str) { let status = value .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown"); if status == "error" { let msg = value .get("message") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); eprintln!("error: {msg}"); std::process::exit(1); } let data = match value.get("data") { Some(d) => d, None => { eprintln!("error: no data in response"); std::process::exit(1); } }; let values: Vec = data .get("values") .and_then(|v| v.as_array()) .map(|arr| arr.iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect()) .unwrap_or_default(); let unit = data.get("unit").and_then(|v| v.as_str()).unwrap_or(""); let granularity_seconds = data .get("granularity_seconds") .and_then(|v| v.as_u64()) .unwrap_or(1); if values.is_empty() { println!("{metric}: no data yet"); return; } let (min, max) = values .iter() .filter(|v| !v.is_nan()) .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| { (lo.min(v), hi.max(v)) }); let (min, max) = if min.is_finite() { (min, max) } else { (0.0, 0.0) }; let last = values .iter() .rev() .find(|v| !v.is_nan()) .copied() .unwrap_or(f64::NAN); let width_secs = (values.len() as u64) * granularity_seconds; let gap_count = values.iter().filter(|v| v.is_nan()).count(); println!( "{metric} ({unit}) — {n} samples @ {g}s = {w}s window{gap}", n = values.len(), g = granularity_seconds, w = width_secs, gap = if gap_count > 0 { format!(" ({gap_count} gaps)") } else { String::new() }, ); let last_str = if last.is_nan() { "-".to_string() } else { format!("{last:.3}") }; println!(" min={min:.3} max={max:.3} last={last_str}"); println!(" {}", sparkline(&values, min, max)); } /// Render a slice of values as Unicode block characters. /// /// Uses eight discrete levels: `▁▂▃▄▅▆▇█`. Constant series and empty /// inputs render as a single-level line (`▄`). fn sparkline(values: &[f64], min: f64, max: f64) -> String { const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let range = max - min; values .iter() .map(|&v| { if v.is_nan() { ' ' } else if !range.is_finite() || range <= 0.0 { BLOCKS[3] } else { let norm = ((v - min) / range).clamp(0.0, 1.0); let idx = (norm * (BLOCKS.len() as f64 - 1.0)).round() as usize; BLOCKS[idx.min(BLOCKS.len() - 1)] } }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_acl_show_command_name() { assert_eq!(AclCommands::Show.command_name(), "show_acl"); } #[test] fn test_cli_parses_acl_show() { let cli = Cli::try_parse_from(["fipsctl", "acl", "show"]).unwrap(); assert!(matches!( cli.command, Commands::Acl { what: AclCommands::Show } )); } #[test] fn detects_bare_ula_literal() { assert!(is_fips_mesh_address("fd9d:abcd::1")); assert!(is_fips_mesh_address("fd00::")); assert!(is_fips_mesh_address( "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" )); } #[test] fn detects_bracketed_ula_with_port() { assert!(is_fips_mesh_address("[fd9d:abcd::1]:2121")); assert!(is_fips_mesh_address("[fd00::1]:8443")); } #[test] fn detects_bare_ula_with_port() { assert!(is_fips_mesh_address("fd9d:abcd::1:2121")); } #[test] fn rejects_non_ula_ipv6() { // fc00::/7 other half (fcXX:) is also ULA but not fd00::/8 — we only // block the fd-prefixed half that FIPS actually uses. assert!(!is_fips_mesh_address("fc00::1")); assert!(!is_fips_mesh_address("::1")); assert!(!is_fips_mesh_address("2001:db8::1")); assert!(!is_fips_mesh_address("[2001:db8::1]:2121")); } #[test] fn ignores_ipv4_and_hostnames() { assert!(!is_fips_mesh_address("192.0.2.1:2121")); assert!(!is_fips_mesh_address("peer.example.com:2121")); assert!(!is_fips_mesh_address("coinos.pro:2121")); } #[test] fn validates_only_target_transports() { assert!(validate_connect_address("fd9d::1:2121", "udp").is_err()); assert!(validate_connect_address("fd9d::1:2121", "tcp").is_err()); assert!(validate_connect_address("fd9d::1:2121", "ethernet").is_err()); // Other transports are not inspected — they may legitimately accept // non-IP endpoints (tor onion, etc.). assert!(validate_connect_address("fd9d::1:2121", "tor").is_ok()); } #[test] fn allows_valid_endpoints() { assert!(validate_connect_address("192.0.2.1:2121", "udp").is_ok()); assert!(validate_connect_address("peer.example.com:2121", "tcp").is_ok()); assert!(validate_connect_address("[2001:db8::1]:2121", "udp").is_ok()); } }