Skip to main content

zlayer_proxy/
cf_ip_list.rs

1//! Cloudflare edge IP range cache.
2//!
3//! The proxy needs to know which inbound TCP peer IPs are Cloudflare edge
4//! nodes so it can honor `CF-Connecting-IP` headers as the real client IP.
5//! Cloudflare publishes its edge ranges at:
6//!
7//! * <https://www.cloudflare.com/ips-v4>
8//! * <https://www.cloudflare.com/ips-v6>
9//!
10//! (newline-delimited CIDRs). This module caches those ranges and exposes a
11//! fast `contains(ip)` check. It includes a baked-in fallback list for
12//! boot-time fetch failures and an auto-refresh background task.
13
14use 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
22/// Baked-in IPv4 Cloudflare edge ranges, accurate as of 2026.
23///
24/// Source: <https://www.cloudflare.com/ips-v4>
25const 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
43/// Baked-in IPv6 Cloudflare edge ranges, accurate as of 2026.
44///
45/// Source: <https://www.cloudflare.com/ips-v6>
46const 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/// In-memory cache of Cloudflare edge CIDR ranges with a fast `contains` check.
60#[derive(Debug)]
61pub struct CloudflareIpCache {
62    ranges: RwLock<Vec<IpNet>>,
63}
64
65impl CloudflareIpCache {
66    /// Build a cache populated from the hardcoded fallback list. No network.
67    #[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    /// GET the two upstream CF URLs, parse CIDRs, and return a new cache.
76    ///
77    /// Falls back to the baked-in list on any error (network failure, DNS,
78    /// timeout, non-200 status, parse error for both lists). If one of the
79    /// two fetches succeeds and the other fails, the successful half is
80    /// merged with the fallback for the failing half.
81    #[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    /// Spawn a tokio task that refreshes the cache immediately, then every
90    /// `interval`. The returned `Arc` is shared with the refresh task, which
91    /// lives for the duration of the process.
92    #[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            // First tick yields immediately, triggering the initial fetch.
102            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    /// Returns true if `ip` is within any cached CF range.
113    #[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    /// Replace the cached ranges atomically. Primarily used by the refresh task.
123    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    /// Snapshot of the current cached ranges (clones).
132    #[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
142/// Parse the baked-in IPv4 fallback list.
143fn parse_fallback_ipv4() -> Vec<IpNet> {
144    parse_cidr_list(FALLBACK_IPV4, "fallback-ipv4")
145}
146
147/// Parse the baked-in IPv6 fallback list.
148fn parse_fallback_ipv6() -> Vec<IpNet> {
149    parse_cidr_list(FALLBACK_IPV6, "fallback-ipv6")
150}
151
152/// Parse both baked-in fallback lists concatenated.
153fn parse_fallback_all() -> Vec<IpNet> {
154    let mut ranges = parse_fallback_ipv4();
155    ranges.extend(parse_fallback_ipv6());
156    ranges
157}
158
159/// Parse a slice of string CIDRs into `IpNet` values, warning on any line
160/// that fails to parse. The `source` label is included in warnings.
161fn 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
185/// Parse a newline-delimited CIDR body (as returned by cloudflare.com/ips-vN).
186fn 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
191/// Fetch a single upstream URL and parse the response into CIDRs.
192async 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
203/// Perform the HTTP fetches for both IPv4 and IPv6 lists and merge them.
204///
205/// On total failure (both fetches error), returns the full baked-in fallback.
206/// On partial failure, returns the successful half merged with the fallback
207/// for the failing half.
208async 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}