add command and various bug fixes (tests needed aaaa)
Some checks failed
Build legacy Nix package on Ubuntu / build (push) Failing after 1m18s

This commit is contained in:
bread 2025-04-20 03:49:48 +02:00
parent e1835d0f2c
commit 9e42f2e53d
6 changed files with 297 additions and 52 deletions

2
Cargo.lock generated
View file

@ -295,7 +295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
[[package]] [[package]]
name = "hetzner_ddns" name = "hetzner_dns"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",

View file

@ -1,5 +1,5 @@
[package] [package]
name = "hetzner_ddns" name = "hetzner_dns"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View file

@ -72,7 +72,7 @@ impl<'de> Deserialize<'de> for RecordType {
*/ */
pub struct HetznerDNSAPIClient { pub struct HetznerDNSAPIClient {
token: String, token: String,
host: &'static str, host: Url,
client: Client, client: Client,
} }
@ -80,12 +80,12 @@ impl HetznerDNSAPIClient {
pub fn new(token: String) -> Self { pub fn new(token: String) -> Self {
HetznerDNSAPIClient { HetznerDNSAPIClient {
token, token,
host: "https://dns.hetzner.com/api/v1", host: Url::parse("https://dns.hetzner.com/api/v1/").unwrap(),
client: Client::new(), 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, &self,
url: &'a str, url: &'a str,
method: Method, method: Method,
@ -100,24 +100,35 @@ impl HetznerDNSAPIClient {
K: AsRef<str>, K: AsRef<str>,
V: AsRef<str>, V: AsRef<str>,
{ {
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( req.headers_mut().append(
"Auth-API-Key", "Auth-API-Token",
HeaderValue::from_str(self.token.as_str()).unwrap(), HeaderValue::from_str(self.token.as_str()).unwrap(),
); );
if let Some(payload) = payload { 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) .execute(req)
.await .await
.map_err(|_| ())? .map_err(|e| {
.json::<T>() println!("request execution error: {}", e);
.await ()
.map_err(|_| ())?) })?
.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>(&t).map_err(|e| {println!("json response parsing error: {}",e); ()})?)
} }
async fn get_zones<'a>( pub async fn get_zones<'a>(
&self, &self,
name: Option<&'a str>, name: Option<&'a str>,
page: Option<u32>, page: Option<u32>,
@ -125,7 +136,7 @@ impl HetznerDNSAPIClient {
search_name: Option<&'a str>, search_name: Option<&'a str>,
) -> Result<ZoneResult, ()> { ) -> Result<ZoneResult, ()> {
self.api_call( self.api_call(
"/zones", "zones",
Method::GET, Method::GET,
Some(&[ Some(&[
("name", name.unwrap_or_default()), ("name", name.unwrap_or_default()),
@ -138,19 +149,19 @@ impl HetznerDNSAPIClient {
.await .await
} }
async fn create_zone(&self, name: String, ttl: Option<u64>) -> Result<Zone, ()> { pub async fn create_zone(&self, name: String, ttl: Option<u64>) -> Result<Zone, ()> {
self.api_call( self.api_call(
"/zones", "zones",
Method::POST, Method::POST,
None::<&[(&str, &str); 0]>, 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 .await
} }
async fn get_zone(&self, id: String) -> Result<Zone, ()> { pub async fn get_zone(&self, id: String) -> Result<Zone, ()> {
self.api_call( self.api_call(
format!("/zones/{}", id).as_str(), format!("zones/{}", id).as_str(),
Method::GET, Method::GET,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
None::<&str>, None::<&str>,
@ -158,19 +169,24 @@ impl HetznerDNSAPIClient {
.await .await
} }
async fn update_zone(&self, id: String, name: String, ttl: Option<u64>) -> Result<Zone, ()> { pub async fn update_zone(
&self,
id: String,
name: String,
ttl: Option<u64>,
) -> Result<Zone, ()> {
self.api_call( self.api_call(
format!("/zones/{}", id).as_str(), format!("zones/{}", id).as_str(),
Method::PUT, Method::PUT,
None::<[(&str, &str); 0]>, 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 .await
} }
async fn delete_zone(&self, id: String) -> Result<(), ()> { pub async fn delete_zone(&self, id: String) -> Result<(), ()> {
self.api_call( self.api_call(
format!("/zones/{}", id).as_str(), format!("zones/{}", id).as_str(),
Method::DELETE, Method::DELETE,
None::<&[(&str, &str); 0]>, None::<&[(&str, &str); 0]>,
None::<&str>, None::<&str>,
@ -178,17 +194,17 @@ impl HetznerDNSAPIClient {
.await? .await?
} }
async fn import_zone() { pub async fn import_zone() {
todo!() todo!()
} }
async fn export_zone() { pub async fn export_zone() {
todo!() todo!()
} }
async fn validate_zone() { pub async fn validate_zone() {
todo!() todo!()
} }
async fn get_records( pub async fn get_records(
&self, &self,
page: Option<u32>, page: Option<u32>,
per_page: Option<u32>, per_page: Option<u32>,
@ -196,7 +212,7 @@ impl HetznerDNSAPIClient {
) -> Result<Vec<Record>, ()> { ) -> Result<Vec<Record>, ()> {
let result: RecordsResult = self let result: RecordsResult = self
.api_call( .api_call(
"/records", "records",
Method::GET, Method::GET,
Some(&[ Some(&[
("page", page.unwrap_or(1).to_string()), ("page", page.unwrap_or(1).to_string()),
@ -210,10 +226,10 @@ impl HetznerDNSAPIClient {
Ok(result.records) Ok(result.records)
} }
async fn create_record(&self, payload: RecordPayload) -> Result<Record, ()> { pub async fn create_record(&self, payload: RecordPayload) -> Result<Record, ()> {
let result: RecordResult = self let result: RecordResult = self
.api_call( .api_call(
"/records", "records",
Method::POST, Method::POST,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
Some(payload), Some(payload),
@ -223,10 +239,10 @@ impl HetznerDNSAPIClient {
Ok(result.record) Ok(result.record)
} }
async fn get_record(&self, record_id: String) -> Result<Record, ()> { pub async fn get_record(&self, record_id: String) -> Result<Record, ()> {
let result: RecordResult = self let result: RecordResult = self
.api_call( .api_call(
format!("/records/{}", record_id).as_str(), format!("records/{}", record_id).as_str(),
Method::GET, Method::GET,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
None::<u8>, None::<u8>,
@ -236,10 +252,14 @@ impl HetznerDNSAPIClient {
Ok(result.record) Ok(result.record)
} }
async fn update_record(&self, record_id: String, payload: RecordPayload) -> Result<Record, ()> { pub async fn update_record(
&self,
record_id: String,
payload: RecordPayload,
) -> Result<Record, ()> {
let result: RecordResult = self let result: RecordResult = self
.api_call( .api_call(
format!("/records/{}", record_id).as_str(), format!("records/{}", record_id).as_str(),
Method::PUT, Method::PUT,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
Some(payload), Some(payload),
@ -249,9 +269,9 @@ impl HetznerDNSAPIClient {
Ok(result.record) 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( self.api_call(
format!("/records/{}", record_id).as_str(), format!("records/{}", record_id).as_str(),
Method::DELETE, Method::DELETE,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
None::<u8>, None::<u8>,
@ -259,10 +279,10 @@ impl HetznerDNSAPIClient {
.await? .await?
} }
async fn create_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> { pub async fn create_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> {
let result: RecordsResult = self let result: RecordsResult = self
.api_call( .api_call(
"/records/bulk", "records/bulk",
Method::POST, Method::POST,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
Some(payloads), Some(payloads),
@ -272,10 +292,10 @@ impl HetznerDNSAPIClient {
Ok(result.records) Ok(result.records)
} }
async fn update_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> { pub async fn update_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> {
let result: RecordsResult = self let result: RecordsResult = self
.api_call( .api_call(
"/records/bulk", "records/bulk",
Method::PUT, Method::PUT,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
Some(payloads), Some(payloads),

9
src/lib.rs Normal file
View file

@ -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;

View file

@ -1,12 +1,193 @@
#![allow(dead_code, unused)] #![allow(dead_code, unused)]
use core::panic;
use serde::{Deserialize, Serialize}; use std::ops::Sub;
use crate::client::*;
use crate::models::*; use crate::models::*;
use std::error::Error; use serde::{Serialize, Deserialize};
mod models; mod models;
mod client; mod client;
#[tokio::main(flavor = "current_thread")] #[derive(PartialEq, Eq, Debug)]
async fn main() -> () { 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<u64>,
}
#[derive(Debug)]
struct RecordContext<'a> {
all: bool,
zone: &'a str,
records: Vec<RecordProto<'a>>,
}
#[derive(Debug)]
struct Context<'a> {
mode: Mode,
submode: SubMode,
token: Option<String>,
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"),
}
}
} }

View file

@ -24,10 +24,12 @@ pub struct Meta {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Zone { pub struct Zone {
pub id: String, pub id: String,
#[serde(with = "hetzner_date")]
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
#[serde(with = "hetzner_date")]
pub modified: DateTime<Utc>, pub modified: DateTime<Utc>,
pub legacy_dns_host: String, pub legacy_dns_host: String,
pub legacy_dns: Vec<String>, pub legacy_dns: Option<Vec<String>>,
pub ns: Vec<String>, pub ns: Vec<String>,
pub owner: String, pub owner: String,
pub paused: bool, pub paused: bool,
@ -35,11 +37,12 @@ pub struct Zone {
pub project: String, pub project: String,
pub registrar: String, pub registrar: String,
pub status: String, pub status: String,
pub ttl: u32, pub ttl: Option<u64>,
pub verified: DateTime<Utc>, //#[serde(with = "hetzner_date")] // verified strings are empty, so its useless anyway
//pub verified: Option<DateTime<Utc>>,
pub records_count: u32, pub records_count: u32,
pub is_secondary_dns: bool, pub is_secondary_dns: bool,
pub txt_verification: Vec<TxtVerification> pub txt_verification: TxtVerification
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -60,13 +63,15 @@ pub struct RecordPayload {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Record { pub struct Record {
id: String, id: String,
#[serde(with = "hetzner_date")]
created: DateTime<Utc>, created: DateTime<Utc>,
#[serde(with = "hetzner_date")]
modified: DateTime<Utc>, modified: DateTime<Utc>,
zone_id: String, zone_id: String,
r#type: String, r#type: String,
name: String, name: String,
value: String, value: String,
ttl: u64 ttl: Option<u64>
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -78,3 +83,33 @@ pub struct RecordResult {
pub struct RecordsResult { pub struct RecordsResult {
pub records: Vec<Record> pub records: Vec<Record>
} }
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<S>(
date: &DateTime<Utc>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = format!("{}", date.format(FORMAT));
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(
deserializer: D,
) -> Result<DateTime<Utc>, 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::<Utc>::from_naive_utc_and_offset(dt, Utc))
}
}