11 Commits

Author SHA1 Message Date
Timothy Miller
22320bea79 Release v2.1.1
Fix rand 0.10 compile error (RngExt trait import) and ship version
bump alongside DELETE_ON_FAILURE, proportional jitter, and dependency
refresh changes already merged on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:57:53 -04:00
Timothy Miller
1bb347bea7 Merge pull request #263 from DMaxter/master
Allow not deleting domains if the IP list is empty
2026-04-29 18:51:59 -04:00
Timothy Miller
1d5ad2738c Merge pull request #265 from timothymiller/dependabot/cargo/reqwest-0.13.3
Bump reqwest from 0.13.2 to 0.13.3
2026-04-29 18:51:16 -04:00
Timothy Miller
08ff76f443 Merge pull request #266 from timothymiller/dependabot/cargo/rand-0.10.1
Bump rand from 0.9.3 to 0.10.1
2026-04-29 18:51:08 -04:00
Timothy Miller
199bbae2bd Merge pull request #267 from timothymiller/dependabot/cargo/rustls-0.23.40
Bump rustls from 0.23.39 to 0.23.40
2026-04-29 18:50:56 -04:00
dependabot[bot]
591f3e4905 Bump rustls from 0.23.39 to 0.23.40
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.39 to 0.23.40.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.39...v/0.23.40)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-29 07:36:47 +00:00
DMaxter
687d299bda docs: document the variable in the README 2026-04-28 23:56:25 +01:00
dependabot[bot]
25122d2ce3 Bump rand from 0.9.3 to 0.10.1
Bumps [rand](https://github.com/rust-random/rand) from 0.9.3 to 0.10.1.
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.9.3...0.10.1)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 07:37:29 +00:00
dependabot[bot]
64c971b198 Bump reqwest from 0.13.2 to 0.13.3
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.13.2 to 0.13.3.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.2...v0.13.3)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 07:37:21 +00:00
DMaxter
b748e80592 tests: added tests for delete_on_failure 2026-04-26 01:06:49 +01:00
DMaxter
714ec4f11f feat: prevent deletion on failure 2026-04-26 00:46:34 +01:00
7 changed files with 376 additions and 74 deletions

104
Cargo.lock generated
View File

@@ -79,9 +79,20 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]] [[package]]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.1.0" version = "2.1.1"
dependencies = [ dependencies = [
"if-addrs", "if-addrs",
"rand", "rand",
@@ -122,6 +133,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "deadpool" name = "deadpool"
version = "0.12.3" version = "0.12.3"
@@ -299,18 +319,6 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.2"
@@ -319,7 +327,8 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi",
"rand_core",
"wasip2", "wasip2",
"wasip3", "wasip3",
] ]
@@ -785,15 +794,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -822,12 +822,6 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
@@ -836,32 +830,20 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.3" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [ dependencies = [
"rand_chacha", "chacha20",
"rand_core", "getrandom 0.4.2",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core", "rand_core",
] ]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "regex" name = "regex"
@@ -900,9 +882,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
@@ -964,9 +946,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.39" version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
@@ -1887,26 +1869,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.6" version = "0.1.6"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cloudflare-ddns" name = "cloudflare-ddns"
version = "2.1.0" version = "2.1.1"
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"
@@ -14,7 +14,7 @@ tokio = { version = "1", features = ["rt", "macros", "time", "signal", "net"] }
regex-lite = "0.1" regex-lite = "0.1"
url = "2" url = "2"
if-addrs = "0.15" if-addrs = "0.15"
rand = "0.9" rand = "0.10"
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"

View File

@@ -107,6 +107,7 @@ To disable this protection, set `REJECT_CLOUDFLARE_IPS=false`.
| `UPDATE_CRON` | `@every 5m` | Update schedule | | `UPDATE_CRON` | `@every 5m` | Update schedule |
| `UPDATE_ON_START` | `true` | Run an update immediately on startup | | `UPDATE_ON_START` | `true` | Run an update immediately on startup |
| `DELETE_ON_STOP` | `false` | Delete managed DNS records on shutdown | | `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: 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_CRON` | `@every 5m` | ⏱️ Update schedule |
| `UPDATE_ON_START` | `true` | 🚀 Update on startup | | `UPDATE_ON_START` | `true` | 🚀 Update on startup |
| `DELETE_ON_STOP` | `false` | 🧹 Delete records on shutdown | | `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 | | `TTL` | `1` | ⏳ DNS record TTL |
| `PROXIED` | `false` | ☁️ Proxied expression | | `PROXIED` | `false` | ☁️ Proxied expression |
| `RECORD_COMMENT` | — | 💬 DNS record comment | | `RECORD_COMMENT` | — | 💬 DNS record comment |

49
RELEASE_NOTES_2.1.1.md Normal file
View File

@@ -0,0 +1,49 @@
# cloudflare-ddns v2.1.1
Maintenance release. Bug fix for `rand` 0.10 API change, plus opt-in failure-safe deletion behavior contributed in the v2.1.0 → v2.1.1 window, dependency refresh, and proportional jitter for IP detection.
## Highlights
- **Fix:** Restore the build under `rand` 0.10 — `random_range` moved to the `RngExt` trait, and the unconditional jitter sleep in `--repeat` mode no longer fails to compile.
- **New:** `DELETE_ON_FAILURE` (env-var mode) controls whether DNS records are removed when an IP detection or update fails. Defaults to `true` to preserve existing behavior; set `DELETE_ON_FAILURE=false` to keep stale records on transient failures instead of yanking them.
- **Improvement:** Proportional jitter (up to 20% of the update interval) is added before each scheduled update to spread requests across clients and reduce synchronized spikes against the Cloudflare API.
## Changes since v2.1.0
### Features
- `DELETE_ON_FAILURE` env var to prevent DNS record deletion on failed updates (#263, thanks @DMaxter)
- Proportional jitter on update intervals to desynchronize API traffic (#253, thanks @jhutchings1)
### Fixes
- Compile fix for `rand` 0.10: import `RngExt` so `random_range` resolves
- `delete_on_failure` regression test coverage added
### Dependencies
- `rustls` 0.23.37 → 0.23.40
- `rustls-webpki` 0.103.10 → 0.103.13
- `tokio` 1.50.0 → 1.52.1
- `reqwest` 0.13.2 → 0.13.3
- `rand` 0.9.2 → 0.10.1
### Docs
- Document `DELETE_ON_FAILURE` in the README
## Upgrade notes
- **Default behavior unchanged.** `DELETE_ON_FAILURE` defaults to `true`, matching pre-2.1.1 behavior. Set it to `false` if you want stale records preserved during outages.
- No config file schema changes. Existing `config.json` deployments continue to work without edits.
## Docker
```sh
docker pull timothyjmiller/cloudflare-ddns:2.1.1
docker pull timothyjmiller/cloudflare-ddns:latest
```
Multi-arch: `linux/amd64`, `linux/arm64`, `linux/ppc64le`.
## Verification
- `cargo test` — 352 tests pass
- Release build succeeds, binary size ~1.7 MiB (pre-UPX)
- Smoke tested in both legacy `config.json` mode and env-var mode against the live Cloudflare API

View File

@@ -84,6 +84,7 @@ pub struct AppConfig {
pub update_cron: CronSchedule, pub update_cron: CronSchedule,
pub update_on_start: bool, pub update_on_start: bool,
pub delete_on_stop: bool, pub delete_on_stop: bool,
pub delete_on_failure: bool,
pub ttl: TTL, pub ttl: TTL,
pub proxied_expression: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>, pub proxied_expression: Option<Box<dyn Fn(&str) -> bool + Send + Sync>>,
pub record_comment: Option<String>, pub record_comment: Option<String>,
@@ -449,6 +450,7 @@ fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> Re
update_cron: schedule, update_cron: schedule,
update_on_start: true, update_on_start: true,
delete_on_stop: false, delete_on_stop: false,
delete_on_failure: true,
ttl, ttl,
proxied_expression: None, proxied_expression: None,
record_comment: None, record_comment: None,
@@ -503,6 +505,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
let update_cron = read_cron_from_env(ppfmt)?; let update_cron = read_cron_from_env(ppfmt)?;
let update_on_start = getenv_bool("UPDATE_ON_START", true); let update_on_start = getenv_bool("UPDATE_ON_START", true);
let delete_on_stop = getenv_bool("DELETE_ON_STOP", false); 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") let ttl_val = getenv("TTL")
.and_then(|s| s.parse::<i64>().ok()) .and_then(|s| s.parse::<i64>().ok())
@@ -571,6 +574,7 @@ pub fn load_env_config(ppfmt: &PP) -> Result<AppConfig, String> {
update_cron, update_cron,
update_on_start, update_on_start,
delete_on_stop, delete_on_stop,
delete_on_failure,
ttl, ttl,
proxied_expression, proxied_expression,
record_comment, record_comment,
@@ -1317,6 +1321,7 @@ mod tests {
update_cron: CronSchedule::Once, update_cron: CronSchedule::Once,
update_on_start: true, update_on_start: true,
delete_on_stop: false, delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO, ttl: TTL::AUTO,
proxied_expression: None, proxied_expression: None,
record_comment: None, record_comment: None,
@@ -1351,6 +1356,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(300)), update_cron: CronSchedule::Every(Duration::from_secs(300)),
update_on_start: true, update_on_start: true,
delete_on_stop: true, delete_on_stop: true,
delete_on_failure: true,
ttl: TTL::new(60), ttl: TTL::new(60),
proxied_expression: None, proxied_expression: None,
record_comment: Some("managed".to_string()), record_comment: Some("managed".to_string()),
@@ -2003,6 +2009,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(300)), update_cron: CronSchedule::Every(Duration::from_secs(300)),
update_on_start: true, update_on_start: true,
delete_on_stop: false, delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO, ttl: TTL::AUTO,
proxied_expression: None, proxied_expression: None,
record_comment: None, record_comment: None,
@@ -2039,6 +2046,7 @@ mod tests {
update_cron: CronSchedule::Every(Duration::from_secs(600)), update_cron: CronSchedule::Every(Duration::from_secs(600)),
update_on_start: true, update_on_start: true,
delete_on_stop: true, delete_on_stop: true,
delete_on_failure: true,
ttl: TTL::new(120), ttl: TTL::new(120),
proxied_expression: None, proxied_expression: None,
record_comment: Some("cf-ddns".to_string()), record_comment: Some("cf-ddns".to_string()),
@@ -2072,6 +2080,7 @@ mod tests {
update_cron: CronSchedule::Once, update_cron: CronSchedule::Once,
update_on_start: true, update_on_start: true,
delete_on_stop: false, delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO, ttl: TTL::AUTO,
proxied_expression: None, proxied_expression: None,
record_comment: None, record_comment: None,

View File

@@ -14,7 +14,7 @@ use crate::pp::PP;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use rand::Rng; use rand::RngExt;
use reqwest::Client; use reqwest::Client;
use tokio::signal; use tokio::signal;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};

View File

@@ -115,6 +115,19 @@ pub async fn update_once(
// Update DNS records (env var mode - domain-based) // Update DNS records (env var mode - domain-based)
for (ip_type, domains) in &config.domains { for (ip_type, domains) in &config.domains {
let ips = detected_ips.get(ip_type).cloned().unwrap_or_default(); 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(); let record_type = ip_type.record_type();
for domain_str in domains { for domain_str in domains {
@@ -713,6 +726,7 @@ mod tests {
update_cron: CronSchedule::Once, update_cron: CronSchedule::Once,
update_on_start: true, update_on_start: true,
delete_on_stop: false, delete_on_stop: false,
delete_on_failure: true,
ttl: TTL::AUTO, ttl: TTL::AUTO,
proxied_expression: None, proxied_expression: None,
record_comment: None, record_comment: None,
@@ -2307,6 +2321,272 @@ mod tests {
ddns.delete_entries("A", &config).await; 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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&heartbeat,
&mut cf_cache,
&ppfmt,
&mut HashSet::new(),
&crate::test_client(),
)
.await;
assert!(ok, "Should succeed with both detections");
}
} }
// Legacy types for backwards compatibility // Legacy types for backwards compatibility