5 Commits

Author SHA1 Message Date
Timothy Miller
9b8aba5e20 Add CachedCloudflareFilter
Introduce CachedCloudflareFilter that caches Cloudflare IP ranges and
refreshes every 24 hours. If a refresh fails the previously cached
ranges
are retained and a warning is emitted. Wire the cache through main and
updater so Cloudflare fetches reuse the cached result. Update tests and
bump crate version to 2.0.7
2026-03-19 19:24:44 -04:00
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 508 additions and 378 deletions

2
Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.4" version = "2.0.7"
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"

View File

@@ -28,7 +28,7 @@ Configure everything with environment variables. Supports notifications, heartbe
- 🎨 **Pretty output with emoji** — Configurable emoji and verbosity levels - 🎨 **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 - 🔒 **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 - 🏠 **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 - 🤏 **Tiny static binary** — ~1.9 MB Docker image built from scratch, zero runtime dependencies
## 🚀 Quick Start ## 🚀 Quick Start
@@ -92,11 +92,13 @@ Available providers:
| Variable | Default | Description | | 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. 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 ## ⏱️ 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 | | `MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX` | — | 🎯 Managed WAF items regex |
| `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout | | `DETECTION_TIMEOUT` | `5s` | ⏳ IP detection timeout |
| `UPDATE_TIMEOUT` | `30s` | ⏳ API request 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 | | `EMOJI` | `true` | 🎨 Enable emoji output |
| `QUIET` | `false` | 🤫 Suppress info output | | `QUIET` | `false` | 🤫 Suppress info output |
| `HEALTHCHECKS` | — | 💓 Healthchecks.io URL | | `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 | | `aaaa` | bool | `true` | Enable IPv6 (AAAA record) updates |
| `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) |
| `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: Each zone entry contains:

View File

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

View File

@@ -1,7 +1,7 @@
use crate::pp::{self, PP}; use crate::pp::{self, PP};
use reqwest::Client; use reqwest::Client;
use std::net::IpAddr; use std::net::IpAddr;
use std::time::Duration; use std::time::{Duration, Instant};
const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4"; const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6"; const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
@@ -59,8 +59,13 @@ impl CloudflareIpFilter {
pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option<Self> { pub async fn fetch(client: &Client, timeout: Duration, ppfmt: &PP) -> Option<Self> {
let mut ranges = Vec::new(); let mut ranges = Vec::new();
for url in [CF_IPV4_URL, CF_IPV6_URL] { let (v4_result, v6_result) = tokio::join!(
match client.get(url).timeout(timeout).send().await { 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(resp) if resp.status().is_success() => match resp.text().await {
Ok(body) => { Ok(body) => {
for line in body.lines() { for line in body.lines() {
@@ -152,6 +157,62 @@ impl CloudflareIpFilter {
} }
} }
/// Refresh interval for Cloudflare IP ranges (24 hours).
const CF_RANGE_REFRESH: Duration = Duration::from_secs(24 * 60 * 60);
/// Cached wrapper around [`CloudflareIpFilter`].
///
/// Fetches once, then re-uses the cached ranges for [`CF_RANGE_REFRESH`].
/// If a refresh fails, the previously cached ranges are kept.
pub struct CachedCloudflareFilter {
filter: Option<CloudflareIpFilter>,
fetched_at: Option<Instant>,
}
impl CachedCloudflareFilter {
pub fn new() -> Self {
Self {
filter: None,
fetched_at: None,
}
}
/// Return a reference to the current filter, refreshing if stale or absent.
pub async fn get(
&mut self,
client: &Client,
timeout: Duration,
ppfmt: &PP,
) -> Option<&CloudflareIpFilter> {
let stale = match self.fetched_at {
Some(t) => t.elapsed() >= CF_RANGE_REFRESH,
None => true,
};
if stale {
match CloudflareIpFilter::fetch(client, timeout, ppfmt).await {
Some(new_filter) => {
self.filter = Some(new_filter);
self.fetched_at = Some(Instant::now());
}
None => {
if self.filter.is_some() {
ppfmt.warningf(
pp::EMOJI_WARNING,
"Failed to refresh Cloudflare IP ranges; using cached version",
);
// Keep using cached filter, but don't update fetched_at
// so we retry next cycle.
}
// If no cached filter exists, return None (caller handles fail-safe).
}
}
}
self.filter.as_ref()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -234,4 +295,127 @@ mod tests {
let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1)); let ip: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1));
assert!(!filter.contains(&ip)); 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, pub purge_unknown_records: bool,
#[serde(default = "default_ttl")] #[serde(default = "default_ttl")]
pub ttl: i64, pub ttl: i64,
#[serde(default)]
pub ip4_provider: Option<String>,
#[serde(default)]
pub ip6_provider: Option<String>,
} }
fn default_true() -> bool { 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 /// 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 // Extract auth from first entry
let auth = if let Some(entry) = legacy.cloudflare.first() { let auth = if let Some(entry) = legacy.cloudflare.first() {
if !entry.authentication.api_token.is_empty() 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()) Auth::Token(String::new())
}; };
// Build providers // Build providers — ip4_provider/ip6_provider override the default cloudflare.trace
let mut providers = HashMap::new(); let mut providers = HashMap::new();
if legacy.a { 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 { 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); 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 CronSchedule::Once
}; };
AppConfig { Ok(AppConfig {
auth, auth,
providers, providers,
domains: HashMap::new(), 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, 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", true),
dry_run, dry_run,
emoji: false, emoji: false,
quiet: false, quiet: false,
legacy_mode: true, legacy_mode: true,
legacy_config: Some(legacy), legacy_config: Some(legacy),
repeat, repeat,
} })
} }
// ============================================================ // ============================================================
@@ -511,7 +529,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let emoji = getenv_bool("EMOJI", true); let emoji = getenv_bool("EMOJI", true);
let quiet = getenv_bool("QUIET", false); 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 // Validate: must have at least one update target
if domains.is_empty() && waf_lists.is_empty() { 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 { } else {
ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration"); ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration");
let legacy = load_legacy_config()?; 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"); inner.infof("", "Delete on stop: enabled");
} }
if config.reject_cloudflare_ips { if !config.reject_cloudflare_ips {
inner.infof("", "Reject Cloudflare IPs: enabled"); inner.warningf("", "Cloudflare IP rejection: DISABLED (REJECT_CLOUDFLARE_IPS=false)");
} }
if let Some(ref comment) = config.record_comment { if let Some(ref comment) = config.record_comment {
@@ -995,8 +1013,10 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, 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!(config.legacy_mode);
assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token")); assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token"));
assert!(config.providers.contains_key(&IpType::V4)); assert!(config.providers.contains_key(&IpType::V4));
@@ -1021,8 +1041,10 @@ mod tests {
aaaa: true, aaaa: true,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 120, 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!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120)));
assert!(config.repeat); assert!(config.repeat);
assert!(config.dry_run); assert!(config.dry_run);
@@ -1047,12 +1069,118 @@ mod tests {
aaaa: true, aaaa: true,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, 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 } assert!(matches!(config.auth, Auth::Key { ref api_key, ref email }
if api_key == "key123" && email == "test@example.com")); 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 --- // --- is_env_config_mode ---
#[test] #[test]

View File

@@ -116,12 +116,14 @@ async fn main() {
// Start heartbeat // Start heartbeat
heartbeat.start().await; heartbeat.start().await;
let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new();
if app_config.legacy_mode { if app_config.legacy_mode {
// --- Legacy mode (original cloudflare-ddns behavior) --- // --- Legacy mode (original cloudflare-ddns behavior) ---
run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await; run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache).await;
} else { } else {
// --- Env var mode (cf-ddns behavior) --- // --- Env var mode (cf-ddns behavior) ---
run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await; run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running, &mut cf_cache).await;
} }
// On shutdown: delete records if configured // On shutdown: delete records if configured
@@ -143,6 +145,7 @@ async fn run_legacy_mode(
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
ppfmt: &PP, ppfmt: &PP,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
) { ) {
let legacy = match &config.legacy_config { let legacy = match &config.legacy_config {
Some(l) => l, Some(l) => l,
@@ -165,7 +168,7 @@ async fn run_legacy_mode(
} }
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt).await;
for _ in 0..legacy.ttl { for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) { if !running.load(Ordering::SeqCst) {
@@ -175,7 +178,7 @@ async fn run_legacy_mode(
} }
} }
} else { } else {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt).await;
} }
} }
@@ -186,11 +189,12 @@ async fn run_env_mode(
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
ppfmt: &PP, ppfmt: &PP,
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
) { ) {
match &config.update_cron { match &config.update_cron {
CronSchedule::Once => { CronSchedule::Once => {
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt).await;
} }
} }
schedule => { schedule => {
@@ -206,7 +210,7 @@ async fn run_env_mode(
// Update on start if configured // Update on start if configured
if config.update_on_start { if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt).await;
} }
// Main loop // Main loop
@@ -233,7 +237,7 @@ async fn run_env_mode(
return; return;
} }
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await; updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt).await;
} }
} }
} }
@@ -301,6 +305,8 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, ttl: 300,
ip4_provider: None,
ip6_provider: None,
} }
} }
@@ -814,6 +820,8 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: true, purge_unknown_records: true,
ttl: 300, ttl: 300,
ip4_provider: None,
ip6_provider: None,
}; };
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true) ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true)
.await; .await;
@@ -913,6 +921,8 @@ mod tests {
aaaa: false, aaaa: false,
purge_unknown_records: false, purge_unknown_records: false,
ttl: 300, ttl: 300,
ip4_provider: None,
ip6_provider: None,
}; };
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false) 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. /// 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

@@ -1,4 +1,4 @@
use crate::cf_ip_filter::CloudflareIpFilter; use crate::cf_ip_filter::CachedCloudflareFilter;
use crate::cloudflare::{CloudflareHandle, SetResult}; use crate::cloudflare::{CloudflareHandle, SetResult};
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry}; use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
use crate::domain::make_fqdn; use crate::domain::make_fqdn;
@@ -16,6 +16,7 @@ pub async fn update_once(
handle: &CloudflareHandle, handle: &CloudflareHandle,
notifier: &CompositeNotifier, notifier: &CompositeNotifier,
heartbeat: &Heartbeat, heartbeat: &Heartbeat,
cf_cache: &mut CachedCloudflareFilter,
ppfmt: &PP, ppfmt: &PP,
) -> bool { ) -> bool {
let detection_client = Client::builder() let detection_client = Client::builder()
@@ -28,7 +29,7 @@ pub async fn update_once(
let mut notify = false; // NEW: track meaningful events let mut notify = false; // NEW: track meaningful events
if config.legacy_mode { if config.legacy_mode {
all_ok = update_legacy(config, ppfmt).await; all_ok = update_legacy(config, cf_cache, ppfmt).await;
} else { } else {
// Detect IPs for each provider // Detect IPs for each provider
let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new(); let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new();
@@ -69,7 +70,7 @@ pub async fn update_once(
// Filter out Cloudflare IPs if enabled // Filter out Cloudflare IPs if enabled
if config.reject_cloudflare_ips { if config.reject_cloudflare_ips {
if let Some(cf_filter) = if let Some(cf_filter) =
CloudflareIpFilter::fetch(&detection_client, config.detection_timeout, ppfmt).await cf_cache.get(&detection_client, config.detection_timeout, ppfmt).await
{ {
for (ip_type, ips) in detected_ips.iter_mut() { for (ip_type, ips) in detected_ips.iter_mut() {
let before_count = ips.len(); let before_count = ips.len();
@@ -101,11 +102,12 @@ pub async fn update_once(
))); )));
} }
} }
} else { } else if !detected_ips.is_empty() {
ppfmt.warningf( ppfmt.warningf(
pp::EMOJI_WARNING, 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 +231,12 @@ 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 { ///
/// 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, cf_cache: &mut CachedCloudflareFilter, 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,29 +250,90 @@ 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(),
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, 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 let mut ips = HashMap::new();
.get_ips(
legacy.a, for (ip_type, provider) in &config.providers {
legacy.aaaa, ppfmt.infof(
legacy.purge_unknown_records, pp::EMOJI_DETECT,
&legacy.cloudflare, &format!("Detecting {} via {}", ip_type.describe(), provider.name()),
&mut warnings, );
) let detected = provider
.detect_ips(&detection_client, *ip_type, config.detection_timeout, ppfmt)
.await; .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) =
cf_cache.get(&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( ddns.update_ips(
&ips, &ips,
&legacy.cloudflare, &legacy.cloudflare,
@@ -323,141 +391,13 @@ pub struct LegacyIpInfo {
pub ip: String, 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 { struct LegacyDdnsClient {
client: Client, client: Client,
cf_api_base: String, cf_api_base: String,
ipv4_urls: Vec<String>,
ipv6_urls: Vec<String>,
dry_run: bool, dry_run: bool,
} }
impl LegacyDdnsClient { 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>( async fn cf_api<T: serde::de::DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
@@ -863,7 +803,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -911,7 +852,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -955,7 +897,8 @@ mod tests {
let ppfmt = pp(); let ppfmt = pp();
// all_ok = true because no zone-level errors occurred (empty ips just noop or warn) // all_ok = true because no zone-level errors occurred (empty ips just noop or warn)
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
// Providers with None are not inserted in loop, so no IP detection warning is emitted, // Providers with None are not inserted in loop, so no IP detection warning is emitted,
// no detected_ips entry is created, and set_ips is called with empty slice -> Noop. // no detected_ips entry is created, and set_ips is called with empty slice -> Noop.
assert!(ok); assert!(ok);
@@ -1004,7 +947,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(!ok, "Expected false when zone is not found"); assert!(!ok, "Expected false when zone is not found");
} }
@@ -1053,7 +997,8 @@ mod tests {
let ppfmt = pp(); let ppfmt = pp();
// dry_run returns Updated from set_ips (it signals intent), all_ok should be true // dry_run returns Updated from set_ips (it signals intent), all_ok should be true
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -1118,7 +1063,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -1171,7 +1117,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -1210,7 +1157,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(!ok, "Expected false when WAF list is not found"); assert!(!ok, "Expected false when WAF list is not found");
} }
@@ -1294,7 +1242,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -1310,7 +1259,8 @@ mod tests {
let heartbeat = empty_heartbeat(); let heartbeat = empty_heartbeat();
let ppfmt = pp(); let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
@@ -1694,93 +1644,14 @@ mod tests {
let ppfmt = pp(); let ppfmt = pp();
// set_ips with empty ips and no existing records = Noop; all_ok = true // set_ips with empty ips and no existing records = Noop; all_ok = true
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await; let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt).await;
assert!(ok); assert!(ok);
} }
// ------------------------------------------------------- // -------------------------------------------------------
// LegacyDdnsClient tests (internal/private struct) // 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] #[tokio::test]
async fn test_legacy_cf_api_get_success() { async fn test_legacy_cf_api_get_success() {
let server = MockServer::start().await; let server = MockServer::start().await;
@@ -1795,8 +1666,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1829,8 +1698,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1860,8 +1727,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1884,8 +1749,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: "http://localhost".to_string(), cf_api_base: "http://localhost".to_string(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1916,8 +1779,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let entry = crate::config::LegacyCloudflareEntry { let entry = crate::config::LegacyCloudflareEntry {
@@ -1938,75 +1799,6 @@ mod tests {
assert!(result.is_some()); 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] #[tokio::test]
async fn test_legacy_commit_record_creates_new() { async fn test_legacy_commit_record_creates_new() {
let server = MockServer::start().await; let server = MockServer::start().await;
@@ -2043,8 +1835,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2101,8 +1891,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2145,8 +1933,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: true, dry_run: true,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2198,8 +1984,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2255,8 +2039,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let ip = LegacyIpInfo { let ip = LegacyIpInfo {
@@ -2306,8 +2088,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let mut ips = HashMap::new(); let mut ips = HashMap::new();
@@ -2352,8 +2132,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: false, dry_run: false,
}; };
let config = vec![crate::config::LegacyCloudflareEntry { let config = vec![crate::config::LegacyCloudflareEntry {
@@ -2386,8 +2164,6 @@ mod tests {
let ddns = LegacyDdnsClient { let ddns = LegacyDdnsClient {
client: Client::new(), client: Client::new(),
cf_api_base: server.uri(), cf_api_base: server.uri(),
ipv4_urls: vec![],
ipv6_urls: vec![],
dry_run: true, dry_run: true,
}; };
let config = vec![crate::config::LegacyCloudflareEntry { let config = vec![crate::config::LegacyCloudflareEntry {
@@ -2403,14 +2179,6 @@ mod tests {
ddns.delete_entries("A", &config).await; 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 // Legacy types for backwards compatibility