6 Commits

Author SHA1 Message Date
Timothy Miller
2446c1d6a0 Bump crate to 2.0.8 and refine updater behavior
Deduplicate up-to-date messages by tracking noop keys and move logging
to the updater so callers only log the first noop.
Reuse a single reqwest Client for IP detection instead of rebuilding it
for each call.
Always ping heartbeat even when there are no meaningful changes.
Fix Pushover shoutrrr parsing (token@user order) and update tests
2026-03-19 23:22:20 -04:00
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
11 changed files with 699 additions and 447 deletions

2
Cargo.lock generated
View File

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

View File

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

@@ -1,7 +1,7 @@
use crate::pp::{self, PP};
use reqwest::Client;
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_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> {
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() {
@@ -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)]
mod tests {
use super::*;
@@ -234,4 +295,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

@@ -467,7 +467,7 @@ impl CloudflareHandle {
self.update_record(zone_id, &record.id, &payload, ppfmt).await;
}
} else {
ppfmt.infof(pp::EMOJI_SKIP, &format!("Record {fqdn} is up to date ({ip_str})"));
// Caller handles "up to date" logging based on SetResult::Noop
}
} else {
// Find an existing managed record to update, or create new
@@ -668,10 +668,7 @@ impl CloudflareHandle {
.collect();
if to_add.is_empty() && ids_to_delete.is_empty() {
ppfmt.infof(
pp::EMOJI_SKIP,
&format!("WAF list {} is up to date", waf_list.describe()),
);
// Caller handles "up to date" logging based on SetResult::Noop
return SetResult::Noop;
}

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

@@ -11,8 +11,10 @@ use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use reqwest::Client;
use tokio::signal;
use tokio::time::{sleep, Duration};
@@ -116,12 +118,18 @@ async fn main() {
// Start heartbeat
heartbeat.start().await;
let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new();
let detection_client = Client::builder()
.timeout(app_config.detection_timeout)
.build()
.unwrap_or_default();
if app_config.legacy_mode {
// --- 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, &detection_client).await;
} else {
// --- 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, &detection_client).await;
}
// On shutdown: delete records if configured
@@ -143,12 +151,16 @@ async fn run_legacy_mode(
heartbeat: &Heartbeat,
ppfmt: &PP,
running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
detection_client: &Client,
) {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return,
};
let mut noop_reported = HashSet::new();
if config.repeat {
match (legacy.a, legacy.aaaa) {
(true, true) => println!(
@@ -165,7 +177,7 @@ async fn run_legacy_mode(
}
while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) {
@@ -175,7 +187,7 @@ async fn run_legacy_mode(
}
}
} else {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
}
}
@@ -186,11 +198,15 @@ async fn run_env_mode(
heartbeat: &Heartbeat,
ppfmt: &PP,
running: Arc<AtomicBool>,
cf_cache: &mut cf_ip_filter::CachedCloudflareFilter,
detection_client: &Client,
) {
let mut noop_reported = HashSet::new();
match &config.update_cron {
CronSchedule::Once => {
if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
}
}
schedule => {
@@ -206,7 +222,7 @@ async fn run_env_mode(
// Update on start if configured
if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
}
// Main loop
@@ -233,7 +249,7 @@ async fn run_env_mode(
return;
}
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
updater::update_once(config, handle, notifier, heartbeat, cf_cache, ppfmt, &mut noop_reported, detection_client).await;
}
}
}
@@ -301,6 +317,8 @@ mod tests {
aaaa: false,
purge_unknown_records: false,
ttl: 300,
ip4_provider: None,
ip6_provider: None,
}
}
@@ -380,6 +398,7 @@ mod tests {
config: &[LegacyCloudflareEntry],
ttl: i64,
purge_unknown_records: bool,
noop_reported: &mut std::collections::HashSet<String>,
) {
for entry in config {
#[derive(serde::Deserialize)]
@@ -481,8 +500,10 @@ mod tests {
}
}
let noop_key = format!("{fqdn}:{record_type}");
if let Some(ref id) = identifier {
if modified {
noop_reported.remove(&noop_key);
if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {ip}");
} else {
@@ -498,23 +519,30 @@ mod tests {
)
.await;
}
} else if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date ({ip})");
} else if noop_reported.insert(noop_key) {
if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date");
} else {
println!("Record {fqdn} is up to date");
}
}
} else if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else {
println!("Adding new record {fqdn} -> {ip}");
let create_endpoint =
format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&create_endpoint,
"POST",
&entry.authentication.api_token,
Some(&record),
)
.await;
noop_reported.remove(&noop_key);
if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else {
println!("Adding new record {fqdn} -> {ip}");
let create_endpoint =
format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&create_endpoint,
"POST",
&entry.authentication.api_token,
Some(&record),
)
.await;
}
}
if purge_unknown_records {
@@ -634,7 +662,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
}
@@ -683,7 +711,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
}
@@ -726,7 +754,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
}
@@ -760,7 +788,7 @@ mod tests {
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
}
@@ -814,8 +842,10 @@ 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)
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true, &mut std::collections::HashSet::new())
.await;
}
@@ -913,9 +943,11 @@ 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)
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false, &mut std::collections::HashSet::new())
.await;
}
}

View File

@@ -406,7 +406,7 @@ fn parse_shoutrrr_url(url_str: &str) -> Result<ShoutrrrService, String> {
service_type: ShoutrrrServiceType::Pushover,
webhook_url: format!(
"https://api.pushover.net/1/messages.json?token={}&user={}",
parts[1], parts[0]
parts[0], parts[1]
),
});
}
@@ -868,7 +868,7 @@ mod tests {
#[test]
fn test_parse_pushover() {
let result = parse_shoutrrr_url("pushover://userkey@apitoken").unwrap();
let result = parse_shoutrrr_url("pushover://apitoken@userkey").unwrap();
assert_eq!(
result.webhook_url,
"https://api.pushover.net/1/messages.json?token=apitoken&user=userkey"
@@ -1307,7 +1307,8 @@ mod tests {
#[test]
fn test_pushover_url_query_parsing() {
// Verify that the pushover webhook URL format contains the right params
let service = parse_shoutrrr_url("pushover://myuser@mytoken").unwrap();
// shoutrrr format: pushover://token@user
let service = parse_shoutrrr_url("pushover://mytoken@myuser").unwrap();
let parsed = url::Url::parse(&service.webhook_url).unwrap();
let params: std::collections::HashMap<_, _> = parsed.query_pairs().collect();
assert_eq!(params.get("token").unwrap().as_ref(), "mytoken");

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

@@ -1,4 +1,4 @@
use crate::cf_ip_filter::CloudflareIpFilter;
use crate::cf_ip_filter::CachedCloudflareFilter;
use crate::cloudflare::{CloudflareHandle, SetResult};
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
use crate::domain::make_fqdn;
@@ -6,7 +6,7 @@ use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::{self, PP};
use crate::provider::IpType;
use reqwest::Client;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::time::Duration;
@@ -16,19 +16,17 @@ pub async fn update_once(
handle: &CloudflareHandle,
notifier: &CompositeNotifier,
heartbeat: &Heartbeat,
cf_cache: &mut CachedCloudflareFilter,
ppfmt: &PP,
noop_reported: &mut HashSet<String>,
detection_client: &Client,
) -> bool {
let detection_client = Client::builder()
.timeout(config.detection_timeout)
.build()
.unwrap_or_default();
let mut all_ok = true;
let mut messages = Vec::new();
let mut notify = false; // NEW: track meaningful events
if config.legacy_mode {
all_ok = update_legacy(config, ppfmt).await;
all_ok = update_legacy(config, cf_cache, ppfmt, noop_reported, detection_client).await;
} else {
// Detect IPs for each provider
let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new();
@@ -69,7 +67,7 @@ pub async fn update_once(
// Filter out Cloudflare IPs if enabled
if config.reject_cloudflare_ips {
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() {
let before_count = ips.len();
@@ -101,11 +99,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();
}
}
@@ -151,9 +150,11 @@ pub async fn update_once(
)
.await;
let noop_key = format!("{domain_str}:{record_type}");
match result {
SetResult::Updated => {
notify = true; // NEW
noop_reported.remove(&noop_key);
notify = true;
let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
messages.push(Message::new_ok(&format!(
"Updated {domain_str} -> {}",
@@ -161,13 +162,18 @@ pub async fn update_once(
)));
}
SetResult::Failed => {
notify = true; // NEW
noop_reported.remove(&noop_key);
notify = true;
all_ok = false;
messages.push(Message::new_fail(&format!(
"Failed to update {domain_str}"
)));
}
SetResult::Noop => {}
SetResult::Noop => {
if noop_reported.insert(noop_key) {
ppfmt.infof(pp::EMOJI_SKIP, &format!("Record {domain_str} is up to date"));
}
}
}
}
}
@@ -192,32 +198,37 @@ pub async fn update_once(
)
.await;
let noop_key = format!("waf:{}", waf_list.describe());
match result {
SetResult::Updated => {
notify = true; // NEW
noop_reported.remove(&noop_key);
notify = true;
messages.push(Message::new_ok(&format!(
"Updated WAF list {}",
waf_list.describe()
)));
}
SetResult::Failed => {
notify = true; // NEW
noop_reported.remove(&noop_key);
notify = true;
all_ok = false;
messages.push(Message::new_fail(&format!(
"Failed to update WAF list {}",
waf_list.describe()
)));
}
SetResult::Noop => {}
SetResult::Noop => {
if noop_reported.insert(noop_key) {
ppfmt.infof(pp::EMOJI_SKIP, &format!("WAF list {} is up to date", waf_list.describe()));
}
}
}
}
}
// Send heartbeat ONLY if something meaningful happened
if notify {
let heartbeat_msg = Message::merge(messages.clone());
heartbeat.ping(&heartbeat_msg).await;
}
// Always ping heartbeat so monitors know the updater is alive
let heartbeat_msg = Message::merge(messages.clone());
heartbeat.ping(&heartbeat_msg).await;
// Send notifications ONLY when IP changed or failed
if notify {
@@ -229,48 +240,113 @@ 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,
cf_cache: &mut CachedCloudflareFilter,
ppfmt: &PP,
noop_reported: &mut HashSet<String>,
detection_client: &Client,
) -> bool {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return false,
};
let client = Client::builder()
.timeout(config.update_timeout)
.build()
.unwrap_or_default();
let ddns = LegacyDdnsClient {
client,
client: Client::builder()
.timeout(config.update_timeout)
.build()
.unwrap_or_default(),
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();
let mut ips = HashMap::new();
let ips = ddns
.get_ips(
legacy.a,
legacy.aaaa,
legacy.purge_unknown_records,
&legacy.cloudflare,
&mut warnings,
)
.await;
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) =
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(
&ips,
&legacy.cloudflare,
legacy.ttl,
legacy.purge_unknown_records,
noop_reported,
)
.await;
@@ -323,141 +399,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,
@@ -550,9 +498,10 @@ impl LegacyDdnsClient {
config: &[LegacyCloudflareEntry],
ttl: i64,
purge_unknown_records: bool,
noop_reported: &mut HashSet<String>,
) {
for ip in ips.values() {
self.commit_record(ip, config, ttl, purge_unknown_records)
self.commit_record(ip, config, ttl, purge_unknown_records, noop_reported)
.await;
}
}
@@ -563,6 +512,7 @@ impl LegacyDdnsClient {
config: &[LegacyCloudflareEntry],
ttl: i64,
purge_unknown_records: bool,
noop_reported: &mut HashSet<String>,
) {
for entry in config {
let zone_resp: Option<LegacyCfResponse<LegacyZoneResult>> = self
@@ -638,8 +588,10 @@ impl LegacyDdnsClient {
}
}
let noop_key = format!("{fqdn}:{}", ip.record_type);
if let Some(ref id) = identifier {
if modified {
noop_reported.remove(&noop_key);
if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {}", ip.ip);
} else {
@@ -650,17 +602,24 @@ impl LegacyDdnsClient {
.cf_api(&update_endpoint, "PUT", entry, Some(&record))
.await;
}
} else if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date ({})", ip.ip);
} else if noop_reported.insert(noop_key) {
if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date");
} else {
println!("Record {fqdn} is up to date");
}
}
} else if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
} else {
println!("Adding new record {fqdn} -> {}", ip.ip);
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(&create_endpoint, "POST", entry, Some(&record))
.await;
noop_reported.remove(&noop_key);
if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
} else {
println!("Adding new record {fqdn} -> {}", ip.ip);
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(&create_endpoint, "POST", entry, Some(&record))
.await;
}
}
if purge_unknown_records {
@@ -863,11 +822,13 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
/// update_once returns true (all_ok) when IP is already correct (Noop).
/// update_once returns true (all_ok) when IP is already correct (Noop),
/// and populates noop_reported so subsequent calls suppress the message.
#[tokio::test]
async fn test_update_once_noop_when_record_up_to_date() {
let server = MockServer::start().await;
@@ -911,8 +872,91 @@ mod tests {
let heartbeat = empty_heartbeat();
let ppfmt = pp();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &ppfmt).await;
let mut cf_cache = CachedCloudflareFilter::new();
let mut noop_reported = HashSet::new();
// First call: noop_reported is empty, so "up to date" is reported and key is inserted
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
assert!(ok);
assert!(noop_reported.contains("home.example.com:A"), "noop_reported should contain the domain key after first noop");
// Second call: noop_reported already has the key, so the message is suppressed
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
assert!(ok);
assert_eq!(noop_reported.len(), 1, "noop_reported should still have exactly one entry");
}
/// noop_reported is cleared when a record is updated, so "up to date" prints again
/// on the next noop cycle.
#[tokio::test]
async fn test_update_once_noop_reported_cleared_on_change() {
let server = MockServer::start().await;
let zone_id = "zone-abc";
let domain = "home.example.com";
let old_ip = "198.51.100.42";
let new_ip = "198.51.100.99";
// Zone lookup
Mock::given(method("GET"))
.and(path("/zones"))
.and(query_param("name", domain))
.respond_with(
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
)
.mount(&server)
.await;
// List existing records - record has old IP, will be updated
Mock::given(method("GET"))
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(dns_records_one("rec-1", domain, old_ip)),
)
.mount(&server)
.await;
// Create record (new IP doesn't match existing, so it creates + deletes stale)
Mock::given(method("POST"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(dns_record_created("rec-2", domain, new_ip)),
)
.mount(&server)
.await;
// Delete stale record
Mock::given(method("DELETE"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": {}})))
.mount(&server)
.await;
let mut providers = HashMap::new();
providers.insert(
IpType::V4,
ProviderType::Literal {
ips: vec![new_ip.parse::<IpAddr>().unwrap()],
},
);
let mut domains = HashMap::new();
domains.insert(IpType::V4, vec![domain.to_string()]);
let config = make_config(providers, domains, vec![], false);
let cf = handle(&server.uri());
let notifier = empty_notifier();
let heartbeat = empty_heartbeat();
let ppfmt = pp();
// Pre-populate noop_reported as if a previous cycle reported it
let mut noop_reported = HashSet::new();
noop_reported.insert("home.example.com:A".to_string());
let mut cf_cache = CachedCloudflareFilter::new();
let ok = update_once(&config, &cf, &notifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
assert!(ok);
assert!(!noop_reported.contains("home.example.com:A"), "noop_reported should be cleared after an update");
}
/// update_once returns true even when IP detection yields empty (no providers configured),
@@ -955,7 +999,8 @@ mod tests {
let ppfmt = pp();
// 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, &mut HashSet::new(), &Client::new()).await;
// 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.
assert!(ok);
@@ -1004,7 +1049,8 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(!ok, "Expected false when zone is not found");
}
@@ -1053,7 +1099,8 @@ mod tests {
let ppfmt = pp();
// 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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
@@ -1118,7 +1165,8 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
@@ -1171,7 +1219,8 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
@@ -1210,7 +1259,8 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(!ok, "Expected false when WAF list is not found");
}
@@ -1294,7 +1344,8 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
@@ -1310,7 +1361,8 @@ mod tests {
let heartbeat = empty_heartbeat();
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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
@@ -1694,93 +1746,14 @@ mod tests {
let ppfmt = pp();
// 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, &mut HashSet::new(), &Client::new()).await;
assert!(ok);
}
// -------------------------------------------------------
// 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 +1768,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 +1800,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 +1829,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 +1851,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 +1881,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 +1901,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 +1937,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 {
@@ -2060,7 +1952,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false,
}];
ddns.commit_record(&ip, &config, 300, false).await;
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
}
#[tokio::test]
@@ -2101,8 +1993,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 {
@@ -2118,7 +2008,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false,
}];
ddns.commit_record(&ip, &config, 300, false).await;
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
}
#[tokio::test]
@@ -2145,8 +2035,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 {
@@ -2163,7 +2051,7 @@ mod tests {
proxied: false,
}];
// Should not POST
ddns.commit_record(&ip, &config, 300, false).await;
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
}
#[tokio::test]
@@ -2198,8 +2086,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 {
@@ -2218,7 +2104,7 @@ mod tests {
}],
proxied: false,
}];
ddns.commit_record(&ip, &config, 300, false).await;
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
}
#[tokio::test]
@@ -2255,8 +2141,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 {
@@ -2272,7 +2156,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false,
}];
ddns.commit_record(&ip, &config, 300, true).await;
ddns.commit_record(&ip, &config, 300, true, &mut HashSet::new()).await;
}
#[tokio::test]
@@ -2306,8 +2190,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();
@@ -2324,7 +2206,7 @@ mod tests {
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
proxied: false,
}];
ddns.update_ips(&ips, &config, 300, false).await;
ddns.update_ips(&ips, &config, 300, false, &mut HashSet::new()).await;
}
#[tokio::test]
@@ -2352,8 +2234,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 +2266,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 +2281,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