Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified binaries/linux-aarch64/dreamhost-ddns
Binary file not shown.
Binary file modified binaries/linux-rpi-armv7/dreamhost-ddns
Binary file not shown.
Binary file modified binaries/linux-x86_64/dreamhost-ddns
Binary file not shown.
Binary file modified binaries/windows/dreamhost-ddns.exe
Binary file not shown.
227 changes: 144 additions & 83 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,40 @@ use std::net::IpAddr;
use std::sync::mpsc;
use std::thread;


#[derive(Parser)]
#[command(
name = "dreamhost-ddns",
version,
about = "Updates a DreamHost DNS A record with the current WAN IP",
long_about = None
about = "Updates a DreamHost DNS A record with the current WAN IP"
)]
struct Args {
#[arg(short, long)]
verbose: bool,

#[arg(short, long, default_value = "config.toml")]
config: String,

#[arg(long)]
dry_run: bool,
#[arg(short, long)]
config: Option<String>,

#[arg(long)]
api_key: Option<String>,

#[arg(long)]
record: Option<String>,

#[arg(long, default_value_t = 300)]
interval: u64,

#[arg(long)]
dry_run: bool,
}

#[derive(Debug, Deserialize)]
struct Record {
record: String,

#[serde(rename = "type")]
record_type: String,
value: String,
}

#[derive(Debug, Deserialize)]
struct ApiResponse {
data: Option<Vec<Record>>,
value: String,
}

#[derive(Debug, Deserialize)]
Expand All @@ -51,6 +49,73 @@ struct Config {
dns_record: String,
}

struct DreamhostClient {
client: Client,
api_key: String,
}

impl DreamhostClient {
fn call(&self, params: &[(&str, &str)]) -> Result<serde_json::Value> {
let mut query = vec![
("key", self.api_key.as_str()),
("format", "json"),
];

query.extend_from_slice(params);

let resp: serde_json::Value = self.client
.get("https://api.dreamhost.com/")
.query(&query)
.send()?
.json()?;

if resp["result"] != "success" {
let reason = resp["reason"]
.as_str()
.unwrap_or("Unknown DreamHost API error");

return Err(anyhow!("DreamHost API error: {}", reason));
}

Ok(resp)
}

fn get_dns_ip(&self, record_name: &str) -> Result<String> {
let resp = self.call(&[
("cmd", "dns-list_records"),
])?;

let records: Vec<Record> = serde_json::from_value(resp["data"].clone())?;

records
.into_iter()
.find(|r| r.record == record_name && r.record_type == "A")
.map(|r| r.value)
.ok_or_else(|| anyhow!("DreamHost error: DNS record '{}' not found", record_name))
}

fn update_dns(&self, record: &str, old_ip: &str, new_ip: &str) -> Result<()> {
info!("Adding new DNS record {} -> {}", record, new_ip);

self.call(&[
("cmd", "dns-add_record"),
("record", record),
("type", "A"),
("value", new_ip),
])?;

info!("Removing old DNS record {} -> {}", record, old_ip);

self.call(&[
("cmd", "dns-remove_record"),
("record", record),
("type", "A"),
("value", old_ip),
])?;

Ok(())
}
}

fn main() -> Result<()> {

Expand All @@ -64,26 +129,35 @@ fn main() -> Result<()> {
env_logger::init();
}

let config = load_config(&args.config)?;
let config = resolve_config(&args)?;

let api_key = args.api_key.unwrap_or(config.dreamhost_api_key);
let record = args.record.unwrap_or(config.dns_record);

info!("Record: {}", record);
info!("Check interval: {} seconds", args.interval);

let client = Client::builder()
.timeout(std::time::Duration::from_secs(3))
.timeout(std::time::Duration::from_secs(5))
.user_agent("dreamhost-ddns/1.0")
.build()?;

let wan_ip = get_wan_ip(&client)?;
let dh = DreamhostClient {
client,
api_key,
};

let wan_ip = get_wan_ip(&dh.client)?;
info!("Detected WAN IP: {}", wan_ip);

let dns_ip = get_dns_ip(&client, &api_key, &record)?;
let dns_ip = dh.get_dns_ip(&record)?;
info!("DNS record IP: {}", dns_ip);

if wan_ip.to_string() == dns_ip {
info!("DNS already up-to-date");
return Ok(());
}

warn!("IP mismatch detected.");

if args.dry_run {
Expand All @@ -93,22 +167,69 @@ fn main() -> Result<()> {
);
} else {
info!("Updating DNS...");
update_dns(&client, &api_key, &record, &dns_ip, &wan_ip.to_string())?;
dh.update_dns(&record, &dns_ip, &wan_ip.to_string())?;
info!("DNS updated successfully");
}

info!("DNS updated successfully");

Ok(())
}

fn resolve_config(args: &Args) -> Result<Config> {

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();
}

if record.is_none() {
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() {
api_key = Some(cfg.dreamhost_api_key);
}

if record.is_none() {
record = Some(cfg.dns_record);
}
}

// 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() {
api_key = Some(cfg.dreamhost_api_key);
}

if record.is_none() {
record = Some(cfg.dns_record);
}
}

let api_key = api_key.ok_or_else(|| anyhow!("Missing DreamHost API key"))?;
let record = record.ok_or_else(|| anyhow!("Missing DNS record"))?;

Ok(Config {
dreamhost_api_key: api_key,
dns_record: record,
})
}

fn load_config(path: &str) -> Result<Config> {
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}


fn get_wan_ip(client: &Client) -> Result<IpAddr> {
let services = [
"https://icanhazip.com",
Expand Down Expand Up @@ -136,73 +257,13 @@ fn get_wan_ip(client: &Client) -> Result<IpAddr> {
});
}

drop(tx); // close channel when threads finish
drop(tx);

match rx.recv() {
Ok((url, ip)) => {
info!("WAN IP detected via {}: {}", url, ip);
Ok(ip)
}
Err(_) => Err(anyhow!("Could not determine WAN IP")),
Err(_) => Err(anyhow!("All WAN IP detection services failed")),
}
}

fn get_dns_ip(client: &Client, api_key: &str, record_name: &str) -> Result<String> {

let res: ApiResponse = client
.get("https://api.dreamhost.com/")
.query(&[
("key", api_key),
("cmd", "dns-list_records"),
("format", "json"),
])
.send()?
.json()?;

let records = res.data.ok_or_else(|| anyhow!("No DNS data returned"))?;

records
.into_iter()
.find(|r| r.record == record_name && r.record_type == "A")
.map(|r| r.value)
.ok_or_else(|| anyhow!("DNS record not found"))
}

fn update_dns(client: &Client, api_key: &str, record: &str, old_ip: &str, new_ip: &str) -> Result<()> {

info!("Adding new DNS record {} -> {}", record, new_ip);

client
.get("https://api.dreamhost.com/")
.query(&[
("key", api_key),
("cmd", "dns-add_record"),
("record", record),
("type", "A"),
("value", new_ip),
("format", "json"),
])
.send()?
.error_for_status()?;

info!("New record added successfully");

info!("Removing old DNS record {} -> {}", record, old_ip);

client
.get("https://api.dreamhost.com/")
.query(&[
("key", api_key),
("cmd", "dns-remove_record"),
("record", record),
("type", "A"),
("value", old_ip),
("format", "json"),
])
.send()?
.error_for_status()?;

info!("Old record removed");

Ok(())
}