diff --git a/Cargo.lock b/Cargo.lock index fa5b045..59a482a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,7 +211,7 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dreamhost-ddns" -version = "0.2.1" +version = "1.1.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 17e5026..d221a1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dreamhost-ddns" -version = "0.2.1" +version = "1.1.0" edition = "2021" [dependencies] diff --git a/binaries/linux-aarch64/dreamhost-ddns b/binaries/linux-aarch64/dreamhost-ddns index 93a4600..0039c0f 100644 Binary files a/binaries/linux-aarch64/dreamhost-ddns and b/binaries/linux-aarch64/dreamhost-ddns differ diff --git a/binaries/linux-rpi-armv7/dreamhost-ddns b/binaries/linux-rpi-armv7/dreamhost-ddns index f68169e..8933f52 100644 Binary files a/binaries/linux-rpi-armv7/dreamhost-ddns and b/binaries/linux-rpi-armv7/dreamhost-ddns differ diff --git a/binaries/linux-x86_64/dreamhost-ddns b/binaries/linux-x86_64/dreamhost-ddns index deee3f8..c2c6a57 100644 Binary files a/binaries/linux-x86_64/dreamhost-ddns and b/binaries/linux-x86_64/dreamhost-ddns differ diff --git a/binaries/windows/dreamhost-ddns.exe b/binaries/windows/dreamhost-ddns.exe index e2f7d87..e42378d 100644 Binary files a/binaries/windows/dreamhost-ddns.exe and b/binaries/windows/dreamhost-ddns.exe differ diff --git a/src/main.rs b/src/main.rs index 932cdb4..856b466 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,19 @@ use anyhow::{anyhow, Result}; -use clap::Parser; -use clap::ValueEnum; +use clap::{Parser, ValueEnum}; use log::{info, warn, debug, trace}; +use rand::seq::SliceRandom; use reqwest::blocking::Client; use serde::Deserialize; use std::net::IpAddr; use std::sync::mpsc; use std::thread; -use rand::seq::SliceRandom; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}, Mutex}; #[derive(Parser)] #[command( name = "dreamhost-ddns", version, - about = "Updates a DreamHost DNS A record with the current WAN IP" + about = "Updates a DreamHost DNS A and AAAA record with the current WAN IP" )] struct Args { #[arg(short, long)] @@ -33,6 +33,12 @@ struct Args { #[arg(long)] dry_run: bool, + + #[arg(long, conflicts_with = "ipv6_only")] + ipv4_only: bool, + + #[arg(long, conflicts_with = "ipv4_only")] + ipv6_only: bool, } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] @@ -44,13 +50,11 @@ enum LogLevel { Trace, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] struct Record { record: String, - #[serde(rename = "type")] record_type: String, - value: String, } @@ -63,6 +67,7 @@ struct Config { struct DreamhostClient { client: Client, api_key: String, + record_cache: Mutex>>, } impl From for log::LevelFilter { @@ -78,7 +83,17 @@ impl From for log::LevelFilter { } impl DreamhostClient { + + pub fn new(client: Client, api_key: String) -> Self { + Self { + client, + api_key, + record_cache: Mutex::new(None), + } + } + fn call(&self, params: &[(&str, &str)]) -> Result { + let mut query = vec![ ("key", self.api_key.as_str()), ("format", "json"), @@ -86,13 +101,65 @@ impl DreamhostClient { query.extend_from_slice(params); - let resp: serde_json::Value = self.client + let mut request = self.client .get("https://api.dreamhost.com/") .query(&query) - .send()? - .json()?; + .build()?; + + // ensure user-agent is visible in trace logs + if !request.headers().contains_key(reqwest::header::USER_AGENT) { + request.headers_mut().insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_str( + &format!("dreamhost-ddns/{}", env!("CARGO_PKG_VERSION")) + )?, + ); + } + + // ---- TRACE REQUEST LOGGING ---- + if log::log_enabled!(log::Level::Trace) { + + let mut url = request.url().to_string(); + + // mask API key + if let Some(start) = url.find("key=") { + let end = url[start..].find('&').map(|i| start + i).unwrap_or(url.len()); + url.replace_range(start + 4..end, "***"); + } + + trace!("HTTP Request: {} {}", request.method(), url); + + if request.headers().is_empty() { + trace!("HTTP Request Headers: "); + } else { + for (name, value) in request.headers() { + trace!("HTTP Header: {} = {:?}", name, value); + } + } + } + + // ---- SEND REQUEST ---- + let response = self.client.execute(request)?; + + // ---- TRACE RESPONSE LOGGING ---- + if log::log_enabled!(log::Level::Trace) { + + trace!("HTTP Status: {}", response.status()); + + for (name, value) in response.headers() { + trace!("Response Header: {} = {:?}", name, value); + } + } + + let resp: serde_json::Value = response.json()?; + if log::log_enabled!(log::Level::Trace) { + trace!("HTTP Response JSON: {:?}", resp); + } + + // ---- DREAMHOST API ERROR HANDLING ---- if resp["result"] != "success" { + let reason = resp["reason"] .as_str() .unwrap_or("Unknown DreamHost API error"); @@ -103,96 +170,159 @@ impl DreamhostClient { Ok(resp) } - fn get_dns_ip(&self, record_name: &str) -> Result { - let resp = self.call(&[ - ("cmd", "dns-list_records"), - ])?; + fn list_records(&self) -> Result> { + let mut cache = self.record_cache.lock().unwrap(); + if let Some(records) = cache.as_ref() { + debug!("Using cached DNS records"); + return Ok(records.clone()); + } + + let resp = self.call(&[("cmd", "dns-list_records")])?; + + // Ensure we have a JSON array for data; else, treat as error + let records: Vec = match &resp["data"] { + serde_json::Value::Array(arr) => serde_json::from_value(serde_json::Value::Array(arr.clone()))?, + _ => { + // "data" might be a string like "slow_down_bucko" + let reason = resp["reason"].as_str().unwrap_or("Unknown DreamHost API error"); + return Err(anyhow!("DreamHost API error: {}", reason)); + } + }; - let records: Vec = serde_json::from_value(resp["data"].clone())?; + *cache = Some(records.clone()); // cache it + Ok(records) + } - debug!("All DNS records: {:?}", records); - trace!("Detailed DNS data: {:?}", resp); + fn get_dns_ip(&self, record_name: &str, record_type: &str) -> Result { + let records = self.list_records()?; // uses cache if available + debug!("All DNS records: {:?}", records); records .into_iter() - .find(|r| r.record == record_name && r.record_type == "A") + .find(|r| r.record == record_name && r.record_type == record_type) .map(|r| r.value) - .ok_or_else(|| anyhow!("DreamHost error: DNS record '{}' not found", record_name)) + .ok_or_else(|| anyhow!("DreamHost error: {} record '{}' not found", record_type, record_name)) } - fn list_records(&self) -> Result> { - - let resp = self.call(&[ - ("cmd", "dns-list_records"), - ])?; - - let records: Vec = serde_json::from_value(resp["data"].clone())?; - - Ok(records) + fn record_exists(&self, record_name: &str, ip: &str, record_type: &str) -> Result { + let records = self.list_records()?; // uses cache if available + Ok(records.iter().any(|r| r.record == record_name && r.record_type == record_type && r.value == ip)) } - fn record_exists( - &self, - record_name: &str, - ip: &str, - ) -> Result { - - let records = self.list_records()?; - - Ok(records.iter().any(|r| - r.record == record_name && - r.record_type == "A" && - r.value == ip - )) + fn invalidate_cache(&self) { + let mut cache = self.record_cache.lock().unwrap(); + *cache = None; } - fn update_dns(&self, record: &str, old_ip: &str, new_ip: &str) -> Result<()> { - - info!("Adding new DNS record {} -> {}", record, new_ip); + fn update_dns( + &self, + record: &str, + old_ip: &str, + new_ip: &str, + record_type: &str + ) -> Result<()> { + info!("Adding new {} DNS record {} -> {}", record_type, record, new_ip); self.call(&[ ("cmd", "dns-add_record"), ("record", record), - ("type", "A"), + ("type", record_type), ("value", new_ip), ])?; + self.invalidate_cache(); // records have changed, refresh cache + info!("Waiting briefly for DNS propagation..."); std::thread::sleep(std::time::Duration::from_secs(3)); for attempt in 1..=5 { - - if self.record_exists(record, new_ip)? { - info!("New DNS record verified"); + if self.record_exists(record, new_ip, record_type)? { + info!("New {} record verified", record_type); break; } - - warn!("New record not visible yet (attempt {})", attempt); + warn!("New {} record not visible yet (attempt {})", record_type, attempt); std::thread::sleep(std::time::Duration::from_secs(2)); if attempt == 5 { return Err(anyhow!( - "New DNS record never appeared; refusing to remove old record" + "New {} record never appeared; refusing to remove old record", + record_type )); } } - info!("Removing old DNS record {} -> {}", record, old_ip); - + info!("Removing old {} DNS record {} -> {}", record_type, record, old_ip); self.call(&[ ("cmd", "dns-remove_record"), ("record", record), - ("type", "A"), + ("type", record_type), ("value", old_ip), ])?; + self.invalidate_cache(); // records changed again + Ok(()) } +} + +fn check_and_update( + dh: &DreamhostClient, + record: &str, + detected_ip: IpAddr, + record_type: &str, + dry_run: bool, +) -> Result<()> { + + match dh.get_dns_ip(record, record_type) { + Ok(current_ip) => { + // Existing record exists, update if necessary + if let Ok(existing_ip) = current_ip.parse::() { + if detected_ip == existing_ip { + info!("{} record already up-to-date", record_type); + return Ok(()); + } + } + + warn!("{} record mismatch detected", record_type); + + if dry_run { + info!("DRY RUN: Would update {} record {} -> {}", record_type, current_ip, detected_ip); + return Ok(()); + } + + dh.update_dns(record, ¤t_ip, &detected_ip.to_string(), record_type)?; + info!("{} record updated successfully", record_type); + } + + Err(e) => { + // Only create record if the error indicates "not found" + let msg = e.to_string(); + if msg.contains("not found") { + warn!("{} record does not exist, creating new one", record_type); + + if dry_run { + info!("DRY RUN: Would create {} record -> {}", record_type, detected_ip); + return Ok(()); + } + + dh.call(&[ + ("cmd", "dns-add_record"), + ("record", record), + ("type", record_type), + ("value", &detected_ip.to_string()), + ])?; + info!("{} record created successfully", record_type); + } else { + // Propagate all other errors (API errors, rate limits, network errors) + return Err(e); + } + } + } + Ok(()) } fn main() -> Result<()> { - let args = Args::parse(); let level = if let Some(level) = args.log_level { @@ -219,45 +349,115 @@ fn main() -> Result<()> { .user_agent(format!("dreamhost-ddns/{}", env!("CARGO_PKG_VERSION"))) .build()?; - let dh = DreamhostClient { - client, - api_key, - }; + let dh = Arc::new(DreamhostClient::new(client.clone(), api_key)); + + // ---- Define detection jobs ---- + struct DetectionJob { + client: Client, + services: Vec<&'static str>, + require_ipv4: bool, + record_type: &'static str, + record_name: String, + dry_run: bool, + } - let wan_ip = get_wan_ip(&dh.client)?; - info!("Detected WAN IP: {}", wan_ip); + impl DetectionJob { + fn run(self, dh: &Arc) -> Result<()> { + + // helper to safely remove a DNS record if it exists + let remove_stale_record = |record_type: &str, record_name: &str| -> Result<()> { + match dh.get_dns_ip(record_name, record_type) { + Ok(existing_ip) => { + warn!("{} record exists but no WAN IP detected: {}", record_type, existing_ip); + if self.dry_run { + info!("DRY RUN: Would remove stale {} record {}", record_type, existing_ip); + } else { + dh.call(&[ + ("cmd", "dns-remove_record"), + ("record", record_name), + ("type", record_type), + ("value", &existing_ip), + ])?; + warn!("Removed stale {} record {}", record_type, existing_ip); + } + } + Err(_) => debug!("No {} record exists; nothing to remove", record_type), + } + Ok(()) + }; + + match detect_ip(&self.client, self.services, self.require_ipv4) { + Ok(ip) => { + let ipv = if self.record_type=="A" { "IPV4" } else { "IPV6" }; + info!("Detected {} WAN: {}", ipv, ip); + check_and_update(dh, &self.record_name, ip, self.record_type, self.dry_run)?; + } + Err(_) => { + if self.record_type == "AAAA" { + info!("No IPv6 WAN detected"); + remove_stale_record("AAAA", &self.record_name)?; + } else { + warn!("No IPv4 WAN detected"); + } + } + } - let dns_ip = dh.get_dns_ip(&record)?; - info!("DNS record IP: {}", dns_ip); + Ok(()) + } + } - if wan_ip.to_string() == dns_ip { - info!("DNS already up-to-date"); - return Ok(()); + // ---- Build jobs according to flags ---- + let mut jobs = Vec::new(); + + // If neither flag is set, run both by default + if !args.ipv6_only { + jobs.push(DetectionJob { + client: client.clone(), + services: ipv4_services(), + require_ipv4: true, + record_type: "A", + record_name: record.clone(), + dry_run: args.dry_run, + }); } - warn!("IP mismatch detected."); + if !args.ipv4_only { + jobs.push(DetectionJob { + client: client.clone(), + services: ipv6_services(), + require_ipv4: false, + record_type: "AAAA", + record_name: record.clone(), + dry_run: args.dry_run, + }); + } - if args.dry_run { - info!( - "DRY RUN: Would update DNS record {} from {} to {}", - record, dns_ip, wan_ip - ); - } else { - info!("Updating DNS..."); - dh.update_dns(&record, &dns_ip, &wan_ip.to_string())?; - info!("DNS updated successfully to {}", wan_ip); + if jobs.is_empty() { + return Err(anyhow!("Both --ipv4-only and --ipv6-only flags cannot be used together; nothing to do")); } + let handles: Vec<_> = jobs + .into_iter() + .map(|job| { + let dh_clone = dh.clone(); + thread::spawn(move || job.run(&dh_clone)) + }) + .collect(); + + // ---- Join threads and propagate any errors ---- + for handle in handles { + handle.join().expect("Thread panicked")?; + } Ok(()) } + fn resolve_config(args: &Args) -> Result { let mut api_key = args.api_key.clone(); let mut record = args.record.clone(); - // Environment variables if api_key.is_none() { api_key = std::env::var("DREAMHOST_API_KEY").ok(); } @@ -266,8 +466,8 @@ fn resolve_config(args: &Args) -> Result { record = std::env::var("DNS_RECORD").ok(); } - // Explicit config file if (api_key.is_none() || record.is_none()) && args.config.is_some() { + let cfg = load_config(args.config.as_ref().unwrap())?; if api_key.is_none() { @@ -279,8 +479,8 @@ fn resolve_config(args: &Args) -> Result { } } - // Default config.toml if (api_key.is_none() || record.is_none()) && std::path::Path::new("config.toml").exists() { + let cfg = load_config("config.toml")?; if api_key.is_none() { @@ -303,24 +503,19 @@ fn resolve_config(args: &Args) -> Result { fn load_config(path: &str) -> Result { let contents = std::fs::read_to_string(path)?; - let config: Config = toml::from_str(&contents)?; - Ok(config) + Ok(toml::from_str(&contents)?) } -fn get_wan_ip(client: &Client) -> Result { - let mut services = vec![ - "https://icanhazip.com", - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://checkip.amazonaws.com", - ]; +fn detect_ip(client: &Client, services: Vec<&str>, require_ipv4: bool) -> Result { + let mut services = services; services.shuffle(&mut rand::thread_rng()); let (tx, rx) = mpsc::channel(); - let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancel = Arc::new(AtomicBool::new(false)); for url in services { + let tx = tx.clone(); let client = client.clone(); let cancel = cancel.clone(); @@ -328,7 +523,7 @@ fn get_wan_ip(client: &Client) -> Result { thread::spawn(move || { - if cancel.load(std::sync::atomic::Ordering::Relaxed) { + if cancel.load(Ordering::Relaxed) { return; } @@ -341,10 +536,17 @@ fn get_wan_ip(client: &Client) -> Result { if let Some(ip) = result { - if !cancel.swap(true, std::sync::atomic::Ordering::Relaxed) { - let _ = tx.send((url, ip)); + if require_ipv4 && !ip.is_ipv4() { + return; } + if !require_ipv4 && !ip.is_ipv6() { + return; + } + + if !cancel.swap(true, Ordering::Relaxed) { + let _ = tx.send((url, ip)); + } } }); } @@ -358,4 +560,26 @@ fn get_wan_ip(client: &Client) -> Result { } Err(_) => Err(anyhow!("All WAN IP detection services failed")), } +} + +fn ipv4_services() -> Vec<&'static str> { + + vec![ + "https://icanhazip.com", + "https://api.ipify.org", + "https://ident.me", + "https://ifconfig.me/ip", + "https://checkip.amazonaws.com", + ] +} + +fn ipv6_services() -> Vec<&'static str> { + + vec![ + "https://api64.ipify.org", + "https://ipv6.icanhazip.com", + "https://v6.ident.me", + "https://api-ipv6.ip.sb/ip", + "https://ifconfig.co/ip", + ] } \ No newline at end of file