diff --git a/.gitea/workflows/build_nix.yml b/.gitea/workflows/build_nix.yml index a06ec6c..8318948 100644 --- a/.gitea/workflows/build_nix.yml +++ b/.gitea/workflows/build_nix.yml @@ -7,6 +7,8 @@ jobs: build: runs-on: ubuntu-latest steps: + - name: install sudo + run: apt update && apt install sudo - uses: actions/checkout@v2 - uses: https://github.com/cachix/install-nix-action@v26 - name: Building package diff --git a/src/client.rs b/src/client.rs index 8eb2731..2b2fc20 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,13 +1,13 @@ use crate::*; use reqwest::{ - header::{HeaderMap, HeaderValue}, Client, Method, Request, Url, + header::{HeaderMap, HeaderValue}, }; -use serde::{de::Visitor, Serializer}; +use serde::{Serializer, de::Visitor}; use serde_json::Value; use std::{borrow::Borrow, collections::HashMap}; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum RecordType { A, AAAA, @@ -24,52 +24,56 @@ pub enum RecordType { 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 ToString for RecordType { + fn to_string(&self) -> String { + match self { + RecordType::A => "A".to_string(), + RecordType::AAAA => "AAAA".to_string(), + RecordType::NS => "NS".to_string(), + RecordType::MX => "MX".to_string(), + RecordType::CNAME => "CNAME".to_string(), + RecordType::RP => "RP".to_string(), + RecordType::TXT => "TXT".to_string(), + RecordType::SOA => "SOA".to_string(), + RecordType::HINFO => "HINFO".to_string(), + RecordType::SRV => "SRV".to_string(), + RecordType::DANE => "DANE".to_string(), + RecordType::TLSA => "TLSA".to_string(), + RecordType::DS => "DS".to_string(), + RecordType::CAA => "CAA".to_string(), + } + } +} + +impl TryFrom<&str> for RecordType { + type Error = &'static str; + fn try_from(value: &str) -> Result { + Ok(match value { + "A" => RecordType::A, + "AAAA" => RecordType::AAAA, + "NS" => RecordType::NS, + "MX" => RecordType::MX, + "CNAME" => RecordType::CNAME, + "RP" => RecordType::RP, + "TXT" => RecordType::TXT, + "SOA" => RecordType::SOA, + "HINFO" => RecordType::HINFO, + "SRV" => RecordType::SRV, + "DANE" => RecordType::DANE, + "TLSA" => RecordType::TLSA, + "DS" => RecordType::DS, + "CAA" => RecordType::CAA, + _ => return Err(""), }) } } -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") - } +#[derive(Serialize)] +struct _RecordQuery { + records: Vec, } -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: Url, @@ -112,9 +116,18 @@ impl HetznerDNSAPIClient { HeaderValue::from_str(self.token.as_str()).unwrap(), ); if let Some(payload) = payload { - *req.body_mut() = Some(serde_json::to_string(&payload).map_err(|e| { println!("body encoding error: {}",e); ()} )?.into()); + *req.body_mut() = Some( + serde_json::to_string(&payload) + .map_err(|e| { + println!("body encoding error: {}", e); + () + })? + .into(), + ); + println!("{:#?}", serde_json::to_string(&payload)); } - let t = self.client + let t = self + .client .execute(req) .await .map_err(|e| { @@ -122,10 +135,20 @@ impl HetznerDNSAPIClient { () })? .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); ()})?) + .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); + () + })?) } pub async fn get_zones<'a>( @@ -134,19 +157,21 @@ impl HetznerDNSAPIClient { 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 + ) -> Result, ()> { + let result: ZoneResult = 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?; + Ok(result.zones) } pub async fn create_zone(&self, name: String, ttl: Option) -> Result { @@ -285,14 +310,17 @@ impl HetznerDNSAPIClient { "records/bulk", Method::POST, None::<[(&str, &str); 0]>, - Some(payloads), + Some(_RecordQuery { records: payloads }), ) .await .map_err(|_| ())?; Ok(result.records) } - pub async fn update_records(&self, payloads: Vec) -> Result, ()> { + pub async fn update_records( + &self, + payloads: Vec<(String, RecordPayload)>, + ) -> Result, ()> { let result: RecordsResult = self .api_call( "records/bulk", diff --git a/src/lib.rs b/src/lib.rs index 86b91d5..3860be9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,8 @@ #![allow(dead_code, unused)] -use serde::{Deserialize, Serialize}; use crate::models::*; +use serde::{Deserialize, Serialize}; use std::error::Error; -pub mod models; pub mod client; - +pub mod models; diff --git a/src/main.rs b/src/main.rs index cebf9a9..96427f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,12 @@ #![allow(dead_code, unused)] -use core::panic; -use std::ops::Sub; use crate::client::*; use crate::models::*; -use serde::{Serialize, Deserialize}; +use core::panic; +use serde::{Deserialize, Serialize}; +use std::ops::Sub; -mod models; mod client; +mod models; #[derive(PartialEq, Eq, Debug)] enum Mode { @@ -35,27 +35,18 @@ struct ZoneContext { } #[derive(Debug)] -struct RecordProto<'a> { - r#type: RecordType, - name: &'a str, - value: &'a str, - ttl: Option, -} - -#[derive(Debug)] -struct RecordContext<'a> { +struct RecordContext { all: bool, - zone: &'a str, - records: Vec>, + records: Vec, } #[derive(Debug)] -struct Context<'a> { +struct Context { mode: Mode, submode: SubMode, token: Option, zone_context: ZoneContext, - record_context: RecordContext<'a>, + record_context: RecordContext, } #[tokio::main(flavor = "current_thread")] @@ -72,8 +63,13 @@ async fn main() { }, record_context: RecordContext { all: false, - zone: "", - records: vec![], + records: vec![RecordPayload { + zone_id: String::new(), + r#type: RecordType::A, + name: String::new(), + value: String::new(), + ttl: 0, + }], }, }; let mut _continue = false; @@ -135,7 +131,32 @@ async fn main() { if arg.starts_with("-") { match arg.as_str() { "--all" => ctx.record_context.all = true, - _ => todo!() + "--zone" => { + ctx.record_context.records[0].zone_id = + std::env::args().nth(idx + 1).unwrap() + } + "--name" => { + ctx.record_context.records[0].name = + std::env::args().nth(idx + 1).unwrap() + } + "--value" => { + ctx.record_context.records[0].value = + std::env::args().nth(idx + 1).unwrap() + } + "--type" => { + ctx.record_context.records[0].r#type = RecordType::try_from( + std::env::args().nth(idx + 1).unwrap().as_str(), + ) + .unwrap() + } + "--ttl" => { + ctx.record_context.records[0].ttl = std::env::args() + .nth(idx + 1) + .unwrap() + .parse() + .unwrap_or(86400) + } + _ => todo!(), } } } @@ -160,6 +181,68 @@ async fn main() { "{:#?}", client.get_zones(None, None, None, None).await.unwrap() ); + } else if !ctx.zone_context.zone.is_empty() { + for name in ctx.zone_context.zone.split(",") { + if let Ok(zones) = + client.get_zones(Some(name), None, None, None).await + { + zones.into_iter().for_each(|z| println!("{:#?}", z)); + } + } + } + } + SubMode::Create => { + if !ctx.zone_context.name.is_empty() { + if let Ok(zone) = client + .create_zone(ctx.zone_context.name, Some(ctx.zone_context.ttl)) + .await + { + println!("{:#?}", zone); + } + } + if !ctx.zone_context.zone.is_empty() { + eprintln!( + "Ignoring additional --zone value - use update/u to update an existing zone" + ); + } + } + SubMode::Update => { + if !ctx.zone_context.zone.is_empty() && !ctx.zone_context.name.is_empty() { + if let Ok(zones) = client + .get_zones(Some(ctx.zone_context.zone.as_str()), None, None, None) + .await + { + if let Ok(zone) = client + .update_zone( + zones.into_iter().next().unwrap().id, + ctx.zone_context.name, + Some(ctx.zone_context.ttl), + ) + .await + { + println!("{:#?}", zone); + } + } else { + eprintln!("Unable to fetch zone {}", ctx.zone_context.zone); + } + } else { + eprintln!("--zone and --name are required for updating!"); + } + } + SubMode::Delete => { + if !ctx.zone_context.zone.is_empty() { + if let Ok(zones) = client + .get_zones(Some(ctx.zone_context.zone.as_str()), None, None, None) + .await + { + if client + .delete_zone(zones.into_iter().next().unwrap().id) + .await + .is_ok() + { + eprintln!("Successfully deleted {}", ctx.zone_context.zone); + } + } } } _ => { @@ -170,10 +253,101 @@ async fn main() { SubMode::Get => { if ctx.record_context.all { println!("{:#?}", client.get_records(None, None, None).await.unwrap()); + } else if !ctx.record_context.records.is_empty() { + for zone in ctx + .record_context + .records + .into_iter() + .map(|r| r.zone_id) + .filter(|z| !z.is_empty()) + { + let zone = client + .get_zones(Some(zone.as_str()), None, None, None) + .await + .unwrap() + .into_iter() + .next() + .unwrap(); + let mut records = + client.get_records(None, None, Some(zone.id)).await.unwrap(); + println!("{:#?}", records); + } } } - _ => { todo!() } - } + SubMode::Create => { + if !ctx.record_context.records.is_empty() { + let zone = &client + .get_zones( + Some(ctx.record_context.records[0].zone_id.as_str()), + None, + None, + None, + ) + .await + .unwrap()[0] + .id; + ctx.record_context.records[0].zone_id = zone.to_string(); + println!("{:#?}", ctx.record_context.records); + if let Ok(record) = + client.create_records(ctx.record_context.records).await + { + println!("{:#?}", record); + } + } + } + SubMode::Update => { + if !ctx.record_context.records.is_empty() { + let mut records = vec![]; + let mut updated_records = vec![]; + let mut records_iter = ctx.record_context.records.into_iter(); + for zone in records_iter + .clone() + .map(|r| r.zone_id) + .filter(|z| !z.is_empty()) + { + let zone = client + .get_zones(Some(zone.as_str()), None, None, None) + .await + .unwrap() + .into_iter() + .next() + .unwrap(); + records.extend( + client.get_records(None, None, Some(zone.id)).await.unwrap(), + ); + } + for old_record in records { + if let Some(mut new_record) = + records_iter.find(|r| r.name == old_record.name) + { + let mut old_record = old_record; + updated_records.push(( + old_record.id, + RecordPayload { + zone_id: old_record.zone_id, + r#type: old_record.r#type, + name: old_record.name, + value: new_record.value, + ttl: new_record.ttl, + }, + )); + } + } + if updated_records.len() > 0 { + let records = client.update_records(updated_records).await.unwrap(); + eprintln!("Updated {} records", records.len()); + } else { + eprintln!( + "No records found that require updating. Did you mean to create/c the records instead?" + ); + } + } + } + SubMode::Delete => {} + _ => { + todo!() + } + }, Mode::PrimaryServer => { todo!() } diff --git a/src/models.rs b/src/models.rs index 38f81bd..2d50ab0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,11 +1,12 @@ -use serde::{Serialize, Deserialize}; -use chrono::{DateTime, Utc}; use crate::client::RecordType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::borrow::Borrow; #[derive(Debug, Deserialize)] pub struct TxtVerification { pub name: String, - pub token: String + pub token: String, } #[derive(Debug, Deserialize)] @@ -13,12 +14,12 @@ pub struct Pagination { pub page: u32, pub per_page: u32, pub last_page: u32, - pub total_entries: u32 + pub total_entries: u32, } #[derive(Debug, Deserialize)] pub struct Meta { - pub pagination: Pagination + pub pagination: Pagination, } #[derive(Debug, Deserialize)] @@ -42,59 +43,56 @@ pub struct Zone { //pub verified: Option>, pub records_count: u32, pub is_secondary_dns: bool, - pub txt_verification: TxtVerification + pub txt_verification: TxtVerification, } #[derive(Debug, Deserialize)] pub struct ZoneResult { pub zones: Vec, - pub meta: Meta + pub meta: Meta, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecordPayload { - zone_id: String, - r#type: RecordType, - name: String, - value: String, - ttl: u64 + pub zone_id: String, + pub r#type: RecordType, + pub name: String, + pub value: String, + pub ttl: u64, } #[derive(Debug, Serialize, Deserialize)] pub struct Record { - id: String, + pub id: String, #[serde(with = "hetzner_date")] - created: DateTime, + pub created: DateTime, #[serde(with = "hetzner_date")] - modified: DateTime, - zone_id: String, - r#type: String, - name: String, - value: String, - ttl: Option + pub modified: DateTime, + pub zone_id: String, + pub r#type: RecordType, + pub name: String, + pub value: String, + pub ttl: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct RecordResult { - pub record: Record + pub record: Record, } #[derive(Debug, Serialize, Deserialize)] pub struct RecordsResult { - pub records: Vec + pub records: Vec, } mod hetzner_date { - use chrono::{DateTime, Utc, NaiveDateTime}; - use serde::{self, Deserialize, Serializer, Deserializer}; + use chrono::{DateTime, NaiveDateTime, Utc}; + use serde::{self, Deserialize, Deserializer, Serializer}; // 2025-01-06 02:18:34.674 +0000 UTC const FORMAT: &str = "%F %T.%-f"; - - pub fn serialize( - date: &DateTime, - serializer: S, - ) -> Result + + pub fn serialize(date: &DateTime, serializer: S) -> Result where S: Serializer, { @@ -102,14 +100,13 @@ mod hetzner_date { serializer.serialize_str(&s) } - pub fn deserialize<'de, D>( - deserializer: D, - ) -> Result, D::Error> + 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)?; + 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)) } }