Files
hetzer_ddns_rust/src/main.rs

148 lines
4.8 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::env;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use dotenv::dotenv;
use clap::Parser;
use dotenv::Error as DotenvError;
#[derive(Deserialize)]
struct Zone {
id: String,
name: String,
}
#[derive(Deserialize)]
struct ZoneList {
zones: Vec<Zone>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct Record {
id: String,
#[serde(rename = "type")]
record_type: String,
name: String,
value: String,
zone_id: String,
ttl: Option<u32>,
}
#[derive(Deserialize)]
struct RecordList {
records: Vec<Record>,
}
#[derive(Parser, Debug)]
#[command(name = "hetzner-ddns", version, about = "Dynamic DNS updater for Hetzner")]
struct Cli {
/// Update the AAAA (IPv6) record as well
#[arg(long)]
ipv6: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Cli::parse();
let update_ipv6 = args.ipv6;
match dotenv() {
Ok(_) => {} // .env loaded
Err(DotenvError::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("❌ Error: .env file not found. Please create one with DNS_FQDN=...");
std::process::exit(1);
}
Err(e) => {
eprintln!("❌ Error loading .env file: {}", e);
std::process::exit(1);
}
}
//dotenv().ok();
let api_token = env::var("HETZNER_API_TOKEN").map_err(|_| "❌ Missing HETZNER_API_TOKEN in environment (check .env file)")?;
let dns_fqdn = env::var("DNS_FQDN").map_err(|_| "❌ Missing DNS_FQDN in environment (check .env file)")?;
// Split domain from record
let parts: Vec<&str> = dns_fqdn.split('.').collect();
if parts.len() < 2 {
return Err("DNS_FQDN must be a valid FQDN (e.g. dyndns.example.com)".into());
}
let record_name = parts[0].to_string();
let zone_name = parts[1..].join(".");
let client = Client::new();
// Fetch public IPs
let ip4 = client.get("https://ipv4.icanhazip.com").send()?.text()?.trim().to_string();
let ip6 = client.get("https://ipv6.icanhazip.com").send().ok()
.and_then(|r| r.text().ok())
.map(|s| s.trim().to_string());
// Get Zone ID
let zones: ZoneList = client.get("https://dns.hetzner.com/api/v1/zones")
.header("Auth-API-Token", &api_token)
.send()?.json()?;
let zone = zones.zones.iter().find(|z| z.name == zone_name)
.ok_or("❌ Zone not found")?;
// Get DNS record
let records: RecordList = client.get(format!("https://dns.hetzner.com/api/v1/records?zone_id={}", zone.id))
.header("Auth-API-Token", &api_token)
.send()?.json()?;
// --- IPv4 (A) Record ---
if let Some(record4) = records.records.iter().find(|r| r.name == record_name && r.record_type == "A") {
if record4.value != ip4 {
println!("🔄 Updating A record from {} to {}", record4.value, ip4);
let updated4 = Record {
value: ip4.clone(),
ttl: Some(60),
..record4.to_owned()
};
client.put(format!("https://dns.hetzner.com/api/v1/records/{}", record4.id))
.header("Auth-API-Token", &api_token)
.header("Content-Type", "application/json")
.json(&updated4)
.send()?;
println!("✅ A record updated.");
} else {
println!("✅ A record already up to date: {}", ip4);
}
} else {
println!("⚠️ A record not found.");
}
// --- IPv6 (AAAA) Record ---
if update_ipv6 {
if let Some(ip6) = ip6 {
if let Some(record6) = records.records.iter().find(|r| r.name == record_name && r.record_type == "AAAA") {
if record6.value != ip6 {
println!("🔄 Updating AAAA record from {} to {}", record6.value, ip6);
let updated6 = Record {
value: ip6.clone(),
ttl: Some(60),
..record6.to_owned()
};
client.put(format!("https://dns.hetzner.com/api/v1/records/{}", record6.id))
.header("Auth-API-Token", &api_token)
.header("Content-Type", "application/json")
.json(&updated6)
.send()?;
println!("✅ AAAA record updated.");
} else {
println!("✅ AAAA record already up to date: {}", ip6);
}
} else {
println!("⚠️ AAAA record not found.");
}
} else {
println!(" No public IPv6 address found. Skipping AAAA update.");
}
} else {
println!(" Skipping AAAA update (use --ipv6 to enable).");
}
Ok(())
}