use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result};
use serde::Deserialize;
const DEFAULT_RIS_URL: &str = "https://stat.ripe.net";
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_default()
}
#[derive(Deserialize)]
struct Envelope {
data: LgData,
}
#[derive(Deserialize)]
struct LgData {
#[serde(default)]
rrcs: Vec<Rrc>,
#[serde(default)]
query_time: Option<String>,
}
#[derive(Deserialize)]
struct Rrc {
#[serde(default)]
rrc: String,
#[serde(default)]
peers: Vec<Peer>,
}
#[derive(Deserialize)]
struct Peer {
#[serde(default)]
asn_origin: String,
#[serde(default)]
as_path: String,
}
pub struct PathStat {
pub origin: String,
pub as_path: String,
pub peers: usize,
pub collectors: usize,
}
pub struct Visibility {
pub query_time: Option<String>,
rrcs: Vec<Rrc>,
}
impl Visibility {
pub fn peer_count(&self) -> usize {
self.rrcs.iter().map(|r| r.peers.len()).sum()
}
pub fn collector_count(&self) -> usize {
self.rrcs.iter().filter(|r| !r.peers.is_empty()).count()
}
pub fn is_visible(&self) -> bool {
self.peer_count() > 0
}
pub fn origins(&self) -> Vec<String> {
self.rrcs
.iter()
.flat_map(|r| r.peers.iter())
.filter(|p| !p.asn_origin.is_empty())
.map(|p| p.asn_origin.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn shortest_path(&self) -> Option<String> {
self.rrcs
.iter()
.flat_map(|r| r.peers.iter())
.map(|p| p.as_path.clone())
.filter(|p| !p.is_empty())
.min_by_key(|p| p.split_whitespace().count())
}
pub fn paths(&self) -> Vec<PathStat> {
let mut map: BTreeMap<String, (String, usize, BTreeSet<String>)> = BTreeMap::new();
for r in &self.rrcs {
for p in &r.peers {
if p.as_path.is_empty() {
continue;
}
let entry = map
.entry(p.as_path.clone())
.or_insert_with(|| (p.asn_origin.clone(), 0, BTreeSet::new()));
entry.1 += 1;
entry.2.insert(r.rrc.clone());
}
}
let mut paths: Vec<PathStat> = map
.into_iter()
.map(|(as_path, (origin, peers, rrcs))| PathStat {
origin,
as_path,
peers,
collectors: rrcs.len(),
})
.collect();
paths.sort_by(|a, b| {
b.peers.cmp(&a.peers).then_with(|| {
a.as_path
.split_whitespace()
.count()
.cmp(&b.as_path.split_whitespace().count())
})
});
paths
}
}
pub struct FullFeedPeers {
pub v4: u64,
pub v6: u64,
}
impl FullFeedPeers {
pub fn for_resource(&self, resource: &str) -> u64 {
if resource.contains(':') {
self.v6
} else {
self.v4
}
}
}
#[derive(Deserialize)]
struct PeerCountEnvelope {
data: PeerCountData,
}
#[derive(Deserialize)]
struct PeerCountData {
peer_count: PeerCountFamilies,
}
#[derive(Deserialize)]
struct PeerCountFamilies {
v4: FeedSeries,
v6: FeedSeries,
}
#[derive(Deserialize)]
struct FeedSeries {
#[serde(default)]
full_feed: Vec<CountSample>,
}
#[derive(Deserialize)]
struct CountSample {
count: u64,
}
pub async fn full_feed_peers() -> Result<FullFeedPeers> {
let base = std::env::var("NXTHDR_RIS_URL").unwrap_or_else(|_| DEFAULT_RIS_URL.to_string());
let url = format!("{base}/data/ris-peer-count/data.json?sourceapp=nxthdr-cli");
let resp = client()
.get(&url)
.header("User-Agent", "nxthdr-cli")
.send()
.await
.context("Failed to query RIPEstat")?;
if !resp.status().is_success() {
anyhow::bail!(
"RIPEstat ris-peer-count failed with status {}",
resp.status()
);
}
let body: PeerCountEnvelope = resp
.json()
.await
.context("Failed to parse RIPEstat response")?;
let latest = |series: &FeedSeries| series.full_feed.last().map(|s| s.count).unwrap_or(0);
Ok(FullFeedPeers {
v4: latest(&body.data.peer_count.v4),
v6: latest(&body.data.peer_count.v6),
})
}
pub fn propagation_pct(peers_seeing: usize, full_feed: u64) -> Option<u8> {
if full_feed == 0 {
return None;
}
Some(((peers_seeing as u64 * 100 / full_feed).min(100)) as u8)
}
pub async fn looking_glass(resource: &str) -> Result<Visibility> {
let base = std::env::var("NXTHDR_RIS_URL").unwrap_or_else(|_| DEFAULT_RIS_URL.to_string());
let url = format!(
"{base}/data/looking-glass/data.json?resource={}&sourceapp=nxthdr-cli",
urlencoding::encode(resource)
);
tracing::debug!("RIS looking-glass: {url}");
let resp = client()
.get(&url)
.header("User-Agent", "nxthdr-cli")
.send()
.await
.context("Failed to query RIPEstat")?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!(
"RIPEstat request failed with status {}: {}",
status,
resp.text().await.unwrap_or_default().trim()
);
}
let envelope: Envelope = resp
.json()
.await
.context("Failed to parse RIPEstat response")?;
Ok(Visibility {
query_time: envelope.data.query_time,
rrcs: envelope.data.rrcs,
})
}