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
This commit is contained in:
Timothy Miller
2026-03-19 19:24:44 -04:00
parent 83dd454c42
commit 9b8aba5e20
4 changed files with 97 additions and 25 deletions

View File

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

@@ -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";
@@ -157,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::*;

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();
@@ -235,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,
@@ -301,7 +302,7 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool {
if config.reject_cloudflare_ips { if config.reject_cloudflare_ips {
let before_count = ips.len(); 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>() {
@@ -802,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);
} }
@@ -850,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);
} }
@@ -894,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);
@@ -943,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");
} }
@@ -992,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);
} }
@@ -1057,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);
} }
@@ -1110,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);
} }
@@ -1149,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");
} }
@@ -1233,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);
} }
@@ -1249,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);
} }
@@ -1633,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);
} }
// ------------------------------------------------------- // -------------------------------------------------------