From 7ff8379cfb8e15011355c8223fb01b3856f89ac4 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Thu, 19 Mar 2026 18:18:32 -0400 Subject: [PATCH] Filter Cloudflare IPs in legacy mode Add support for REJECT_CLOUDFLARE_IPS in legacy config and fetch Cloudflare IP ranges to drop matching detected addresses. Improve IP detection in legacy mode by using literal-IP primary trace URLs with hostname fallbacks, binding dedicated IPv4/IPv6 HTTP clients, and setting a Host override for literal-IP trace endpoints so TLS SNI works. Expose build_split_client and update tests accordingly. --- README.md | 26 ++++++++++++ src/config.rs | 2 +- src/provider.rs | 2 +- src/updater.rs | 105 +++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a465d16..3489ef7 100755 --- a/README.md +++ b/README.md @@ -369,6 +369,32 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t | `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records | | `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) | +### 🚫 Cloudflare IP Rejection (Legacy Mode) + +The `REJECT_CLOUDFLARE_IPS` environment variable is supported in legacy config mode. Set it alongside your `config.json`: + +```bash +REJECT_CLOUDFLARE_IPS=true cloudflare-ddns +``` + +Or in Docker Compose: + +```yml +environment: + - REJECT_CLOUDFLARE_IPS=true +volumes: + - ./config.json:/config.json +``` + +### 🔍 IP Detection (Legacy Mode) + +Legacy mode uses [Cloudflare's `/cdn-cgi/trace`](https://www.cloudflare.com/cdn-cgi/trace) endpoint for IP detection. To ensure the correct address family is detected on dual-stack hosts: + +- **Primary:** Literal IP URLs (`1.0.0.1` for IPv4, `[2606:4700:4700::1001]` for IPv6) — guarantees the connection uses the correct address family +- **Fallback:** Hostname URL (`api.cloudflare.com`) — works when literal IPs are intercepted (e.g. Cloudflare WARP or Zero Trust) + +Each address family uses a dedicated HTTP client bound to the correct local address (`0.0.0.0` for IPv4, `[::]` for IPv6), preventing the wrong address type from being returned on dual-stack networks. + Each zone entry contains: | Key | Type | Description | diff --git a/src/config.rs b/src/config.rs index 8ed158b..9eb285f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -440,7 +440,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), - reject_cloudflare_ips: false, + reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", false), dry_run, emoji: false, quiet: false, diff --git a/src/provider.rs b/src/provider.rs index 0ca705f..dffec55 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -183,7 +183,7 @@ async fn fetch_trace_ip( /// Build an HTTP client that only connects via the given IP family. /// Binding to 0.0.0.0 forces IPv4-only; binding to [::] forces IPv6-only. /// This ensures the trace endpoint sees the correct address family. -fn build_split_client(ip_type: IpType, timeout: Duration) -> Client { +pub fn build_split_client(ip_type: IpType, timeout: Duration) -> Client { let local_addr: IpAddr = match ip_type { IpType::V4 => Ipv4Addr::UNSPECIFIED.into(), IpType::V6 => Ipv6Addr::UNSPECIFIED.into(), diff --git a/src/updater.rs b/src/updater.rs index 4d8ab44..4b77056 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -229,7 +229,7 @@ pub async fn update_once( } /// Run legacy mode update (using the original cloudflare-ddns logic with zone_id-based config). -async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool { +async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { let legacy = match &config.legacy_config { Some(l) => l, None => return false, @@ -243,20 +243,23 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool { let ddns = LegacyDdnsClient { client, cf_api_base: "https://api.cloudflare.com/client/v4".to_string(), + // Literal IPs primary (guarantees correct address family on dual-stack hosts), + // hostname fallback (works when literal IPs are intercepted by WARP/Zero Trust). ipv4_urls: vec![ - "https://api.cloudflare.com/cdn-cgi/trace".to_string(), "https://1.0.0.1/cdn-cgi/trace".to_string(), + "https://api.cloudflare.com/cdn-cgi/trace".to_string(), ], ipv6_urls: vec![ - "https://api.cloudflare.com/cdn-cgi/trace".to_string(), "https://[2606:4700:4700::1001]/cdn-cgi/trace".to_string(), + "https://api.cloudflare.com/cdn-cgi/trace".to_string(), ], + detection_timeout: config.detection_timeout, dry_run: config.dry_run, }; let mut warnings = LegacyWarningState::default(); - let ips = ddns + let mut ips = ddns .get_ips( legacy.a, legacy.aaaa, @@ -266,6 +269,38 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool { ) .await; + // Filter out Cloudflare IPs if enabled + if config.reject_cloudflare_ips { + let detection_client = Client::builder() + .timeout(config.detection_timeout) + .build() + .unwrap_or_default(); + if let Some(cf_filter) = + CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await + { + ips.retain(|key, ip_info| { + if let Ok(addr) = ip_info.ip.parse::() { + if cf_filter.contains(&addr) { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Rejected {}: matches Cloudflare IP range ({})", + ip_info.ip, key + ), + ); + return false; + } + } + true + }); + } else { + ppfmt.warningf( + pp::EMOJI_WARNING, + "Could not fetch Cloudflare IP ranges; skipping filter", + ); + } + } + ddns.update_ips( &ips, &legacy.cloudflare, @@ -346,9 +381,19 @@ struct LegacyDdnsClient { cf_api_base: String, ipv4_urls: Vec, ipv6_urls: Vec, + detection_timeout: Duration, dry_run: bool, } +/// Return a Host header override for literal-IP trace URLs so TLS SNI works. +fn legacy_host_override(url: &str) -> Option<&'static str> { + if url.contains("1.0.0.1") || url.contains("2606:4700:4700::1001") { + Some("one.one.one.one") + } else { + None + } +} + impl LegacyDdnsClient { async fn get_ips( &self, @@ -361,8 +406,11 @@ impl LegacyDdnsClient { let mut ips = HashMap::new(); if ipv4_enabled { + // Use an IPv4-bound client so the trace endpoint sees the correct address family. + let v4_client = crate::provider::build_split_client(IpType::V4, self.detection_timeout); let a = self .try_trace_urls( + &v4_client, &self.ipv4_urls, &mut warnings.shown_ipv4, &mut warnings.shown_ipv4_secondary, @@ -385,8 +433,11 @@ impl LegacyDdnsClient { } if ipv6_enabled { + // Use an IPv6-bound client so the trace endpoint sees the correct address family. + let v6_client = crate::provider::build_split_client(IpType::V6, self.detection_timeout); let aaaa = self .try_trace_urls( + &v6_client, &self.ipv6_urls, &mut warnings.shown_ipv6, &mut warnings.shown_ipv6_secondary, @@ -413,6 +464,7 @@ impl LegacyDdnsClient { async fn try_trace_urls( &self, + trace_client: &Client, urls: &[String], shown_primary: &mut bool, shown_secondary: &mut bool, @@ -420,7 +472,11 @@ impl LegacyDdnsClient { expect_v4: bool, ) -> Option { for (i, url) in urls.iter().enumerate() { - match self.client.get(url).send().await { + let mut req = trace_client.get(url); + if let Some(host) = legacy_host_override(url) { + req = req.header("Host", host); + } + match req.send().await { Ok(resp) => { if let Some(ip) = crate::provider::parse_trace_ip(&resp.text().await.unwrap_or_default()) @@ -1718,12 +1774,13 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![format!("{}/trace", server.uri())], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut shown_primary = false; let mut shown_secondary = false; let result = ddns - .try_trace_urls(&ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) + .try_trace_urls(&ddns.client, &ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) .await; assert_eq!(result, Some("198.51.100.1".to_string())); } @@ -1748,12 +1805,13 @@ mod tests { format!("{}/fallback", server.uri()), ], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut shown_primary = false; let mut shown_secondary = false; let result = ddns - .try_trace_urls(&ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) + .try_trace_urls(&ddns.client, &ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) .await; assert_eq!(result, Some("198.51.100.2".to_string())); assert!(shown_primary); @@ -1769,12 +1827,13 @@ mod tests { "http://127.0.0.1:1/fail2".to_string(), ], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut shown_primary = false; let mut shown_secondary = false; let result = ddns - .try_trace_urls(&ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) + .try_trace_urls(&ddns.client, &ddns.ipv4_urls, &mut shown_primary, &mut shown_secondary, "IPv4", true) .await; assert!(result.is_none()); assert!(shown_primary); @@ -1797,6 +1856,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1831,6 +1891,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1862,6 +1923,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1886,6 +1948,7 @@ mod tests { cf_api_base: "http://localhost".to_string(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1918,6 +1981,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let entry = crate::config::LegacyCloudflareEntry { @@ -1955,6 +2019,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![format!("{}/trace", server.uri())], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut warnings = LegacyWarningState::default(); @@ -1967,6 +2032,8 @@ mod tests { #[tokio::test] async fn test_legacy_get_ips_ipv6_enabled() { + // Test the IPv6 trace URL parsing via try_trace_urls directly, since + // get_ips creates an IPv6-bound client that can't reach IPv4-only mock servers. let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/trace6")) @@ -1982,14 +2049,15 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![format!("{}/trace6", server.uri())], + detection_timeout: Duration::from_secs(5), dry_run: false, }; - let mut warnings = LegacyWarningState::default(); - let config: Vec = vec![]; - let ips = ddns.get_ips(false, true, false, &config, &mut warnings).await; - assert!(ips.contains_key("ipv6")); - assert_eq!(ips["ipv6"].ip, "2001:db8::1"); - assert_eq!(ips["ipv6"].record_type, "AAAA"); + let mut shown_primary = false; + let mut shown_secondary = false; + let result = ddns + .try_trace_urls(&ddns.client, &ddns.ipv6_urls, &mut shown_primary, &mut shown_secondary, "IPv6", false) + .await; + assert_eq!(result, Some("2001:db8::1".to_string())); } #[tokio::test] @@ -1999,6 +2067,7 @@ mod tests { cf_api_base: String::new(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut warnings = LegacyWarningState::default(); @@ -2045,6 +2114,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2103,6 +2173,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2147,6 +2218,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: true, }; let ip = LegacyIpInfo { @@ -2200,6 +2272,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2257,6 +2330,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let ip = LegacyIpInfo { @@ -2308,6 +2382,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let mut ips = HashMap::new(); @@ -2354,6 +2429,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: false, }; let config = vec![crate::config::LegacyCloudflareEntry { @@ -2388,6 +2464,7 @@ mod tests { cf_api_base: server.uri(), ipv4_urls: vec![], ipv6_urls: vec![], + detection_timeout: Duration::from_secs(5), dry_run: true, }; let config = vec![crate::config::LegacyCloudflareEntry {