Add connect/disconnect control commands and maelstrom chaos scenario

Add runtime peer management to the FIPS daemon via control socket
commands, and a new chaos simulation scenario that exercises dynamic
topology mutation with ephemeral node identities.

Daemon (connect/disconnect commands):
- Extend control socket Request with optional params field
- Add commands.rs module for mutating command dispatch, separate from
  read-only queries
- Add api_connect() on Node: builds ephemeral PeerConfig (no auto-
  reconnect), pre-seeds identity cache, reuses initiate_peer_connection
- Add api_disconnect() on Node: calls remove_active_peer(), clears
  retry_pending to suppress reconnection
- Route non-show_* commands to async command dispatch in rx_loop

fipsctl CLI:
- Add Connect and Disconnect subcommands accepting npub or hostname
- Resolve hostnames from /etc/fips/hosts before sending to daemon
- Refactor socket I/O into reusable send_request helper

Chaos simulator (maelstrom scenario):
- Add PeerChurnManager: periodically disconnects a random active link
  and connects a random unconnected node pair via control socket
- Add send_command() to control.py using base64-encoded JSON payloads
  to avoid shell quoting issues in docker exec
- Add PeerChurnConfig to scenario with interval and ephemeral_fraction
- Ephemeral identity support: nodes configured without nsec generate
  fresh keypairs on restart; simulator queries show_status for new
  npub and updates its cache via on_node_restart callback
- Add maelstrom.yaml: all chaos dimensions (netem, link flaps, node
  churn, peer topology churn, traffic) with 50% ephemeral identity
This commit is contained in:
Johnathan Corgan
2026-03-20 05:44:31 +00:00
parent 8d51dbd268
commit 5053cf673d
13 changed files with 757 additions and 124 deletions
+169 -113
View File
@@ -1,10 +1,11 @@
//! fipsctl — FIPS control client
//!
//! Connects to the FIPS daemon's Unix domain control socket, sends a
//! query command, and pretty-prints the JSON response.
//! Connects to the FIPS daemon's Unix domain control socket, sends
//! commands, and pretty-prints the JSON response.
use clap::{Parser, Subcommand};
use fips::config::{write_key_file, write_pub_file};
use fips::upper::hosts::HostMap;
use fips::version;
use fips::{encode_nsec, Identity};
use std::io::{BufRead, BufReader, Write};
@@ -18,7 +19,7 @@ use std::time::Duration;
name = "fipsctl",
version = version::short_version(),
long_version = version::long_version(),
about = "Query a running FIPS daemon"
about = "Control a running FIPS daemon"
)]
struct Cli {
/// Control socket path override
@@ -48,6 +49,20 @@ enum Commands {
#[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,
},
}
#[derive(Subcommand, Debug)]
@@ -108,123 +123,58 @@ fn default_socket_path() -> PathBuf {
}
}
fn main() {
let cli = Cli::parse();
// Commands that don't require a running daemon
match &cli.command {
Commands::Keygen {
dir,
force,
stdout,
} => {
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 {}: {}", dir.display(), e);
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;
}
Commands::Show { .. } => {}
}
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
let command_name = match &cli.command {
Commands::Show { what } => what.command_name(),
Commands::Keygen { .. } => unreachable!(),
};
// Connect to the control socket
let mut stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot connect to {}: {}",
/// Send a JSON request to the control socket and return the response.
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
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
);
if e.kind() == std::io::ErrorKind::PermissionDenied {
eprintln!(
"Hint: add your user to the 'fips' group: sudo usermod -aG fips $USER"
);
eprintln!("Then log out and back in for the change to take effect.");
} else {
eprintln!("Is the FIPS daemon running?");
}
std::process::exit(1);
)
} else {
format!(
"cannot connect to {}: {}\nIs the FIPS daemon running?",
socket_path.display(),
e
)
}
};
})?;
// Set timeouts
let timeout = Duration::from_secs(2);
let timeout = Duration::from_secs(5);
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
// Send request
let request = format!("{{\"command\":\"{}\"}}\n", command_name);
if let Err(e) = stream.write_all(request.as_bytes()) {
eprintln!("error: failed to send request: {}", e);
std::process::exit(1);
}
// Shutdown write half to signal end of request
stream
.write_all(request_json.as_bytes())
.map_err(|e| format!("failed to send request: {e}"))?;
let _ = stream.shutdown(std::net::Shutdown::Write);
// Read response
let reader = BufReader::new(&stream);
let response_line = match reader.lines().next() {
Some(Ok(line)) => line,
Some(Err(e)) => {
eprintln!("error: failed to read response: {}", e);
std::process::exit(1);
}
None => {
eprintln!("error: no response from daemon");
std::process::exit(1);
}
};
let line = reader
.lines()
.next()
.ok_or("no response from daemon")?
.map_err(|e| format!("failed to read response: {e}"))?;
// Parse and pretty-print
let Ok(value) = serde_json::from_str::<serde_json::Value>(&response_line) else {
// Not JSON, print raw
println!("{}", response_line);
return;
};
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())
@@ -235,15 +185,121 @@ fn main() {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
eprintln!("error: {}", msg);
eprintln!("error: {msg}");
std::process::exit(1);
}
// Pretty-print the data field (or whole response if no data field)
let output = if let Some(data) = value.get("data") {
serde_json::to_string_pretty(data).unwrap_or(response_line)
serde_json::to_string_pretty(data)
} else {
serde_json::to_string_pretty(&value).unwrap_or(response_line)
serde_json::to_string_pretty(value)
};
println!("{}", output);
println!("{}", output.unwrap_or_else(|_| format!("{value}")));
}
/// 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 /etc/fips/hosts.
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 /etc/fips/hosts and not an npub.");
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::Connect {
peer,
address,
transport,
} => {
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::Keygen { .. } => unreachable!(),
};
match send_request(&socket_path, &request) {
Ok(value) => print_response(&value),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Mutating control socket commands.
//!
//! Commands that modify node state (connect, disconnect) are handled here,
//! separate from read-only queries in `queries.rs`.
use super::protocol::Response;
use crate::node::Node;
use serde_json::Value;
use tracing::info;
/// Dispatch a mutating command to the appropriate handler.
pub async fn dispatch(node: &mut Node, command: &str, params: Option<&Value>) -> Response {
match command {
"connect" => connect(node, params).await,
"disconnect" => disconnect(node, params),
_ => Response::error(format!("unknown command: {command}")),
}
}
/// Connect to a peer.
///
/// Params: `{"npub": "npub1...", "address": "host:port", "transport": "udp"}`
async fn connect(node: &mut Node, params: Option<&Value>) -> Response {
let Some(params) = params else {
return Response::error("missing params for connect");
};
let npub = match params.get("npub").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'npub' parameter"),
};
let address = match params.get("address").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'address' parameter"),
};
let transport = match params.get("transport").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'transport' parameter"),
};
info!(npub = %npub, address = %address, transport = %transport, "API connect requested");
match node.api_connect(npub, address, transport).await {
Ok(data) => Response::ok(data),
Err(msg) => Response::error(msg),
}
}
/// Disconnect a peer.
///
/// Params: `{"npub": "npub1..."}`
fn disconnect(node: &mut Node, params: Option<&Value>) -> Response {
let Some(params) = params else {
return Response::error("missing params for disconnect");
};
let npub = match params.get("npub").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'npub' parameter"),
};
info!(npub = %npub, "API disconnect requested");
match node.api_disconnect(npub) {
Ok(data) => Response::ok(data),
Err(msg) => Response::error(msg),
}
}
+5 -4
View File
@@ -1,9 +1,10 @@
//! Control socket for runtime observability.
//! Control socket for runtime management and observability.
//!
//! Provides a Unix domain socket that accepts query commands and returns
//! structured JSON data about the node's current state. Read-only queries
//! only — no state mutation through this channel.
//! Provides a Unix domain socket that accepts commands and returns
//! structured JSON responses. Supports both read-only queries (show_*)
//! and mutating commands (connect, disconnect).
pub mod commands;
pub mod protocol;
pub mod queries;
+22 -1
View File
@@ -8,8 +8,11 @@ use serde::{Deserialize, Serialize};
/// A control request from a client.
#[derive(Debug, Deserialize)]
pub struct Request {
/// The command to execute (e.g., "show_status", "show_peers").
/// The command to execute (e.g., "show_status", "connect").
pub command: String,
/// Optional parameters for mutating commands.
#[serde(default)]
pub params: Option<serde_json::Value>,
}
/// A control response to a client.
@@ -81,6 +84,24 @@ mod tests {
assert_eq!(req.command, "show_peers");
}
#[test]
fn test_deserialize_request_with_params() {
let json = r#"{"command": "connect", "params": {"npub": "npub1abc", "address": "1.2.3.4:2121", "transport": "udp"}}"#;
let req: Request = serde_json::from_str(json).unwrap();
assert_eq!(req.command, "connect");
let params = req.params.unwrap();
assert_eq!(params["npub"], "npub1abc");
assert_eq!(params["transport"], "udp");
}
#[test]
fn test_deserialize_request_without_params() {
let json = r#"{"command": "show_status"}"#;
let req: Request = serde_json::from_str(json).unwrap();
assert_eq!(req.command, "show_status");
assert!(req.params.is_none());
}
#[test]
fn test_deserialize_malformed_request() {
let json = r#"{"not_command": "foo"}"#;
+10 -2
View File
@@ -1,6 +1,6 @@
//! RX event loop and packet dispatch.
use crate::control::ControlSocket;
use crate::control::{commands, ControlSocket};
use crate::control::queries;
use crate::node::{Node, NodeError};
use crate::transport::ReceivedPacket;
@@ -98,7 +98,15 @@ impl Node {
self.register_identity(identity.node_addr, identity.pubkey);
}
Some((request, response_tx)) = control_rx.recv() => {
let response = queries::dispatch(self, &request.command);
let response = if request.command.starts_with("show_") {
queries::dispatch(self, &request.command)
} else {
commands::dispatch(
self,
&request.command,
request.params.as_ref(),
).await
};
let _ = response_tx.send(response);
}
_ = tick.tick() => {
+72
View File
@@ -739,4 +739,76 @@ impl Node {
info!(sent, total = peer_addrs.len(), reason = %reason, "Sent disconnect notifications");
}
// === Control API methods ===
/// Connect to a peer via the control API.
///
/// Creates an ephemeral peer connection (not persisted to config, no
/// auto-reconnect). Reuses the same connection path as auto-connect
/// peers. Returns JSON data on success or an error message.
pub(crate) async fn api_connect(
&mut self,
npub: &str,
address: &str,
transport: &str,
) -> Result<serde_json::Value, String> {
let peer_config = crate::config::PeerConfig {
npub: npub.to_string(),
alias: None,
addresses: vec![crate::config::PeerAddress::new(transport, address)],
connect_policy: crate::config::ConnectPolicy::Manual,
auto_reconnect: false,
};
// Pre-seed identity cache (same as initiate_peer_connections does)
if let Ok(identity) = PeerIdentity::from_npub(npub) {
self.peer_aliases
.insert(*identity.node_addr(), identity.short_npub());
self.register_identity(*identity.node_addr(), identity.pubkey_full());
}
self.initiate_peer_connection(&peer_config)
.await
.map(|()| {
info!(
npub = %npub,
address = %address,
transport = %transport,
"API connect initiated"
);
serde_json::json!({
"npub": npub,
"address": address,
"transport": transport,
})
})
.map_err(|e| e.to_string())
}
/// Disconnect a peer via the control API.
///
/// Removes the peer and suppresses auto-reconnect.
pub(crate) fn api_disconnect(&mut self, npub: &str) -> Result<serde_json::Value, String> {
let peer_identity = PeerIdentity::from_npub(npub)
.map_err(|e| format!("invalid npub '{npub}': {e}"))?;
let node_addr = *peer_identity.node_addr();
if !self.peers.contains_key(&node_addr) {
return Err(format!("peer not found: {npub}"));
}
// Remove the peer (full cleanup: sessions, indices, links, tree, bloom)
self.remove_active_peer(&node_addr);
// Suppress any pending auto-reconnect
self.retry_pending.remove(&node_addr);
info!(npub = %npub, "API disconnect completed");
Ok(serde_json::json!({
"npub": npub,
"disconnected": true,
}))
}
}