Files
cloudflare_ddns/src/main.rs
Timothy Miller b1a2fa7af3 Migrate cloudflare-ddns to Rust
Add Cargo.toml, Cargo.lock and a full src/ tree with modules and tests
Update Dockerfile to build a Rust release binary and simplify CI/publish
Remove legacy Python script, requirements.txt, and startup helper
Switch .gitignore to Rust artifacts; update Dependabot and workflows to
cargo
Add .env example, docker-compose env, and update README and VSCode
settings

Remove the old Python implementation and requirements; add a Rust
implementation with Cargo.toml/Cargo.lock and full src/ modules, tests,
and notifier/heartbeat support. Update Dockerfile, build/publish
scripts, dependabot and workflows, README, and provide env-based
docker-compose and .env examples.
2026-03-10 01:21:21 -04:00

921 lines
31 KiB
Rust

mod cloudflare;
mod config;
mod domain;
mod notifier;
mod pp;
mod provider;
mod updater;
use crate::cloudflare::{Auth, CloudflareHandle};
use crate::config::{AppConfig, CronSchedule};
use crate::notifier::{CompositeNotifier, Heartbeat, Message};
use crate::pp::PP;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::signal;
use tokio::time::{sleep, Duration};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
async fn main() {
// Parse CLI args
let args: Vec<String> = std::env::args().collect();
let dry_run = args.iter().any(|a| a == "--dry-run");
let repeat = args.iter().any(|a| a == "--repeat");
// Check for unknown args (legacy behavior)
let known_args = ["--dry-run", "--repeat"];
let unknown: Vec<&str> = args
.iter()
.skip(1)
.filter(|a| !known_args.contains(&a.as_str()))
.map(|a| a.as_str())
.collect();
if !unknown.is_empty() {
eprintln!(
"Unrecognized parameter(s): {}. Stopping now.",
unknown.join(", ")
);
return;
}
// Determine config mode and create initial PP for config loading
let initial_pp = if config::is_env_config_mode() {
// In env mode, read emoji/quiet from env before loading full config
let emoji = std::env::var("EMOJI")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(true);
let quiet = std::env::var("QUIET")
.map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
.unwrap_or(false);
PP::new(emoji, quiet)
} else {
// Legacy mode: no emoji, not quiet (preserves original output behavior)
PP::new(false, false)
};
println!("cloudflare-ddns v{VERSION}");
// Load config
let app_config = match config::load_config(dry_run, repeat, &initial_pp) {
Ok(c) => c,
Err(e) => {
eprintln!("{e}");
sleep(Duration::from_secs(10)).await;
std::process::exit(1);
}
};
// Create PP with final settings
let ppfmt = PP::new(app_config.emoji, app_config.quiet);
if dry_run {
ppfmt.noticef(
pp::EMOJI_WARNING,
"[DRY RUN] No records will be created, updated, or deleted.",
);
}
// Print config summary (env mode only)
config::print_config_summary(&app_config, &ppfmt);
// Setup notifiers and heartbeats
let notifier = config::setup_notifiers(&ppfmt);
let heartbeat = config::setup_heartbeats(&ppfmt);
// Create Cloudflare handle (for env mode)
let handle = if !app_config.legacy_mode {
CloudflareHandle::new(
app_config.auth.clone(),
app_config.update_timeout,
app_config.managed_comment_regex.clone(),
app_config.managed_waf_comment_regex.clone(),
)
} else {
// Create a dummy handle for legacy mode (won't be used)
CloudflareHandle::new(
Auth::Token(String::new()),
Duration::from_secs(30),
None,
None,
)
};
// Signal handler for graceful shutdown
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
tokio::spawn(async move {
let _ = signal::ctrl_c().await;
println!("Stopping...");
r.store(false, Ordering::SeqCst);
});
// Start heartbeat
heartbeat.start().await;
if app_config.legacy_mode {
// --- Legacy mode (original cloudflare-ddns behavior) ---
run_legacy_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await;
} else {
// --- Env var mode (cf-ddns behavior) ---
run_env_mode(&app_config, &handle, &notifier, &heartbeat, &ppfmt, running).await;
}
// On shutdown: delete records if configured
if app_config.delete_on_stop && !app_config.legacy_mode {
ppfmt.noticef(pp::EMOJI_STOP, "Deleting records on stop...");
updater::final_delete(&app_config, &handle, &notifier, &heartbeat, &ppfmt).await;
}
// Exit heartbeat
heartbeat
.exit(&Message::new_ok("Shutting down"))
.await;
}
async fn run_legacy_mode(
config: &AppConfig,
handle: &CloudflareHandle,
notifier: &CompositeNotifier,
heartbeat: &Heartbeat,
ppfmt: &PP,
running: Arc<AtomicBool>,
) {
let legacy = match &config.legacy_config {
Some(l) => l,
None => return,
};
if config.repeat {
match (legacy.a, legacy.aaaa) {
(true, true) => println!(
"Updating IPv4 (A) & IPv6 (AAAA) records every {} seconds",
legacy.ttl
),
(true, false) => {
println!("Updating IPv4 (A) records every {} seconds", legacy.ttl)
}
(false, true) => {
println!("Updating IPv6 (AAAA) records every {} seconds", legacy.ttl)
}
(false, false) => println!("Both IPv4 and IPv6 are disabled"),
}
while running.load(Ordering::SeqCst) {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
for _ in 0..legacy.ttl {
if !running.load(Ordering::SeqCst) {
break;
}
sleep(Duration::from_secs(1)).await;
}
}
} else {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
}
async fn run_env_mode(
config: &AppConfig,
handle: &CloudflareHandle,
notifier: &CompositeNotifier,
heartbeat: &Heartbeat,
ppfmt: &PP,
running: Arc<AtomicBool>,
) {
match &config.update_cron {
CronSchedule::Once => {
if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
}
schedule => {
let interval = schedule.next_duration().unwrap_or(Duration::from_secs(300));
ppfmt.noticef(
pp::EMOJI_LAUNCH,
&format!(
"Started cloudflare-ddns, updating every {}",
describe_duration(interval)
),
);
// Update on start if configured
if config.update_on_start {
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
// Main loop
while running.load(Ordering::SeqCst) {
// Sleep for interval, checking running flag each second
let secs = interval.as_secs();
let next_time = chrono::Local::now() + chrono::Duration::seconds(secs as i64);
ppfmt.infof(
pp::EMOJI_SLEEP,
&format!(
"Next update at {}",
next_time.format("%Y-%m-%d %H:%M:%S %Z")
),
);
for _ in 0..secs {
if !running.load(Ordering::SeqCst) {
return;
}
sleep(Duration::from_secs(1)).await;
}
if !running.load(Ordering::SeqCst) {
return;
}
updater::update_once(config, handle, notifier, heartbeat, ppfmt).await;
}
}
}
}
fn describe_duration(d: Duration) -> String {
let secs = d.as_secs();
if secs >= 3600 {
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
if mins > 0 {
format!("{hours}h{mins}m")
} else {
format!("{hours}h")
}
} else if secs >= 60 {
let mins = secs / 60;
let s = secs % 60;
if s > 0 {
format!("{mins}m{s}s")
} else {
format!("{mins}m")
}
} else {
format!("{secs}s")
}
}
// ============================================================
// Tests (backwards compatible with original test suite)
// ============================================================
#[cfg(test)]
mod tests {
use crate::config::{
LegacyAuthentication, LegacyCloudflareEntry, LegacyConfig, LegacySubdomainEntry,
parse_legacy_config,
};
use crate::provider::parse_trace_ip;
use reqwest::Client;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_config(zone_id: &str) -> LegacyConfig {
LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "test-token".to_string(),
api_key: None,
},
zone_id: zone_id.to_string(),
subdomains: vec![
LegacySubdomainEntry::Detailed {
name: "".to_string(),
proxied: false,
},
LegacySubdomainEntry::Detailed {
name: "vpn".to_string(),
proxied: true,
},
],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: false,
ttl: 300,
}
}
// Helper to create a legacy client for testing
struct TestDdnsClient {
client: Client,
cf_api_base: String,
ipv4_urls: Vec<String>,
dry_run: bool,
}
impl TestDdnsClient {
fn new(base_url: &str) -> Self {
Self {
client: Client::new(),
cf_api_base: base_url.to_string(),
ipv4_urls: vec![format!("{base_url}/cdn-cgi/trace")],
dry_run: false,
}
}
fn dry_run(mut self) -> Self {
self.dry_run = true;
self
}
async fn cf_api<T: serde::de::DeserializeOwned>(
&self,
endpoint: &str,
method_str: &str,
token: &str,
body: Option<&impl serde::Serialize>,
) -> Option<T> {
let url = format!("{}/{endpoint}", self.cf_api_base);
let mut req = match method_str {
"GET" => self.client.get(&url),
"POST" => self.client.post(&url),
"PUT" => self.client.put(&url),
"DELETE" => self.client.delete(&url),
_ => return None,
};
req = req.header("Authorization", format!("Bearer {token}"));
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(),
Ok(resp) => {
let text = resp.text().await.unwrap_or_default();
eprintln!("Error: {text}");
None
}
Err(e) => {
eprintln!("Exception: {e}");
None
}
}
}
async fn get_ip(&self) -> Option<String> {
for url in &self.ipv4_urls {
if let Ok(resp) = self.client.get(url).send().await {
if let Ok(body) = resp.text().await {
if let Some(ip) = parse_trace_ip(&body) {
return Some(ip);
}
}
}
}
None
}
async fn commit_record(
&self,
ip: &str,
record_type: &str,
config: &[LegacyCloudflareEntry],
ttl: i64,
purge_unknown_records: bool,
) {
for entry in config {
#[derive(serde::Deserialize)]
struct Resp<T> {
result: Option<T>,
}
#[derive(serde::Deserialize)]
struct Zone {
name: String,
}
#[derive(serde::Deserialize)]
struct Rec {
id: String,
name: String,
content: String,
proxied: bool,
}
let zone_resp: Option<Resp<Zone>> = self
.cf_api(
&format!("zones/{}", entry.zone_id),
"GET",
&entry.authentication.api_token,
None::<&()>.as_ref(),
)
.await;
let base_domain = match zone_resp.and_then(|r| r.result) {
Some(z) => z.name,
None => 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 = crate::domain::make_fqdn(&name, &base_domain);
#[derive(serde::Serialize)]
struct Payload {
#[serde(rename = "type")]
record_type: String,
name: String,
content: String,
proxied: bool,
ttl: i64,
}
let record = Payload {
record_type: record_type.to_string(),
name: fqdn.clone(),
content: ip.to_string(),
proxied,
ttl,
};
let dns_endpoint = format!(
"zones/{}/dns_records?per_page=100&type={record_type}",
entry.zone_id
);
let dns_records: Option<Resp<Vec<Rec>>> = self
.cf_api(
&dns_endpoint,
"GET",
&entry.authentication.api_token,
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 {
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 != ip || r.proxied != proxied {
modified = true;
}
}
}
}
}
}
if let Some(ref id) = identifier {
if modified {
if self.dry_run {
println!("[DRY RUN] Would update record {fqdn} -> {ip}");
} else {
println!("Updating record {fqdn} -> {ip}");
let update_endpoint =
format!("zones/{}/dns_records/{id}", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&update_endpoint,
"PUT",
&entry.authentication.api_token,
Some(&record),
)
.await;
}
} else if self.dry_run {
println!("[DRY RUN] Record {fqdn} is up to date ({ip})");
}
} else if self.dry_run {
println!("[DRY RUN] Would add new record {fqdn} -> {ip}");
} else {
println!("Adding new record {fqdn} -> {ip}");
let create_endpoint =
format!("zones/{}/dns_records", entry.zone_id);
let _: Option<serde_json::Value> = self
.cf_api(
&create_endpoint,
"POST",
&entry.authentication.api_token,
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.authentication.api_token,
None::<&()>.as_ref(),
)
.await;
}
}
}
}
}
}
}
#[test]
fn test_parse_trace_ip() {
let body = "fl=1f1\nh=1.1.1.1\nip=203.0.113.42\nts=1234567890\nvisit_scheme=https\n";
assert_eq!(parse_trace_ip(body), Some("203.0.113.42".to_string()));
}
#[test]
fn test_parse_trace_ip_missing() {
let body = "fl=1f1\nh=1.1.1.1\nts=1234567890\n";
assert_eq!(parse_trace_ip(body), None);
}
#[test]
fn test_parse_config_minimal() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok123" },
"zone_id": "zone1",
"subdomains": ["@"]
}]
}"#;
let config = parse_legacy_config(json).unwrap();
assert!(config.a);
assert!(config.aaaa);
assert!(!config.purge_unknown_records);
assert_eq!(config.ttl, 300);
}
#[test]
fn test_parse_config_low_ttl() {
let json = r#"{
"cloudflare": [{
"authentication": { "api_token": "tok123" },
"zone_id": "zone1",
"subdomains": ["@"]
}],
"ttl": 10
}"#;
let config = parse_legacy_config(json).unwrap();
assert_eq!(config.ttl, 1);
}
#[tokio::test]
async fn test_ip_detection() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cdn-cgi/trace"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("fl=1f1\nh=mock\nip=198.51.100.7\nts=0\n"),
)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let ip = ddns.get_ip().await;
assert_eq!(ip, Some("198.51.100.7".to_string()));
}
#[tokio::test]
async fn test_creates_new_record() {
let mock_server = MockServer::start().await;
let zone_id = "zone-abc-123";
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(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": []
})))
.mount(&mock_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-record-1" }
})))
.expect(2)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_updates_existing_record() {
let mock_server = MockServer::start().await;
let zone_id = "zone-update-1";
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(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.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 },
{ "id": "rec-2", "name": "vpn.example.com", "content": "10.0.0.1", "proxied": true }
]
})))
.mount(&mock_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(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-2")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "id": "rec-2" }
})))
.expect(1)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_skips_up_to_date_record() {
let mock_server = MockServer::start().await;
let zone_id = "zone-noop";
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(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-1", "name": "example.com", "content": "198.51.100.7", "proxied": false },
{ "id": "rec-2", "name": "vpn.example.com", "content": "198.51.100.7", "proxied": true }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_dry_run_does_not_mutate() {
let mock_server = MockServer::start().await;
let zone_id = "zone-dry";
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(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": []
})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri()).dry_run();
let config = test_config(zone_id);
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, false)
.await;
}
#[tokio::test]
async fn test_purge_duplicate_records() {
let mock_server = MockServer::start().await;
let zone_id = "zone-purge";
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(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-keep", "name": "example.com", "content": "198.51.100.7", "proxied": false },
{ "id": "rec-dup", "name": "example.com", "content": "198.51.100.7", "proxied": false }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("DELETE"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-keep")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let config = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "test-token".to_string(),
api_key: None,
},
zone_id: zone_id.to_string(),
subdomains: vec![LegacySubdomainEntry::Detailed {
name: "".to_string(),
proxied: false,
}],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: true,
ttl: 300,
};
ddns.commit_record("198.51.100.7", "A", &config.cloudflare, 300, true)
.await;
}
// --- describe_duration tests ---
#[test]
fn test_describe_duration_seconds_only() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(45)), "45s");
}
#[test]
fn test_describe_duration_exact_minutes() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(300)), "5m");
}
#[test]
fn test_describe_duration_minutes_and_seconds() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(330)), "5m30s");
}
#[test]
fn test_describe_duration_exact_hours() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(7200)), "2h");
}
#[test]
fn test_describe_duration_hours_and_minutes() {
use tokio::time::Duration;
assert_eq!(super::describe_duration(Duration::from_secs(5400)), "1h30m");
}
#[tokio::test]
async fn test_end_to_end_detect_and_update() {
let mock_server = MockServer::start().await;
let zone_id = "zone-e2e";
Mock::given(method("GET"))
.and(path("/cdn-cgi/trace"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("fl=1f1\nh=mock\nip=203.0.113.99\nts=0\n"),
)
.mount(&mock_server)
.await;
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(&mock_server)
.await;
Mock::given(method("GET"))
.and(path(format!("/zones/{zone_id}/dns_records")))
.and(query_param("type", "A"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [
{ "id": "rec-root", "name": "example.com", "content": "10.0.0.1", "proxied": false }
]
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path(format!("/zones/{zone_id}/dns_records/rec-root")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": { "id": "rec-root" }
})))
.expect(1)
.mount(&mock_server)
.await;
let ddns = TestDdnsClient::new(&mock_server.uri());
let ip = ddns.get_ip().await;
assert_eq!(ip, Some("203.0.113.99".to_string()));
let config = LegacyConfig {
cloudflare: vec![LegacyCloudflareEntry {
authentication: LegacyAuthentication {
api_token: "test-token".to_string(),
api_key: None,
},
zone_id: zone_id.to_string(),
subdomains: vec![LegacySubdomainEntry::Detailed {
name: "".to_string(),
proxied: false,
}],
proxied: false,
}],
a: true,
aaaa: false,
purge_unknown_records: false,
ttl: 300,
};
ddns.commit_record("203.0.113.99", "A", &config.cloudflare, 300, false)
.await;
}
}