Files
fips/src/bin/fipsctl.rs
T
Johnathan Corgan 7493153a89 Add a feature-gated profiler for the rx-loop maintenance tick
The tick arm runs twenty-six housekeeping steps in sequence on the one
runtime thread, and is polled last, so anything slow in it holds up
inbound packets, TUN traffic and control commands behind it. Field
evidence says that happens for over a second at a time, but the
attribution behind that is two months old and predates the
connect-on-send gate, the control read isolation, and the peer lifecycle
rework. This measures it rather than continuing to reason about it.

Per step it records exact count, max and total into fixed static
counters; a dedicated writer thread drains them every ten seconds to a
TSV under /var/log/fips, one file per capture, capped at 32 MB. Nothing
accumulates: the counters are swapped to zero each interval and the
thread holds no history. Arming is `fipsctl profile tick on`, served in
the control accept task so the toggle cannot queue behind the very
behaviour it measures, and it does not survive a restart.

The whole thing is behind a Cargo feature that is off by default,
because the risk worth eliminating is the twenty-six edited call sites
in the hot loop. With the feature off the macro expands to the bare
expression, which makes the default build's neutrality something you
read off the generated code rather than something a benchmark fails to
disprove.

The measurement that matters is how late each tick is against the
deadline it was scheduled for, since the arm is polled last and that
lateness is the delay. Two earlier designs derived it from the interval
between entries and both under-reported: the schedule is fixed, so a
steady delay leaves every gap exactly one period and any gap-derived
figure reads zero under precisely the sustained overload this is meant
to find. The interval hands back its own deadline, so the delay is now a
subtraction with no model behind it, and a test drives three late ticks
at a constant gap to keep it that way.

CI gains a default-features clippy and a feature-on build and test on
both runners, closing the gap left by clippy already running with all
features.
2026-07-28 00:56:54 +00:00

726 lines
22 KiB
Rust

//! 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<PathBuf>,
#[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<PathBuf>,
},
/// 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<String>,
/// Window duration — `<N>s`, `<N>m`, `<N>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<serde_json::Value, String> {
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<serde_json::Value, String> {
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::<Ipv6Addr>() {
return is_ula(&a);
}
if let Ok(sa) = address.parse::<SocketAddrV6>() {
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::<Ipv6Addr>() {
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<f64> = 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());
}
}