From 83dd454c42574f3a8e9071e6d7d04ca4b60d776e Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Thu, 19 Mar 2026 18:56:11 -0400 Subject: [PATCH] Fetch CF ranges concurrently and prevent writes Use tokio::join to fetch IPv4 and IPv6 Cloudflare ranges in parallel. When range fetch fails, avoid performing updates that could write Cloudflare addresses by clearing detected/filtered IP lists and emitting warnings. Add unit tests to validate parsing and boundary checks for the current Cloudflare ranges. Bump crate version to 2.0.6. Fetch Cloudflare ranges concurrently; avoid writes Skip updates (clear detected IPs) if Cloudflare ranges can't be retrieved to avoid writing Cloudflare anycast addresses. Default REJECT_CLOUDFLARE_IPS=true, update README, add comprehensive CF-range tests, and bump crate version Fetch CF ranges concurrently and avoid updates Enable rejecting Cloudflare IPs by default and skip any updates if the published ranges cannot be fetched to avoid writing Cloudflare anycast addresses. Fetch IPv4 and IPv6 ranges concurrently, add parsing/matching tests, and update README and version. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 16 +++--- src/cf_ip_filter.rs | 132 +++++++++++++++++++++++++++++++++++++++++++- src/config.rs | 8 +-- src/updater.rs | 17 ++++-- 6 files changed, 158 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d21aa7..f43c49a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,7 @@ dependencies = [ [[package]] name = "cloudflare-ddns" -version = "2.0.5" +version = "2.0.6" dependencies = [ "chrono", "idna", diff --git a/Cargo.toml b/Cargo.toml index cd20572..9d7e991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cloudflare-ddns" -version = "2.0.5" +version = "2.0.6" edition = "2021" description = "Access your home network remotely via a custom domain name without a static IP" license = "GPL-3.0" diff --git a/README.md b/README.md index cbb65cc..f347e02 100755 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Configure everything with environment variables. Supports notifications, heartbe - 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels - 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default - 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges -- 🚫 **Cloudflare IP rejection** — Optionally reject Cloudflare anycast IPs to prevent incorrect DNS updates +- 🚫 **Cloudflare IP rejection** — Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates - 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies ## 🚀 Quick Start @@ -92,11 +92,13 @@ Available providers: | Variable | Default | Description | |----------|---------|-------------| -| `REJECT_CLOUDFLARE_IPS` | `false` | Reject detected IPs that fall within Cloudflare's IP ranges | +| `REJECT_CLOUDFLARE_IPS` | `true` | Reject detected IPs that fall within Cloudflare's IP ranges | Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address. -Setting `REJECT_CLOUDFLARE_IPS=true` prevents this. Each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP. +By default, each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP. + +To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`. ## ⏱️ Scheduling @@ -221,7 +223,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent. | `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex | | `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout | | `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout | -| `REJECT_CLOUDFLARE_IPS` | `false` | 🚫 Reject Cloudflare anycast IPs | +| `REJECT_CLOUDFLARE_IPS` | `true` | 🚫 Reject Cloudflare anycast IPs | | `EMOJI` | `true` | 🎨 Enable emoji output | | `QUIET` | `false` | 🤫 Suppress info output | | `HEALTHCHECKS` | — | 💓 Healthchecks.io URL | @@ -373,17 +375,17 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t ### 🚫 Cloudflare IP Rejection (Legacy Mode) -The `REJECT_CLOUDFLARE_IPS` environment variable is supported in legacy config mode. Set it alongside your `config.json`: +Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set `REJECT_CLOUDFLARE_IPS=false` alongside your `config.json`: ```bash -REJECT_CLOUDFLARE_IPS=true cloudflare-ddns +REJECT_CLOUDFLARE_IPS=false cloudflare-ddns ``` Or in Docker Compose: ```yml environment: - - REJECT_CLOUDFLARE_IPS=true + - REJECT_CLOUDFLARE_IPS=false volumes: - ./config.json:/config.json ``` diff --git a/src/cf_ip_filter.rs b/src/cf_ip_filter.rs index aef9aa2..fffaf14 100644 --- a/src/cf_ip_filter.rs +++ b/src/cf_ip_filter.rs @@ -59,8 +59,13 @@ impl CloudflareIpFilter { pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option { let mut ranges = Vec::new(); - for url in [CF_IPV4_URL, CF_IPV6_URL] { - match client.get(url).timeout(timeout).send().await { + let (v4_result, v6_result) = tokio::join!( + client.get(CF_IPV4_URL).timeout(timeout).send(), + client.get(CF_IPV6_URL).timeout(timeout).send(), + ); + + for (url, result) in [(CF_IPV4_URL, v4_result), (CF_IPV6_URL, v6_result)] { + match result { Ok(resp) if resp.status().is_success() => match resp.text().await { Ok(body) => { for line in body.lines() { @@ -234,4 +239,127 @@ mod tests { let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1)); assert!(!filter.contains(&ip)); } + + /// All real Cloudflare ranges as of 2026-03. Verifies every range parses + /// and that the first and last IP in each range is matched while the + /// address just past the end is not. + const ALL_CF_RANGES: &str = "\ +173.245.48.0/20 +103.21.244.0/22 +103.22.200.0/22 +103.31.4.0/22 +141.101.64.0/18 +108.162.192.0/18 +190.93.240.0/20 +188.114.96.0/20 +197.234.240.0/22 +198.41.128.0/17 +162.158.0.0/15 +104.16.0.0/13 +104.24.0.0/14 +172.64.0.0/13 +131.0.72.0/22 +2400:cb00::/32 +2606:4700::/32 +2803:f800::/32 +2405:b500::/32 +2405:8100::/32 +2a06:98c0::/29 +2c0f:f248::/32 +"; + + #[test] + fn test_all_real_ranges_parse() { + let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap(); + assert_eq!(filter.ranges.len(), 22); + } + + /// For a /N IPv4 range starting at `base`, return (first, last, just_outside). + fn v4_range_bounds(a: u8, b: u8, c: u8, d: u8, prefix: u8) -> (Ipv4Addr, Ipv4Addr, Ipv4Addr) { + let base = u32::from(Ipv4Addr::new(a, b, c, d)); + let size = 1u32 << (32 - prefix); + let first = Ipv4Addr::from(base); + let last = Ipv4Addr::from(base + size - 1); + let outside = Ipv4Addr::from(base + size); + (first, last, outside) + } + + #[test] + fn test_all_real_ipv4_ranges_match() { + // Test each range individually so adjacent ranges (e.g. 104.16.0.0/13 + // and 104.24.0.0/14) don't cause false failures on boundary checks. + let ranges: &[(u8, u8, u8, u8, u8)] = &[ + (173, 245, 48, 0, 20), + (103, 21, 244, 0, 22), + (103, 22, 200, 0, 22), + (103, 31, 4, 0, 22), + (141, 101, 64, 0, 18), + (108, 162, 192, 0, 18), + (190, 93, 240, 0, 20), + (188, 114, 96, 0, 20), + (197, 234, 240, 0, 22), + (198, 41, 128, 0, 17), + (162, 158, 0, 0, 15), + (104, 16, 0, 0, 13), + (104, 24, 0, 0, 14), + (172, 64, 0, 0, 13), + (131, 0, 72, 0, 22), + ]; + + for &(a, b, c, d, prefix) in ranges { + let cidr = format!("{a}.{b}.{c}.{d}/{prefix}"); + let filter = CloudflareIpFilter::from_lines(&cidr).unwrap(); + let (first, last, outside) = v4_range_bounds(a, b, c, d, prefix); + assert!( + filter.contains(&IpAddr::V4(first)), + "First IP {first} should be in {cidr}" + ); + assert!( + filter.contains(&IpAddr::V4(last)), + "Last IP {last} should be in {cidr}" + ); + assert!( + !filter.contains(&IpAddr::V4(outside)), + "IP {outside} should NOT be in {cidr}" + ); + } + } + + #[test] + fn test_all_real_ipv6_ranges_match() { + let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap(); + + // (base high 16-bit segment, prefix len) + let ranges: &[(u16, u16, u8)] = &[ + (0x2400, 0xcb00, 32), + (0x2606, 0x4700, 32), + (0x2803, 0xf800, 32), + (0x2405, 0xb500, 32), + (0x2405, 0x8100, 32), + (0x2a06, 0x98c0, 29), + (0x2c0f, 0xf248, 32), + ]; + + for &(seg0, seg1, prefix) in ranges { + let base = u128::from(Ipv6Addr::new(seg0, seg1, 0, 0, 0, 0, 0, 0)); + let size = 1u128 << (128 - prefix); + + let first = Ipv6Addr::from(base); + let last = Ipv6Addr::from(base + size - 1); + let outside = Ipv6Addr::from(base + size); + + assert!( + filter.contains(&IpAddr::V6(first)), + "First IP {first} should be in {seg0:x}:{seg1:x}::/{prefix}" + ); + assert!( + filter.contains(&IpAddr::V6(last)), + "Last IP {last} should be in {seg0:x}:{seg1:x}::/{prefix}" + ); + assert!( + !filter.contains(&IpAddr::V6(outside)), + "IP {outside} should NOT be in {seg0:x}:{seg1:x}::/{prefix}" + ); + } + } } diff --git a/src/config.rs b/src/config.rs index 3eba8ba..6585a37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -458,7 +458,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), - reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", false), + reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", true), dry_run, emoji: false, quiet: false, @@ -529,7 +529,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { let emoji = getenv_bool("EMOJI", true); let quiet = getenv_bool("QUIET", false); - let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", false); + let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", true); // Validate: must have at least one update target if domains.is_empty() && waf_lists.is_empty() { @@ -681,8 +681,8 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) { inner.infof("", "Delete on stop: enabled"); } - if config.reject_cloudflare_ips { - inner.infof("", "Reject Cloudflare IPs: enabled"); + if !config.reject_cloudflare_ips { + inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)"); } if let Some(ref comment) = config.record_comment { diff --git a/src/updater.rs b/src/updater.rs index 0a5908d..3c94977 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -101,11 +101,12 @@ pub async fn update_once( ))); } } - } else { + } else if !detected_ips.is_empty() { ppfmt.warningf( pp::EMOJI_WARNING, - "Could not fetch Cloudflare IP ranges; skipping filter", + "Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs", ); + detected_ips.clear(); } } @@ -298,6 +299,7 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { // Filter out Cloudflare IPs if enabled if config.reject_cloudflare_ips { + let before_count = ips.len(); if let Some(cf_filter) = CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await { @@ -316,11 +318,18 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { } true }); - } else { + if ips.is_empty() && before_count > 0 { + ppfmt.warningf( + pp::EMOJI_WARNING, + "All detected addresses were Cloudflare IPs; skipping updates", + ); + } + } else if !ips.is_empty() { ppfmt.warningf( pp::EMOJI_WARNING, - "Could not fetch Cloudflare IP ranges; skipping filter", + "Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs", ); + ips.clear(); } }