mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-12 01:27:32 +00:00
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:
+169
-113
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"}"#;
|
||||
|
||||
@@ -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() => {
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user