Files
fast-xray/src/cloudflare.rs
T
2026-06-23 19:29:34 +08:00

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");
}
}