zlayer_proxy/
cf_ip_list.rs1use std::net::IpAddr;
15use std::str::FromStr;
16use std::sync::{Arc, RwLock};
17use std::time::Duration;
18
19use ipnet::IpNet;
20use tokio::time::MissedTickBehavior;
21
22const FALLBACK_IPV4: &[&str] = &[
26 "173.245.48.0/20",
27 "103.21.244.0/22",
28 "103.22.200.0/22",
29 "103.31.4.0/22",
30 "141.101.64.0/18",
31 "108.162.192.0/18",
32 "190.93.240.0/20",
33 "188.114.96.0/20",
34 "197.234.240.0/22",
35 "198.41.128.0/17",
36 "162.158.0.0/15",
37 "104.16.0.0/13",
38 "104.24.0.0/14",
39 "172.64.0.0/13",
40 "131.0.72.0/22",
41];
42
43const FALLBACK_IPV6: &[&str] = &[
47 "2400:cb00::/32",
48 "2606:4700::/32",
49 "2803:f800::/32",
50 "2405:b500::/32",
51 "2405:8100::/32",
52 "2a06:98c0::/29",
53 "2c0f:f248::/32",
54];
55
56const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
57const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
58
59#[derive(Debug)]
61pub struct CloudflareIpCache {
62 ranges: RwLock<Vec<IpNet>>,
63}
64
65impl CloudflareIpCache {
66 #[must_use]
68 pub fn new_with_fallback() -> Arc<Self> {
69 let ranges = parse_fallback_all();
70 Arc::new(Self {
71 ranges: RwLock::new(ranges),
72 })
73 }
74
75 #[must_use]
82 pub async fn fetch_from_upstream() -> Arc<Self> {
83 let ranges = fetch_ranges_from_upstream().await;
84 Arc::new(Self {
85 ranges: RwLock::new(ranges),
86 })
87 }
88
89 #[must_use]
93 pub fn spawn_auto_refresh(interval: Duration) -> Arc<Self> {
94 let cache = Self::new_with_fallback();
95 let task_cache = Arc::clone(&cache);
96
97 tokio::spawn(async move {
98 let mut ticker = tokio::time::interval(interval);
99 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
100
101 loop {
103 ticker.tick().await;
104 let new_ranges = fetch_ranges_from_upstream().await;
105 task_cache.replace(new_ranges);
106 }
107 });
108
109 cache
110 }
111
112 #[must_use]
114 pub fn contains(&self, ip: IpAddr) -> bool {
115 let guard = match self.ranges.read() {
116 Ok(g) => g,
117 Err(poisoned) => poisoned.into_inner(),
118 };
119 guard.iter().any(|net| net.contains(&ip))
120 }
121
122 pub fn replace(&self, new_ranges: Vec<IpNet>) {
124 let mut guard = match self.ranges.write() {
125 Ok(g) => g,
126 Err(poisoned) => poisoned.into_inner(),
127 };
128 *guard = new_ranges;
129 }
130
131 #[must_use]
133 pub fn ranges(&self) -> Vec<IpNet> {
134 let guard = match self.ranges.read() {
135 Ok(g) => g,
136 Err(poisoned) => poisoned.into_inner(),
137 };
138 guard.clone()
139 }
140}
141
142fn parse_fallback_ipv4() -> Vec<IpNet> {
144 parse_cidr_list(FALLBACK_IPV4, "fallback-ipv4")
145}
146
147fn parse_fallback_ipv6() -> Vec<IpNet> {
149 parse_cidr_list(FALLBACK_IPV6, "fallback-ipv6")
150}
151
152fn parse_fallback_all() -> Vec<IpNet> {
154 let mut ranges = parse_fallback_ipv4();
155 ranges.extend(parse_fallback_ipv6());
156 ranges
157}
158
159fn parse_cidr_list<S: AsRef<str>>(lines: &[S], source: &str) -> Vec<IpNet> {
162 lines
163 .iter()
164 .filter_map(|line| {
165 let trimmed = line.as_ref().trim();
166 if trimmed.is_empty() {
167 return None;
168 }
169 match IpNet::from_str(trimmed) {
170 Ok(net) => Some(net),
171 Err(err) => {
172 tracing::warn!(
173 source = %source,
174 line = %trimmed,
175 error = %err,
176 "failed to parse Cloudflare CIDR"
177 );
178 None
179 }
180 }
181 })
182 .collect()
183}
184
185fn parse_cidr_body(body: &str, source: &str) -> Vec<IpNet> {
187 let lines: Vec<&str> = body.lines().collect();
188 parse_cidr_list(&lines, source)
189}
190
191async fn fetch_single(client: &reqwest::Client, url: &str) -> Result<Vec<IpNet>, reqwest::Error> {
193 let body = client
194 .get(url)
195 .send()
196 .await?
197 .error_for_status()?
198 .text()
199 .await?;
200 Ok(parse_cidr_body(&body, url))
201}
202
203async fn fetch_ranges_from_upstream() -> Vec<IpNet> {
209 let client = match reqwest::Client::builder()
210 .timeout(Duration::from_secs(15))
211 .build()
212 {
213 Ok(c) => c,
214 Err(err) => {
215 tracing::error!(
216 error = %err,
217 "failed to build reqwest client for Cloudflare IP fetch; using fallback"
218 );
219 return parse_fallback_all();
220 }
221 };
222
223 let (v4_result, v6_result) = tokio::join!(
224 fetch_single(&client, CF_IPV4_URL),
225 fetch_single(&client, CF_IPV6_URL),
226 );
227
228 match (v4_result, v6_result) {
229 (Ok(v4), Ok(v6)) => {
230 let mut all = v4;
231 all.extend(v6);
232 if all.is_empty() {
233 tracing::warn!("Cloudflare upstream returned zero parseable CIDRs; using fallback");
234 return parse_fallback_all();
235 }
236 all
237 }
238 (Ok(v4), Err(v6_err)) => {
239 tracing::warn!(
240 error = %v6_err,
241 "failed to fetch Cloudflare IPv6 list; merging IPv4 fetch with fallback IPv6"
242 );
243 let mut all = v4;
244 all.extend(parse_fallback_ipv6());
245 all
246 }
247 (Err(v4_err), Ok(v6)) => {
248 tracing::warn!(
249 error = %v4_err,
250 "failed to fetch Cloudflare IPv4 list; merging fallback IPv4 with IPv6 fetch"
251 );
252 let mut all = parse_fallback_ipv4();
253 all.extend(v6);
254 all
255 }
256 (Err(v4_err), Err(v6_err)) => {
257 tracing::error!(
258 ipv4_error = %v4_err,
259 ipv6_error = %v6_err,
260 "failed to fetch both Cloudflare IP lists; using baked-in fallback"
261 );
262 parse_fallback_all()
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn fallback_list_is_non_empty() {
273 let cache = CloudflareIpCache::new_with_fallback();
274 let ranges = cache.ranges();
275
276 let ipv4_count = ranges.iter().filter(|n| matches!(n, IpNet::V4(_))).count();
277 let ipv6_count = ranges.iter().filter(|n| matches!(n, IpNet::V6(_))).count();
278
279 assert!(
280 ipv4_count >= 10,
281 "expected >= 10 IPv4 ranges in fallback, got {ipv4_count}"
282 );
283 assert!(
284 ipv6_count >= 5,
285 "expected >= 5 IPv6 ranges in fallback, got {ipv6_count}"
286 );
287 }
288
289 #[test]
290 fn contains_known_cf_ipv4() {
291 let cache = CloudflareIpCache::new_with_fallback();
292 let ip: IpAddr = "104.16.0.1".parse().expect("valid ipv4");
293 assert!(
294 cache.contains(ip),
295 "104.16.0.1 should be within 104.16.0.0/13"
296 );
297 }
298
299 #[test]
300 fn contains_known_cf_ipv6() {
301 let cache = CloudflareIpCache::new_with_fallback();
302 let ip: IpAddr = "2606:4700::1".parse().expect("valid ipv6");
303 assert!(
304 cache.contains(ip),
305 "2606:4700::1 should be within 2606:4700::/32"
306 );
307 }
308
309 #[test]
310 fn rejects_non_cf_ipv4() {
311 let cache = CloudflareIpCache::new_with_fallback();
312 let ip: IpAddr = "8.8.8.8".parse().expect("valid ipv4");
313 assert!(
314 !cache.contains(ip),
315 "8.8.8.8 (Google DNS) should not be in Cloudflare ranges"
316 );
317 }
318
319 #[test]
320 fn replace_swaps_ranges() {
321 let cache = CloudflareIpCache::new_with_fallback();
322 let ip: IpAddr = "104.16.0.1".parse().expect("valid ipv4");
323 assert!(cache.contains(ip), "precondition: IP should be present");
324
325 cache.replace(Vec::new());
326 assert!(
327 !cache.contains(ip),
328 "after replace with empty vec, IP should no longer be contained"
329 );
330 assert!(
331 cache.ranges().is_empty(),
332 "ranges snapshot should be empty after replace"
333 );
334 }
335
336 #[test]
337 fn parse_cidr_body_skips_blank_and_bad_lines() {
338 let body = "\n104.16.0.0/13\n\n \nnot-a-cidr\n2606:4700::/32\n";
339 let parsed = parse_cidr_body(body, "test");
340 assert_eq!(parsed.len(), 2, "expected exactly 2 valid CIDRs parsed");
341 }
342}