diff --git a/Cargo.toml b/Cargo.toml index 9d7e991..b2ff7b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cloudflare-ddns" -version = "2.0.6" +version = "2.0.7" edition = "2021" description = "Access your home network remotely via a custom domain name without a static IP" license = "GPL-3.0" diff --git a/src/cf_ip_filter.rs b/src/cf_ip_filter.rs index fffaf14..8f5b3c6 100644 --- a/src/cf_ip_filter.rs +++ b/src/cf_ip_filter.rs @@ -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"; @@ -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, + fetched_at: Option, +} + +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::*; diff --git a/src/main.rs b/src/main.rs index 0e870d1..bb6df3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,12 +116,14 @@ async fn main() { // Start heartbeat heartbeat.start().await; + let mut cf_cache = cf_ip_filter::CachedCloudflareFilter::new(); + if app_config.legacy_mode { // --- Legacy mode (original cloudflare-ddns behavior) --- - run_legacy_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running).await; + run_legacy_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running, &mut cf_cache).await; } else { // --- Env var mode (cf-ddns behavior) --- - run_env_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running).await; + run_env_mode(&app_config, &handle, ¬ifier, &heartbeat, &ppfmt, running, &mut cf_cache).await; } // On shutdown: delete records if configured @@ -143,6 +145,7 @@ async fn run_legacy_mode( heartbeat: &Heartbeat, ppfmt: &PP, running: Arc, + cf_cache: &mut cf_ip_filter::CachedCloudflareFilter, ) { let legacy = match &config.legacy_config { Some(l) => l, @@ -165,7 +168,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).await; for _ in 0..legacy.ttl { if !running.load(Ordering::SeqCst) { @@ -175,7 +178,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).await; } } @@ -186,11 +189,12 @@ async fn run_env_mode( heartbeat: &Heartbeat, ppfmt: &PP, running: Arc, + cf_cache: &mut cf_ip_filter::CachedCloudflareFilter, ) { 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).await; } } schedule => { @@ -206,7 +210,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).await; } // Main loop @@ -233,7 +237,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).await; } } } diff --git a/src/updater.rs b/src/updater.rs index 3c94977..960fbc5 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -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; @@ -16,6 +16,7 @@ pub async fn update_once( handle: &CloudflareHandle, notifier: &CompositeNotifier, heartbeat: &Heartbeat, + cf_cache: &mut CachedCloudflareFilter, ppfmt: &PP, ) -> bool { let detection_client = Client::builder() @@ -28,7 +29,7 @@ pub async fn update_once( 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).await; } else { // Detect IPs for each provider let mut detected_ips: HashMap> = HashMap::new(); @@ -69,7 +70,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(); @@ -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 /// wrong-family warning on dual-stack hosts and honours `ip4_provider`/`ip6_provider` /// overrides from config.json. -async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { +async fn update_legacy(config: &AppConfig, cf_cache: &mut CachedCloudflareFilter, ppfmt: &PP) -> bool { let legacy = match &config.legacy_config { Some(l) => l, None => return false, @@ -301,7 +302,7 @@ async fn update_legacy(config: &AppConfig, ppfmt: &PP) -> bool { if config.reject_cloudflare_ips { let before_count = ips.len(); 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| { if let Ok(addr) = ip_info.ip.parse::() { @@ -802,7 +803,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -850,7 +852,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -894,7 +897,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, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).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); @@ -943,7 +947,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(!ok, "Expected false when zone is not found"); } @@ -992,7 +997,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, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -1057,7 +1063,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -1110,7 +1117,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -1149,7 +1157,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(!ok, "Expected false when WAF list is not found"); } @@ -1233,7 +1242,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -1249,7 +1259,8 @@ mod tests { let heartbeat = empty_heartbeat(); let ppfmt = pp(); - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } @@ -1633,7 +1644,8 @@ mod tests { let ppfmt = pp(); // set_ips with empty ips and no existing records = Noop; all_ok = true - let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await; + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt).await; assert!(ok); } // -------------------------------------------------------