//! 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, pub v6: Vec, } 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 { 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 { 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 { 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 { 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 { text.lines() .filter_map(|line| line.split_whitespace().next()) .filter_map(|token| token.parse::().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"); } }