119 lines
3.5 KiB
Rust
119 lines
3.5 KiB
Rust
//! Fetch Cloudflare's published CDN IP ranges from the web.
|
|
//!
|
|
//! Cloudflare publishes its anycast ranges as plain-text CIDR lists, one per
|
|
//! line. This module fetches them and returns parsed, typed CIDR lists for
|
|
//! other stages (sampling, probing) to consume. It does not write files or
|
|
//! shell out.
|
|
|
|
use anyhow::{Context, Result};
|
|
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
|
|
|
|
pub const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
|
|
pub const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
|
|
|
|
const USER_AGENT: &str = "cfopt/0.1";
|
|
|
|
/// Which address families to fetch.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[allow(dead_code)] // V4 / V6 reserved for a future --family CLI flag.
|
|
pub enum Family {
|
|
V4,
|
|
V6,
|
|
Both,
|
|
}
|
|
|
|
/// Parsed Cloudflare CDN ranges, split by address family.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct CfRanges {
|
|
pub v4: Vec<Ipv4Net>,
|
|
pub v6: Vec<Ipv6Net>,
|
|
}
|
|
|
|
impl CfRanges {
|
|
#[allow(dead_code)] // paired with len(); used by later stages.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.v4.is_empty() && self.v6.is_empty()
|
|
}
|
|
|
|
#[allow(dead_code)] // used by later stages.
|
|
pub fn len(&self) -> usize {
|
|
self.v4.len() + self.v6.len()
|
|
}
|
|
}
|
|
|
|
/// Fetch Cloudflare ranges for the requested family and return them parsed.
|
|
pub async fn fetch_ranges(family: Family) -> Result<CfRanges> {
|
|
let client = reqwest::Client::builder()
|
|
.user_agent(USER_AGENT)
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.build()
|
|
.context("build http client")?;
|
|
|
|
let mut ranges = CfRanges::default();
|
|
if matches!(family, Family::V4 | Family::Both) {
|
|
let text = fetch_text(&client, CF_IPV4_URL).await?;
|
|
ranges.v4 = parse_v4_lines(&text);
|
|
}
|
|
if matches!(family, Family::V6 | Family::Both) {
|
|
let text = fetch_text(&client, CF_IPV6_URL).await?;
|
|
ranges.v6 = parse_v6_lines(&text);
|
|
}
|
|
Ok(ranges)
|
|
}
|
|
|
|
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String> {
|
|
client
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("request {url}"))?
|
|
.error_for_status()
|
|
.with_context(|| format!("bad status from {url}"))?
|
|
.text()
|
|
.await
|
|
.with_context(|| format!("read body from {url}"))
|
|
}
|
|
|
|
/// Parse a plain-text CIDR list, keeping only IPv4 networks.
|
|
pub fn parse_v4_lines(text: &str) -> Vec<Ipv4Net> {
|
|
parse_cidr_lines(text)
|
|
.into_iter()
|
|
.filter_map(|net| match net {
|
|
IpNet::V4(v4) => Some(v4),
|
|
IpNet::V6(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Parse a plain-text CIDR list, keeping only IPv6 networks.
|
|
pub fn parse_v6_lines(text: &str) -> Vec<Ipv6Net> {
|
|
parse_cidr_lines(text)
|
|
.into_iter()
|
|
.filter_map(|net| match net {
|
|
IpNet::V6(v6) => Some(v6),
|
|
IpNet::V4(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Parse a plain-text CIDR list, skipping blanks and anything unparseable.
|
|
fn parse_cidr_lines(text: &str) -> Vec<IpNet> {
|
|
text.lines()
|
|
.filter_map(|line| line.split_whitespace().next())
|
|
.filter_map(|token| token.parse::<IpNet>().ok())
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn splits_v4_and_v6() {
|
|
let text = "173.245.48.0/20\n# comment\n\n103.21.244.0/22 trailing\n2400:cb00::/32\n";
|
|
assert_eq!(parse_v4_lines(text).len(), 2);
|
|
assert_eq!(parse_v6_lines(text).len(), 1);
|
|
assert_eq!(parse_v4_lines(text)[0].to_string(), "173.245.48.0/20");
|
|
}
|
|
}
|