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