1 Commits

Author SHA1 Message Date
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 175 additions and 36 deletions

6
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -243,7 +243,7 @@ fn read_providers_from_env(ppfmt: &PP) -> Result<HashMap<IpType, ProviderType>,
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],

View File

@@ -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<String> {
@@ -171,16 +173,34 @@ async fn fetch_trace_ip(client: &Client, url: &str, timeout: Duration) -> Option
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(
client: &Client,
_client: &Client,
ip_type: IpType,
timeout: Duration,
custom_url: Option<&str>,
ppfmt: &PP,
) -> 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(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::<IpAddr>() {
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::<IpAddr>() {
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::<IpAddr>() {
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<IpAddr> {
ips.iter()
.copied()
@@ -867,6 +915,35 @@ mod tests {
assert_eq!(result_ok[0], "93.184.216.34".parse::<IpAddr>().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::<IpAddr>().unwrap());
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().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::<IpAddr>().unwrap());
assert_eq!(result[0], "2606:4700:4700::1111".parse::<IpAddr>().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::<IpAddr>().unwrap());
assert_eq!(result[0], "93.184.216.34".parse::<IpAddr>().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());
}

View File

@@ -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,