From 93d351d9971bede1d333c76ee2e8469756da97f9 Mon Sep 17 00:00:00 2001 From: Timothy Miller Date: Wed, 11 Mar 2026 18:42:46 -0400 Subject: [PATCH] Use Cloudflare trace by default and validate IPs Default IPv4 provider is now CloudflareTrace. Primary uses api.cloudflare.com; fallbacks are literal IPs. Build per-family HTTP clients by binding to 0.0.0.0/[::] so the trace endpoint observes the requested address family. Add validate_detected_ip to reject wrong-family or non-global addresses (loopback, link-local, private, documentation ranges, etc). Update tests and legacy updater URLs. Default to Cloudflare trace and validate IPs Use api.cloudflare.com as the primary trace endpoint (fallbacks remain literal IPs) to avoid WARP/Zero Trust interception. Build IP-family-specific HTTP clients by binding to the unspecified address so the trace endpoint sees the correct family. Add validate_detected_ip to reject non-global or wrong-family addresses and expand tests. Bump crate version and tempfile dev-dependency. --- Cargo.lock | 6 +- Cargo.toml | 4 +- src/config.rs | 6 +- src/provider.rs | 191 +++++++++++++++++++++++++++++++++++++++++------- src/updater.rs | 4 +- 5 files changed, 175 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64c05aa..1e8930b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,7 +103,7 @@ dependencies = [ [[package]] name = "cloudflare-ddns" -version = "2.0.0" +version = "2.0.1" dependencies = [ "chrono", "idna", @@ -1135,9 +1135,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index b1cbb85..c3dd5cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cloudflare-ddns" -version = "2.0.0" +version = "2.0.1" edition = "2021" description = "Access your home network remotely via a custom domain name without a static IP" license = "GPL-3.0" @@ -24,5 +24,5 @@ strip = true panic = "abort" [dev-dependencies] -tempfile = "3.26.0" +tempfile = "3.27.0" wiremock = "0.6" diff --git a/src/config.rs b/src/config.rs index ae512fe..99560fd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -243,7 +243,7 @@ fn read_providers_from_env(ppfmt: &PP) -> Result, let ip4_provider = match ip4_str { Some(s) => ProviderType::parse(&s) .map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?, - None => ProviderType::Ipify, + None => ProviderType::CloudflareTrace { url: None }, }; let ip6_provider = match ip6_str { @@ -1429,12 +1429,12 @@ mod tests { let pp = PP::new(false, true); let providers = read_providers_from_env(&pp).unwrap(); drop(g); - // V4 defaults to Ipify, V6 defaults to CloudflareTrace. + // Both V4 and V6 default to CloudflareTrace. assert!(providers.contains_key(&IpType::V4)); assert!(providers.contains_key(&IpType::V6)); assert!(matches!( providers[&IpType::V4], - ProviderType::Ipify + ProviderType::CloudflareTrace { url: None } )); assert!(matches!( providers[&IpType::V6], diff --git a/src/provider.rs b/src/provider.rs index 8042607..3dfd0da 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -145,9 +145,11 @@ impl ProviderType { // --- Cloudflare Trace --- -const CF_TRACE_V4_PRIMARY: &str = "https://1.1.1.1/cdn-cgi/trace"; +/// Primary trace URL uses a hostname so DNS resolves normally, avoiding the +/// problem where WARP/Zero Trust intercepts requests to literal 1.1.1.1. +const CF_TRACE_PRIMARY: &str = "https://api.cloudflare.com/cdn-cgi/trace"; +/// Fallback URLs use literal IPs for when api.cloudflare.com is unreachable. const CF_TRACE_V4_FALLBACK: &str = "https://1.0.0.1/cdn-cgi/trace"; -const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1111]/cdn-cgi/trace"; const CF_TRACE_V6_FALLBACK: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace"; pub fn parse_trace_ip(body: &str) -> Option { @@ -171,16 +173,34 @@ async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option ip_str.parse::().ok() } +/// 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 { + let local_addr: IpAddr = match ip_type { + IpType::V4 => Ipv4Addr::UNSPECIFIED.into(), + IpType::V6 => Ipv6Addr::UNSPECIFIED.into(), + }; + Client::builder() + .local_address(local_addr) + .timeout(timeout) + .build() + .unwrap_or_default() +} + async fn detect_cloudflare_trace( - client: &Client, + _client: &Client, ip_type: IpType, timeout: Duration, custom_url: Option<&str>, ppfmt: &PP, ) -> Vec { + // Use an IP-family-specific client so the trace endpoint sees the right address family. + let client = build_split_client(ip_type, timeout); + if let Some(url) = custom_url { - if let Some(ip) = fetch_trace_ip(client, url, timeout).await { - if matches_ip_type(&ip, ip_type) { + if let Some(ip) = fetch_trace_ip(&client, url, timeout).await { + if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } } @@ -191,14 +211,14 @@ async fn detect_cloudflare_trace( return Vec::new(); } - let (primary, fallback) = match ip_type { - IpType::V4 => (CF_TRACE_V4_PRIMARY, CF_TRACE_V4_FALLBACK), - IpType::V6 => (CF_TRACE_V6_PRIMARY, CF_TRACE_V6_FALLBACK), + let fallback = match ip_type { + IpType::V4 => CF_TRACE_V4_FALLBACK, + IpType::V6 => CF_TRACE_V6_FALLBACK, }; - // Try primary - if let Some(ip) = fetch_trace_ip(client, primary, timeout).await { - if matches_ip_type(&ip, ip_type) { + // Try primary (api.cloudflare.com — resolves via DNS, avoids literal-IP interception) + if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_PRIMARY, timeout).await { + if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } } @@ -207,9 +227,9 @@ async fn detect_cloudflare_trace( &format!("{} not detected via primary, trying fallback", ip_type.describe()), ); - // Try fallback - if let Some(ip) = fetch_trace_ip(client, fallback, timeout).await { - if matches_ip_type(&ip, ip_type) { + // Try fallback (literal IP — useful when DNS is broken) + if let Some(ip) = fetch_trace_ip(&client, fallback, timeout).await { + if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } } @@ -249,7 +269,7 @@ async fn detect_cloudflare_doh( if let Ok(body) = r.bytes().await { if let Some(ip_str) = parse_dns_txt_response(&body) { if let Ok(ip) = ip_str.parse::() { - if matches_ip_type(&ip, ip_type) { + if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } } @@ -379,7 +399,7 @@ async fn detect_ipify( if let Ok(body) = resp.text().await { let ip_str = body.trim(); if let Ok(ip) = ip_str.parse::() { - if matches_ip_type(&ip, ip_type) { + if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } } @@ -491,7 +511,7 @@ async fn detect_custom_url( if let Ok(body) = resp.text().await { let ip_str = body.trim(); if let Ok(ip) = ip_str.parse::() { - if matches_ip_type(&ip, ip_type) { + if validate_detected_ip(&ip, ip_type, ppfmt) { return vec![ip]; } } @@ -516,6 +536,34 @@ fn matches_ip_type(ip: &IpAddr, ip_type: IpType) -> bool { } } +/// Validate a detected IP: must match the requested address family and be a +/// global unicast address. Mirrors the checks in favonia/cloudflare-ddns's +/// NormalizeDetectedIPs — rejects loopback, link-local, multicast, +/// unspecified, and non-global addresses. +fn validate_detected_ip(ip: &IpAddr, ip_type: IpType, ppfmt: &PP) -> bool { + if !matches_ip_type(ip, ip_type) { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Detected IP {} does not match expected type {}", + ip, ip_type.describe() + ), + ); + return false; + } + if !ip.is_global_() { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Detected {} address {} is not a global unicast address", + ip_type.describe(), ip + ), + ); + return false; + } + true +} + fn filter_ips_by_type(ips: &[IpAddr], ip_type: IpType) -> Vec { ips.iter() .copied() @@ -867,6 +915,35 @@ mod tests { assert_eq!(result_ok[0], "93.184.216.34".parse::().unwrap()); } + // ---- trace URL constants ---- + + #[test] + fn test_trace_primary_uses_hostname_not_ip() { + // Primary must use a hostname (api.cloudflare.com) so DNS resolves normally + // and WARP/Zero Trust doesn't intercept the request. + assert_eq!(CF_TRACE_PRIMARY, "https://api.cloudflare.com/cdn-cgi/trace"); + assert!(CF_TRACE_PRIMARY.contains("api.cloudflare.com")); + // Fallbacks use literal IPs for when DNS is broken. + assert!(CF_TRACE_V4_FALLBACK.contains("1.0.0.1")); + assert!(CF_TRACE_V6_FALLBACK.contains("2606:4700:4700::1001")); + } + + // ---- build_split_client ---- + + #[test] + fn test_build_split_client_v4() { + let client = build_split_client(IpType::V4, Duration::from_secs(5)); + // Client should build successfully — we can't inspect local_address, + // but we verify it doesn't panic. + drop(client); + } + + #[test] + fn test_build_split_client_v6() { + let client = build_split_client(IpType::V6, Duration::from_secs(5)); + drop(client); + } + // ---- detect_ipify with wiremock ---- #[tokio::test] @@ -875,7 +952,7 @@ mod tests { Mock::given(method("GET")) .and(path("/")) - .respond_with(ResponseTemplate::new(200).set_body_string("198.51.100.1\n")) + .respond_with(ResponseTemplate::new(200).set_body_string("93.184.216.34\n")) .mount(&server) .await; @@ -887,7 +964,7 @@ mod tests { // which uses the same logic let result = detect_custom_url(&client, &server.uri(), IpType::V4, timeout, &ppfmt).await; assert_eq!(result.len(), 1); - assert_eq!(result[0], "198.51.100.1".parse::().unwrap()); + assert_eq!(result[0], "93.184.216.34".parse::().unwrap()); } #[tokio::test] @@ -897,7 +974,7 @@ mod tests { Mock::given(method("GET")) .and(path("/")) .respond_with( - ResponseTemplate::new(200).set_body_string("2001:db8::1\n"), + ResponseTemplate::new(200).set_body_string("2606:4700:4700::1111\n"), ) .mount(&server) .await; @@ -908,7 +985,7 @@ mod tests { let result = detect_custom_url(&client, &server.uri(), IpType::V6, timeout, &ppfmt).await; assert_eq!(result.len(), 1); - assert_eq!(result[0], "2001:db8::1".parse::().unwrap()); + assert_eq!(result[0], "2606:4700:4700::1111".parse::().unwrap()); } // ---- detect_custom_url with wiremock ---- @@ -919,7 +996,7 @@ mod tests { Mock::given(method("GET")) .and(path("/my-ip")) - .respond_with(ResponseTemplate::new(200).set_body_string("10.0.0.1")) + .respond_with(ResponseTemplate::new(200).set_body_string("93.184.216.34")) .mount(&server) .await; @@ -928,16 +1005,79 @@ mod tests { let timeout = Duration::from_secs(5); let url = format!("{}/my-ip", server.uri()); - // 10.0.0.1 is a valid IPv4, should match V4 let result = detect_custom_url(&client, &url, IpType::V4, timeout, &ppfmt).await; assert_eq!(result.len(), 1); - assert_eq!(result[0], "10.0.0.1".parse::().unwrap()); + assert_eq!(result[0], "93.184.216.34".parse::().unwrap()); } #[tokio::test] async fn test_detect_custom_url_wrong_ip_type() { let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/my-ip")) + .respond_with(ResponseTemplate::new(200).set_body_string("93.184.216.34")) + .mount(&server) + .await; + + let client = Client::new(); + let ppfmt = PP::default_pp(); + let timeout = Duration::from_secs(5); + let url = format!("{}/my-ip", server.uri()); + + // 93.184.216.34 is IPv4 but we ask for V6 -> empty + let result = detect_custom_url(&client, &url, IpType::V6, timeout, &ppfmt).await; + assert!(result.is_empty()); + } + + // ---- validate_detected_ip ---- + + #[test] + fn test_validate_detected_ip_accepts_global() { + let ppfmt = PP::default_pp(); + assert!(validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V4, &ppfmt)); + assert!(validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V6, &ppfmt)); + } + + #[test] + fn test_validate_detected_ip_rejects_wrong_family() { + let ppfmt = PP::default_pp(); + assert!(!validate_detected_ip(&"93.184.216.34".parse().unwrap(), IpType::V6, &ppfmt)); + assert!(!validate_detected_ip(&"2606:4700:4700::1111".parse().unwrap(), IpType::V4, &ppfmt)); + } + + #[test] + fn test_validate_detected_ip_rejects_private() { + let ppfmt = PP::default_pp(); + assert!(!validate_detected_ip(&"10.0.0.1".parse().unwrap(), IpType::V4, &ppfmt)); + assert!(!validate_detected_ip(&"192.168.1.1".parse().unwrap(), IpType::V4, &ppfmt)); + assert!(!validate_detected_ip(&"172.16.0.1".parse().unwrap(), IpType::V4, &ppfmt)); + } + + #[test] + fn test_validate_detected_ip_rejects_loopback() { + let ppfmt = PP::default_pp(); + assert!(!validate_detected_ip(&"127.0.0.1".parse().unwrap(), IpType::V4, &ppfmt)); + assert!(!validate_detected_ip(&"::1".parse().unwrap(), IpType::V6, &ppfmt)); + } + + #[test] + fn test_validate_detected_ip_rejects_link_local() { + let ppfmt = PP::default_pp(); + assert!(!validate_detected_ip(&"169.254.0.1".parse().unwrap(), IpType::V4, &ppfmt)); + } + + #[test] + fn test_validate_detected_ip_rejects_documentation() { + let ppfmt = PP::default_pp(); + assert!(!validate_detected_ip(&"198.51.100.1".parse().unwrap(), IpType::V4, &ppfmt)); + assert!(!validate_detected_ip(&"203.0.113.1".parse().unwrap(), IpType::V4, &ppfmt)); + } + + #[tokio::test] + async fn test_detect_custom_url_rejects_private_ip() { + let server = MockServer::start().await; + Mock::given(method("GET")) .and(path("/my-ip")) .respond_with(ResponseTemplate::new(200).set_body_string("10.0.0.1")) @@ -949,8 +1089,7 @@ mod tests { let timeout = Duration::from_secs(5); let url = format!("{}/my-ip", server.uri()); - // 10.0.0.1 is IPv4 but we ask for V6 -> empty - let result = detect_custom_url(&client, &url, IpType::V6, timeout, &ppfmt).await; + let result = detect_custom_url(&client, &url, IpType::V4, timeout, &ppfmt).await; assert!(result.is_empty()); } diff --git a/src/updater.rs b/src/updater.rs index 0f763c2..ae260dd 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -191,11 +191,11 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool { client, cf_api_base: "https://api.cloudflare.com/client/v4".to_string(), ipv4_urls: vec![ - "https://1.1.1.1/cdn-cgi/trace".to_string(), + "https://api.cloudflare.com/cdn-cgi/trace".to_string(), "https://1.0.0.1/cdn-cgi/trace".to_string(), ], ipv6_urls: vec![ - "https://[2606:4700:4700::1111]/cdn-cgi/trace".to_string(), + "https://api.cloudflare.com/cdn-cgi/trace".to_string(), "https://[2606:4700:4700::1001]/cdn-cgi/trace".to_string(), ], dry_run: config.dry_run,