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"
[[package]]
name = "hetzner_ddns"
name = "hetzner_dns"
version = "0.1.0"
dependencies = [
"chrono",

View file

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

View file

@ -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<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(
"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::<T>()
.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>(&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<u32>,
@ -125,7 +136,7 @@ impl HetznerDNSAPIClient {
search_name: Option<&'a str>,
) -> Result<ZoneResult, ()> {
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<u64>) -> Result<Zone, ()> {
pub async fn create_zone(&self, name: String, ttl: Option<u64>) -> Result<Zone, ()> {
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<Zone, ()> {
pub async fn get_zone(&self, id: String) -> Result<Zone, ()> {
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<u64>) -> Result<Zone, ()> {
pub async fn update_zone(
&self,
id: String,
name: String,
ttl: Option<u64>,
) -> Result<Zone, ()> {
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<u32>,
per_page: Option<u32>,
@ -196,7 +212,7 @@ impl HetznerDNSAPIClient {
) -> Result<Vec<Record>, ()> {
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<Record, ()> {
pub async fn create_record(&self, payload: RecordPayload) -> Result<Record, ()> {
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<Record, ()> {
pub async fn get_record(&self, record_id: String) -> Result<Record, ()> {
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::<u8>,
@ -236,10 +252,14 @@ impl HetznerDNSAPIClient {
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
.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::<u8>,
@ -259,10 +279,10 @@ impl HetznerDNSAPIClient {
.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
.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<RecordPayload>) -> Result<Vec<Record>, ()> {
pub async fn update_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> {
let result: RecordsResult = self
.api_call(
"/records/bulk",
"records/bulk",
Method::PUT,
None::<[(&str, &str); 0]>,
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)]
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<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)]
pub struct Zone {
pub id: String,
#[serde(with = "hetzner_date")]
pub created: DateTime<Utc>,
#[serde(with = "hetzner_date")]
pub modified: DateTime<Utc>,
pub legacy_dns_host: String,
pub legacy_dns: Vec<String>,
pub legacy_dns: Option<Vec<String>>,
pub ns: Vec<String>,
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<Utc>,
pub ttl: Option<u64>,
//#[serde(with = "hetzner_date")] // verified strings are empty, so its useless anyway
//pub verified: Option<DateTime<Utc>>,
pub records_count: u32,
pub is_secondary_dns: bool,
pub txt_verification: Vec<TxtVerification>
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<Utc>,
#[serde(with = "hetzner_date")]
modified: DateTime<Utc>,
zone_id: String,
r#type: String,
name: String,
value: String,
ttl: u64
ttl: Option<u64>
}
#[derive(Debug, Serialize, Deserialize)]
@ -78,3 +83,33 @@ pub struct RecordResult {
pub struct RecordsResult {
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))
}
}