use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use ant_quic::{P2pConfig, P2pEndpoint};
use clap::Parser;
use serde::{Deserialize, Serialize};
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, default_value = "docs/saorsa-bootstrap-nodes.yaml")]
config: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
nodes: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BootstrapDatabase {
nodes: Vec<BootstrapNode>,
validation: BootstrapValidationConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct BootstrapNode {
name: String,
address: SocketAddr,
provider: String,
region: String,
role: String,
notes: String,
}
#[derive(Debug, Clone, Deserialize)]
struct BootstrapValidationConfig {
threshold_percent: f32,
connect_timeout_seconds: u64,
per_node_timeout_seconds: u64,
require_external_address: bool,
}
impl BootstrapValidationConfig {
fn validate(&self) -> anyhow::Result<()> {
if !self.threshold_percent.is_finite() {
anyhow::bail!("validation threshold_percent must be finite");
}
if !(0.0..=100.0).contains(&self.threshold_percent) {
anyhow::bail!("validation threshold_percent must be between 0 and 100 inclusive");
}
if self.connect_timeout_seconds == 0 {
anyhow::bail!("validation connect_timeout_seconds must be greater than zero");
}
if self.per_node_timeout_seconds == 0 {
anyhow::bail!("validation per_node_timeout_seconds must be greater than zero");
}
Ok(())
}
}
#[derive(Debug, Serialize)]
struct BootstrapValidationResult {
node: BootstrapNode,
success: bool,
connected_peers: usize,
external_addr: Option<SocketAddr>,
elapsed_ms: u128,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct BootstrapValidationSummary {
total_nodes: usize,
passed_nodes: usize,
failed_nodes: usize,
success_rate: f32,
threshold_percent: f32,
threshold_met: bool,
}
#[derive(Debug, Serialize)]
struct BootstrapValidationReport {
summary: BootstrapValidationSummary,
results: Vec<BootstrapValidationResult>,
}
fn filter_nodes(
nodes: Vec<BootstrapNode>,
filter: Option<&str>,
) -> anyhow::Result<Vec<BootstrapNode>> {
let Some(filter) = filter else {
return Ok(nodes);
};
let wanted: std::collections::BTreeSet<_> = filter
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.collect();
let available: std::collections::BTreeSet<_> =
nodes.iter().map(|node| node.name.as_str()).collect();
let missing: Vec<_> = wanted.difference(&available).copied().collect();
if !missing.is_empty() {
anyhow::bail!(
"unknown Saorsa bootstrap node(s) requested by --nodes: {}",
missing.join(", ")
);
}
Ok(nodes
.into_iter()
.filter(|node| wanted.contains(node.name.as_str()))
.collect())
}
fn summarize(
results: Vec<BootstrapValidationResult>,
threshold_percent: f32,
) -> BootstrapValidationReport {
let total_nodes = results.len();
let passed_nodes = results.iter().filter(|result| result.success).count();
let failed_nodes = total_nodes.saturating_sub(passed_nodes);
let success_rate = if total_nodes == 0 {
0.0
} else {
(passed_nodes as f32 / total_nodes as f32) * 100.0
};
let threshold_met = total_nodes > 0 && success_rate >= threshold_percent;
BootstrapValidationReport {
summary: BootstrapValidationSummary {
total_nodes,
passed_nodes,
failed_nodes,
success_rate,
threshold_percent,
threshold_met,
},
results,
}
}
async fn validate_node(
node: BootstrapNode,
validation: BootstrapValidationConfig,
) -> BootstrapValidationResult {
let start = Instant::now();
let result = validate_node_inner(&node, &validation).await;
match result {
Ok((connected_peers, external_addr)) => {
let success = connected_peers > 0
&& (!validation.require_external_address || external_addr.is_some());
BootstrapValidationResult {
node,
success,
connected_peers,
external_addr,
elapsed_ms: start.elapsed().as_millis(),
error: if success {
None
} else {
Some("connected but did not satisfy validation requirements".to_string())
},
}
}
Err(error) => BootstrapValidationResult {
node,
success: false,
connected_peers: 0,
external_addr: None,
elapsed_ms: start.elapsed().as_millis(),
error: Some(error),
},
}
}
fn remaining_validation_time(deadline: tokio::time::Instant) -> Option<Duration> {
deadline
.checked_duration_since(tokio::time::Instant::now())
.filter(|duration| !duration.is_zero())
}
async fn connect_known_peers_with_deadline<Connect, ConnectError, Shutdown, ShutdownFuture>(
connect_timeout: Duration,
validation_deadline: tokio::time::Instant,
connect: Connect,
shutdown: Shutdown,
) -> Result<usize, String>
where
Connect: std::future::Future<Output = Result<usize, ConnectError>>,
ConnectError: ToString,
Shutdown: FnOnce() -> ShutdownFuture,
ShutdownFuture: std::future::Future<Output = ()>,
{
let Some(remaining) = remaining_validation_time(validation_deadline) else {
shutdown().await;
return Err("validation timed out".to_string());
};
let timeout_error = if remaining <= connect_timeout {
"validation timed out"
} else {
"connect_known_peers timed out"
};
match tokio::time::timeout(connect_timeout.min(remaining), connect).await {
Ok(Ok(count)) => Ok(count),
Ok(Err(error)) => {
shutdown().await;
Err(error.to_string())
}
Err(_) => {
shutdown().await;
Err(timeout_error.to_string())
}
}
}
async fn validate_node_inner(
node: &BootstrapNode,
validation: &BootstrapValidationConfig,
) -> Result<(usize, Option<SocketAddr>), String> {
let per_node_timeout = Duration::from_secs(validation.per_node_timeout_seconds);
let validation_deadline = tokio::time::Instant::now() + per_node_timeout;
let config = P2pConfig::builder()
.bind_addr(
"[::]:0"
.parse::<SocketAddr>()
.map_err(|error| error.to_string())?,
)
.known_peer(node.address)
.port_mapping_enabled(false)
.build()
.map_err(|error| error.to_string())?;
let endpoint_timeout = remaining_validation_time(validation_deadline)
.ok_or_else(|| "validation timed out".to_string())?;
let endpoint = tokio::time::timeout(endpoint_timeout, P2pEndpoint::new(config))
.await
.map_err(|_| "validation timed out".to_string())?
.map_err(|error| error.to_string())?;
let connected = connect_known_peers_with_deadline(
Duration::from_secs(validation.connect_timeout_seconds),
validation_deadline,
endpoint.connect_known_peers(),
|| endpoint.shutdown(),
)
.await?;
let external_addr = endpoint.external_addr();
endpoint.shutdown().await;
Ok((connected, external_addr))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let args = Args::parse();
let config = std::fs::read_to_string(&args.config)?;
let database: BootstrapDatabase = serde_yaml::from_str(&config)?;
database.validation.validate()?;
let nodes = filter_nodes(database.nodes, args.nodes.as_deref())?;
if nodes.is_empty() {
anyhow::bail!(
"no Saorsa bootstrap nodes configured for filter '{}' in {}",
args.nodes.as_deref().unwrap_or("all"),
args.config.display()
);
}
println!("================================================");
println!("Saorsa Bootstrap Node Validation");
println!("================================================");
let mut results = Vec::new();
for node in nodes {
println!("Testing {} ({})...", node.name, node.address);
let result = validate_node(node, database.validation.clone()).await;
println!(
" {} connected_peers={} external_addr={:?} elapsed={}ms{}",
if result.success { "OK" } else { "FAILED" },
result.connected_peers,
result.external_addr,
result.elapsed_ms,
result
.error
.as_ref()
.map(|error| format!(" error={error}"))
.unwrap_or_default()
);
results.push(result);
}
let report = summarize(results, database.validation.threshold_percent);
println!("\nSummary:");
println!("Total nodes tested: {}", report.summary.total_nodes);
println!(
"Successful connections: {} ({:.1}%)",
report.summary.passed_nodes, report.summary.success_rate
);
println!("Threshold: {:.1}%", report.summary.threshold_percent);
if let Some(output) = args.output {
std::fs::write(&output, serde_json::to_string_pretty(&report)?)?;
println!("Results saved to: {}", output.display());
}
if !report.summary.threshold_met {
anyhow::bail!(
"bootstrap validation success rate {:.1}% below threshold {:.1}%",
report.summary.success_rate,
report.summary.threshold_percent
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
fn node(name: &str) -> BootstrapNode {
BootstrapNode {
name: name.to_string(),
address: "142.93.199.50:9000".parse().expect("valid addr"),
provider: "DigitalOcean".to_string(),
region: "NYC".to_string(),
role: "bootstrap".to_string(),
notes: "test".to_string(),
}
}
fn result(name: &str, success: bool) -> BootstrapValidationResult {
BootstrapValidationResult {
node: node(name),
success,
connected_peers: usize::from(success),
external_addr: None,
elapsed_ms: 1,
error: (!success).then(|| "failed".to_string()),
}
}
fn validation_config(threshold_percent: f32) -> BootstrapValidationConfig {
BootstrapValidationConfig {
threshold_percent,
connect_timeout_seconds: 10,
per_node_timeout_seconds: 20,
require_external_address: true,
}
}
#[tokio::test]
async fn connect_deadline_shutdown_on_validation_timeout() {
let shutdowns = Arc::new(AtomicUsize::new(0));
let shutdowns_for_future = Arc::clone(&shutdowns);
let error = connect_known_peers_with_deadline(
Duration::from_secs(10),
tokio::time::Instant::now() + Duration::from_millis(1),
std::future::pending::<Result<usize, String>>(),
move || {
let shutdowns = Arc::clone(&shutdowns_for_future);
async move {
shutdowns.fetch_add(1, Ordering::SeqCst);
}
},
)
.await
.err();
assert_eq!(error.as_deref(), Some("validation timed out"));
assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
}
#[test]
fn validation_config_rejects_invalid_thresholds() {
for threshold_percent in [-1.0, 100.1, f32::INFINITY, f32::NAN] {
let config = validation_config(threshold_percent);
assert!(config.validate().is_err());
}
}
#[test]
fn validation_config_rejects_zero_timeouts() {
let mut config = validation_config(80.0);
config.connect_timeout_seconds = 0;
assert!(config.validate().is_err());
let mut config = validation_config(80.0);
config.per_node_timeout_seconds = 0;
assert!(config.validate().is_err());
}
#[test]
fn validation_config_accepts_boundary_thresholds() {
assert!(validation_config(0.0).validate().is_ok());
assert!(validation_config(100.0).validate().is_ok());
}
#[test]
fn filter_nodes_without_filter_returns_all_nodes() -> anyhow::Result<()> {
let nodes = vec![node("a"), node("b")];
let filtered = filter_nodes(nodes, None)?;
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].name, "a");
assert_eq!(filtered[1].name, "b");
Ok(())
}
#[test]
fn filter_nodes_selects_requested_names() -> anyhow::Result<()> {
let nodes = vec![node("a"), node("b")];
let filtered = filter_nodes(nodes, Some("b"))?;
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "b");
Ok(())
}
#[test]
fn filter_nodes_trims_empty_filter_entries() -> anyhow::Result<()> {
let nodes = vec![node("a"), node("b"), node("c")];
let filtered = filter_nodes(nodes, Some(" b, ,c "))?;
let names: Vec<_> = filtered.into_iter().map(|node| node.name).collect();
assert_eq!(names, vec!["b", "c"]);
Ok(())
}
#[test]
fn filter_nodes_unknown_filter_reports_missing_name() -> anyhow::Result<()> {
let nodes = vec![node("a"), node("b")];
let Err(error) = filter_nodes(nodes, Some("missing")) else {
anyhow::bail!("unknown filter should fail");
};
assert!(error.to_string().contains("missing"));
Ok(())
}
#[test]
fn filter_nodes_mixed_unknown_filter_reports_missing_name() -> anyhow::Result<()> {
let nodes = vec![node("a"), node("b")];
let Err(error) = filter_nodes(nodes, Some("a,missing")) else {
anyhow::bail!("mixed unknown filter should fail");
};
assert!(error.to_string().contains("missing"));
Ok(())
}
#[test]
fn summarize_requires_non_empty_results_for_threshold() {
let report = summarize(Vec::new(), 50.0);
assert_eq!(report.summary.total_nodes, 0);
assert_eq!(report.summary.passed_nodes, 0);
assert_eq!(report.summary.failed_nodes, 0);
assert_eq!(report.summary.success_rate, 0.0);
assert!(!report.summary.threshold_met);
}
#[test]
fn summarize_marks_all_success_above_threshold() {
let report = summarize(vec![result("a", true), result("b", true)], 80.0);
assert_eq!(report.summary.total_nodes, 2);
assert_eq!(report.summary.passed_nodes, 2);
assert_eq!(report.summary.failed_nodes, 0);
assert_eq!(report.summary.success_rate, 100.0);
assert!(report.summary.threshold_met);
}
#[test]
fn summarize_marks_threshold_edge_as_met() {
let report = summarize(vec![result("a", true), result("b", false)], 50.0);
assert_eq!(report.summary.total_nodes, 2);
assert_eq!(report.summary.passed_nodes, 1);
assert_eq!(report.summary.failed_nodes, 1);
assert_eq!(report.summary.success_rate, 50.0);
assert!(report.summary.threshold_met);
}
#[test]
fn summarize_marks_below_threshold_as_not_met() {
let report = summarize(
vec![result("a", true), result("b", false), result("c", false)],
50.0,
);
assert_eq!(report.summary.total_nodes, 3);
assert_eq!(report.summary.passed_nodes, 1);
assert_eq!(report.summary.failed_nodes, 2);
assert!((report.summary.success_rate - 33.333336).abs() < f32::EPSILON);
assert!(!report.summary.threshold_met);
}
#[test]
fn summarize_preserves_results_for_json_report() {
let report = summarize(vec![result("a", true), result("b", false)], 50.0);
assert_eq!(report.results.len(), 2);
assert_eq!(report.results[0].node.name, "a");
assert_eq!(report.results[1].error.as_deref(), Some("failed"));
}
}