4 Commits

Author SHA1 Message Date
Timothy Miller
83dd454c42 Fetch CF ranges concurrently and prevent writes
Use tokio::join to fetch IPv4 and IPv6 Cloudflare ranges in parallel.
When range fetch fails, avoid performing updates that could write
Cloudflare addresses by clearing detected/filtered IP lists and emitting
warnings. Add unit tests to validate parsing and boundary checks for the
current Cloudflare ranges. Bump crate version to 2.0.6.
Fetch Cloudflare ranges concurrently; avoid writes

Skip updates (clear detected IPs) if Cloudflare ranges can't be
retrieved to avoid writing Cloudflare anycast addresses.
Default REJECT_CLOUDFLARE_IPS=true, update README, add comprehensive
CF-range tests, and bump crate version
Fetch CF ranges concurrently and avoid updates

Enable rejecting Cloudflare IPs by default and skip any updates
if the published ranges cannot be fetched to avoid writing Cloudflare
anycast addresses. Fetch IPv4 and IPv6 ranges concurrently, add
parsing/matching tests, and update README and version.
2026-03-19 18:56:11 -04:00
Timothy Miller
f8d5b5cb7e Bump version to 2.0.5 2026-03-19 18:19:41 -04:00
Timothy Miller
bb5cc43651 Add ip4_provider and ip6_provider for legacy mode
Use the shared provider abstraction for IPv4/IPv6 detection in legacy
mode.
Allow per-family provider overrides in config.json (ip4_provider /
ip6_provider)
and support disabling a family with "none". Update config parsing,
examples,
and the legacy update flow to use the provider-based detection client.
2026-03-19 18:18:53 -04:00
Timothy Miller
7ff8379cfb 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.
2026-03-19 18:18:32 -04:00
9 changed files with 414 additions and 356 deletions

2
Cargo.lock generated
View File

@@ -109,7 +109,7 @@ dependencies = [
[[package]]
name = "cloudflare-ddns"
version = "2.0.4"
version = "2.0.6"
dependencies = [
"chrono",
"idna",

View File

@@ -1,6 +1,6 @@
[package]
name = "cloudflare-ddns"
version = "2.0.4"
version = "2.0.6"
edition = "2021"
description = "Access your home network remotely via a custom domain name without a static IP"
license = "GPL-3.0"

View File

@@ -28,7 +28,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels
- 🔒 **Zero-log IP detection** — Uses Cloudflare's [cdn-cgi/trace](https://www.cloudflare.com/cdn-cgi/trace) by default
- 🏠 **CGNAT-aware local detection** — Filters out shared address space (100.64.0.0/10) and private ranges
- 🚫 **Cloudflare IP rejection**Optionally reject Cloudflare anycast IPs to prevent incorrect DNS updates
- 🚫 **Cloudflare IP rejection**Automatically rejects Cloudflare anycast IPs to prevent incorrect DNS updates
- 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start
@@ -92,11 +92,13 @@ Available providers:
| Variable | Default | Description |
|----------|---------|-------------|
| `REJECT_CLOUDFLARE_IPS` | `false` | Reject detected IPs that fall within Cloudflare's IP ranges |
| `REJECT_CLOUDFLARE_IPS` | `true` | Reject detected IPs that fall within Cloudflare's IP ranges |
Some IP detection providers occasionally return a Cloudflare anycast IP instead of your real public IP. When this happens, your DNS record gets updated to point at Cloudflare infrastructure rather than your actual address.
Setting `REJECT_CLOUDFLARE_IPS=true` prevents this. Each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP.
By default, each update cycle fetches [Cloudflare's published IP ranges](https://www.cloudflare.com/ips/) and skips any detected IP that falls within them. A warning is logged for every rejected IP. If the ranges cannot be fetched, the update is skipped entirely to prevent writing a Cloudflare IP.
To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
## ⏱️ Scheduling
@@ -221,7 +223,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent.
| `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request timeout |
| `REJECT_CLOUDFLARE_IPS` | `false` | 🚫 Reject Cloudflare anycast IPs |
| `REJECT_CLOUDFLARE_IPS` | `true` | 🚫 Reject Cloudflare anycast IPs |
| `EMOJI` | `true` | 🎨 Enable emoji output |
| `QUIET` | `false` | 🤫 Suppress info output |
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL |
@@ -368,6 +370,42 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
| `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates |
| `purgeUnknownRecords` | bool | `false` | Delete stale/duplicate DNS records |
| `ttl` | int | `300` | DNS record TTL in seconds (30-86400, values < 30 become auto) |
| `ip4_provider` | string | `"cloudflare.trace"` | IPv4 detection provider (same values as `IP4_PROVIDER` env var) |
| `ip6_provider` | string | `"cloudflare.trace"` | IPv6 detection provider (same values as `IP6_PROVIDER` env var) |
### 🚫 Cloudflare IP Rejection (Legacy Mode)
Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set `REJECT_CLOUDFLARE_IPS=false` alongside your `config.json`:
```bash
REJECT_CLOUDFLARE_IPS=false cloudflare-ddns
```
Or in Docker Compose:
```yml
environment:
- REJECT_CLOUDFLARE_IPS=false
volumes:
- ./config.json:/config.json
```
### 🔍 IP Detection (Legacy Mode)
Legacy mode now uses the same shared provider abstraction as environment variable mode. By default it uses the `cloudflare.trace` provider, which builds an IP-family-bound HTTP client (`0.0.0.0` for IPv4, `[::]` for IPv6) to guarantee the correct address family on dual-stack hosts.
You can override the detection method per address family with `ip4_provider` and `ip6_provider` in your `config.json`. Supported values are the same as the `IP4_PROVIDER` / `IP6_PROVIDER` environment variables: `cloudflare.trace`, `cloudflare.doh`, `ipify`, `local`, `local.iface:<name>`, `url:<https://...>`, `none`.
Set a provider to `"none"` to disable detection for that address family (overrides `a`/`aaaa`):
```json
{
"a": true,
"aaaa": true,
"ip4_provider": "cloudflare.trace",
"ip6_provider": "none"
}
```
Each zone entry contains:

View File

@@ -24,5 +24,7 @@
"a": true,
"aaaa": true,
"purgeUnknownRecords": false,
"ttl": 300
"ttl": 300,
"ip4_provider": "cloudflare.trace",
"ip6_provider": "cloudflare.trace"
}

View File

@@ -59,8 +59,13 @@ impl CloudflareIpFilter {
pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option<Self> {
let mut ranges = Vec::new();
for url in [CF_IPV4_URL, CF_IPV6_URL] {
match client.get(url).timeout(timeout).send().await {
let (v4_result, v6_result) = tokio::join!(
client.get(CF_IPV4_URL).timeout(timeout).send(),
client.get(CF_IPV6_URL).timeout(timeout).send(),
);
for (url, result) in [(CF_IPV4_URL, v4_result), (CF_IPV6_URL, v6_result)] {
match result {
Ok(resp) if resp.status().is_success() => match resp.text().await {
Ok(body) => {
for line in body.lines() {
@@ -234,4 +239,127 @@ mod tests {
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1));
assert!(!filter.contains(&ip));
}
/// All real Cloudflare ranges as of 2026-03. Verifies every range parses
/// and that the first and last IP in each range is matched while the
/// address just past the end is not.
const ALL_CF_RANGES: &str = "\
173.245.48.0/20
103.21.244.0/22
103.22.200.0/22
103.31.4.0/22
141.101.64.0/18
108.162.192.0/18
190.93.240.0/20
188.114.96.0/20
197.234.240.0/22
198.41.128.0/17
162.158.0.0/15
104.16.0.0/13
104.24.0.0/14
172.64.0.0/13
131.0.72.0/22
2400:cb00::/32
2606:4700::/32
2803:f800::/32
2405:b500::/32
2405:8100::/32
2a06:98c0::/29
2c0f:f248::/32
";
#[test]
fn test_all_real_ranges_parse() {
let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap();
assert_eq!(filter.ranges.len(), 22);
}
/// For a /N IPv4 range starting at `base`, return (first, last, just_outside).
fn v4_range_bounds(a: u8, b: u8, c: u8, d: u8, prefix: u8) -> (Ipv4Addr, Ipv4Addr, Ipv4Addr) {
let base = u32::from(Ipv4Addr::new(a, b, c, d));
let size = 1u32 << (32 - prefix);
let first = Ipv4Addr::from(base);
let last = Ipv4Addr::from(base + size - 1);
let outside = Ipv4Addr::from(base + size);
(first, last, outside)
}
#[test]
fn test_all_real_ipv4_ranges_match() {
// Test each range individually so adjacent ranges (e.g. 104.16.0.0/13
// and 104.24.0.0/14) don't cause false failures on boundary checks.
let ranges: &[(u8, u8, u8, u8, u8)] = &[
(173, 245, 48, 0, 20),
(103, 21, 244, 0, 22),
(103, 22, 200, 0, 22),
(103, 31, 4, 0, 22),
(141, 101, 64, 0, 18),
(108, 162, 192, 0, 18),
(190, 93, 240, 0, 20),
(188, 114, 96, 0, 20),
(197, 234, 240, 0, 22),
(198, 41, 128, 0, 17),
(162, 158, 0, 0, 15),
(104, 16, 0, 0, 13),
(104, 24, 0, 0, 14),
(172, 64, 0, 0, 13),
(131, 0, 72, 0, 22),
];
for &(a, b, c, d, prefix) in ranges {
let cidr = format!("{a}.{b}.{c}.{d}/{prefix}");
let filter = CloudflareIpFilter::from_lines(&cidr).unwrap();
let (first, last, outside) = v4_range_bounds(a, b, c, d, prefix);
assert!(
filter.contains(&IpAddr::V4(first)),
"First IP {first} should be in {cidr}"
);
assert!(
filter.contains(&IpAddr::V4(last)),
"Last IP {last} should be in {cidr}"
);
assert!(
!filter.contains(&IpAddr::V4(outside)),
"IP {outside} should NOT be in {cidr}"
);
}
}
#[test]
fn test_all_real_ipv6_ranges_match() {
let filter = CloudflareIpFilter::from_lines(ALL_CF_RANGES).unwrap();
// (base high 16-bit segment, prefix len)
let ranges: &[(u16, u16, u8)] = &[
(0x2400, 0xcb00, 32),
(0x2606, 0x4700, 32),
(0x2803, 0xf800, 32),
(0x2405, 0xb500, 32),
(0x2405, 0x8100, 32),
(0x2a06, 0x98c0, 29),
(0x2c0f, 0xf248, 32),
];
for &(seg0, seg1, prefix) in ranges {
let base = u128::from(Ipv6Addr::new(seg0, seg1, 0, 0, 0, 0, 0, 0));
let size = 1u128 << (128 - prefix);
let first = Ipv6Addr::from(base);
let last = Ipv6Addr::from(base + size - 1);
let outside = Ipv6Addr::from(base + size);
assert!(
filter.contains(&IpAddr::V6(first)),
"First IP {first} should be in {seg0:x}:{seg1:x}::/{prefix}"
);
assert!(
filter.contains(&IpAddr::V6(last)),
"Last IP {last} should be in {seg0:x}:{seg1:x}::/{prefix}"
);
assert!(
!filter.contains(&IpAddr::V6(outside)),
"IP {outside} should NOT be in {seg0:x}:{seg1:x}::/{prefix}"
);
}
}
}

View File

@@ -27,6 +27,10 @@ pub struct LegacyConfig {
pub purge_unknown_records: bool,
#[serde(default = "default_ttl")]
pub ttl: i64,
#[serde(default)]
pub ip4_provider: Option<String>,
#[serde(default)]
pub ip6_provider: Option<String>,
}
fn default_true() -> bool {
@@ -387,7 +391,7 @@ pub fn parse_legacy_config(content: &str) -> Result<LegacyConfig, String> {
}
/// Convert a legacy config into a unified AppConfig
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> AppConfig {
fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Result<AppConfig, String> {
// Extract auth from first entry
let auth = if let Some(entry) = legacy.cloudflare.first() {
if !entry.authentication.api_token.is_empty()
@@ -406,13 +410,27 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
Auth::Token(String::new())
};
// Build providers
// Build providers — ip4_provider/ip6_provider override the default cloudflare.trace
let mut providers = HashMap::new();
if legacy.a {
providers.insert(IpType::V4, ProviderType::CloudflareTrace { url: None });
let provider = match &legacy.ip4_provider {
Some(s) => ProviderType::parse(s)
.map_err(|e| format!("Invalid ip4_provider in config.json: {e}"))?,
None => ProviderType::CloudflareTrace { url: None },
};
if !matches!(provider, ProviderType::None) {
providers.insert(IpType::V4, provider);
}
}
if legacy.aaaa {
providers.insert(IpType::V6, ProviderType::CloudflareTrace { url: None });
let provider = match &legacy.ip6_provider {
Some(s) => ProviderType::parse(s)
.map_err(|e| format!("Invalid ip6_provider in config.json: {e}"))?,
None => ProviderType::CloudflareTrace { url: None },
};
if !matches!(provider, ProviderType::None) {
providers.insert(IpType::V6, provider);
}
}
let ttl = TTL::new(legacy.ttl);
@@ -423,7 +441,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Ap
CronSchedule::Once
};
AppConfig {
Ok(AppConfig {
auth,
providers,
domains: HashMap::new(),
@@ -440,14 +458,14 @@ 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", true),
dry_run,
emoji: false,
quiet: false,
legacy_mode: true,
legacy_config: Some(legacy),
repeat,
}
})
}
// ============================================================
@@ -511,7 +529,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let emoji = getenv_bool("EMOJI", true);
let quiet = getenv_bool("QUIET", false);
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", false);
let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", true);
// Validate: must have at least one update target
if domains.is_empty() && waf_lists.is_empty() {
@@ -583,7 +601,7 @@ pub fn load_config(dry_run: bool, repeat: bool, ppfmt: &PP) -> Result<AppConfig,
} else {
ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration");
let legacy = load_legacy_config()?;
Ok(legacy_to_app_config(legacy, dry_run, repeat))
legacy_to_app_config(legacy, dry_run, repeat)
}
}
@@ -663,8 +681,8 @@ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) {
inner.infof("", "Delete on stop: enabled");
}
if config.reject_cloudflare_ips {
inner.infof("", "Reject Cloudflare IPs: enabled");
if !config.reject_cloudflare_ips {
inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)");
}
if let Some(ref comment) = config.record_comment {
@@ -995,8 +1013,10 @@ mod tests {
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
};
let config = legacy_to_app_config(legacy, false, false);
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(config.legacy_mode);
assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token"));
assert!(config.providers.contains_key(&IpType::V4));
@@ -1021,8 +1041,10 @@ mod tests {
aaaa: true,
purge_unknown_records: false,
ttl: 120,
ip4_provider: None,
ip6_provider: None,
};
let config = legacy_to_app_config(legacy, true, true);
let config = legacy_to_app_config(legacy, true, true).unwrap();
assert!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120)));
assert!(config.repeat);
assert!(config.dry_run);
@@ -1047,12 +1069,118 @@ mod tests {
aaaa: true,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
};
let config = legacy_to_app_config(legacy, false, false);
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
if api_key == "key123" && email == "test@example.com"));
}
#[test]
fn test_legacy_to_app_config_custom_providers() {
let legacy = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "tok".to_string(),
api_key: None,
},
zone_id: "z".to_string(),
subdomains: vec![],
proxied: false,
}],
a: true,
aaaa: true,
purge_unknown_records: false,
ttl: 300,
ip4_provider: Some("ipify".to_string()),
ip6_provider: Some("cloudflare.doh".to_string()),
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
assert!(matches!(config.providers[&IpType::V4], ProviderType::Ipify));
assert!(matches!(config.providers[&IpType::V6], ProviderType::CloudflareDOH));
}
#[test]
fn test_legacy_to_app_config_provider_none_overrides_a_flag() {
let legacy = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "tok".to_string(),
api_key: None,
},
zone_id: "z".to_string(),
subdomains: vec![],
proxied: false,
}],
a: true,
aaaa: true,
purge_unknown_records: false,
ttl: 300,
ip4_provider: Some("none".to_string()),
ip6_provider: None,
};
let config = legacy_to_app_config(legacy, false, false).unwrap();
// ip4_provider=none should exclude V4 even though a=true
assert!(!config.providers.contains_key(&IpType::V4));
assert!(config.providers.contains_key(&IpType::V6));
}
#[test]
fn test_legacy_to_app_config_invalid_provider_returns_error() {
let legacy = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "tok".to_string(),
api_key: None,
},
zone_id: "z".to_string(),
subdomains: vec![],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: Some("totally_invalid".to_string()),
ip6_provider: None,
};
let result = legacy_to_app_config(legacy, false, false);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.contains("ip4_provider"));
}
#[test]
fn test_legacy_config_deserializes_providers() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok" },
"zone_id": "z",
"subdomains": ["@"]
}],
"ip4_provider": "ipify",
"ip6_provider": "none"
}"#;
let config = parse_legacy_config(json).unwrap();
assert_eq!(config.ip4_provider, Some("ipify".to_string()));
assert_eq!(config.ip6_provider, Some("none".to_string()));
}
#[test]
fn test_legacy_config_deserializes_without_providers() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok" },
"zone_id": "z",
"subdomains": ["@"]
}]
}"#;
let config = parse_legacy_config(json).unwrap();
assert!(config.ip4_provider.is_none());
assert!(config.ip6_provider.is_none());
}
// --- is_env_config_mode ---
#[test]

View File

@@ -301,6 +301,8 @@ mod tests {
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
}
}
@@ -814,6 +816,8 @@ mod tests {
aaaa: false,
purge_unknown_records: true,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
};
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true)
.await;
@@ -913,6 +917,8 @@ mod tests {
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
};
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false)

View File

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

View File

@@ -101,11 +101,12 @@ pub async fn update_once(
)));
}
}
} else {
} else if !detected_ips.is_empty() {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Could not fetch Cloudflare IP ranges; skipping filter",
"Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs",
);
detected_ips.clear();
}
}
@@ -229,7 +230,12 @@ 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 {
///
/// IP detection uses the shared provider abstraction (`config.providers`), which builds
/// IP-family-bound clients (0.0.0.0 for IPv4, [::] for IPv6). This prevents the old
/// wrong-family warning on dual-stack hosts and honours `ip4_provider`/`ip6_provider`
/// overrides from config.json.
async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return false,
@@ -243,28 +249,89 @@ async fn update_legacy(config: &AppConfig, _ppfmt: &PP) -> bool {
let ddns = LegacyDdnsClient {
client,
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(),
ipv4_urls: vec![
"https://api.cloudflare.com/cdn-cgi/trace".to_string(),
"https://1.0.0.1/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(),
],
dry_run: config.dry_run,
};
let mut warnings = LegacyWarningState::default();
// Detect IPs using the shared provider abstraction
let detection_client = Client::builder()
.timeout(config.detection_timeout)
.build()
.unwrap_or_default();
let ips = ddns
.get_ips(
legacy.a,
legacy.aaaa,
legacy.purge_unknown_records,
&legacy.cloudflare,
&mut warnings,
)
.await;
let mut ips = HashMap::new();
for (ip_type, provider) in &config.providers {
ppfmt.infof(
pp::EMOJI_DETECT,
&format!("Detecting {} via {}", ip_type.describe(), provider.name()),
);
let detected = provider
.detect_ips(&detection_client, *ip_type, config.detection_timeout, ppfmt)
.await;
if detected.is_empty() {
ppfmt.warningf(
pp::EMOJI_WARNING,
&format!("No {} address detected", ip_type.describe()),
);
if legacy.purge_unknown_records {
ddns.delete_entries(ip_type.record_type(), &legacy.cloudflare)
.await;
}
} else {
let key = match ip_type {
IpType::V4 => "ipv4",
IpType::V6 => "ipv6",
};
ppfmt.infof(
pp::EMOJI_DETECT,
&format!("Detected {}: {}", ip_type.describe(), detected[0]),
);
ips.insert(
key.to_string(),
LegacyIpInfo {
record_type: ip_type.record_type().to_string(),
ip: detected[0].to_string(),
},
);
}
}
// Filter out Cloudflare IPs if enabled
if config.reject_cloudflare_ips {
let before_count = ips.len();
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
});
if ips.is_empty() && before_count > 0 {
ppfmt.warningf(
pp::EMOJI_WARNING,
"All detected addresses were Cloudflare IPs; skipping updates",
);
}
} else if !ips.is_empty() {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs",
);
ips.clear();
}
}
ddns.update_ips(
&ips,
@@ -323,141 +390,13 @@ pub struct LegacyIpInfo {
pub ip: String,
}
struct LegacyWarningState {
shown_ipv4: bool,
shown_ipv4_secondary: bool,
shown_ipv6: bool,
shown_ipv6_secondary: bool,
}
impl Default for LegacyWarningState {
fn default() -> Self {
Self {
shown_ipv4: false,
shown_ipv4_secondary: false,
shown_ipv6: false,
shown_ipv6_secondary: false,
}
}
}
struct LegacyDdnsClient {
client: Client,
cf_api_base: String,
ipv4_urls: Vec<String>,
ipv6_urls: Vec<String>,
dry_run: bool,
}
impl LegacyDdnsClient {
async fn get_ips(
&self,
ipv4_enabled: bool,
ipv6_enabled: bool,
purge_unknown_records: bool,
config: &[LegacyCloudflareEntry],
warnings: &mut LegacyWarningState,
) -> HashMap<String, LegacyIpInfo> {
let mut ips = HashMap::new();
if ipv4_enabled {
let a = self
.try_trace_urls(
&self.ipv4_urls,
&mut warnings.shown_ipv4,
&mut warnings.shown_ipv4_secondary,
"IPv4",
true,
)
.await;
if a.is_none() && purge_unknown_records {
self.delete_entries("A", config).await;
}
if let Some(ip) = a {
ips.insert(
"ipv4".to_string(),
LegacyIpInfo {
record_type: "A".to_string(),
ip,
},
);
}
}
if ipv6_enabled {
let aaaa = self
.try_trace_urls(
&self.ipv6_urls,
&mut warnings.shown_ipv6,
&mut warnings.shown_ipv6_secondary,
"IPv6",
false,
)
.await;
if aaaa.is_none() && purge_unknown_records {
self.delete_entries("AAAA", config).await;
}
if let Some(ip) = aaaa {
ips.insert(
"ipv6".to_string(),
LegacyIpInfo {
record_type: "AAAA".to_string(),
ip,
},
);
}
}
ips
}
async fn try_trace_urls(
&self,
urls: &[String],
shown_primary: &mut bool,
shown_secondary: &mut bool,
label: &str,
expect_v4: bool,
) -> Option<String> {
for (i, url) in urls.iter().enumerate() {
match self.client.get(url).send().await {
Ok(resp) => {
if let Some(ip) =
crate::provider::parse_trace_ip(&resp.text().await.unwrap_or_default())
{
// Validate the IP matches the expected address family
if let Ok(addr) = ip.parse::<std::net::IpAddr>() {
if expect_v4 && !addr.is_ipv4() {
eprintln!("{label} trace returned IPv6 address, skipping");
continue;
}
if !expect_v4 && !addr.is_ipv6() {
eprintln!("{label} trace returned IPv4 address, skipping");
continue;
}
}
return Some(ip);
}
}
Err(_) => {
if i == 0 && !*shown_primary {
*shown_primary = true;
let next = if urls.len() > 1 {
", trying fallback"
} else {
""
};
eprintln!("{label} not detected via primary{next}");
} else if i > 0 && !*shown_secondary {
*shown_secondary = true;
eprintln!("{label} not detected via fallback. Verify your ISP or DNS provider isn't blocking Cloudflare's IPs.");
}
}
}
}
None
}
async fn cf_api<T: serde::de::DeserializeOwned>(
&self,
endpoint: &str,
@@ -1701,86 +1640,6 @@ mod tests {
// LegacyDdnsClient tests (internal/private struct)
// -------------------------------------------------------
#[tokio::test]
async fn test_legacy_try_trace_urls_primary_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/trace"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("fl=1\nh=mock\nip=198.51.100.1\nts=0\n"),
)
.mount(&server)
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![format!("{}/trace", server.uri())],
ipv6_urls: vec![],
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)
.await;
assert_eq!(result, Some("198.51.100.1".to_string()));
}
#[tokio::test]
async fn test_legacy_try_trace_urls_primary_fails_fallback_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/fallback"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("fl=1\nh=mock\nip=198.51.100.2\nts=0\n"),
)
.mount(&server)
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![
"http://127.0.0.1:1/nonexistent".to_string(), // will fail
format!("{}/fallback", server.uri()),
],
ipv6_urls: vec![],
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)
.await;
assert_eq!(result, Some("198.51.100.2".to_string()));
assert!(shown_primary);
}
#[tokio::test]
async fn test_legacy_try_trace_urls_all_fail() {
let ddns = LegacyDdnsClient {
client: Client::builder().timeout(Duration::from_millis(100)).build().unwrap(),
cf_api_base: String::new(),
ipv4_urls: vec![
"http://127.0.0.1:1/fail1".to_string(),
"http://127.0.0.1:1/fail2".to_string(),
],
ipv6_urls: vec![],
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)
.await;
assert!(result.is_none());
assert!(shown_primary);
assert!(shown_secondary);
}
#[tokio::test]
async fn test_legacy_cf_api_get_success() {
let server = MockServer::start().await;
@@ -1795,8 +1654,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let entry = crate::config::LegacyCloudflareEntry {
@@ -1829,8 +1686,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let entry = crate::config::LegacyCloudflareEntry {
@@ -1860,8 +1715,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let entry = crate::config::LegacyCloudflareEntry {
@@ -1884,8 +1737,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: "http://localhost".to_string(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let entry = crate::config::LegacyCloudflareEntry {
@@ -1916,8 +1767,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let entry = crate::config::LegacyCloudflareEntry {
@@ -1938,75 +1787,6 @@ mod tests {
assert!(result.is_some());
}
#[tokio::test]
async fn test_legacy_get_ips_ipv4_enabled() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/trace"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("ip=198.51.100.42\n"),
)
.mount(&server)
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![format!("{}/trace", server.uri())],
ipv6_urls: vec![],
dry_run: false,
};
let mut warnings = LegacyWarningState::default();
let config: Vec<crate::config::LegacyCloudflareEntry> = vec![];
let ips = ddns.get_ips(true, false, false, &config, &mut warnings).await;
assert!(ips.contains_key("ipv4"));
assert_eq!(ips["ipv4"].ip, "198.51.100.42");
assert_eq!(ips["ipv4"].record_type, "A");
}
#[tokio::test]
async fn test_legacy_get_ips_ipv6_enabled() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/trace6"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("ip=2001:db8::1\n"),
)
.mount(&server)
.await;
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![format!("{}/trace6", server.uri())],
dry_run: false,
};
let mut warnings = LegacyWarningState::default();
let config: Vec<crate::config::LegacyCloudflareEntry> = 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");
}
#[tokio::test]
async fn test_legacy_get_ips_both_disabled() {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: String::new(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let mut warnings = LegacyWarningState::default();
let config: Vec<crate::config::LegacyCloudflareEntry> = vec![];
let ips = ddns.get_ips(false, false, false, &config, &mut warnings).await;
assert!(ips.is_empty());
}
#[tokio::test]
async fn test_legacy_commit_record_creates_new() {
let server = MockServer::start().await;
@@ -2043,8 +1823,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let ip = LegacyIpInfo {
@@ -2101,8 +1879,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let ip = LegacyIpInfo {
@@ -2145,8 +1921,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: true,
};
let ip = LegacyIpInfo {
@@ -2198,8 +1972,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let ip = LegacyIpInfo {
@@ -2255,8 +2027,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let ip = LegacyIpInfo {
@@ -2306,8 +2076,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let mut ips = HashMap::new();
@@ -2352,8 +2120,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false,
};
let config = vec![crate::config::LegacyCloudflareEntry {
@@ -2386,8 +2152,6 @@ mod tests {
let ddns = LegacyDdnsClient {
client: Client::new(),
cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: true,
};
let config = vec![crate::config::LegacyCloudflareEntry {
@@ -2403,14 +2167,6 @@ mod tests {
ddns.delete_entries("A", &config).await;
}
#[test]
fn test_legacy_warning_state_default() {
let w = LegacyWarningState::default();
assert!(!w.shown_ipv4);
assert!(!w.shown_ipv4_secondary);
assert!(!w.shown_ipv6);
assert!(!w.shown_ipv6_secondary);
}
}
// Legacy types for backwards compatibility