diff --git a/.gitignore b/.gitignore index b56fe6e..3cf2c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ Cargo.lock **/*.rs.bk .idea -*.sqlite3 \ No newline at end of file +*.sqlite3 + +.env* diff --git a/Cargo.lock b/Cargo.lock index 0005a8e..005754b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.8.1" @@ -1086,6 +1092,7 @@ dependencies = [ "ipnetwork", "itertools", "oneio 0.11.0", + "radar-rs", "rayon", "regex", "rpki", @@ -1416,6 +1423,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radar-rs" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65b3d4f8b54ac753d05c26b7107b120d5f96411c4d7b7fe19f2e95906f0557d" +dependencies = [ + "dotenvy", + "reqwest", + "serde", + "thiserror", +] + [[package]] name = "rayon" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index ff387c3..639c1f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ ureq = {version="2.6.2", features=["json"]} regex = "1.6.0" oneio = "0.11.0" rpki = {version= "0.16.1", features = ["repository"]} +radar-rs = "0.0.2" # progress bar indicatif = "0.17.0" diff --git a/src/datasets/mod.rs b/src/datasets/mod.rs index 53ba835..b2cf875 100644 --- a/src/datasets/mod.rs +++ b/src/datasets/mod.rs @@ -1,7 +1,9 @@ mod as2org; mod country; +mod radar; mod rpki; pub use crate::datasets::as2org::*; pub use crate::datasets::country::*; +pub use crate::datasets::radar::*; pub use crate::datasets::rpki::*; diff --git a/src/datasets/radar.rs b/src/datasets/radar.rs new file mode 100644 index 0000000..91ad98a --- /dev/null +++ b/src/datasets/radar.rs @@ -0,0 +1,35 @@ +//! Cloudflare Radar data access. +//! +//! Note: using this module requires setting CF_API_TOKEN in environment variables + +use radar_rs::{PrefixOriginsResult, RadarClient, RadarError, RoutingStatsResult}; + +pub struct CfRadar { + client: RadarClient, +} + +impl CfRadar { + pub fn new() -> Result { + Ok(Self { + client: RadarClient::new()?, + }) + } + + pub fn get_bgp_routing_stats( + &self, + asn: Option, + country_code: Option, + ) -> Result { + self.client.get_bgp_routing_stats(asn, country_code) + } + + pub fn get_prefix_origins( + &self, + origin: Option, + prefix: Option, + rpki_status: Option, + ) -> Result { + self.client + .get_bgp_prefix_origins(origin, prefix, rpki_status) + } +} diff --git a/src/monocle.rs b/src/monocle.rs index bf75596..8c9e377 100644 --- a/src/monocle.rs +++ b/src/monocle.rs @@ -10,10 +10,11 @@ use chrono::DateTime; use clap::{Args, Parser, Subcommand}; use ipnetwork::IpNetwork; use monocle::*; +use radar_rs::RadarClient; use rayon::prelude::*; use serde_json::json; use tabled::settings::{Merge, Style}; -use tabled::Table; +use tabled::{Table, Tabled}; use tracing::{info, Level}; trait Validate { @@ -287,6 +288,12 @@ enum Commands { #[clap(subcommand)] commands: RpkiCommands, }, + + /// Cloudflare Radar API lookup (set CF_API_TOKEN to enable) + Radar { + #[clap(subcommand)] + commands: RadarCommands, + }, } #[derive(Subcommand)] @@ -331,6 +338,27 @@ enum RpkiCommands { }, } +#[derive(Subcommand)] +enum RadarCommands { + /// get routing stats + Stats { + /// a two-letter country code or asn number (e.g. US or 13335) + #[clap(name = "QUERY")] + query: String, + }, + + /// look up prefix to origin mapping on the most recent global routing table snapshot + Pfx2as { + /// a IP prefix or an AS number (e.g. 1.1.1.0/24 or 13335) + #[clap(name = "QUERY")] + query: String, + + /// filter by RPKI validation status, valid, invalid, or unknown + #[clap(short, long)] + rpki_status: Option, + }, +} + fn elem_to_string(elem: &BgpElem, json: bool, pretty: bool) -> String { if json { let val = json!(elem); @@ -761,5 +789,191 @@ fn main() { println!("{}", Table::new(res).with(Style::markdown())); } }, + Commands::Radar { commands } => { + let client = RadarClient::new().unwrap(); + + match commands { + RadarCommands::Stats { query } => { + let (country, asn) = match query.parse::() { + Ok(asn) => (None, Some(asn)), + Err(_) => (Some(query), None), + }; + + let res = match client.get_bgp_routing_stats(asn, country.clone()) { + Ok(res) => res, + Err(e) => { + eprintln!("unable to get routing stats: {}", e); + return; + } + }; + + let scope = match (country, &asn) { + (None, None) => "global".to_string(), + (Some(c), None) => c, + (None, Some(asn)) => format!("as{}", asn), + (Some(_), Some(_)) => { + eprintln!("cannot specify both country and ASN"); + return; + } + }; + + #[derive(Tabled)] + struct Stats { + pub scope: String, + pub origins: u32, + pub prefixes: u32, + pub rpki_valid: String, + pub rpki_invalid: String, + pub rpki_unknown: String, + } + let table_data = vec![ + Stats { + scope: scope.clone(), + origins: res.stats.distinct_origins, + prefixes: res.stats.distinct_prefixes, + rpki_valid: format!( + "{} ({:.2}%)", + res.stats.routes_valid, + (res.stats.routes_valid as f64 / res.stats.routes_total as f64) + * 100.0 + ), + rpki_invalid: format!( + "{} ({:.2}%)", + res.stats.routes_invalid, + (res.stats.routes_invalid as f64 / res.stats.routes_total as f64) + * 100.0 + ), + rpki_unknown: format!( + "{} ({:.2}%)", + res.stats.routes_unknown, + (res.stats.routes_unknown as f64 / res.stats.routes_total as f64) + * 100.0 + ), + }, + Stats { + scope: format!("{} ipv4", scope), + origins: res.stats.distinct_origins_ipv4, + prefixes: res.stats.distinct_prefixes_ipv4, + rpki_valid: format!( + "{} ({:.2}%)", + res.stats.routes_valid_ipv4, + (res.stats.routes_valid_ipv4 as f64 + / res.stats.routes_total_ipv4 as f64) + * 100.0 + ), + rpki_invalid: format!( + "{} ({:.2}%)", + res.stats.routes_invalid_ipv4, + (res.stats.routes_invalid_ipv4 as f64 + / res.stats.routes_total_ipv4 as f64) + * 100.0 + ), + rpki_unknown: format!( + "{} ({:.2}%)", + res.stats.routes_unknown_ipv4, + (res.stats.routes_unknown_ipv4 as f64 + / res.stats.routes_total_ipv4 as f64) + * 100.0 + ), + }, + Stats { + scope: format!("{} ipv6", scope), + origins: res.stats.distinct_origins_ipv6, + prefixes: res.stats.distinct_prefixes_ipv6, + rpki_valid: format!( + "{} ({:.2}%)", + res.stats.routes_valid_ipv6, + (res.stats.routes_valid_ipv6 as f64 + / res.stats.routes_total_ipv6 as f64) + * 100.0 + ), + rpki_invalid: format!( + "{} ({:.2}%)", + res.stats.routes_invalid_ipv6, + (res.stats.routes_invalid_ipv6 as f64 + / res.stats.routes_total_ipv6 as f64) + * 100.0 + ), + rpki_unknown: format!( + "{} ({:.2}%)", + res.stats.routes_unknown_ipv6, + (res.stats.routes_unknown_ipv6 as f64 + / res.stats.routes_total_ipv6 as f64) + * 100.0 + ), + }, + ]; + println!("{}", Table::new(table_data).with(Style::modern())); + println!("\nData generated at {} UTC.", res.meta.data_time); + } + RadarCommands::Pfx2as { query, rpki_status } => { + let (asn, prefix) = match query.parse::() { + Ok(asn) => (Some(asn), None), + Err(_) => (None, Some(query)), + }; + + let rpki = if let Some(rpki_status) = rpki_status { + match rpki_status.to_lowercase().as_str() { + "valid" | "invalid" | "unknown" => Some(rpki_status), + _ => { + eprintln!("invalid rpki status: {}", rpki_status); + return; + } + } + } else { + None + }; + + let res = match client.get_bgp_prefix_origins(asn, prefix, rpki) { + Ok(res) => res, + Err(e) => { + eprintln!("unable to get prefix origins: {}", e); + return; + } + }; + + #[derive(Tabled)] + struct Pfx2origin { + pub prefix: String, + pub origin: String, + pub rpki: String, + pub visibility: String, + } + + if res.prefix_origins.is_empty() { + println!("no prefix origins found for the given query"); + return; + } + + fn count_to_visibility(count: u32, total: u32) -> String { + let ratio = count as f64 / total as f64; + if ratio > 0.8 { + format!("high ({:.2}%)", ratio * 100.0) + } else if ratio < 0.2 { + format!("low ({:.2}%)", ratio * 100.0) + } else { + format!("mid ({:.2}%)", ratio * 100.0) + } + } + + let table_data = res + .prefix_origins + .into_iter() + .map(|entry| Pfx2origin { + prefix: entry.prefix, + origin: format!("as{}", entry.origin), + rpki: entry.rpki_validation.to_lowercase(), + visibility: count_to_visibility( + entry.peer_count as u32, + res.meta.total_peers as u32, + ), + }) + .collect::>(); + + println!("{}", Table::new(table_data).with(Style::modern())); + println!("\nData generated at {} UTC.", res.meta.data_time); + } + } + } } }