diff --git a/.envrc b/.envrc index 3550a30..7e04175 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ use flake +export HETZNER_API_KEY=pBCqf2QRkrLgFglTgEH4PJMQvxSuNOiH diff --git a/Cargo.toml b/Cargo.toml index 7a6340f..0f47e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,4 @@ chrono = { version = "0.4.40", features = ["serde"] } reqwest = { version = "0.12.15", features = ["json"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" -tokio = { version = "1.44.2", features = ["macros", "rt", "time"] } +tokio = { version = "1.44.2", features = ["macros", "time"] } diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..8de0bd2 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,224 @@ +use crate::*; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + Client, Method, Request, Url, +}; +use serde::{Serializer, de::Visitor}; +use serde_json::Value; +use std::{borrow::Borrow, collections::HashMap}; + +#[derive(Debug, Serialize, Deserialize)] +pub enum RecordType { + A, + AAAA, + NS, + MX, + CNAME, + RP, + TXT, + SOA, + HINFO, + SRV, + DANE, + TLSA, + DS, + CAA, +} +/* +impl Serialize for RecordType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(match self { + RecordType::A => "A", + RecordType::AAAA => "AAAA", + RecordType::NS => "NS", + RecordType::MX => "MX", + RecordType::CNAME => "CNAME", + RecordType::RP => "RP", + RecordType::TXT => "TXT", + RecordType::SOA => "SOA", + RecordType::HINFO => "HINFO", + RecordType::SRV => "SRV", + RecordType::DANE => "DANE", + RecordType::TLSA => "TLSA", + RecordType::DS => "DS", + RecordType::CAA => "CAA", + }) + } +} + +impl<'de> Visitor<'de> for RecordType { + type Value = &'static str; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a dns record string") + } +} + +impl<'de> Deserialize<'de> for RecordType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de> { + deserializer.deserialize_str(RecordType) + } + fn deserialize_in_place(deserializer: D, place: &mut Self) -> Result<(), D::Error> + where + D: serde::Deserializer<'de>, { + deserializer.deserialize_str() + } +} +*/ +pub struct HetznerDNSAPIClient { + token: String, + host: &'static str, + client: Client, +} + +impl HetznerDNSAPIClient { + pub fn new(token: String) -> Self { + HetznerDNSAPIClient { + token, + host: "https://dns.hetzner.com/api/v1", + client: Client::new(), + } + } + + async fn api_call<'a, T, U, I, K, V>( + &self, + url: &'a str, + method: Method, + query: Option, + payload: Option, + ) -> Result + where + T: for<'de> Deserialize<'de>, + U: Serialize, + I: IntoIterator, + ::Item: Borrow<(K, V)>, + K: AsRef, + V: AsRef, + { + let mut req = Request::new(method, Url::parse(&url).map_err(|_| ())?); + req.headers_mut().append( + "Auth-API-Key", + HeaderValue::from_str(self.token.as_str()).unwrap(), + ); + if let Some(payload) = payload { + *req.body_mut() = Some(serde_json::to_string(&payload).unwrap().into()); + } + Ok(Client::new() + .execute(req) + .await + .map_err(|_| ())? + .json::() + .await + .map_err(|_| ())?) + } + + async fn get_zones<'a>( + &self, + name: Option<&'a str>, + page: Option, + per_page: Option, + search_name: Option<&'a str>, + ) -> Result { + self.api_call( + "/zones", + Method::GET, + Some(&[ + ("name", name.unwrap_or_default()), + ("page", page.unwrap_or(1).to_string().as_str()), + ("per_page", per_page.unwrap_or(100).to_string().as_str()), + ("search_name", search_name.unwrap_or_default()), + ]), + None::, + ) + .await + } + + async fn create_zone(&self, name: String, ttl: Option) -> Result { + self.api_call( + "/zones", + Method::POST, + None::<&[(&str, &str); 0]>, + Some(&[("name", name), ("ttl", ttl.unwrap_or(u64::MAX).to_string())]), + ) + .await + } + + async fn get_zone(&self, id: String) -> Result { + self.api_call( + format!("/zones/{}", id).as_str(), + Method::GET, + None::<[(&str, &str); 0]>, + None::<&str>, + ) + .await + } + + async fn update_zone(&self, id: String, name: String, ttl: Option) -> Result { + self.api_call( + format!("/zones/{}", id).as_str(), + Method::PUT, + None::<[(&str, &str); 0]>, + Some(&[("name", name), ("ttl", ttl.unwrap_or(u64::MAX).to_string())]), + ) + .await + } + + async fn delete_zone(&self, id: String) -> Result<(), ()> { + self.api_call( + format!("/zones/{}", id).as_str(), + Method::DELETE, + None::<&[(&str, &str); 0]>, + None::<&str>, + ) + .await? + } + + async fn import_zone() { + todo!() + } + async fn export_zone() { + todo!() + } + async fn validate_zone() { + todo!() + } + + async fn get_records(&self, page: Option, per_page: Option, zone_id: Option) -> Result, ()> { + let result: RecordsResult = self.api_call("/records", Method::GET, Some(&[("page", page.unwrap_or(1).to_string()), ("per_page", per_page.unwrap_or(100).to_string()), ("zone_id", zone_id.unwrap_or_default())]), None::).await.map_err(|_| ())?; + Ok(result.records) + } + + async fn create_record(&self, payload: RecordPayload) -> Result { + let result: RecordResult = self.api_call("/records", Method::POST, None::<[(&str, &str); 0]>, Some(payload)).await.map_err(|_| ())?; + Ok(result.record) + } + + async fn get_record(&self, record_id: String) -> Result { + let result: RecordResult = self.api_call(format!("/records/{}", record_id).as_str(), Method::GET, None::<[(&str, &str); 0]>, None::).await.map_err(|_| ())?; + Ok(result.record) + } + + async fn update_record(&self, record_id: String, payload: RecordPayload) -> Result { + let result: RecordResult = self.api_call(format!("/records/{}", record_id).as_str(), Method::PUT, None::<[(&str, &str); 0]>, Some(payload)).await.map_err(|_| ())?; + Ok(result.record) + } + + async fn delete_record(&self, record_id: String) -> Result<(), ()> { + self.api_call(format!("/records/{}", record_id).as_str(), Method::DELETE, None::<[(&str, &str); 0]>, None::).await? + } + + async fn create_records(&self, payloads: Vec) -> Result, ()> { + let result: RecordsResult = self.api_call("/records/bulk", Method::POST, None::<[(&str, &str); 0]>, Some(payloads)).await.map_err(|_| ())?; + Ok(result.records) + } + + async fn update_records(&self, payloads: Vec) -> Result, ()> { + let result: RecordsResult = self.api_call("/records/bulk", Method::PUT, None::<[(&str, &str); 0]>, Some(payloads)).await.map_err(|_| ())?; + Ok(result.records) + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..e76567f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,12 @@ -fn main() { - println!("Hello, world!"); +#![allow(dead_code, unused)] + +use serde::{Deserialize, Serialize}; +use crate::models::*; +use std::error::Error; + +mod models; +mod client; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> () { } diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..41bd45d --- /dev/null +++ b/src/models.rs @@ -0,0 +1,80 @@ +use serde::{Serialize, Deserialize}; +use chrono::{DateTime, Utc}; +use crate::client::RecordType; + +#[derive(Debug, Deserialize)] +pub struct TxtVerification { + pub name: String, + pub token: String +} + +#[derive(Debug, Deserialize)] +pub struct Pagination { + pub page: u32, + pub per_page: u32, + pub last_page: u32, + pub total_entries: u32 +} + +#[derive(Debug, Deserialize)] +pub struct Meta { + pub pagination: Pagination +} + +#[derive(Debug, Deserialize)] +pub struct Zone { + pub id: String, + pub created: DateTime, + pub modified: DateTime, + pub legacy_dns_host: String, + pub legacy_dns: Vec, + pub ns: Vec, + pub owner: String, + pub paused: bool, + pub permission: String, + pub project: String, + pub registrar: String, + pub status: String, + pub ttl: u32, + pub verified: DateTime, + pub records_count: u32, + pub is_secondary_dns: bool, + pub txt_verification: Vec +} + +#[derive(Debug, Deserialize)] +pub struct ZoneResult { + pub zones: Vec, + pub meta: Meta +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RecordPayload { + zone_id: String, + r#type: RecordType, + name: String, + value: String, + ttl: u64 +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Record { + id: String, + created: DateTime, + modified: DateTime, + zone_id: String, + r#type: String, + name: String, + value: String, + ttl: u64 +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RecordResult { + pub record: Record +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RecordsResult { + pub records: Vec +}