mod api;
mod auth;
mod config;
mod output;
mod peering;
mod probing;
mod ris;
use clap::{CommandFactory, Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Parser)]
#[command(name = "nxthdr")]
#[command(version)]
#[command(about = "CLI tool to interact with nxthdr platform", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[command(flatten)]
verbose: Verbosity<InfoLevel>,
#[arg(
long,
short = 'o',
global = true,
value_enum,
default_value = "text",
help = "Output format"
)]
output: output::OutputFormat,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "Authenticate with the nxthdr platform")]
Auth {
#[command(subcommand)]
command: AuthCommands,
},
#[command(about = "Interact with peering platform")]
Peering {
#[command(subcommand)]
command: PeeringCommands,
},
#[command(about = "Interact with probing platform")]
Probing {
#[command(subcommand)]
command: ProbingCommands,
},
#[command(about = "Generate shell completion scripts")]
Completions {
#[arg(value_enum, help = "Shell to generate completions for")]
shell: clap_complete::Shell,
},
}
#[derive(Subcommand)]
enum AuthCommands {
#[command(about = "Login to nxthdr platform")]
Login,
#[command(about = "Logout from nxthdr platform")]
Logout,
#[command(about = "Show authentication status")]
Status,
}
#[derive(Subcommand)]
enum ProbingCommands {
#[command(about = "Manage probing agents")]
Agent {
#[command(subcommand)]
command: AgentCommands,
},
#[command(about = "Show your probing credits usage")]
Credits {
#[command(subcommand)]
command: CreditsCommands,
},
#[command(about = "Send, list, and manage measurements")]
Measurement {
#[command(subcommand)]
command: MeasurementCommands,
},
#[command(about = "Query probe replies")]
Reply {
#[command(subcommand)]
command: ReplyCommands,
},
}
#[derive(Subcommand)]
enum AgentCommands {
#[command(about = "List available probing agents")]
List,
}
#[derive(Subcommand)]
enum CreditsCommands {
#[command(about = "Show your probing credits usage")]
Get,
}
#[derive(Subcommand)]
enum MeasurementCommands {
#[command(
about = "Send probes from one or more agents",
long_about = "Send probes read from a file or stdin.\n\nEach line must be: dst_addr,src_port,dst_port,ttl,protocol\nProtocol is 'icmpv6' or 'udp' (case-insensitive).\n\nExamples:\n nxthdr probing measurement send --agent vltcdg01 probes.csv\n prowl | nxthdr probing measurement send --agent vltcdg01"
)]
Send {
#[arg(help = "Input file with probes (reads from stdin if omitted)")]
file: Option<std::path::PathBuf>,
#[arg(short, long, help = "Agent ID(s) to use", required = true)]
agent: Vec<String>,
#[arg(
long,
help = "Override source IPv6 address (auto-detected per agent if not set)"
)]
src_ip: Option<String>,
},
#[command(about = "List your recent measurements")]
List {
#[arg(
long,
default_value_t = 20,
help = "Maximum number of measurements to list (1-100)"
)]
limit: u32,
#[arg(
long,
value_delimiter = ',',
help = "Filter by status (comma-separated): complete, in-progress, cancelled"
)]
status: Vec<probing::StatusFilter>,
#[arg(
long,
help = "Only measurements started at/after this time (e.g. '2026-03-22' or '2026-03-22 10:00:00')"
)]
since: Option<String>,
#[arg(long, help = "Only measurements started at/before this time")]
until: Option<String>,
#[arg(long, help = "Only measurements involving this agent ID")]
agent: Option<String>,
#[arg(
long,
value_enum,
default_value = "updated",
help = "Sort by 'started' or 'updated' time"
)]
sort: probing::SortField,
#[arg(long, help = "Reverse the order (oldest first)")]
reverse: bool,
},
#[command(about = "Get status of a measurement by ID")]
Get {
#[arg(help = "Measurement ID returned by 'send'")]
id: String,
},
#[command(about = "Cancel a stuck/in-progress measurement by ID")]
Cancel {
#[arg(help = "Measurement ID to cancel")]
id: String,
},
}
#[derive(Subcommand)]
enum ReplyCommands {
#[command(about = "Query replies from ClickHouse")]
List {
#[arg(long, help = "Source IP(s) to filter by", required = true, num_args = 1..)]
src_ip: Vec<String>,
#[arg(long, help = "Start of time window (e.g. '2026-03-19 21:00:00')")]
since: Option<String>,
#[arg(long, help = "End of time window (e.g. '2026-03-19 22:00:00')")]
until: Option<String>,
},
}
#[derive(Subcommand)]
enum PeeringCommands {
#[command(about = "Manage your ASN")]
Asn {
#[command(subcommand)]
command: AsnCommands,
},
#[command(about = "Manage prefix leases")]
Prefix {
#[command(subcommand)]
command: PrefixCommands,
},
#[command(about = "Inspect prefix visibility in public BGP collectors (RIPE RIS)")]
Route {
#[command(subcommand)]
command: RouteCommands,
},
#[command(about = "PeerLab utilities")]
Peerlab {
#[command(subcommand)]
command: PeerlabCommands,
},
}
#[derive(Subcommand)]
enum RouteCommands {
#[command(about = "Show your leased prefixes as seen by public BGP collectors (RIPE RIS)")]
List,
#[command(about = "Looking glass: how a prefix is seen by public BGP collectors (RIPE RIS)")]
Lookup {
#[arg(help = "Prefix or IP to look up (e.g., 2001:db8::/48)")]
prefix: String,
},
}
#[derive(Subcommand)]
enum AsnCommands {
#[command(about = "Get your ASN")]
Get,
}
#[derive(Subcommand)]
enum PeerlabCommands {
#[command(about = "Generate .env file for PeerLab")]
Env,
}
#[derive(Subcommand)]
enum PrefixCommands {
#[command(about = "List your active prefix leases")]
List,
#[command(about = "Request a new prefix lease")]
Request {
#[arg(value_name = "HOURS", help = "Lease duration in hours (1-24)")]
duration: u32,
},
#[command(about = "Revoke a prefix lease")]
Revoke {
#[arg(help = "Prefix to revoke (e.g., 2001:db8::/48)")]
prefix: String,
},
#[command(about = "Manage RPKI ROA for a leased prefix")]
Rpki {
#[command(subcommand)]
command: RpkiCommands,
},
}
#[derive(Subcommand)]
enum RpkiCommands {
#[command(about = "Enable RPKI ROA for a leased prefix")]
Enable {
#[arg(help = "Prefix (e.g., 2001:db8::/48)")]
prefix: String,
},
#[command(about = "Disable RPKI ROA for a leased prefix")]
Disable {
#[arg(help = "Prefix (e.g., 2001:db8::/48)")]
prefix: String,
},
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
output::set_format(cli.output);
tracing_subscriber::fmt().with_max_level(cli.verbose).init();
match cli.command {
Commands::Auth { command } => match command {
AuthCommands::Login => handle_login().await?,
AuthCommands::Logout => handle_logout()?,
AuthCommands::Status => handle_status()?,
},
Commands::Peering { command } => handle_peering(command).await?,
Commands::Probing { command } => handle_probing(command).await?,
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let bin = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, bin, &mut std::io::stdout());
}
}
Ok(())
}
async fn handle_probing(command: ProbingCommands) -> anyhow::Result<()> {
match command {
ProbingCommands::Agent { command } => match command {
AgentCommands::List => probing::agents().await,
},
ProbingCommands::Credits { command } => match command {
CreditsCommands::Get => probing::credits().await,
},
ProbingCommands::Measurement { command } => match command {
MeasurementCommands::Send {
file,
agent,
src_ip,
} => probing::send(file, agent, src_ip).await,
MeasurementCommands::List {
limit,
status,
since,
until,
agent,
sort,
reverse,
} => probing::measurements(limit, status, since, until, agent, sort, reverse).await,
MeasurementCommands::Get { id } => probing::measurement_status(&id).await,
MeasurementCommands::Cancel { id } => probing::cancel(&id).await,
},
ProbingCommands::Reply { command } => match command {
ReplyCommands::List {
src_ip,
since,
until,
} => probing::results(src_ip, since, until).await,
},
}
}
async fn handle_peering(command: PeeringCommands) -> anyhow::Result<()> {
match command {
PeeringCommands::Asn { command } => match command {
AsnCommands::Get => peering::asn().await,
},
PeeringCommands::Prefix { command } => match command {
PrefixCommands::List => peering::prefix_list().await,
PrefixCommands::Request { duration } => peering::prefix_request(duration).await,
PrefixCommands::Revoke { prefix } => peering::prefix_revoke(&prefix).await,
PrefixCommands::Rpki { command } => match command {
RpkiCommands::Enable { prefix } => peering::prefix_rpki(&prefix, true).await,
RpkiCommands::Disable { prefix } => peering::prefix_rpki(&prefix, false).await,
},
},
PeeringCommands::Route { command } => match command {
RouteCommands::List => peering::routes().await,
RouteCommands::Lookup { prefix } => peering::lookup(&prefix).await,
},
PeeringCommands::Peerlab { command } => match command {
PeerlabCommands::Env => peering::peerlab_env().await,
},
}
}
async fn handle_login() -> anyhow::Result<()> {
if config::tokens_exist() {
let tokens = config::load_tokens()?;
if tokens.expires_at >= now_secs() {
output::kv(&[("auth", "already logged in")]);
output::hint("nxthdr auth logout # to switch accounts");
return Ok(());
}
if tokens.refresh_token.is_empty() {
anyhow::bail!("access token expired and no refresh token available — run 'nxthdr auth logout' then 'nxthdr auth login'");
}
output::info("refreshing token...");
let (access_token, refresh_token, expires_at) =
auth::refresh_access_token(&tokens.refresh_token)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to refresh token: {e} — run 'nxthdr auth logout' then 'nxthdr auth login'"
)
})?;
config::save_tokens(&config::TokenStorage {
access_token,
refresh_token,
expires_at,
})?;
output::success("token refreshed");
return Ok(());
}
let device_code = auth::start_device_flow().await?;
output::info("open the following URL to authenticate:");
output::info(&format!("\n {}\n", device_code.verification_uri_complete));
output::info(&format!(
"or go to {} and enter code: {}\n",
device_code.verification_uri, device_code.user_code
));
output::info("waiting...");
let (access_token, refresh_token, expires_at) =
auth::poll_for_token(&device_code.device_code, device_code.interval).await?;
config::save_tokens(&config::TokenStorage {
access_token,
refresh_token,
expires_at,
})?;
output::success("authenticated");
Ok(())
}
fn handle_logout() -> anyhow::Result<()> {
if !config::tokens_exist() {
output::info("not logged in");
return Ok(());
}
config::delete_tokens()?;
output::success("logged out");
Ok(())
}
fn handle_status() -> anyhow::Result<()> {
output::section("status");
if !config::tokens_exist() {
output::kv(&[("auth", "not logged in")]);
output::hint("nxthdr auth login");
return Ok(());
}
let tokens = config::load_tokens()?;
let now = now_secs();
if tokens.expires_at < now {
output::kv(&[("auth", "logged in"), ("token", "expired")]);
output::hint("nxthdr auth login # to refresh");
} else {
let secs = tokens.expires_at - now;
let expiry = format!("valid {}h {}m", secs / 3600, (secs % 3600) / 60);
output::kv(&[("auth", "logged in"), ("token", &expiry)]);
}
Ok(())
}