mirror of
https://github.com/timothymiller/cloudflare-ddns.git
synced 2026-03-21 22:48:57 -03:00
Deduplicate up-to-date messages by tracking noop keys and move logging to the updater so callers only log the first noop. Reuse a single reqwest Client for IP detection instead of rebuilding it for each call. Always ping heartbeat even when there are no meaningful changes. Fix Pushover shoutrrr parsing (token@user order) and update tests
2314 lines
82 KiB
Rust
2314 lines
82 KiB
Rust
use crate::cf_ip_filter::CachedCloudflareFilter;
|
|
use crate::cloudflare::{CloudflareHandle, SetResult};
|
|
use crate::config::{AppConfig, LegacyCloudflareEntry, LegacySubdomainEntry};
|
|
use crate::domain::make_fqdn;
|
|
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
|
|
use crate::pp::{self, PP};
|
|
use crate::provider::IpType;
|
|
use reqwest::Client;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::net::IpAddr;
|
|
use std::time::Duration;
|
|
|
|
/// Run a single update cycle.
|
|
pub async fn update_once(
|
|
config: &AppConfig,
|
|
handle: &CloudflareHandle,
|
|
notifier: &CompositeNotifier,
|
|
heartbeat: &Heartbeat,
|
|
cf_cache: &mut CachedCloudflareFilter,
|
|
ppfmt: &PP,
|
|
noop_reported: &mut HashSet<String>,
|
|
detection_client: &Client,
|
|
) -> bool {
|
|
let mut all_ok = true;
|
|
let mut messages = Vec::new();
|
|
let mut notify = false; // NEW: track meaningful events
|
|
|
|
if config.legacy_mode {
|
|
all_ok = update_legacy(config, cf_cache, ppfmt, noop_reported, detection_client).await;
|
|
} else {
|
|
// Detect IPs for each provider
|
|
let mut detected_ips: HashMap<IpType, Vec<IpAddr>> = HashMap::new();
|
|
|
|
for (ip_type, provider) in &config.providers {
|
|
ppfmt.infof(
|
|
pp::EMOJI_DETECT,
|
|
&format!("Detecting {} via {}", ip_type.describe(), provider.name()),
|
|
);
|
|
let ips = provider
|
|
.detect_ips(&detection_client, *ip_type, config.detection_timeout, ppfmt)
|
|
.await;
|
|
|
|
if ips.is_empty() {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
&format!("No {} address detected", ip_type.describe()),
|
|
);
|
|
messages.push(Message::new_fail(&format!(
|
|
"Failed to detect {} address",
|
|
ip_type.describe()
|
|
)));
|
|
} else {
|
|
let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
|
|
ppfmt.infof(
|
|
pp::EMOJI_DETECT,
|
|
&format!("Detected {}: {}", ip_type.describe(), ip_strs.join(", ")),
|
|
);
|
|
messages.push(Message::new_ok(&format!(
|
|
"Detected {}: {}",
|
|
ip_type.describe(),
|
|
ip_strs.join(", ")
|
|
)));
|
|
detected_ips.insert(*ip_type, ips);
|
|
}
|
|
}
|
|
|
|
// Filter out Cloudflare IPs if enabled
|
|
if config.reject_cloudflare_ips {
|
|
if let Some(cf_filter) =
|
|
cf_cache.get(&detection_client, config.detection_timeout, ppfmt).await
|
|
{
|
|
for (ip_type, ips) in detected_ips.iter_mut() {
|
|
let before_count = ips.len();
|
|
ips.retain(|ip| {
|
|
if cf_filter.contains(ip) {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
&format!(
|
|
"Rejected {ip}: matches Cloudflare IP range ({})",
|
|
ip_type.describe()
|
|
),
|
|
);
|
|
false
|
|
} else {
|
|
true
|
|
}
|
|
});
|
|
if ips.is_empty() && before_count > 0 {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
&format!(
|
|
"All detected {} addresses were Cloudflare IPs; skipping updates for this type",
|
|
ip_type.describe()
|
|
),
|
|
);
|
|
messages.push(Message::new_fail(&format!(
|
|
"All {} addresses rejected (Cloudflare IPs)",
|
|
ip_type.describe()
|
|
)));
|
|
}
|
|
}
|
|
} else if !detected_ips.is_empty() {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
"Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs",
|
|
);
|
|
detected_ips.clear();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
let record_type = ip_type.record_type();
|
|
|
|
for domain_str in domains {
|
|
// Find zone ID for this domain
|
|
let zone_id = match handle.zone_id_of_domain(domain_str, ppfmt).await {
|
|
Some(id) => id,
|
|
None => {
|
|
ppfmt.errorf(
|
|
pp::EMOJI_ERROR,
|
|
&format!("Could not find zone for domain {domain_str}"),
|
|
);
|
|
all_ok = false;
|
|
messages.push(Message::new_fail(&format!(
|
|
"Failed to find zone for {domain_str}"
|
|
)));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let proxied = config
|
|
.proxied_expression
|
|
.as_ref()
|
|
.map(|f| f(domain_str))
|
|
.unwrap_or(false);
|
|
|
|
let result = handle
|
|
.set_ips(
|
|
&zone_id,
|
|
domain_str,
|
|
record_type,
|
|
&ips,
|
|
proxied,
|
|
config.ttl,
|
|
config.record_comment.as_deref(),
|
|
config.dry_run,
|
|
ppfmt,
|
|
)
|
|
.await;
|
|
|
|
let noop_key = format!("{domain_str}:{record_type}");
|
|
match result {
|
|
SetResult::Updated => {
|
|
noop_reported.remove(&noop_key);
|
|
notify = true;
|
|
let ip_strs: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
|
|
messages.push(Message::new_ok(&format!(
|
|
"Updated {domain_str} -> {}",
|
|
ip_strs.join(", ")
|
|
)));
|
|
}
|
|
SetResult::Failed => {
|
|
noop_reported.remove(&noop_key);
|
|
notify = true;
|
|
all_ok = false;
|
|
messages.push(Message::new_fail(&format!(
|
|
"Failed to update {domain_str}"
|
|
)));
|
|
}
|
|
SetResult::Noop => {
|
|
if noop_reported.insert(noop_key) {
|
|
ppfmt.infof(pp::EMOJI_SKIP, &format!("Record {domain_str} is up to date"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update WAF lists
|
|
for waf_list in &config.waf_lists {
|
|
// Collect all detected IPs for WAF lists
|
|
let all_ips: Vec<IpAddr> = detected_ips
|
|
.values()
|
|
.flatten()
|
|
.copied()
|
|
.collect();
|
|
|
|
let result = handle
|
|
.set_waf_list(
|
|
waf_list,
|
|
&all_ips,
|
|
config.waf_list_item_comment.as_deref(),
|
|
config.waf_list_description.as_deref(),
|
|
config.dry_run,
|
|
ppfmt,
|
|
)
|
|
.await;
|
|
|
|
let noop_key = format!("waf:{}", waf_list.describe());
|
|
match result {
|
|
SetResult::Updated => {
|
|
noop_reported.remove(&noop_key);
|
|
notify = true;
|
|
messages.push(Message::new_ok(&format!(
|
|
"Updated WAF list {}",
|
|
waf_list.describe()
|
|
)));
|
|
}
|
|
SetResult::Failed => {
|
|
noop_reported.remove(&noop_key);
|
|
notify = true;
|
|
all_ok = false;
|
|
messages.push(Message::new_fail(&format!(
|
|
"Failed to update WAF list {}",
|
|
waf_list.describe()
|
|
)));
|
|
}
|
|
SetResult::Noop => {
|
|
if noop_reported.insert(noop_key) {
|
|
ppfmt.infof(pp::EMOJI_SKIP, &format!("WAF list {} is up to date", waf_list.describe()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always ping heartbeat so monitors know the updater is alive
|
|
let heartbeat_msg = Message::merge(messages.clone());
|
|
heartbeat.ping(&heartbeat_msg).await;
|
|
|
|
// Send notifications ONLY when IP changed or failed
|
|
if notify {
|
|
let notifier_msg = Message::merge(messages);
|
|
notifier.send(¬ifier_msg).await;
|
|
}
|
|
|
|
all_ok
|
|
}
|
|
|
|
/// Run legacy mode update (using the original cloudflare-ddns logic with zone_id-based config).
|
|
///
|
|
/// IP detection uses the shared provider abstraction (`config.providers`), which builds
|
|
/// 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,
|
|
cf_cache: &mut CachedCloudflareFilter,
|
|
ppfmt: &PP,
|
|
noop_reported: &mut HashSet<String>,
|
|
detection_client: &Client,
|
|
) -> bool {
|
|
let legacy = match &config.legacy_config {
|
|
Some(l) => l,
|
|
None => return false,
|
|
};
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::builder()
|
|
.timeout(config.update_timeout)
|
|
.build()
|
|
.unwrap_or_default(),
|
|
cf_api_base: "https://api.cloudflare.com/client/v4".to_string(),
|
|
dry_run: config.dry_run,
|
|
};
|
|
|
|
let mut ips = HashMap::new();
|
|
|
|
for (ip_type, provider) in &config.providers {
|
|
ppfmt.infof(
|
|
pp::EMOJI_DETECT,
|
|
&format!("Detecting {} via {}", ip_type.describe(), provider.name()),
|
|
);
|
|
let detected = provider
|
|
.detect_ips(&detection_client, *ip_type, config.detection_timeout, ppfmt)
|
|
.await;
|
|
|
|
if detected.is_empty() {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
&format!("No {} address detected", ip_type.describe()),
|
|
);
|
|
if legacy.purge_unknown_records {
|
|
ddns.delete_entries(ip_type.record_type(), &legacy.cloudflare)
|
|
.await;
|
|
}
|
|
} else {
|
|
let key = match ip_type {
|
|
IpType::V4 => "ipv4",
|
|
IpType::V6 => "ipv6",
|
|
};
|
|
ppfmt.infof(
|
|
pp::EMOJI_DETECT,
|
|
&format!("Detected {}: {}", ip_type.describe(), detected[0]),
|
|
);
|
|
ips.insert(
|
|
key.to_string(),
|
|
LegacyIpInfo {
|
|
record_type: ip_type.record_type().to_string(),
|
|
ip: detected[0].to_string(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// Filter out Cloudflare IPs if enabled
|
|
if config.reject_cloudflare_ips {
|
|
let before_count = ips.len();
|
|
if let Some(cf_filter) =
|
|
cf_cache.get(&detection_client, config.detection_timeout, ppfmt).await
|
|
{
|
|
ips.retain(|key, ip_info| {
|
|
if let Ok(addr) = ip_info.ip.parse::<std::net::IpAddr>() {
|
|
if cf_filter.contains(&addr) {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
&format!(
|
|
"Rejected {}: matches Cloudflare IP range ({})",
|
|
ip_info.ip, key
|
|
),
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
});
|
|
if ips.is_empty() && before_count > 0 {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
"All detected addresses were Cloudflare IPs; skipping updates",
|
|
);
|
|
}
|
|
} else if !ips.is_empty() {
|
|
ppfmt.warningf(
|
|
pp::EMOJI_WARNING,
|
|
"Could not fetch Cloudflare IP ranges; skipping update to avoid writing Cloudflare IPs",
|
|
);
|
|
ips.clear();
|
|
}
|
|
}
|
|
|
|
ddns.update_ips(
|
|
&ips,
|
|
&legacy.cloudflare,
|
|
legacy.ttl,
|
|
legacy.purge_unknown_records,
|
|
noop_reported,
|
|
)
|
|
.await;
|
|
|
|
true
|
|
}
|
|
|
|
/// Delete records on stop (for env var mode).
|
|
pub async fn final_delete(
|
|
config: &AppConfig,
|
|
handle: &CloudflareHandle,
|
|
notifier: &CompositeNotifier,
|
|
heartbeat: &Heartbeat,
|
|
ppfmt: &PP,
|
|
) {
|
|
let mut messages = Vec::new();
|
|
|
|
// Delete DNS records
|
|
for (ip_type, domains) in &config.domains {
|
|
let record_type = ip_type.record_type();
|
|
|
|
for domain_str in domains {
|
|
if let Some(zone_id) = handle.zone_id_of_domain(domain_str, ppfmt).await {
|
|
handle.final_delete(&zone_id, domain_str, record_type, ppfmt).await;
|
|
messages.push(Message::new_ok(&format!("Deleted records for {domain_str}")));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear WAF lists
|
|
for waf_list in &config.waf_lists {
|
|
handle.final_clear_waf_list(waf_list, ppfmt).await;
|
|
messages.push(Message::new_ok(&format!(
|
|
"Cleared WAF list {}",
|
|
waf_list.describe()
|
|
)));
|
|
}
|
|
|
|
// Send notifications
|
|
let msg = Message::merge(messages);
|
|
heartbeat.exit(&msg).await;
|
|
notifier.send(&msg).await;
|
|
}
|
|
|
|
// ============================================================
|
|
// Legacy DDNS Client (preserved for backwards compatibility)
|
|
// ============================================================
|
|
|
|
pub struct LegacyIpInfo {
|
|
pub record_type: String,
|
|
pub ip: String,
|
|
}
|
|
|
|
struct LegacyDdnsClient {
|
|
client: Client,
|
|
cf_api_base: String,
|
|
dry_run: bool,
|
|
}
|
|
|
|
impl LegacyDdnsClient {
|
|
async fn cf_api<T: serde::de::DeserializeOwned>(
|
|
&self,
|
|
endpoint: &str,
|
|
method: &str,
|
|
entry: &LegacyCloudflareEntry,
|
|
body: Option<&impl serde::Serialize>,
|
|
) -> Option<T> {
|
|
let url = format!("{}/{endpoint}", self.cf_api_base);
|
|
|
|
let mut req = match method {
|
|
"GET" => self.client.get(&url),
|
|
"POST" => self.client.post(&url),
|
|
"PUT" => self.client.put(&url),
|
|
"PATCH" => self.client.patch(&url),
|
|
"DELETE" => self.client.delete(&url),
|
|
_ => return None,
|
|
};
|
|
|
|
if !entry.authentication.api_token.is_empty()
|
|
&& entry.authentication.api_token != "api_token_here"
|
|
{
|
|
req = req.header(
|
|
"Authorization",
|
|
format!("Bearer {}", entry.authentication.api_token),
|
|
);
|
|
} else if let Some(api_key) = &entry.authentication.api_key {
|
|
req = req
|
|
.header("X-Auth-Email", &api_key.account_email)
|
|
.header("X-Auth-Key", &api_key.api_key);
|
|
}
|
|
|
|
if let Some(b) = body {
|
|
req = req.json(b);
|
|
}
|
|
|
|
match req.send().await {
|
|
Ok(resp) => {
|
|
if resp.status().is_success() {
|
|
resp.json::<T>().await.ok()
|
|
} else {
|
|
let url_str = resp.url().to_string();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
eprintln!("Error sending '{method}' request to '{url_str}': {text}");
|
|
None
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Exception sending '{method}' request to '{endpoint}': {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn delete_entries(&self, record_type: &str, entries: &[LegacyCloudflareEntry]) {
|
|
for entry in entries {
|
|
let endpoint = format!(
|
|
"zones/{}/dns_records?per_page=100&type={record_type}",
|
|
entry.zone_id
|
|
);
|
|
let answer: Option<LegacyCfResponse<Vec<LegacyDnsRecord>>> =
|
|
self.cf_api(&endpoint, "GET", entry, None::<&()>.as_ref())
|
|
.await;
|
|
|
|
if let Some(resp) = answer {
|
|
if let Some(records) = resp.result {
|
|
for record in records {
|
|
if self.dry_run {
|
|
println!("[DRY RUN] Would delete stale record {}", record.id);
|
|
continue;
|
|
}
|
|
let del_endpoint = format!(
|
|
"zones/{}/dns_records/{}",
|
|
entry.zone_id, record.id
|
|
);
|
|
let _: Option<serde_json::Value> = self
|
|
.cf_api(&del_endpoint, "DELETE", entry, None::<&()>.as_ref())
|
|
.await;
|
|
println!("Deleted stale record {}", record.id);
|
|
}
|
|
}
|
|
} else {
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn update_ips(
|
|
&self,
|
|
ips: &HashMap<String, LegacyIpInfo>,
|
|
config: &[LegacyCloudflareEntry],
|
|
ttl: i64,
|
|
purge_unknown_records: bool,
|
|
noop_reported: &mut HashSet<String>,
|
|
) {
|
|
for ip in ips.values() {
|
|
self.commit_record(ip, config, ttl, purge_unknown_records, noop_reported)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn commit_record(
|
|
&self,
|
|
ip: &LegacyIpInfo,
|
|
config: &[LegacyCloudflareEntry],
|
|
ttl: i64,
|
|
purge_unknown_records: bool,
|
|
noop_reported: &mut HashSet<String>,
|
|
) {
|
|
for entry in config {
|
|
let zone_resp: Option<LegacyCfResponse<LegacyZoneResult>> = self
|
|
.cf_api(
|
|
&format!("zones/{}", entry.zone_id),
|
|
"GET",
|
|
entry,
|
|
None::<&()>.as_ref(),
|
|
)
|
|
.await;
|
|
|
|
let base_domain = match zone_resp.and_then(|r| r.result) {
|
|
Some(z) => z.name,
|
|
None => {
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
for subdomain in &entry.subdomains {
|
|
let (name, proxied) = match subdomain {
|
|
LegacySubdomainEntry::Detailed { name, proxied } => {
|
|
(name.to_lowercase().trim().to_string(), *proxied)
|
|
}
|
|
LegacySubdomainEntry::Simple(name) => {
|
|
(name.to_lowercase().trim().to_string(), entry.proxied)
|
|
}
|
|
};
|
|
|
|
let fqdn = make_fqdn(&name, &base_domain);
|
|
|
|
let record = LegacyDnsRecordPayload {
|
|
record_type: ip.record_type.clone(),
|
|
name: fqdn.clone(),
|
|
content: ip.ip.clone(),
|
|
proxied,
|
|
ttl,
|
|
};
|
|
|
|
let dns_endpoint = format!(
|
|
"zones/{}/dns_records?per_page=100&type={}",
|
|
entry.zone_id, ip.record_type
|
|
);
|
|
let dns_records: Option<LegacyCfResponse<Vec<LegacyDnsRecord>>> =
|
|
self.cf_api(&dns_endpoint, "GET", entry, None::<&()>.as_ref())
|
|
.await;
|
|
|
|
let mut identifier: Option<String> = None;
|
|
let mut modified = false;
|
|
let mut duplicate_ids: Vec<String> = Vec::new();
|
|
|
|
if let Some(resp) = dns_records {
|
|
if let Some(records) = resp.result {
|
|
for r in &records {
|
|
if r.name == fqdn {
|
|
if let Some(ref existing_id) = identifier {
|
|
if r.content == ip.ip {
|
|
duplicate_ids.push(existing_id.clone());
|
|
identifier = Some(r.id.clone());
|
|
} else {
|
|
duplicate_ids.push(r.id.clone());
|
|
}
|
|
} else {
|
|
identifier = Some(r.id.clone());
|
|
if r.content != record.content
|
|
|| r.proxied != record.proxied
|
|
{
|
|
modified = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let noop_key = format!("{fqdn}:{}", ip.record_type);
|
|
if let Some(ref id) = identifier {
|
|
if modified {
|
|
noop_reported.remove(&noop_key);
|
|
if self.dry_run {
|
|
println!("[DRY RUN] Would update record {fqdn} -> {}", ip.ip);
|
|
} else {
|
|
println!("Updating record {fqdn} -> {}", ip.ip);
|
|
let update_endpoint =
|
|
format!("zones/{}/dns_records/{id}", entry.zone_id);
|
|
let _: Option<serde_json::Value> = self
|
|
.cf_api(&update_endpoint, "PUT", entry, Some(&record))
|
|
.await;
|
|
}
|
|
} else if noop_reported.insert(noop_key) {
|
|
if self.dry_run {
|
|
println!("[DRY RUN] Record {fqdn} is up to date");
|
|
} else {
|
|
println!("Record {fqdn} is up to date");
|
|
}
|
|
}
|
|
} else {
|
|
noop_reported.remove(&noop_key);
|
|
if self.dry_run {
|
|
println!("[DRY RUN] Would add new record {fqdn} -> {}", ip.ip);
|
|
} else {
|
|
println!("Adding new record {fqdn} -> {}", ip.ip);
|
|
let create_endpoint = format!("zones/{}/dns_records", entry.zone_id);
|
|
let _: Option<serde_json::Value> = self
|
|
.cf_api(&create_endpoint, "POST", entry, Some(&record))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
if purge_unknown_records {
|
|
for dup_id in &duplicate_ids {
|
|
if self.dry_run {
|
|
println!("[DRY RUN] Would delete stale record {dup_id}");
|
|
} else {
|
|
println!("Deleting stale record {dup_id}");
|
|
let del_endpoint =
|
|
format!("zones/{}/dns_records/{dup_id}", entry.zone_id);
|
|
let _: Option<serde_json::Value> = self
|
|
.cf_api(&del_endpoint, "DELETE", entry, None::<&()>.as_ref())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cloudflare::{Auth, CloudflareHandle, TTL, WAFList};
|
|
use crate::config::{AppConfig, CronSchedule};
|
|
use crate::notifier::{CompositeNotifier, Heartbeat};
|
|
use crate::pp::PP;
|
|
use crate::provider::{IpType, ProviderType};
|
|
use std::collections::HashMap;
|
|
use std::net::IpAddr;
|
|
use std::time::Duration;
|
|
use wiremock::matchers::{method, path, path_regex, query_param};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
// -------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------
|
|
|
|
fn pp() -> PP {
|
|
// quiet=true suppresses output during tests
|
|
PP::new(false, true)
|
|
}
|
|
|
|
fn empty_notifier() -> CompositeNotifier {
|
|
CompositeNotifier::new(vec![])
|
|
}
|
|
|
|
fn empty_heartbeat() -> Heartbeat {
|
|
Heartbeat::new(vec![])
|
|
}
|
|
|
|
/// Build a minimal AppConfig for env-var (non-legacy) mode with a single V4 domain.
|
|
fn make_config(
|
|
providers: HashMap<IpType, ProviderType>,
|
|
domains: HashMap<IpType, Vec<String>>,
|
|
waf_lists: Vec<WAFList>,
|
|
dry_run: bool,
|
|
) -> AppConfig {
|
|
AppConfig {
|
|
auth: Auth::Token("test-token".to_string()),
|
|
providers,
|
|
domains,
|
|
waf_lists,
|
|
update_cron: CronSchedule::Once,
|
|
update_on_start: true,
|
|
delete_on_stop: false,
|
|
ttl: TTL::AUTO,
|
|
proxied_expression: None,
|
|
record_comment: None,
|
|
managed_comment_regex: None,
|
|
waf_list_description: None,
|
|
waf_list_item_comment: None,
|
|
managed_waf_comment_regex: None,
|
|
detection_timeout: Duration::from_secs(5),
|
|
update_timeout: Duration::from_secs(5),
|
|
reject_cloudflare_ips: false,
|
|
dry_run,
|
|
emoji: false,
|
|
quiet: true,
|
|
legacy_mode: false,
|
|
legacy_config: None,
|
|
repeat: false,
|
|
}
|
|
}
|
|
|
|
fn handle(base_url: &str) -> CloudflareHandle {
|
|
CloudflareHandle::with_base_url(base_url, Auth::Token("test-token".to_string()))
|
|
}
|
|
|
|
/// JSON for a Cloudflare zones list response returning a single zone.
|
|
fn zones_response(zone_id: &str, name: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"result": [{ "id": zone_id, "name": name }]
|
|
})
|
|
}
|
|
|
|
/// JSON for an empty zones list response (zone not found).
|
|
fn zones_empty_response() -> serde_json::Value {
|
|
serde_json::json!({ "result": [] })
|
|
}
|
|
|
|
/// JSON for an empty DNS records list.
|
|
fn dns_records_empty() -> serde_json::Value {
|
|
serde_json::json!({ "result": [] })
|
|
}
|
|
|
|
/// JSON for a DNS records list containing one record.
|
|
fn dns_records_one(id: &str, name: &str, content: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"result": [{
|
|
"id": id,
|
|
"name": name,
|
|
"content": content,
|
|
"proxied": false,
|
|
"ttl": 1,
|
|
"comment": null
|
|
}]
|
|
})
|
|
}
|
|
|
|
/// JSON for a successful DNS record create/update response.
|
|
fn dns_record_created(id: &str, name: &str, content: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"result": {
|
|
"id": id,
|
|
"name": name,
|
|
"content": content,
|
|
"proxied": false,
|
|
"ttl": 1,
|
|
"comment": null
|
|
}
|
|
})
|
|
}
|
|
|
|
/// JSON for a WAF lists response returning a single list.
|
|
fn waf_lists_response(list_id: &str, list_name: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"result": [{ "id": list_id, "name": list_name }]
|
|
})
|
|
}
|
|
|
|
/// JSON for WAF list items response.
|
|
fn waf_items_response(items: serde_json::Value) -> serde_json::Value {
|
|
serde_json::json!({ "result": items })
|
|
}
|
|
|
|
// -------------------------------------------------------
|
|
// update_once tests
|
|
// -------------------------------------------------------
|
|
|
|
/// update_once with a Literal IP provider creates a new DNS record when none exists.
|
|
#[tokio::test]
|
|
async fn test_update_once_creates_new_record() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
let ip = "198.51.100.42";
|
|
|
|
// Zone lookup: GET zones?name=home.example.com
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List existing records: GET zones/{zone_id}/dns_records?...
|
|
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;
|
|
|
|
// Create record: POST zones/{zone_id}/dns_records
|
|
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", domain, ip)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], 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(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
|
|
/// update_once returns true (all_ok) when IP is already correct (Noop),
|
|
/// and populates noop_reported so subsequent calls suppress the message.
|
|
#[tokio::test]
|
|
async fn test_update_once_noop_when_record_up_to_date() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
let ip = "198.51.100.42";
|
|
|
|
// Zone lookup
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List existing records - record already exists with correct IP
|
|
Mock::given(method("GET"))
|
|
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_records_one("rec-1", domain, ip)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
let mut cf_cache = CachedCloudflareFilter::new();
|
|
let mut noop_reported = HashSet::new();
|
|
|
|
// First call: noop_reported is empty, so "up to date" is reported and key is inserted
|
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
|
|
assert!(ok);
|
|
assert!(noop_reported.contains("home.example.com:A"), "noop_reported should contain the domain key after first noop");
|
|
|
|
// Second call: noop_reported already has the key, so the message is suppressed
|
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
|
|
assert!(ok);
|
|
assert_eq!(noop_reported.len(), 1, "noop_reported should still have exactly one entry");
|
|
}
|
|
|
|
/// noop_reported is cleared when a record is updated, so "up to date" prints again
|
|
/// on the next noop cycle.
|
|
#[tokio::test]
|
|
async fn test_update_once_noop_reported_cleared_on_change() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
let old_ip = "198.51.100.42";
|
|
let new_ip = "198.51.100.99";
|
|
|
|
// Zone lookup
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List existing records - record has old IP, will be updated
|
|
Mock::given(method("GET"))
|
|
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_records_one("rec-1", domain, old_ip)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Create record (new IP doesn't match existing, so it creates + deletes stale)
|
|
Mock::given(method("POST"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_record_created("rec-2", domain, new_ip)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Delete stale record
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": {}})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![new_ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
// Pre-populate noop_reported as if a previous cycle reported it
|
|
let mut noop_reported = HashSet::new();
|
|
noop_reported.insert("home.example.com:A".to_string());
|
|
|
|
let mut cf_cache = CachedCloudflareFilter::new();
|
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut noop_reported, &Client::new()).await;
|
|
assert!(ok);
|
|
assert!(!noop_reported.contains("home.example.com:A"), "noop_reported should be cleared after an update");
|
|
}
|
|
|
|
/// update_once returns true even when IP detection yields empty (no providers configured),
|
|
/// but marks the result as degraded via messages (all_ok = false only on zone/record errors).
|
|
/// Here we use ProviderType::None so no IPs are detected - all_ok stays true since there
|
|
/// is no domain update attempted (empty ips -> set_ips with empty slice -> Noop).
|
|
#[tokio::test]
|
|
async fn test_update_once_empty_ip_detection_with_none_provider() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
|
|
// Zone lookup - still called even with empty IPs
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List records (set_ips called with empty ips, will list to delete managed records)
|
|
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;
|
|
|
|
// Provider that returns no IPs
|
|
let mut providers = HashMap::new();
|
|
providers.insert(IpType::V4, ProviderType::None);
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
// all_ok = true because no zone-level errors occurred (empty ips just noop or warn)
|
|
let mut cf_cache = CachedCloudflareFilter::new();
|
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).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);
|
|
}
|
|
|
|
/// When the Literal provider is used but the zone is not found, update_once returns false.
|
|
#[tokio::test]
|
|
async fn test_update_once_returns_false_when_zone_not_found() {
|
|
let server = MockServer::start().await;
|
|
let domain = "missing.example.com";
|
|
let ip = "198.51.100.1";
|
|
|
|
// Zone lookup for full domain fails
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_empty_response()),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Zone lookup for parent domain also fails
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", "example.com"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_empty_response()),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], 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(), &Client::new()).await;
|
|
assert!(!ok, "Expected false when zone is not found");
|
|
}
|
|
|
|
/// update_once in dry_run mode does NOT POST to create records.
|
|
#[tokio::test]
|
|
async fn test_update_once_dry_run_does_not_create_record() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
let ip = "198.51.100.42";
|
|
|
|
// Zone lookup
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List existing records - none exist
|
|
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 must NOT be called in dry_run - if it is, wiremock will panic at drop
|
|
// (no Mock registered for POST, and strict mode is default for unexpected requests)
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], true /* dry_run */);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
// dry_run returns Updated from set_ips (it signals intent), all_ok should be true
|
|
let mut cf_cache = CachedCloudflareFilter::new();
|
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
|
|
/// update_once with WAF lists: IPs are detected and WAF list is updated.
|
|
#[tokio::test]
|
|
async fn test_update_once_with_waf_list() {
|
|
let server = MockServer::start().await;
|
|
let account_id = "acc-123";
|
|
let list_name = "my_list";
|
|
let list_id = "list-id-1";
|
|
let ip = "198.51.100.42";
|
|
|
|
// GET accounts/{account_id}/rules/lists - returns our list
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/accounts/{account_id}/rules/lists")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_lists_response(list_id, list_name)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// GET list items - empty (need to add the IP)
|
|
Mock::given(method("GET"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_items_response(serde_json::json!([]))),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// POST to add items
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": {}
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let waf_list = WAFList {
|
|
account_id: account_id.to_string(),
|
|
list_name: list_name.to_string(),
|
|
};
|
|
|
|
// No DNS domains - only WAF list
|
|
let config = make_config(providers, HashMap::new(), vec![waf_list], 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(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
|
|
/// update_once with WAF list in dry_run mode: items are NOT POSTed.
|
|
#[tokio::test]
|
|
async fn test_update_once_waf_list_dry_run() {
|
|
let server = MockServer::start().await;
|
|
let account_id = "acc-123";
|
|
let list_name = "my_list";
|
|
let list_id = "list-id-1";
|
|
let ip = "198.51.100.42";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/accounts/{account_id}/rules/lists")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_lists_response(list_id, list_name)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_items_response(serde_json::json!([]))),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// No POST mock registered - dry_run must not POST
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let waf_list = WAFList {
|
|
account_id: account_id.to_string(),
|
|
list_name: list_name.to_string(),
|
|
};
|
|
|
|
let config = make_config(providers, HashMap::new(), vec![waf_list], true /* dry_run */);
|
|
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(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
|
|
/// update_once with WAF list when WAF list is not found returns false (Failed).
|
|
#[tokio::test]
|
|
async fn test_update_once_waf_list_not_found_returns_false() {
|
|
let server = MockServer::start().await;
|
|
let account_id = "acc-123";
|
|
let list_name = "my_list";
|
|
let ip = "198.51.100.42";
|
|
|
|
// GET accounts/{account_id}/rules/lists - returns empty (list not found)
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/accounts/{account_id}/rules/lists")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "result": [] })),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
let waf_list = WAFList {
|
|
account_id: account_id.to_string(),
|
|
list_name: list_name.to_string(),
|
|
};
|
|
|
|
let config = make_config(providers, HashMap::new(), vec![waf_list], 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(), &Client::new()).await;
|
|
assert!(!ok, "Expected false when WAF list is not found");
|
|
}
|
|
|
|
/// update_once with two domains (V4 and V6) - both updated independently.
|
|
#[tokio::test]
|
|
async fn test_update_once_v4_and_v6_domains() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain_v4 = "v4.example.com";
|
|
let domain_v6 = "v6.example.com";
|
|
let ip_v4 = "198.51.100.42";
|
|
let ip_v6 = "2001:db8::1";
|
|
|
|
// Zone lookups
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain_v4))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain_v6))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List records for both domains (no existing records)
|
|
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;
|
|
|
|
// Create record for V4
|
|
Mock::given(method("POST"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_record_created("rec-v4", domain_v4, ip_v4)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Create record for V6
|
|
Mock::given(method("POST"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_record_created("rec-v6", domain_v6, ip_v6)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip_v4.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
providers.insert(
|
|
IpType::V6,
|
|
ProviderType::Literal {
|
|
ips: vec![ip_v6.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain_v4.to_string()]);
|
|
domains.insert(IpType::V6, vec![domain_v6.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], 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(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
|
|
/// update_once with no providers and no domains is a degenerate but valid case - returns true.
|
|
#[tokio::test]
|
|
async fn test_update_once_no_providers_no_domains() {
|
|
let server = MockServer::start().await;
|
|
// No HTTP mocks needed - nothing should be called
|
|
|
|
let config = make_config(HashMap::new(), HashMap::new(), vec![], 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(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
|
|
// -------------------------------------------------------
|
|
// final_delete tests
|
|
// -------------------------------------------------------
|
|
|
|
/// final_delete removes existing DNS records for a domain.
|
|
#[tokio::test]
|
|
async fn test_final_delete_removes_dns_records() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
let record_id = "rec-to-delete";
|
|
let ip = "198.51.100.1";
|
|
|
|
// Zone lookup
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List records - one record exists
|
|
Mock::given(method("GET"))
|
|
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_records_one(record_id, domain, ip)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// DELETE the record
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records/{record_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": record_id }
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(HashMap::new(), domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
// Should complete without panic
|
|
final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
|
}
|
|
|
|
/// final_delete does nothing when no records exist for the domain.
|
|
#[tokio::test]
|
|
async fn test_final_delete_noop_when_no_records() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
|
|
// Zone lookup
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List records - empty
|
|
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;
|
|
|
|
// No DELETE mock - ensures DELETE is not called
|
|
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(HashMap::new(), domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
|
}
|
|
|
|
/// final_delete skips DNS deletion when zone is not found.
|
|
#[tokio::test]
|
|
async fn test_final_delete_skips_when_zone_not_found() {
|
|
let server = MockServer::start().await;
|
|
let domain = "missing.example.com";
|
|
|
|
// Zone lookup - not found at either level
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_empty_response()),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", "example.com"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_empty_response()),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
|
|
let config = make_config(HashMap::new(), domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
// Should complete without error - zone not found means skip
|
|
final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
|
}
|
|
|
|
/// final_delete clears WAF list items.
|
|
#[tokio::test]
|
|
async fn test_final_delete_clears_waf_list() {
|
|
let server = MockServer::start().await;
|
|
let account_id = "acc-123";
|
|
let list_name = "my_list";
|
|
let list_id = "list-id-1";
|
|
let item_id = "item-abc";
|
|
let ip = "198.51.100.42";
|
|
|
|
// GET lists
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/accounts/{account_id}/rules/lists")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_lists_response(list_id, list_name)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// GET items - one item exists
|
|
Mock::given(method("GET"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(waf_items_response(serde_json::json!([
|
|
{ "id": item_id, "ip": ip, "comment": null }
|
|
]))),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// DELETE items
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": {}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let waf_list = WAFList {
|
|
account_id: account_id.to_string(),
|
|
list_name: list_name.to_string(),
|
|
};
|
|
|
|
let config = make_config(HashMap::new(), HashMap::new(), vec![waf_list], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
|
}
|
|
|
|
/// final_delete with no WAF items does not call DELETE.
|
|
#[tokio::test]
|
|
async fn test_final_delete_waf_list_no_items() {
|
|
let server = MockServer::start().await;
|
|
let account_id = "acc-123";
|
|
let list_name = "my_list";
|
|
let list_id = "list-id-1";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/accounts/{account_id}/rules/lists")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_lists_response(list_id, list_name)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// GET items - empty
|
|
Mock::given(method("GET"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_items_response(serde_json::json!([]))),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// No DELETE mock - ensures DELETE is not called for empty list
|
|
|
|
let waf_list = WAFList {
|
|
account_id: account_id.to_string(),
|
|
list_name: list_name.to_string(),
|
|
};
|
|
|
|
let config = make_config(HashMap::new(), HashMap::new(), vec![waf_list], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
|
}
|
|
|
|
/// final_delete with both DNS domains and WAF lists - both are cleaned up.
|
|
#[tokio::test]
|
|
async fn test_final_delete_dns_and_waf() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain = "home.example.com";
|
|
let record_id = "rec-del";
|
|
let ip = "198.51.100.5";
|
|
let account_id = "acc-999";
|
|
let list_name = "ddns_ips";
|
|
let list_id = "list-xyz";
|
|
let item_id = "item-xyz";
|
|
|
|
// Zone lookup
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List DNS records
|
|
Mock::given(method("GET"))
|
|
.and(path_regex(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(dns_records_one(record_id, domain, ip)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// DELETE DNS record
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records/{record_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": record_id }
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// WAF: GET lists
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/accounts/{account_id}/rules/lists")))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(waf_lists_response(list_id, list_name)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// WAF: GET items
|
|
Mock::given(method("GET"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(waf_items_response(serde_json::json!([
|
|
{ "id": item_id, "ip": ip, "comment": null }
|
|
]))),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// WAF: DELETE items
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!(
|
|
"/accounts/{account_id}/rules/lists/{list_id}/items"
|
|
)))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": {}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V4, vec![domain.to_string()]);
|
|
let waf_list = WAFList {
|
|
account_id: account_id.to_string(),
|
|
list_name: list_name.to_string(),
|
|
};
|
|
|
|
let config = make_config(HashMap::new(), domains, vec![waf_list], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
final_delete(&config, &cf, ¬ifier, &heartbeat, &ppfmt).await;
|
|
}
|
|
|
|
// -------------------------------------------------------
|
|
// Literal provider IP detection filtering
|
|
// -------------------------------------------------------
|
|
|
|
/// Literal provider only injects IPs of the matching type into the update cycle.
|
|
/// V6 Literal IPs are ignored when the domain is V4-only.
|
|
#[tokio::test]
|
|
async fn test_update_once_literal_v4_not_used_for_v6_domain() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-abc";
|
|
let domain_v6 = "v6only.example.com";
|
|
// Only a V4 literal provider is configured but domain is V6
|
|
let ip_v4 = "198.51.100.1";
|
|
|
|
// Zone lookup for V6 domain
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones"))
|
|
.and(query_param("name", domain_v6))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(zones_response(zone_id, "example.com")),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// List AAAA records - no existing records; set_ips called with empty ips -> Noop
|
|
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;
|
|
|
|
// V4 literal provider but V6 domain - the V4 provider will not be in detected_ips for V6
|
|
let mut providers = HashMap::new();
|
|
providers.insert(
|
|
IpType::V4,
|
|
ProviderType::Literal {
|
|
ips: vec![ip_v4.parse::<IpAddr>().unwrap()],
|
|
},
|
|
);
|
|
// No V6 provider -> detected_ips won't have V6 -> set_ips called with empty slice
|
|
let mut domains = HashMap::new();
|
|
domains.insert(IpType::V6, vec![domain_v6.to_string()]);
|
|
|
|
let config = make_config(providers, domains, vec![], false);
|
|
let cf = handle(&server.uri());
|
|
let notifier = empty_notifier();
|
|
let heartbeat = empty_heartbeat();
|
|
let ppfmt = pp();
|
|
|
|
// set_ips with empty ips and no existing records = Noop; all_ok = true
|
|
let mut cf_cache = CachedCloudflareFilter::new();
|
|
let ok = update_once(&config, &cf, ¬ifier, &heartbeat, &mut cf_cache, &ppfmt, &mut HashSet::new(), &Client::new()).await;
|
|
assert!(ok);
|
|
}
|
|
// -------------------------------------------------------
|
|
// LegacyDdnsClient tests (internal/private struct)
|
|
// -------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_cf_api_get_success() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/zones/zone1"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let entry = crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: "zone1".to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
};
|
|
let result: Option<LegacyCfResponse<LegacyZoneResult>> = ddns
|
|
.cf_api("zones/zone1", "GET", &entry, None::<&()>.as_ref())
|
|
.await;
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().result.unwrap().name, "example.com");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_cf_api_post_success() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/zones/zone1/dns_records"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": "new-rec" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let entry = crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: "zone1".to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
};
|
|
let body = serde_json::json!({"name": "test"});
|
|
let result: Option<serde_json::Value> = ddns
|
|
.cf_api("zones/zone1/dns_records", "POST", &entry, Some(&body))
|
|
.await;
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_cf_api_error_response() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let entry = crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: "zone1".to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
};
|
|
let result: Option<serde_json::Value> = ddns
|
|
.cf_api("zones/zone1", "GET", &entry, None::<&()>.as_ref())
|
|
.await;
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_cf_api_unknown_method() {
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: "http://localhost".to_string(),
|
|
dry_run: false,
|
|
};
|
|
let entry = crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: "zone1".to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
};
|
|
let result: Option<serde_json::Value> = ddns
|
|
.cf_api("zones/zone1", "OPTIONS", &entry, None::<&()>.as_ref())
|
|
.await;
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_cf_api_with_api_key_auth() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let entry = crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: String::new(),
|
|
api_key: Some(crate::config::LegacyApiKey {
|
|
api_key: "key123".to_string(),
|
|
account_email: "user@example.com".to_string(),
|
|
}),
|
|
},
|
|
zone_id: "zone1".to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
};
|
|
let result: Option<serde_json::Value> = ddns
|
|
.cf_api("zones/zone1", "GET", &entry, None::<&()>.as_ref())
|
|
.await;
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_commit_record_creates_new() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg1";
|
|
|
|
// GET zone
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// GET dns_records - empty
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": []
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// POST create
|
|
Mock::given(method("POST"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": "new-rec" }
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let ip = LegacyIpInfo {
|
|
record_type: "A".to_string(),
|
|
ip: "198.51.100.1".to_string(),
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
|
proxied: false,
|
|
}];
|
|
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_commit_record_updates_existing() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg2";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": [{
|
|
"id": "rec-1",
|
|
"name": "example.com",
|
|
"content": "10.0.0.1",
|
|
"proxied": false
|
|
}]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("PUT"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": "rec-1" }
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let ip = LegacyIpInfo {
|
|
record_type: "A".to_string(),
|
|
ip: "198.51.100.1".to_string(),
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
|
proxied: false,
|
|
}];
|
|
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_commit_record_dry_run() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg3";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": []
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: true,
|
|
};
|
|
let ip = LegacyIpInfo {
|
|
record_type: "A".to_string(),
|
|
ip: "198.51.100.1".to_string(),
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
|
proxied: false,
|
|
}];
|
|
// Should not POST
|
|
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_commit_record_with_detailed_subdomain() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg4";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": []
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": "new-rec" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let ip = LegacyIpInfo {
|
|
record_type: "A".to_string(),
|
|
ip: "198.51.100.1".to_string(),
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![LegacySubdomainEntry::Detailed {
|
|
name: "vpn".to_string(),
|
|
proxied: true,
|
|
}],
|
|
proxied: false,
|
|
}];
|
|
ddns.commit_record(&ip, &config, 300, false, &mut HashSet::new()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_commit_record_purge_duplicates() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg5";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": [
|
|
{ "id": "rec-1", "name": "example.com", "content": "198.51.100.1", "proxied": false },
|
|
{ "id": "rec-dup", "name": "example.com", "content": "198.51.100.1", "proxied": false }
|
|
]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let ip = LegacyIpInfo {
|
|
record_type: "A".to_string(),
|
|
ip: "198.51.100.1".to_string(),
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
|
proxied: false,
|
|
}];
|
|
ddns.commit_record(&ip, &config, 300, true, &mut HashSet::new()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_update_ips_calls_commit_for_each_ip() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg6";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "name": "example.com" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": []
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": { "id": "new-rec" }
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let mut ips = HashMap::new();
|
|
ips.insert("ipv4".to_string(), LegacyIpInfo {
|
|
record_type: "A".to_string(),
|
|
ip: "198.51.100.1".to_string(),
|
|
});
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())],
|
|
proxied: false,
|
|
}];
|
|
ddns.update_ips(&ips, &config, 300, false, &mut HashSet::new()).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_delete_entries() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg7";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": [
|
|
{ "id": "rec-1", "name": "example.com", "content": "10.0.0.1", "proxied": false }
|
|
]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
Mock::given(method("DELETE"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records/rec-1")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: false,
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
}];
|
|
ddns.delete_entries("A", &config).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_legacy_delete_entries_dry_run() {
|
|
let server = MockServer::start().await;
|
|
let zone_id = "zone-leg8";
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/zones/{zone_id}/dns_records")))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"result": [
|
|
{ "id": "rec-1", "name": "example.com", "content": "10.0.0.1", "proxied": false }
|
|
]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let ddns = LegacyDdnsClient {
|
|
client: Client::new(),
|
|
cf_api_base: server.uri(),
|
|
dry_run: true,
|
|
};
|
|
let config = vec![crate::config::LegacyCloudflareEntry {
|
|
authentication: crate::config::LegacyAuthentication {
|
|
api_token: "test-token".to_string(),
|
|
api_key: None,
|
|
},
|
|
zone_id: zone_id.to_string(),
|
|
subdomains: vec![],
|
|
proxied: false,
|
|
}];
|
|
// dry_run: should not DELETE
|
|
ddns.delete_entries("A", &config).await;
|
|
}
|
|
|
|
}
|
|
|
|
// Legacy types for backwards compatibility
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct LegacyCfResponse<T> {
|
|
result: Option<T>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct LegacyZoneResult {
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct LegacyDnsRecord {
|
|
id: String,
|
|
name: String,
|
|
content: String,
|
|
proxied: bool,
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct LegacyDnsRecordPayload {
|
|
#[serde(rename = "type")]
|
|
record_type: String,
|
|
name: String,
|
|
content: String,
|
|
proxied: bool,
|
|
ttl: i64,
|
|
}
|