3 Commits

Author SHA1 Message Date
Timothy Miller
560a3b7b28 Bump version to 2.0.2 2026-03-13 00:10:31 -04:00
Timothy Miller
1b3928865b Use literal IP trace URLs as primary
Primary trace endpoints now use literal IPs per address family to
guarantee correct address family selection. Fallback uses
api.cloudflare.com to work around WARP/Zero Trust interception. Rename
constants and update tests accordingly.
2026-03-13 00:04:08 -04:00
Timothy Miller
93d351d997 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.
2026-03-11 18:42:46 -04:00
5 changed files with 179 additions and 38 deletions

6
Cargo.lock generated
View File

@@ -103,7 +103,7 @@ dependencies = [
[[package]] [[package]]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.0" version = "2.0.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"idna", "idna",
@@ -1135,9 +1135,9 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.26.0" version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.3.4",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.0" version = "2.0.2"
edition = "2021" edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP" description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0" license = "GPL-3.0"
@@ -24,5 +24,5 @@ strip = true
panic = "abort" panic = "abort"
[dev-dependencies] [dev-dependencies]
tempfile = "3.26.0" tempfile = "3.27.0"
wiremock = "0.6" wiremock = "0.6"

View File

@@ -243,7 +243,7 @@ fn read_providers_from_env(ppfmt: &PP) -> Result<HashMap<IpType, ProviderType>,
let ip4_provider = match ip4_str { let ip4_provider = match ip4_str {
Some(s) => ProviderType::parse(&s) Some(s) => ProviderType::parse(&s)
.map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?, .map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?,
None => ProviderType::Ipify, None => ProviderType::CloudflareTrace { url: None },
}; };
let ip6_provider = match ip6_str { let ip6_provider = match ip6_str {
@@ -1429,12 +1429,12 @@ mod tests {
let pp = PP::new(false, true); let pp = PP::new(false, true);
let providers = read_providers_from_env(&pp).unwrap(); let providers = read_providers_from_env(&pp).unwrap();
drop(g); 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::V4));
assert!(providers.contains_key(&IpType::V6)); assert!(providers.contains_key(&IpType::V6));
assert!(matches!( assert!(matches!(
providers[&IpType::V4], providers[&IpType::V4],
ProviderType::Ipify ProviderType::CloudflareTrace { url: None }
)); ));
assert!(matches!( assert!(matches!(
providers[&IpType::V6], providers[&IpType::V6],

View File

@@ -145,10 +145,15 @@ impl ProviderType {
// --- Cloudflare Trace --- // --- Cloudflare Trace ---
const CF_TRACE_V4_PRIMARY: &str = "https://1.1.1.1/cdn-cgi/trace"; /// Primary trace URLs use literal IPs to guarantee the correct address family.
const CF_TRACE_V4_FALLBACK: &str = "https://1.0.0.1/cdn-cgi/trace"; /// api.cloudflare.com is dual-stack, so on dual-stack hosts (e.g. Docker
const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1111]/cdn-cgi/trace"; /// --net=host with IPv6) the connection may go via IPv6 even when detecting
const CF_TRACE_V6_FALLBACK: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace"; /// IPv4, causing the trace endpoint to return the wrong address family.
const CF_TRACE_V4_PRIMARY: &str = "https://1.0.0.1/cdn-cgi/trace";
const CF_TRACE_V6_PRIMARY: &str = "https://[2606:4700:4700::1001]/cdn-cgi/trace";
/// Fallback uses a hostname, which works when literal IPs are intercepted
/// (e.g. Cloudflare WARP/Zero Trust).
const CF_TRACE_FALLBACK: &str = "https://api.cloudflare.com/cdn-cgi/trace";
pub fn parse_trace_ip(body: &str) -> Option<String> { pub fn parse_trace_ip(body: &str) -> Option<String> {
for line in body.lines() { for line in body.lines() {
@@ -171,16 +176,34 @@ async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option
ip_str.parse::<IpAddr>().ok() ip_str.parse::<IpAddr>().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( async fn detect_cloudflare_trace(
client: &Client, _client: &Client,
ip_type: IpType, ip_type: IpType,
timeout: Duration, timeout: Duration,
custom_url: Option<&str>, custom_url: Option<&str>,
ppfmt: &PP, ppfmt: &PP,
) -> Vec<IpAddr> { ) -> Vec<IpAddr> {
// 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(url) = custom_url {
if let Some(ip) = fetch_trace_ip(client, url, timeout).await { if let Some(ip) = fetch_trace_ip(&client, url, timeout).await {
if matches_ip_type(&ip, ip_type) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
} }
@@ -191,14 +214,14 @@ async fn detect_cloudflare_trace(
return Vec::new(); return Vec::new();
} }
let (primary, fallback) = match ip_type { let primary = match ip_type {
IpType::V4 => (CF_TRACE_V4_PRIMARY, CF_TRACE_V4_FALLBACK), IpType::V4 => CF_TRACE_V4_PRIMARY,
IpType::V6 => (CF_TRACE_V6_PRIMARY, CF_TRACE_V6_FALLBACK), IpType::V6 => CF_TRACE_V6_PRIMARY,
}; };
// Try primary // Try primary (literal IP — guarantees correct address family)
if let Some(ip) = fetch_trace_ip(client, primary, timeout).await { if let Some(ip) = fetch_trace_ip(&client, primary, timeout).await {
if matches_ip_type(&ip, ip_type) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
} }
@@ -207,9 +230,9 @@ async fn detect_cloudflare_trace(
&format!("{} not detected via primary, trying fallback", ip_type.describe()), &format!("{} not detected via primary, trying fallback", ip_type.describe()),
); );
// Try fallback // Try fallback (hostname-based — works when literal IPs are intercepted by WARP/Zero Trust)
if let Some(ip) = fetch_trace_ip(client, fallback, timeout).await { if let Some(ip) = fetch_trace_ip(&client, CF_TRACE_FALLBACK, timeout).await {
if matches_ip_type(&ip, ip_type) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
} }
@@ -249,7 +272,7 @@ async fn detect_cloudflare_doh(
if let Ok(body) = r.bytes().await { if let Ok(body) = r.bytes().await {
if let Some(ip_str) = parse_dns_txt_response(&body) { if let Some(ip_str) = parse_dns_txt_response(&body) {
if let Ok(ip) = ip_str.parse::<IpAddr>() { if let Ok(ip) = ip_str.parse::<IpAddr>() {
if matches_ip_type(&ip, ip_type) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
} }
@@ -379,7 +402,7 @@ async fn detect_ipify(
if let Ok(body) = resp.text().await { if let Ok(body) = resp.text().await {
let ip_str = body.trim(); let ip_str = body.trim();
if let Ok(ip) = ip_str.parse::<IpAddr>() { if let Ok(ip) = ip_str.parse::<IpAddr>() {
if matches_ip_type(&ip, ip_type) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
} }
@@ -491,7 +514,7 @@ async fn detect_custom_url(
if let Ok(body) = resp.text().await { if let Ok(body) = resp.text().await {
let ip_str = body.trim(); let ip_str = body.trim();
if let Ok(ip) = ip_str.parse::<IpAddr>() { if let Ok(ip) = ip_str.parse::<IpAddr>() {
if matches_ip_type(&ip, ip_type) { if validate_detected_ip(&ip, ip_type, ppfmt) {
return vec![ip]; return vec![ip];
} }
} }
@@ -516,6 +539,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<IpAddr> { fn filter_ips_by_type(ips: &[IpAddr], ip_type: IpType) -> Vec<IpAddr> {
ips.iter() ips.iter()
.copied() .copied()
@@ -867,6 +918,34 @@ mod tests {
assert_eq!(result_ok[0], "93.184.216.34".parse::<IpAddr>().unwrap()); assert_eq!(result_ok[0], "93.184.216.34".parse::<IpAddr>().unwrap());
} }
// ---- trace URL constants ----
#[test]
fn test_trace_urls() {
// Primary URLs use literal IPs to guarantee correct address family.
assert!(CF_TRACE_V4_PRIMARY.contains("1.0.0.1"));
assert!(CF_TRACE_V6_PRIMARY.contains("2606:4700:4700::1001"));
// Fallback uses a hostname for when literal IPs are intercepted (WARP/Zero Trust).
assert_eq!(CF_TRACE_FALLBACK, "https://api.cloudflare.com/cdn-cgi/trace");
assert!(CF_TRACE_FALLBACK.contains("api.cloudflare.com"));
}
// ---- 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 ---- // ---- detect_ipify with wiremock ----
#[tokio::test] #[tokio::test]
@@ -875,7 +954,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/")) .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) .mount(&server)
.await; .await;
@@ -887,7 +966,7 @@ mod tests {
// which uses the same logic // which uses the same logic
let result = detect_custom_url(&client, &server.uri(), IpType::V4, timeout, &ppfmt).await; let result = detect_custom_url(&client, &server.uri(), IpType::V4, timeout, &ppfmt).await;
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert_eq!(result[0], "198.51.100.1".parse::<IpAddr>().unwrap()); assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
} }
#[tokio::test] #[tokio::test]
@@ -897,7 +976,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/")) .and(path("/"))
.respond_with( .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) .mount(&server)
.await; .await;
@@ -908,7 +987,7 @@ mod tests {
let result = detect_custom_url(&client, &server.uri(), IpType::V6, timeout, &ppfmt).await; let result = detect_custom_url(&client, &server.uri(), IpType::V6, timeout, &ppfmt).await;
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert_eq!(result[0], "2001:db8::1".parse::<IpAddr>().unwrap()); assert_eq!(result[0], "2606:4700:4700::1111".parse::<IpAddr>().unwrap());
} }
// ---- detect_custom_url with wiremock ---- // ---- detect_custom_url with wiremock ----
@@ -919,7 +998,7 @@ mod tests {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/my-ip")) .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) .mount(&server)
.await; .await;
@@ -928,16 +1007,79 @@ mod tests {
let timeout = Duration::from_secs(5); let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri()); 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; let result = detect_custom_url(&client, &url, IpType::V4, timeout, &ppfmt).await;
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert_eq!(result[0], "10.0.0.1".parse::<IpAddr>().unwrap()); assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().unwrap());
} }
#[tokio::test] #[tokio::test]
async fn test_detect_custom_url_wrong_ip_type() { async fn test_detect_custom_url_wrong_ip_type() {
let server = MockServer::start().await; 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")) Mock::given(method("GET"))
.and(path("/my-ip")) .and(path("/my-ip"))
.respond_with(ResponseTemplate::new(200).set_body_string("10.0.0.1")) .respond_with(ResponseTemplate::new(200).set_body_string("10.0.0.1"))
@@ -949,8 +1091,7 @@ mod tests {
let timeout = Duration::from_secs(5); let timeout = Duration::from_secs(5);
let url = format!("{}/my-ip", server.uri()); 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::V4, timeout, &ppfmt).await;
let result = detect_custom_url(&client, &url, IpType::V6, timeout, &ppfmt).await;
assert!(result.is_empty()); assert!(result.is_empty());
} }

View File

@@ -191,11 +191,11 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool {
client, client,
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(), cf_api_base: "https://api.cloudflare.com/client/v4".to_string(),
ipv4_urls: vec![ 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(), "https://1.0.0.1/cdn-cgi/trace".to_string(),
], ],
ipv6_urls: vec![ 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(), "https://[2606:4700:4700::1001]/cdn-cgi/trace".to_string(),
], ],
dry_run: config.dry_run, dry_run: config.dry_run,