mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-05-06 09:53:40 -03:00
Merge pull request #263 from DMaxter/master
Allow not deleting domains if the IP list is empty
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
280
src/updater.rs
280
src/updater.rs
@@ -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,
|
||||||
|
¬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
|
// Legacy types for backwards compatibility
|
||||||
|
|||||||
Reference in New Issue
Block a user