Files
fips/src/bin/fipsctl.rs
T
Alex XieandJohnathan Corgan 745b523ac6 Add peer ACL enforcement with reloadable allow/deny files (#50)
Implement TCP Wrappers-style peer access control using
/etc/fips/peers.allow and /etc/fips/peers.deny files. Evaluation
order: allow overrides deny, default permit when no files exist.

Three enforcement points: outbound connect (before dialing), inbound
handshake (msg1 receipt, after restart/rekey classification), and
outbound handshake completion (msg2, before peer promotion). Files
support npub, hex pubkey, host alias, and ALL wildcard entries with
automatic mtime-based reload.

Adds fipsctl acl show query, 954-line acl module with unit tests,
and a 6-node Docker integration harness (testing/acl-allowlist/)
exercising insider, outsider, and allowed-remote scenarios. CI
matrix entry included.

Closes #50

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-04-16 05:13:07 +00:00

678 lines
21 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,
},
}
#[derive(Subcommand, Debug)]
enum StatsCommands {
/// List available history metrics
List,
/// 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::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)
}
},
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());
}
}