diff --git a/Cargo.lock b/Cargo.lock index 6eaf4bc..1947c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,7 +295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] -name = "hetzner_ddns" +name = "hetzner_dns" version = "0.1.0" dependencies = [ "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0f47e54..f693817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "hetzner_ddns" +name = "hetzner_dns" version = "0.1.0" edition = "2024" diff --git a/src/client.rs b/src/client.rs index f270c23..8eb2731 100644 --- a/src/client.rs +++ b/src/client.rs @@ -72,7 +72,7 @@ impl<'de> Deserialize<'de> for RecordType { */ pub struct HetznerDNSAPIClient { token: String, - host: &'static str, + host: Url, client: Client, } @@ -80,12 +80,12 @@ impl HetznerDNSAPIClient { pub fn new(token: String) -> Self { HetznerDNSAPIClient { token, - host: "https://dns.hetzner.com/api/v1", + host: Url::parse("https://dns.hetzner.com/api/v1/").unwrap(), client: Client::new(), } } - async fn api_call<'a, T, U, I, K, V>( + pub async fn api_call<'a, T, U, I, K, V>( &self, url: &'a str, method: Method, @@ -100,24 +100,35 @@ impl HetznerDNSAPIClient { K: AsRef, V: AsRef, { - let mut req = Request::new(method, Url::parse(&url).map_err(|_| ())?); + let mut req = Request::new( + method, + self.host.join(url).map_err(|e| { + println!("url formatting error: {}", e); + () + })?, + ); req.headers_mut().append( - "Auth-API-Key", + "Auth-API-Token", HeaderValue::from_str(self.token.as_str()).unwrap(), ); if let Some(payload) = payload { - *req.body_mut() = Some(serde_json::to_string(&payload).unwrap().into()); + *req.body_mut() = Some(serde_json::to_string(&payload).map_err(|e| { println!("body encoding error: {}",e); ()} )?.into()); } - Ok(Client::new() + let t = self.client .execute(req) .await - .map_err(|_| ())? - .json::() - .await - .map_err(|_| ())?) + .map_err(|e| { + println!("request execution error: {}", e); + () + })? + .error_for_status() + .map_err(|e| { println!("request error: {}", e); ()})? + .text().await + .map_err(|e| { println!("request decoding error: {}", e); ()})?; + Ok(serde_json::from_str::(&t).map_err(|e| {println!("json response parsing error: {}",e); ()})?) } - async fn get_zones<'a>( + pub async fn get_zones<'a>( &self, name: Option<&'a str>, page: Option, @@ -125,7 +136,7 @@ impl HetznerDNSAPIClient { search_name: Option<&'a str>, ) -> Result { self.api_call( - "/zones", + "zones", Method::GET, Some(&[ ("name", name.unwrap_or_default()), @@ -138,19 +149,19 @@ impl HetznerDNSAPIClient { .await } - async fn create_zone(&self, name: String, ttl: Option) -> Result { + pub async fn create_zone(&self, name: String, ttl: Option) -> Result { self.api_call( - "/zones", + "zones", Method::POST, None::<&[(&str, &str); 0]>, - Some(&[("name", name), ("ttl", ttl.unwrap_or(u64::MAX).to_string())]), + Some(&[("name", name), ("ttl", ttl.unwrap_or(86400).to_string())]), ) .await } - async fn get_zone(&self, id: String) -> Result { + pub async fn get_zone(&self, id: String) -> Result { self.api_call( - format!("/zones/{}", id).as_str(), + format!("zones/{}", id).as_str(), Method::GET, None::<[(&str, &str); 0]>, None::<&str>, @@ -158,19 +169,24 @@ impl HetznerDNSAPIClient { .await } - async fn update_zone(&self, id: String, name: String, ttl: Option) -> Result { + pub async fn update_zone( + &self, + id: String, + name: String, + ttl: Option, + ) -> Result { self.api_call( - format!("/zones/{}", id).as_str(), + format!("zones/{}", id).as_str(), Method::PUT, None::<[(&str, &str); 0]>, - Some(&[("name", name), ("ttl", ttl.unwrap_or(u64::MAX).to_string())]), + Some(&[("name", name), ("ttl", ttl.unwrap_or(86400).to_string())]), ) .await } - async fn delete_zone(&self, id: String) -> Result<(), ()> { + pub async fn delete_zone(&self, id: String) -> Result<(), ()> { self.api_call( - format!("/zones/{}", id).as_str(), + format!("zones/{}", id).as_str(), Method::DELETE, None::<&[(&str, &str); 0]>, None::<&str>, @@ -178,17 +194,17 @@ impl HetznerDNSAPIClient { .await? } - async fn import_zone() { + pub async fn import_zone() { todo!() } - async fn export_zone() { + pub async fn export_zone() { todo!() } - async fn validate_zone() { + pub async fn validate_zone() { todo!() } - async fn get_records( + pub async fn get_records( &self, page: Option, per_page: Option, @@ -196,7 +212,7 @@ impl HetznerDNSAPIClient { ) -> Result, ()> { let result: RecordsResult = self .api_call( - "/records", + "records", Method::GET, Some(&[ ("page", page.unwrap_or(1).to_string()), @@ -210,10 +226,10 @@ impl HetznerDNSAPIClient { Ok(result.records) } - async fn create_record(&self, payload: RecordPayload) -> Result { + pub async fn create_record(&self, payload: RecordPayload) -> Result { let result: RecordResult = self .api_call( - "/records", + "records", Method::POST, None::<[(&str, &str); 0]>, Some(payload), @@ -223,10 +239,10 @@ impl HetznerDNSAPIClient { Ok(result.record) } - async fn get_record(&self, record_id: String) -> Result { + pub async fn get_record(&self, record_id: String) -> Result { let result: RecordResult = self .api_call( - format!("/records/{}", record_id).as_str(), + format!("records/{}", record_id).as_str(), Method::GET, None::<[(&str, &str); 0]>, None::, @@ -236,10 +252,14 @@ impl HetznerDNSAPIClient { Ok(result.record) } - async fn update_record(&self, record_id: String, payload: RecordPayload) -> Result { + pub async fn update_record( + &self, + record_id: String, + payload: RecordPayload, + ) -> Result { let result: RecordResult = self .api_call( - format!("/records/{}", record_id).as_str(), + format!("records/{}", record_id).as_str(), Method::PUT, None::<[(&str, &str); 0]>, Some(payload), @@ -249,9 +269,9 @@ impl HetznerDNSAPIClient { Ok(result.record) } - async fn delete_record(&self, record_id: String) -> Result<(), ()> { + pub async fn delete_record(&self, record_id: String) -> Result<(), ()> { self.api_call( - format!("/records/{}", record_id).as_str(), + format!("records/{}", record_id).as_str(), Method::DELETE, None::<[(&str, &str); 0]>, None::, @@ -259,10 +279,10 @@ impl HetznerDNSAPIClient { .await? } - async fn create_records(&self, payloads: Vec) -> Result, ()> { + pub async fn create_records(&self, payloads: Vec) -> Result, ()> { let result: RecordsResult = self .api_call( - "/records/bulk", + "records/bulk", Method::POST, None::<[(&str, &str); 0]>, Some(payloads), @@ -272,10 +292,10 @@ impl HetznerDNSAPIClient { Ok(result.records) } - async fn update_records(&self, payloads: Vec) -> Result, ()> { + pub async fn update_records(&self, payloads: Vec) -> Result, ()> { let result: RecordsResult = self .api_call( - "/records/bulk", + "records/bulk", Method::PUT, None::<[(&str, &str); 0]>, Some(payloads), diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..86b91d5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +#![allow(dead_code, unused)] + +use serde::{Deserialize, Serialize}; +use crate::models::*; +use std::error::Error; + +pub mod models; +pub mod client; + diff --git a/src/main.rs b/src/main.rs index e76567f..cebf9a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,193 @@ #![allow(dead_code, unused)] - -use serde::{Deserialize, Serialize}; +use core::panic; +use std::ops::Sub; +use crate::client::*; use crate::models::*; -use std::error::Error; +use serde::{Serialize, Deserialize}; mod models; mod client; -#[tokio::main(flavor = "current_thread")] -async fn main() -> () { +#[derive(PartialEq, Eq, Debug)] +enum Mode { + Unset, + Errornous, + Zone, + Record, + PrimaryServer, +} + +#[derive(PartialEq, Eq, Debug)] +enum SubMode { + Help, + Get, + Create, + Update, + Delete, +} + +#[derive(Debug)] +struct ZoneContext { + all: bool, + zone: String, + name: String, + ttl: u64, +} + +#[derive(Debug)] +struct RecordProto<'a> { + r#type: RecordType, + name: &'a str, + value: &'a str, + ttl: Option, +} + +#[derive(Debug)] +struct RecordContext<'a> { + all: bool, + zone: &'a str, + records: Vec>, +} + +#[derive(Debug)] +struct Context<'a> { + mode: Mode, + submode: SubMode, + token: Option, + zone_context: ZoneContext, + record_context: RecordContext<'a>, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let mut ctx = Context { + mode: Mode::Unset, + submode: SubMode::Help, + token: std::env::var("HETZNER_DNS_API_TOKEN").ok(), + zone_context: ZoneContext { + all: false, + zone: String::new(), + name: String::new(), + ttl: 86400, + }, + record_context: RecordContext { + all: false, + zone: "", + records: vec![], + }, + }; + let mut _continue = false; + for (idx, arg) in std::env::args().enumerate() { + if _continue { + continue; + } + match idx { + 0 => continue, + 1 => { + ctx.mode = match arg.as_str() { + "zones" => Mode::Zone, + "records" => Mode::Record, + "primary" => Mode::PrimaryServer, + "z" => Mode::Zone, + "r" => Mode::Record, + "p" => Mode::PrimaryServer, + _ => Mode::Errornous, + } + } + 2 => { + ctx.submode = match arg.as_str() { + "get" => SubMode::Get, + "create" => SubMode::Create, + "update" => SubMode::Update, + "delete" => SubMode::Delete, + "g" => SubMode::Get, + "c" => SubMode::Create, + "u" => SubMode::Update, + "d" => SubMode::Delete, + _ => SubMode::Help, + } + } + _ => match ctx.mode { + Mode::Zone => { + if arg.starts_with("-") { + match arg.as_str() { + "--all" => ctx.zone_context.all = true, + "--name" => { + ctx.zone_context.name = std::env::args().nth(idx + 1).unwrap() + } + "--ttl" => { + ctx.zone_context.ttl = std::env::args() + .nth(idx + 1) + .unwrap() + .parse() + .unwrap_or(86400) + } + "--zone" => { + ctx.zone_context.zone = std::env::args().nth(idx + 1).unwrap() + } + _ => panic!("unknown parameter {}", arg), + } + } else { + continue; // value, ignore + } + } + Mode::Record => { + if arg.starts_with("-") { + match arg.as_str() { + "--all" => ctx.record_context.all = true, + _ => todo!() + } + } + } + Mode::PrimaryServer => { + todo!() + } + _ => { + continue; + } + }, + } + } + + if ctx.mode != Mode::Unset && ctx.mode != Mode::Errornous && ctx.submode != SubMode::Help { + if let Some(ref token) = ctx.token { + let client = HetznerDNSAPIClient::new(String::from(token.trim())); + match ctx.mode { + Mode::Zone => match ctx.submode { + SubMode::Get => { + if ctx.zone_context.all { + println!( + "{:#?}", + client.get_zones(None, None, None, None).await.unwrap() + ); + } + } + _ => { + todo!() + } + }, + Mode::Record => match ctx.submode { + SubMode::Get => { + if ctx.record_context.all { + println!("{:#?}", client.get_records(None, None, None).await.unwrap()); + } + } + _ => { todo!() } + } + Mode::PrimaryServer => { + todo!() + } + _ => panic!("how in the even"), + } + } else { + panic!("missing token!"); + } + } else { + match ctx.mode { + Mode::Zone => println!("zone help"), + Mode::Record => println!("record help"), + Mode::PrimaryServer => println!("primary server help"), + _ => println!("full help"), + } + } } diff --git a/src/models.rs b/src/models.rs index 41bd45d..38f81bd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -24,10 +24,12 @@ pub struct Meta { #[derive(Debug, Deserialize)] pub struct Zone { pub id: String, + #[serde(with = "hetzner_date")] pub created: DateTime, + #[serde(with = "hetzner_date")] pub modified: DateTime, pub legacy_dns_host: String, - pub legacy_dns: Vec, + pub legacy_dns: Option>, pub ns: Vec, pub owner: String, pub paused: bool, @@ -35,11 +37,12 @@ pub struct Zone { pub project: String, pub registrar: String, pub status: String, - pub ttl: u32, - pub verified: DateTime, + pub ttl: Option, + //#[serde(with = "hetzner_date")] // verified strings are empty, so its useless anyway + //pub verified: Option>, pub records_count: u32, pub is_secondary_dns: bool, - pub txt_verification: Vec + pub txt_verification: TxtVerification } #[derive(Debug, Deserialize)] @@ -60,13 +63,15 @@ pub struct RecordPayload { #[derive(Debug, Serialize, Deserialize)] pub struct Record { id: String, + #[serde(with = "hetzner_date")] created: DateTime, + #[serde(with = "hetzner_date")] modified: DateTime, zone_id: String, r#type: String, name: String, value: String, - ttl: u64 + ttl: Option } #[derive(Debug, Serialize, Deserialize)] @@ -78,3 +83,33 @@ pub struct RecordResult { pub struct RecordsResult { pub records: Vec } + +mod hetzner_date { + use chrono::{DateTime, Utc, NaiveDateTime}; + use serde::{self, Deserialize, Serializer, Deserializer}; + + // 2025-01-06 02:18:34.674 +0000 UTC + const FORMAT: &str = "%F %T.%-f"; + + pub fn serialize( + date: &DateTime, + serializer: S, + ) -> Result + where + S: Serializer, + { + let s = format!("{}", date.format(FORMAT)); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let dt = NaiveDateTime::parse_from_str(&s.split(" +").next().unwrap(), FORMAT).map_err(serde::de::Error::custom)?; + Ok(DateTime::::from_naive_utc_and_offset(dt, Utc)) + } +}