nxthdr 0.4.0

Command line interface for the nxthdr platform
mod api;
mod auth;
mod config;
mod output;
mod peering;
mod probing;

use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Parser)]
#[command(name = "nxthdr")]
#[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 = "Login to nxthdr platform")]
    Login,
    #[command(about = "Logout from nxthdr platform")]
    Logout,
    #[command(about = "Show authentication status")]
    Status,
    #[command(about = "Interact with peering platform")]
    Peering {
        #[command(subcommand)]
        command: PeeringCommands,
    },
    #[command(about = "Interact with probing platform")]
    Probing {
        #[command(subcommand)]
        command: ProbingCommands,
    },
}

#[derive(Subcommand)]
enum ProbingCommands {
    #[command(about = "Show your probing credits usage")]
    Credits,
    #[command(about = "List available probing agents")]
    Agents,
    #[command(about = "Query replies from ClickHouse")]
    Results {
        #[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>,
    },
    #[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 send --agent vltcdg01 probes.csv\n  prowl | nxthdr probing 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 = "Get status of a measurement by ID")]
    MeasurementStatus {
        #[arg(help = "Measurement ID returned by 'send'")]
        id: String,
    },
}

#[derive(Subcommand)]
enum PeeringCommands {
    #[command(about = "Get your ASN")]
    Asn,
    #[command(about = "Manage prefix leases")]
    Prefix {
        #[command(subcommand)]
        command: PrefixCommands,
    },
    #[command(about = "PeerLab utilities")]
    Peerlab {
        #[command(subcommand)]
        command: PeerlabCommands,
    },
}

#[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,
    },
}

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::Login => handle_login().await?,
        Commands::Logout => handle_logout()?,
        Commands::Status => handle_status()?,
        Commands::Peering { command } => handle_peering(command).await?,
        Commands::Probing { command } => handle_probing(command).await?,
    }

    Ok(())
}

async fn handle_probing(command: ProbingCommands) -> anyhow::Result<()> {
    match command {
        ProbingCommands::Credits => probing::credits().await,
        ProbingCommands::Agents => probing::agents().await,
        ProbingCommands::Send { file, agent, src_ip } => probing::send(file, agent, src_ip).await,
        ProbingCommands::Results { src_ip, since, until } => probing::results(src_ip, since, until).await,
        ProbingCommands::MeasurementStatus { id } => probing::measurement_status(&id).await,
    }
}

async fn handle_peering(command: PeeringCommands) -> anyhow::Result<()> {
    match command {
        PeeringCommands::Asn => 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,
        },
        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 logout  # to switch accounts");
            return Ok(());
        }

        if tokens.refresh_token.is_empty() {
            anyhow::bail!("access token expired and no refresh token available — run 'nxthdr logout' then 'nxthdr 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 logout' then 'nxthdr 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 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 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(())
}