use crate::database::MonocleDatabase;
use crate::lens::rpki::RpkiLens;
use crate::utils::{truncate_name, OutputFormat, DEFAULT_NAME_MAX_LEN};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tabled::Tabled;
#[derive(Debug, Clone, Serialize, Deserialize, Tabled)]
pub struct Pfx2asEntry {
pub asn: u32,
pub count: u32,
pub prefix: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Tabled)]
pub struct Pfx2asResult {
pub prefix: String,
pub asns: String,
pub match_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pfx2asDetailedResult {
pub prefix: String,
pub matched_prefix: String,
pub origin_asns: Vec<u32>,
pub match_type: Pfx2asLookupMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, Tabled)]
pub struct Pfx2asPrefixRecord {
pub prefix: String,
pub origin_asn: u32,
pub validation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pfx2asSearchResult {
pub prefix: String,
pub origin_asn: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub as_name: Option<String>,
pub rpki: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_type: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum Pfx2asOutputFormat {
#[default]
Json,
JsonPretty,
Table,
Simple,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum Pfx2asLookupMode {
Exact,
#[default]
Longest,
Covering,
Covered,
}
impl std::fmt::Display for Pfx2asLookupMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Pfx2asLookupMode::Exact => write!(f, "exact"),
Pfx2asLookupMode::Longest => write!(f, "longest"),
Pfx2asLookupMode::Covering => write!(f, "covering"),
Pfx2asLookupMode::Covered => write!(f, "covered"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pfx2asQueryType {
Asn(u32),
Prefix(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::Args))]
pub struct Pfx2asLookupArgs {
#[cfg_attr(feature = "cli", clap(value_name = "PREFIX"))]
pub prefix: String,
#[cfg_attr(feature = "cli", clap(short, long, default_value = "longest"))]
#[serde(default)]
pub mode: Pfx2asLookupMode,
#[cfg_attr(feature = "cli", clap(short, long, default_value = "json"))]
#[serde(default)]
pub format: Pfx2asOutputFormat,
}
impl Pfx2asLookupArgs {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
mode: Pfx2asLookupMode::default(),
format: Pfx2asOutputFormat::default(),
}
}
pub fn with_mode(mut self, mode: Pfx2asLookupMode) -> Self {
self.mode = mode;
self
}
pub fn with_format(mut self, format: Pfx2asOutputFormat) -> Self {
self.format = format;
self
}
pub fn exact(mut self) -> Self {
self.mode = Pfx2asLookupMode::Exact;
self
}
pub fn longest(mut self) -> Self {
self.mode = Pfx2asLookupMode::Longest;
self
}
pub fn covering(mut self) -> Self {
self.mode = Pfx2asLookupMode::Covering;
self
}
pub fn covered(mut self) -> Self {
self.mode = Pfx2asLookupMode::Covered;
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::Args))]
pub struct Pfx2asSearchArgs {
#[cfg_attr(feature = "cli", clap(value_name = "QUERY"))]
pub query: String,
#[cfg_attr(feature = "cli", clap(long))]
#[serde(default)]
pub include_sub: bool,
#[cfg_attr(feature = "cli", clap(long))]
#[serde(default)]
pub include_super: bool,
#[cfg_attr(feature = "cli", clap(long))]
#[serde(default)]
pub show_name: bool,
#[cfg_attr(feature = "cli", clap(long))]
#[serde(default)]
pub show_full_name: bool,
#[cfg_attr(feature = "cli", clap(long, short, value_name = "N"))]
#[serde(default)]
pub limit: Option<usize>,
}
impl Pfx2asSearchArgs {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
..Default::default()
}
}
pub fn with_include_sub(mut self, include_sub: bool) -> Self {
self.include_sub = include_sub;
self
}
pub fn with_include_super(mut self, include_super: bool) -> Self {
self.include_super = include_super;
self
}
pub fn with_show_name(mut self, show_name: bool) -> Self {
self.show_name = show_name;
self
}
pub fn with_show_full_name(mut self, show_full_name: bool) -> Self {
self.show_full_name = show_full_name;
self
}
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
pub fn validate(&self) -> Result<(), String> {
if self.query.is_empty() {
return Err("Query cannot be empty".to_string());
}
Ok(())
}
}
pub struct Pfx2asLens<'a> {
db: &'a MonocleDatabase,
}
impl<'a> Pfx2asLens<'a> {
pub fn new(db: &'a MonocleDatabase) -> Self {
Self { db }
}
pub fn is_empty(&self) -> Result<bool> {
Ok(self.db.pfx2as().is_empty())
}
pub fn needs_refresh(&self, ttl: std::time::Duration) -> Result<bool> {
Ok(self.db.pfx2as().needs_refresh(ttl))
}
pub fn refresh_reason(
&self,
ttl: std::time::Duration,
) -> Result<Option<crate::utils::RefreshReason>> {
use crate::utils::RefreshReason;
let pfx2as = self.db.pfx2as();
if pfx2as.is_empty() {
return Ok(Some(RefreshReason::Empty));
}
if pfx2as.needs_refresh(ttl) {
return Ok(Some(RefreshReason::Outdated));
}
Ok(None)
}
pub fn get_metadata(&self) -> Result<Option<crate::database::Pfx2asCacheDbMetadata>> {
self.db.pfx2as().get_metadata()
}
pub fn refresh(&self, url: Option<&str>) -> Result<usize> {
use crate::database::Pfx2asDbRecord;
let default_url = "https://data.bgpkit.com/pfx2as/pfx2as-latest.json.bz2";
let url = url.unwrap_or(default_url);
#[derive(serde::Deserialize)]
struct Pfx2asEntry {
prefix: String,
asn: u32,
}
let entries: Vec<Pfx2asEntry> = oneio::read_json_struct(url)?;
let records: Vec<Pfx2asDbRecord> = entries
.into_iter()
.filter(|e| !e.prefix.ends_with("/0"))
.map(|e| Pfx2asDbRecord {
prefix: e.prefix,
origin_asn: e.asn,
validation: "unknown".to_string(),
})
.collect();
let count = records.len();
self.db.pfx2as().store(&records, url)?;
Ok(count)
}
pub fn detect_query_type(&self, query: &str) -> Pfx2asQueryType {
let trimmed = query.trim();
let asn_str = if trimmed.to_uppercase().starts_with("AS") {
&trimmed[2..]
} else {
trimmed
};
if let Ok(asn) = asn_str.parse::<u32>() {
if trimmed.to_uppercase().starts_with("AS")
|| (!trimmed.contains('.') && !trimmed.contains(':'))
{
return Pfx2asQueryType::Asn(asn);
}
}
Pfx2asQueryType::Prefix(trimmed.to_string())
}
pub fn search(&self, args: &Pfx2asSearchArgs) -> Result<Vec<Pfx2asSearchResult>> {
args.validate().map_err(|e| anyhow::anyhow!(e))?;
let query_type = self.detect_query_type(&args.query);
match query_type {
Pfx2asQueryType::Asn(asn) => self.search_by_asn(asn, args),
Pfx2asQueryType::Prefix(prefix) => self.search_by_prefix(&prefix, args),
}
}
pub fn search_by_asn(
&self,
asn: u32,
args: &Pfx2asSearchArgs,
) -> Result<Vec<Pfx2asSearchResult>> {
let records = self.get_prefixes_for_asn(asn)?;
if records.is_empty() {
return Ok(Vec::new());
}
let records: Vec<_> = if let Some(n) = args.limit {
records.into_iter().take(n).collect()
} else {
records
};
let show_name = args.show_name || args.show_full_name;
let as_names = if show_name {
self.get_as_names(&[asn])
} else {
HashMap::new()
};
let rpki_lens = RpkiLens::new(self.db);
let mut results = Vec::new();
for record in &records {
let rpki_state = match rpki_lens.validate(&record.prefix, record.origin_asn) {
Ok(result) => result.state.to_string(),
Err(_) => "unknown".to_string(),
};
let as_name = if show_name {
let name = as_names
.get(&record.origin_asn)
.cloned()
.unwrap_or_default();
let display_name = if args.show_full_name {
name
} else {
truncate_name(&name, DEFAULT_NAME_MAX_LEN)
};
Some(display_name)
} else {
None
};
results.push(Pfx2asSearchResult {
prefix: record.prefix.clone(),
origin_asn: record.origin_asn,
as_name,
rpki: rpki_state,
match_type: None,
});
}
Ok(results)
}
pub fn search_by_prefix(
&self,
prefix: &str,
args: &Pfx2asSearchArgs,
) -> Result<Vec<Pfx2asSearchResult>> {
let mut all_results: Vec<(String, u32, String)> = Vec::new();
let longest_results = self.lookup_longest(prefix)?;
for result in longest_results {
for asn in &result.origin_asns {
all_results.push((result.matched_prefix.clone(), *asn, "longest".to_string()));
}
}
if args.include_super {
let covering_results = self.lookup_covering(prefix)?;
for result in covering_results {
for asn in &result.origin_asns {
if !all_results
.iter()
.any(|(p, a, _)| p == &result.matched_prefix && a == asn)
{
all_results.push((
result.matched_prefix.clone(),
*asn,
"super".to_string(),
));
}
}
}
}
if args.include_sub {
let covered_results = self.lookup_covered(prefix)?;
for result in covered_results {
for asn in &result.origin_asns {
if !all_results
.iter()
.any(|(p, a, _)| p == &result.matched_prefix && a == asn)
{
all_results.push((result.matched_prefix.clone(), *asn, "sub".to_string()));
}
}
}
}
if all_results.is_empty() {
return Ok(Vec::new());
}
let all_results: Vec<_> = if let Some(n) = args.limit {
all_results.into_iter().take(n).collect()
} else {
all_results
};
let unique_asns: Vec<u32> = all_results
.iter()
.map(|(_, asn, _)| *asn)
.collect::<HashSet<_>>()
.into_iter()
.collect();
let show_name = args.show_name || args.show_full_name;
let as_names = if show_name {
self.get_as_names(&unique_asns)
} else {
HashMap::new()
};
let rpki_lens = RpkiLens::new(self.db);
let mut results = Vec::new();
for (pfx, asn, match_type) in &all_results {
let rpki_state = match rpki_lens.validate(pfx, *asn) {
Ok(result) => result.state.to_string(),
Err(_) => "unknown".to_string(),
};
let as_name = if show_name {
let name = as_names.get(asn).cloned().unwrap_or_default();
let display_name = if args.show_full_name {
name
} else {
truncate_name(&name, DEFAULT_NAME_MAX_LEN)
};
Some(display_name)
} else {
None
};
results.push(Pfx2asSearchResult {
prefix: pfx.clone(),
origin_asn: *asn,
as_name,
rpki: rpki_state,
match_type: Some(match_type.clone()),
});
}
Ok(results)
}
fn get_as_names(&self, asns: &[u32]) -> HashMap<u32, String> {
self.db.asinfo().lookup_preferred_names_batch(asns)
}
pub fn lookup(&self, args: &Pfx2asLookupArgs) -> Result<Vec<Pfx2asDetailedResult>> {
match args.mode {
Pfx2asLookupMode::Exact => self.lookup_exact(&args.prefix),
Pfx2asLookupMode::Longest => self.lookup_longest(&args.prefix),
Pfx2asLookupMode::Covering => self.lookup_covering(&args.prefix),
Pfx2asLookupMode::Covered => self.lookup_covered(&args.prefix),
}
}
pub fn lookup_exact(&self, prefix: &str) -> Result<Vec<Pfx2asDetailedResult>> {
let asns = self.db.pfx2as().lookup_exact(prefix)?;
if asns.is_empty() {
Ok(Vec::new())
} else {
Ok(vec![Pfx2asDetailedResult {
prefix: prefix.to_string(),
matched_prefix: prefix.to_string(),
origin_asns: asns,
match_type: Pfx2asLookupMode::Exact,
}])
}
}
pub fn lookup_longest(&self, prefix: &str) -> Result<Vec<Pfx2asDetailedResult>> {
let result = self.db.pfx2as().lookup_longest(prefix)?;
if result.origin_asns.is_empty() {
Ok(Vec::new())
} else {
Ok(vec![Pfx2asDetailedResult {
prefix: prefix.to_string(),
matched_prefix: result.prefix,
origin_asns: result.origin_asns,
match_type: Pfx2asLookupMode::Longest,
}])
}
}
pub fn lookup_covering(&self, prefix: &str) -> Result<Vec<Pfx2asDetailedResult>> {
let results = self.db.pfx2as().lookup_covering(prefix)?;
Ok(results
.into_iter()
.map(|r| Pfx2asDetailedResult {
prefix: prefix.to_string(),
matched_prefix: r.prefix,
origin_asns: r.origin_asns,
match_type: Pfx2asLookupMode::Covering,
})
.collect())
}
pub fn lookup_covered(&self, prefix: &str) -> Result<Vec<Pfx2asDetailedResult>> {
let results = self.db.pfx2as().lookup_covered(prefix)?;
Ok(results
.into_iter()
.map(|r| Pfx2asDetailedResult {
prefix: prefix.to_string(),
matched_prefix: r.prefix,
origin_asns: r.origin_asns,
match_type: Pfx2asLookupMode::Covered,
})
.collect())
}
pub fn get_prefixes_for_asn(&self, asn: u32) -> Result<Vec<Pfx2asPrefixRecord>> {
let records = self.db.pfx2as().get_by_asn(asn)?;
Ok(records
.into_iter()
.map(|r| Pfx2asPrefixRecord {
prefix: r.prefix,
origin_asn: r.origin_asn,
validation: r.validation,
})
.collect())
}
pub fn record_count(&self) -> Result<usize> {
Ok(self.db.pfx2as().record_count()? as usize)
}
pub fn prefix_count(&self) -> Result<usize> {
Ok(self.db.pfx2as().prefix_count()? as usize)
}
pub fn format_search_results(
&self,
results: &[Pfx2asSearchResult],
format: &OutputFormat,
show_name: bool,
) -> String {
match format {
OutputFormat::Json => serde_json::to_string(results).unwrap_or_default(),
OutputFormat::JsonPretty => serde_json::to_string_pretty(results).unwrap_or_default(),
OutputFormat::JsonLine => results
.iter()
.filter_map(|r| serde_json::to_string(r).ok())
.collect::<Vec<_>>()
.join("\n"),
OutputFormat::Table | OutputFormat::Markdown => {
use tabled::settings::Style;
use tabled::Table;
if show_name {
#[derive(Tabled)]
struct Row {
prefix: String,
origin_asn: u32,
as_name: String,
rpki: String,
}
let rows: Vec<Row> = results
.iter()
.map(|r| Row {
prefix: r.prefix.clone(),
origin_asn: r.origin_asn,
as_name: r.as_name.clone().unwrap_or_default(),
rpki: r.rpki.clone(),
})
.collect();
let mut table = Table::new(rows);
if matches!(format, OutputFormat::Markdown) {
table.with(Style::markdown())
} else {
table.with(Style::rounded())
}
.to_string()
} else {
#[derive(Tabled)]
struct Row {
prefix: String,
origin_asn: u32,
rpki: String,
}
let rows: Vec<Row> = results
.iter()
.map(|r| Row {
prefix: r.prefix.clone(),
origin_asn: r.origin_asn,
rpki: r.rpki.clone(),
})
.collect();
let mut table = Table::new(rows);
if matches!(format, OutputFormat::Markdown) {
table.with(Style::markdown())
} else {
table.with(Style::rounded())
}
.to_string()
}
}
OutputFormat::Psv => {
let mut output = if show_name {
"prefix|origin_asn|as_name|rpki\n".to_string()
} else {
"prefix|origin_asn|rpki\n".to_string()
};
for r in results {
if show_name {
output.push_str(&format!(
"{}|{}|{}|{}\n",
r.prefix,
r.origin_asn,
r.as_name.as_deref().unwrap_or(""),
r.rpki
));
} else {
output.push_str(&format!("{}|{}|{}\n", r.prefix, r.origin_asn, r.rpki));
}
}
output.trim_end().to_string()
}
}
}
pub fn format_results(
&self,
results: &[Pfx2asDetailedResult],
format: &Pfx2asOutputFormat,
) -> String {
match format {
Pfx2asOutputFormat::Json => serde_json::to_string(results).unwrap_or_default(),
Pfx2asOutputFormat::JsonPretty => {
serde_json::to_string_pretty(results).unwrap_or_default()
}
Pfx2asOutputFormat::Table => {
use tabled::settings::Style;
use tabled::Table;
let rows: Vec<Pfx2asResult> = results
.iter()
.map(|r| Pfx2asResult {
prefix: r.matched_prefix.clone(),
asns: r
.origin_asns
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", "),
match_type: r.match_type.to_string(),
})
.collect();
Table::new(rows).with(Style::rounded()).to_string()
}
Pfx2asOutputFormat::Simple => results
.iter()
.map(|r| {
r.origin_asns
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn format_prefixes(
&self,
prefixes: &[Pfx2asPrefixRecord],
format: &Pfx2asOutputFormat,
) -> String {
match format {
Pfx2asOutputFormat::Json => serde_json::to_string(prefixes).unwrap_or_default(),
Pfx2asOutputFormat::JsonPretty => {
serde_json::to_string_pretty(prefixes).unwrap_or_default()
}
Pfx2asOutputFormat::Table => {
use tabled::settings::Style;
use tabled::Table;
Table::new(prefixes).with(Style::rounded()).to_string()
}
Pfx2asOutputFormat::Simple => prefixes
.iter()
.map(|p| p.prefix.clone())
.collect::<Vec<_>>()
.join("\n"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lookup_args() {
let args = Pfx2asLookupArgs::new("1.1.1.0/24")
.exact()
.with_format(Pfx2asOutputFormat::Table);
assert_eq!(args.prefix, "1.1.1.0/24");
assert!(matches!(args.mode, Pfx2asLookupMode::Exact));
assert!(matches!(args.format, Pfx2asOutputFormat::Table));
}
#[test]
fn test_lookup_modes() {
let args = Pfx2asLookupArgs::new("10.0.0.0/8").covering();
assert!(matches!(args.mode, Pfx2asLookupMode::Covering));
let args = Pfx2asLookupArgs::new("10.0.0.0/8").covered();
assert!(matches!(args.mode, Pfx2asLookupMode::Covered));
let args = Pfx2asLookupArgs::new("10.0.0.0/8").longest();
assert!(matches!(args.mode, Pfx2asLookupMode::Longest));
}
#[test]
fn test_lookup_mode_display() {
assert_eq!(Pfx2asLookupMode::Exact.to_string(), "exact");
assert_eq!(Pfx2asLookupMode::Longest.to_string(), "longest");
assert_eq!(Pfx2asLookupMode::Covering.to_string(), "covering");
assert_eq!(Pfx2asLookupMode::Covered.to_string(), "covered");
}
#[test]
fn test_detailed_result_serialization() {
let result = Pfx2asDetailedResult {
prefix: "1.1.1.0/24".to_string(),
matched_prefix: "1.1.0.0/20".to_string(),
origin_asns: vec![13335, 13336],
match_type: Pfx2asLookupMode::Longest,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("1.1.1.0/24"));
assert!(json.contains("13335"));
}
#[test]
fn test_search_args() {
let args = Pfx2asSearchArgs::new("1.1.1.0/24")
.with_include_sub(true)
.with_show_name(true)
.with_limit(10);
assert_eq!(args.query, "1.1.1.0/24");
assert!(args.include_sub);
assert!(args.show_name);
assert_eq!(args.limit, Some(10));
}
#[test]
fn test_search_args_validation() {
let args = Pfx2asSearchArgs::new("");
assert!(args.validate().is_err());
let args = Pfx2asSearchArgs::new("1.1.1.0/24");
assert!(args.validate().is_ok());
}
#[test]
fn test_search_result_serialization() {
let result = Pfx2asSearchResult {
prefix: "1.1.1.0/24".to_string(),
origin_asn: 13335,
as_name: Some("CLOUDFLARENET".to_string()),
rpki: "valid".to_string(),
match_type: Some("longest".to_string()),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("1.1.1.0/24"));
assert!(json.contains("13335"));
assert!(json.contains("CLOUDFLARENET"));
assert!(json.contains("valid"));
}
#[test]
fn test_search_result_without_optional_fields() {
let result = Pfx2asSearchResult {
prefix: "1.1.1.0/24".to_string(),
origin_asn: 13335,
as_name: None,
rpki: "valid".to_string(),
match_type: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("1.1.1.0/24"));
assert!(!json.contains("as_name")); assert!(!json.contains("match_type")); }
}