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.
This commit is contained in:
Timothy Miller
2026-03-19 18:18:32 -04:00
parent 943e38d70c
commit 7ff8379cfb
4 changed files with 119 additions and 16 deletions

View File

@@ -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 | | `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records |
| `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) | | `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: Each zone entry contains:
| Key | Type | Description | | Key | Type | Description |

View File

@@ -440,7 +440,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
managed_waf_comment_regex: None, managed_waf_comment_regex: None,
detection_timeout: Duration::from_secs(5), detection_timeout: Duration::from_secs(5),
update_timeout: Duration::from_secs(30), update_timeout: Duration::from_secs(30),
reject_cloudflare_ips: false, reject_cloudflare_ips: getenv_bool("REJECT_CLOUDFLARE_IPS", false),
dry_run, dry_run,
emoji: false, emoji: false,
quiet: false, quiet: false,

View File

@@ -183,7 +183,7 @@ async fn fetch_trace_ip(
/// Build an HTTP client that only connects via the given IP family. /// 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. /// Binding to 0.0.0.0 forces IPv4-only; binding to [::] forces IPv6-only.
/// This ensures the trace endpoint sees the correct address family. /// 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 { let local_addr: IpAddr = match ip_type {
IpType::V4 => Ipv4Addr::UNSPECIFIED.into(), IpType::V4 => Ipv4Addr::UNSPECIFIED.into(),
IpType::V6 => Ipv6Addr::UNSPECIFIED.into(), IpType::V6 => Ipv6Addr::UNSPECIFIED.into(),

View File

@@ -229,7 +229,7 @@ pub async fn update_once(
} }
/// Run legacy mode update (using the original cloudflare-ddns logic with zone_id-based config). /// 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 { let legacy = match &config.legacy_config {
Some(l) => l, Some(l) => l,
None => return false, None => return false,
@@ -243,20 +243,23 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client, client,
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(), 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![ ipv4_urls: vec![
"https://api.cloudflare.com/cdn-cgi/trace".to_string(),
"https://1.0.0.1/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![ ipv6_urls: vec![
"https://api.cloudflare.com/cdn-cgi/trace".to_string(),
"https://[2606:4700:4700::1001]/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, dry_run: config.dry_run,
}; };
let mut warnings = LegacyWarningState::default(); let mut warnings = LegacyWarningState::default();
let ips = ddns let mut ips = ddns
.get_ips( .get_ips(
legacy.a, legacy.a,
legacy.aaaa, legacy.aaaa,
@@ -266,6 +269,38 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool {
) )
.await; .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::<std::net::IpAddr>() {
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( ddns.update_ips(
&ips, &ips,
&legacy.cloudflare, &legacy.cloudflare,
@@ -346,9 +381,19 @@ struct LegacyDdnsClient {
cf_api_base: String, cf_api_base: String,
ipv4_urls: Vec<String>, ipv4_urls: Vec<String>,
ipv6_urls: Vec<String>, ipv6_urls: Vec<String>,
detection_timeout: Duration,
dry_run: bool, 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 { impl LegacyDdnsClient {
async fn get_ips( async fn get_ips(
&self, &self,
@@ -361,8 +406,11 @@ impl LegacyDdnsClient {
let mut ips = HashMap::new(); let mut ips = HashMap::new();
if ipv4_enabled { 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 let a = self
.try_trace_urls( .try_trace_urls(
&v4_client,
&self.ipv4_urls, &self.ipv4_urls,
&mut warnings.shown_ipv4, &mut warnings.shown_ipv4,
&mut warnings.shown_ipv4_secondary, &mut warnings.shown_ipv4_secondary,
@@ -385,8 +433,11 @@ impl LegacyDdnsClient {
} }
if ipv6_enabled { 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 let aaaa = self
.try_trace_urls( .try_trace_urls(
&v6_client,
&self.ipv6_urls, &self.ipv6_urls,
&mut warnings.shown_ipv6, &mut warnings.shown_ipv6,
&mut warnings.shown_ipv6_secondary, &mut warnings.shown_ipv6_secondary,
@@ -413,6 +464,7 @@ impl LegacyDdnsClient {
async fn try_trace_urls( async fn try_trace_urls(
&self, &self,
trace_client: &Client,
urls: &[String], urls: &[String],
shown_primary: &mut bool, shown_primary: &mut bool,
shown_secondary: &mut bool, shown_secondary: &mut bool,
@@ -420,7 +472,11 @@ impl LegacyDdnsClient {
expect_v4: bool, expect_v4: bool,
) -> Option<String> { ) -> Option<String> {
for (i, url) in urls.iter().enumerate() { 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) => { Ok(resp) => {
if let Some(ip) = if let Some(ip) =
crate::provider::parse_trace_ip(&resp.text().await.unwrap_or_default()) crate::provider::parse_trace_ip(&resp.text().await.unwrap_or_default())
@@ -1718,12 +1774,13 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![format!("{}/trace", server.uri())], ipv4_urls: vec![format!("{}/trace", server.uri())],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut shown_primary = false; let mut shown_primary = false;
let mut shown_secondary = false; let mut shown_secondary = false;
let result = ddns 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; .await;
assert_eq!(result, Some("198.51.100.1".to_string())); assert_eq!(result, Some("198.51.100.1".to_string()));
} }
@@ -1748,12 +1805,13 @@ mod tests {
format!("{}/fallback", server.uri()), format!("{}/fallback", server.uri()),
], ],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut shown_primary = false; let mut shown_primary = false;
let mut shown_secondary = false; let mut shown_secondary = false;
let result = ddns 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; .await;
assert_eq!(result, Some("198.51.100.2".to_string())); assert_eq!(result, Some("198.51.100.2".to_string()));
assert!(shown_primary); assert!(shown_primary);
@@ -1769,12 +1827,13 @@ mod tests {
"http://127.0.0.1:1/fail2".to_string(), "http://127.0.0.1:1/fail2".to_string(),
], ],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut shown_primary = false; let mut shown_primary = false;
let mut shown_secondary = false; let mut shown_secondary = false;
let result = ddns 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; .await;
assert!(result.is_none()); assert!(result.is_none());
assert!(shown_primary); assert!(shown_primary);
@@ -1797,6 +1856,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1831,6 +1891,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1862,6 +1923,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1886,6 +1948,7 @@ mod tests {
cf_api_base: "http://localhost".to_string(), cf_api_base: "http://localhost".to_string(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1918,6 +1981,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1955,6 +2019,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![format!("{}/trace", server.uri())], ipv4_urls: vec![format!("{}/trace", server.uri())],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut warnings = LegacyWarningState::default(); let mut warnings = LegacyWarningState::default();
@@ -1967,6 +2032,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_legacy_get_ips_ipv6_enabled() { 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; let server = MockServer::start().await;
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/trace6")) .and(path("/trace6"))
@@ -1982,14 +2049,15 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![format!("{}/trace6", server.uri())], ipv6_urls: vec![format!("{}/trace6", server.uri())],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut warnings = LegacyWarningState::default(); let mut shown_primary = false;
let config: Vec<crate::config::LegacyCloudflareEntry> = vec![]; let mut shown_secondary = false;
let ips = ddns.get_ips(false, true, false, &config, &mut warnings).await; let result = ddns
assert!(ips.contains_key("ipv6")); .try_trace_urls(&ddns.client, &ddns.ipv6_urls, &mut shown_primary, &mut shown_secondary, "IPv6", false)
assert_eq!(ips["ipv6"].ip, "2001:db8::1"); .await;
assert_eq!(ips["ipv6"].record_type, "AAAA"); assert_eq!(result, Some("2001:db8::1".to_string()));
} }
#[tokio::test] #[tokio::test]
@@ -1999,6 +2067,7 @@ mod tests {
cf_api_base: String::new(), cf_api_base: String::new(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut warnings = LegacyWarningState::default(); let mut warnings = LegacyWarningState::default();
@@ -2045,6 +2114,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2103,6 +2173,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2147,6 +2218,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: true, dry_run: true,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2200,6 +2272,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2257,6 +2330,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2308,6 +2382,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let mut ips = HashMap::new(); let mut ips = HashMap::new();
@@ -2354,6 +2429,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: false, dry_run: false,
}; };
let config = vec![crate::config::LegacyCloudflareEntry { let config = vec![crate::config::LegacyCloudflareEntry {
@@ -2388,6 +2464,7 @@ mod tests {
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![], ipv4_urls: vec![],
ipv6_urls: vec![], ipv6_urls: vec![],
detection_timeout: Duration::from_secs(5),
dry_run: true, dry_run: true,
}; };
let config = vec![crate::config::LegacyCloudflareEntry { let config = vec![crate::config::LegacyCloudflareEntry {