From 714ec4f11fb1d87fcda831a957f61386cc93fb0a Mon Sep 17 00:00:00 2001 From: DMaxter Date: Sun, 26 Apr 2026 00:32:39 +0100 Subject: [PATCH 1/3] feat: prevent deletion on failure --- src/config.rs | 9 +++++++++ src/updater.rs | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/config.rs b/src/config.rs index 8572d2c..d71cc27 100644 --- a/src/config.rs +++ b/src/config.rs @@ -84,6 +84,7 @@ pub struct AppConfig { pub update_cron: CronSchedule, pub update_on_start: bool, pub delete_on_stop: bool, + pub delete_on_failure: bool, pub ttl: TTL, pub proxied_expression: Option bool + Send + Sync>>, pub record_comment: Option, @@ -449,6 +450,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re update_cron: schedule, update_on_start: true, delete_on_stop: false, + delete_on_failure: true, ttl, proxied_expression: None, record_comment: None, @@ -503,6 +505,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { let update_cron = read_cron_from_env(ppfmt)?; let update_on_start = getenv_bool("UPDATE_ON_START", true); let delete_on_stop = getenv_bool("DELETE_ON_STOP", false); + let delete_on_failure = getenv_bool("DELETE_ON_FAILURE", true); let ttl_val = getenv("TTL") .and_then(|s| s.parse::().ok()) @@ -571,6 +574,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result { update_cron, update_on_start, delete_on_stop, + delete_on_failure, ttl, proxied_expression, record_comment, @@ -1317,6 +1321,7 @@ mod tests { update_cron: CronSchedule::Once, update_on_start: true, delete_on_stop: false, + delete_on_failure: true, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, @@ -1351,6 +1356,7 @@ mod tests { update_cron: CronSchedule::Every(Duration::from_secs(300)), update_on_start: true, delete_on_stop: true, + delete_on_failure: true, ttl: TTL::new(60), proxied_expression: None, record_comment: Some("managed".to_string()), @@ -2003,6 +2009,7 @@ mod tests { update_cron: CronSchedule::Every(Duration::from_secs(300)), update_on_start: true, delete_on_stop: false, + delete_on_failure: true, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, @@ -2039,6 +2046,7 @@ mod tests { update_cron: CronSchedule::Every(Duration::from_secs(600)), update_on_start: true, delete_on_stop: true, + delete_on_failure: true, ttl: TTL::new(120), proxied_expression: None, record_comment: Some("cf-ddns".to_string()), @@ -2072,6 +2080,7 @@ mod tests { update_cron: CronSchedule::Once, update_on_start: true, delete_on_stop: false, + delete_on_failure: true, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, diff --git a/src/updater.rs b/src/updater.rs index 8c028fb..3bcb3f5 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -115,6 +115,19 @@ pub async fn update_once( // Update DNS records (env var mode - domain-based) for (ip_type, domains) in &config.domains { let ips = detected_ips.get(ip_type).cloned().unwrap_or_default(); + + if ips.is_empty() && !config.delete_on_failure { + ppfmt.warningf( + pp::EMOJI_WARNING, + &format!( + "Skipping {} domain update for {}", + ip_type.describe(), + domains.join(", ") + ), + ); + continue; + } + let record_type = ip_type.record_type(); for domain_str in domains { @@ -713,6 +726,7 @@ mod tests { update_cron: CronSchedule::Once, update_on_start: true, delete_on_stop: false, + delete_on_failure: true, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, From b748e805924d40b38b8c21bc865f9070727b8de1 Mon Sep 17 00:00:00 2001 From: DMaxter Date: Sun, 26 Apr 2026 00:48:36 +0100 Subject: [PATCH 2/3] tests: added tests for delete_on_failure --- src/updater.rs | 266 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/src/updater.rs b/src/updater.rs index 3bcb3f5..67cda88 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -2321,6 +2321,272 @@ mod tests { ddns.delete_entries("A", &config).await; } + // ------------------------------------------------------- + // delete_on_failure tests + // ------------------------------------------------------- + + /// When IPv4 detection fails but IPv6 succeeds, and delete_on_failure=false, skip V4 domains but update V6 + #[tokio::test] + async fn test_skip_v4_domains_when_v4_detection_fails() { + let server = MockServer::start().await; + let zone_id = "zone-abc"; + let ip_v6 = "2001:db8::1"; + + // Zone lookup for V6 domain + Mock::given(method("GET")) + .and(path("/zones")) + .and(query_param("name", "v6.example.com")) + .respond_with( + ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")), + ) + .mount(&server) + .await; + + // LIST existing records for V6 + Mock::given(method("GET")) + .and(path_regex(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty())) + .mount(&server) + .await; + + // POST for V6 should be called (V6 succeeds) + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_record_created( + "rec-1", + "v6.example.com", + "2001:db8::1", + ))) + .expect(1) + .mount(&server) + .await; + + // Providers: V4 fails (None), V6 succeeds + let mut providers = HashMap::new(); + providers.insert(IpType::V4, ProviderType::None); + providers.insert( + IpType::V6, + ProviderType::Literal { + ips: vec![ip_v6.parse().unwrap()], + }, + ); + + let mut domains = HashMap::new(); + domains.insert(IpType::V4, vec!["v4.example.com".to_string()]); + domains.insert(IpType::V6, vec!["v6.example.com".to_string()]); + + let mut config = make_config(providers, domains, vec![], false); + config.delete_on_failure = false; + + let cf = handle(&server.uri()); + let notifier = empty_notifier(); + let heartbeat = empty_heartbeat(); + let ppfmt = pp(); + + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once( + &config, + &cf, + ¬ifier, + &heartbeat, + &mut cf_cache, + &ppfmt, + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + assert!(ok, "Should succeed with partial detection"); + } + + /// When IPv6 detection fails but IPv4 succeeds, and delete_on_failure=false, skip V6 domains but update V4 + #[tokio::test] + async fn test_skip_v6_domains_when_v6_detection_fails() { + let server = MockServer::start().await; + let zone_id = "zone-abc"; + let ip_v4 = "198.51.100.42"; + + // Zone lookup for V4 domain + Mock::given(method("GET")) + .and(path("/zones")) + .and(query_param("name", "v4.example.com")) + .respond_with( + ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")), + ) + .mount(&server) + .await; + + // LIST existing records for V4 + Mock::given(method("GET")) + .and(path_regex(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty())) + .mount(&server) + .await; + + // POST for V4 should be called (V4 succeeds) + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_record_created( + "rec-1", + "v4.example.com", + "198.51.100.42", + ))) + .expect(1) + .mount(&server) + .await; + + // Providers: V4 succeeds, V6 fails (None) + let mut providers = HashMap::new(); + providers.insert( + IpType::V4, + ProviderType::Literal { + ips: vec![ip_v4.parse().unwrap()], + }, + ); + providers.insert(IpType::V6, ProviderType::None); + + let mut domains = HashMap::new(); + domains.insert(IpType::V4, vec!["v4.example.com".to_string()]); + domains.insert(IpType::V6, vec!["v6.example.com".to_string()]); + + let mut config = make_config(providers, domains, vec![], false); + config.delete_on_failure = false; + + let cf = handle(&server.uri()); + let notifier = empty_notifier(); + let heartbeat = empty_heartbeat(); + let ppfmt = pp(); + + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once( + &config, + &cf, + ¬ifier, + &heartbeat, + &mut cf_cache, + &ppfmt, + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + assert!(ok, "Should succeed with partial detection"); + } + + /// When both IPv4 and IPv6 detection fail, and delete_on_failure=false, skip all domains + #[tokio::test] + async fn test_skip_all_domains_when_both_detect_fail() { + let server = MockServer::start().await; + + // No POST/DELETE should be called at all + + // Providers: both fail (None) + let mut providers = HashMap::new(); + providers.insert(IpType::V4, ProviderType::None); + providers.insert(IpType::V6, ProviderType::None); + + let mut domains = HashMap::new(); + domains.insert(IpType::V4, vec!["v4.example.com".to_string()]); + domains.insert(IpType::V6, vec!["v6.example.com".to_string()]); + + let mut config = make_config(providers, domains, vec![], false); + config.delete_on_failure = false; + + let cf = handle(&server.uri()); + let notifier = empty_notifier(); + let heartbeat = empty_heartbeat(); + let ppfmt = pp(); + + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once( + &config, + &cf, + ¬ifier, + &heartbeat, + &mut cf_cache, + &ppfmt, + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + assert!(ok, "Should succeed (no updates, no failures)"); + } + + /// When both IPv4 and IPv6 detection succeed, and delete_on_failure=false, update all domains + #[tokio::test] + async fn test_update_all_domains_when_both_detect() { + let server = MockServer::start().await; + let zone_id = "zone-abc"; + let ip_v4 = "198.51.100.42"; + let ip_v6 = "2001:db8::1"; + + // Zone lookups for both domains + Mock::given(method("GET")) + .and(path("/zones")) + .respond_with( + ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")), + ) + .mount(&server) + .await; + + // LIST existing records (empty for both) + Mock::given(method("GET")) + .and(path_regex(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_records_empty())) + .mount(&server) + .await; + + // POST for both should be called + Mock::given(method("POST")) + .and(path(format!("/zones/{zone_id}/dns_records"))) + .respond_with(ResponseTemplate::new(200).set_body_json(dns_record_created( + "rec-new", + "example.com", + "198.51.100.42", + ))) + .expect(2) // Two POSTs: one for V4, one for V6 + .mount(&server) + .await; + + // Providers: both succeed + let mut providers = HashMap::new(); + providers.insert( + IpType::V4, + ProviderType::Literal { + ips: vec![ip_v4.parse().unwrap()], + }, + ); + providers.insert( + IpType::V6, + ProviderType::Literal { + ips: vec![ip_v6.parse().unwrap()], + }, + ); + + let mut domains = HashMap::new(); + domains.insert(IpType::V4, vec!["v4.example.com".to_string()]); + domains.insert(IpType::V6, vec!["v6.example.com".to_string()]); + + let mut config = make_config(providers, domains, vec![], false); + config.delete_on_failure = false; + + let cf = handle(&server.uri()); + let notifier = empty_notifier(); + let heartbeat = empty_heartbeat(); + let ppfmt = pp(); + + let mut cf_cache = CachedCloudflareFilter::new(); + let ok = update_once( + &config, + &cf, + ¬ifier, + &heartbeat, + &mut cf_cache, + &ppfmt, + &mut HashSet::new(), + &crate::test_client(), + ) + .await; + assert!(ok, "Should succeed with both detections"); + } } // Legacy types for backwards compatibility From 687d299bdabc3e9f1aa17d622320cddb4fc20475 Mon Sep 17 00:00:00 2001 From: DMaxter Date: Tue, 28 Apr 2026 23:56:25 +0100 Subject: [PATCH 3/3] docs: document the variable in the README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cdf6dcd..81e7ee6 100755 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`. | `UPDATE_CRON` | `@every 5m` | Update schedule | | `UPDATE_ON_START` | `true` | Run an update immediately on startup | | `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown | +| `DELETE_ON_FAILURE` | `true` | Delete managed DNS records when failed to obtain IP from provider | Schedule formats: @@ -213,6 +214,7 @@ Heartbeats are sent after each update cycle. On failure, a fail signal is sent. | `UPDATE_CRON` | `@every 5m` | ⏱️ Update schedule | | `UPDATE_ON_START` | `true` | 🚀 Update on startup | | `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown | +| `DELETE_ON_FAILURE` | `true` | 🧹 Delete records if failed to obtain new records | | `TTL` | `1` | ⏳ DNS record TTL | | `PROXIED` | `false` | ☁️ Proxied expression | | `RECORD_COMMENT` | — | 💬 DNS record comment |