2 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
7 changed files with 254 additions and 43 deletions

2
Cargo.lock generated
View File

@@ -109,7 +109,7 @@ dependencies = [
[[package]] [[package]]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.0.5" 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.5" 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 |
@@ -373,17 +375,17 @@ Some ISP provided modems only allow port forwarding over IPv4 or IPv6. Disable t
### 🚫 Cloudflare IP Rejection (Legacy Mode) ### 🚫 Cloudflare IP Rejection (Legacy Mode)
The `REJECT_CLOUDFLARE_IPS` environment variable is supported in legacy config mode. Set it alongside your `config.json`: Cloudflare IP rejection is enabled by default in legacy mode too. To disable it, set `REJECT_CLOUDFLARE_IPS=false` alongside your `config.json`:
```bash ```bash
REJECT_CLOUDFLARE_IPS=true cloudflare-ddns REJECT_CLOUDFLARE_IPS=false cloudflare-ddns
``` ```
Or in Docker Compose: Or in Docker Compose:
```yml ```yml
environment: environment:
- REJECT_CLOUDFLARE_IPS=true - REJECT_CLOUDFLARE_IPS=false
volumes: volumes:
- ./config.json:/config.json - ./config.json:/config.json
``` ```

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

@@ -458,7 +458,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re
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: getenv_bool("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,
@@ -529,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() {
@@ -681,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 {

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;
} }
} }
} }

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();
} }
} }
@@ -234,7 +236,7 @@ pub async fn update_once(
/// IP-family-bound clients (0.0.0.0 for IPv4, [::] for IPv6). This prevents the old /// 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` /// wrong-family warning on dual-stack hosts and honours `ip4_provider`/`ip6_provider`
/// overrides from config.json. /// overrides from config.json.
async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { 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,
@@ -298,8 +300,9 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
// Filter out Cloudflare IPs if enabled // Filter out Cloudflare IPs if enabled
if config.reject_cloudflare_ips { if config.reject_cloudflare_ips {
let before_count = ips.len();
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
{ {
ips.retain(|key, ip_info| { ips.retain(|key, ip_info| {
if let Ok(addr) = ip_info.ip.parse::<std::net::IpAddr>() { if let Ok(addr) = ip_info.ip.parse::<std::net::IpAddr>() {
@@ -316,11 +319,18 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
} }
true true
}); });
} else { 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( 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",
); );
ips.clear();
} }
} }
@@ -793,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);
} }
@@ -841,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);
} }
@@ -885,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);
@@ -934,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");
} }
@@ -983,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);
} }
@@ -1048,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);
} }
@@ -1101,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);
} }
@@ -1140,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");
} }
@@ -1224,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);
} }
@@ -1240,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);
} }
@@ -1624,7 +1644,8 @@ 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);
} }
// ------------------------------------------------------- // -------------------------------------------------------