more command options and api fixes
All checks were successful
Build legacy Nix package on Ubuntu / build (push) Successful in 10m21s

This commit is contained in:
bread 2025-04-21 04:10:09 +02:00
parent 4e6248cd0d
commit 824218ad73
5 changed files with 323 additions and 123 deletions

View file

@ -7,6 +7,8 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: install sudo
run: apt update && apt install sudo
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: https://github.com/cachix/install-nix-action@v26 - uses: https://github.com/cachix/install-nix-action@v26
- name: Building package - name: Building package

View file

@ -1,13 +1,13 @@
use crate::*; use crate::*;
use reqwest::{ use reqwest::{
header::{HeaderMap, HeaderValue},
Client, Method, Request, Url, Client, Method, Request, Url,
header::{HeaderMap, HeaderValue},
}; };
use serde::{de::Visitor, Serializer}; use serde::{Serializer, de::Visitor};
use serde_json::Value; use serde_json::Value;
use std::{borrow::Borrow, collections::HashMap}; use std::{borrow::Borrow, collections::HashMap};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecordType { pub enum RecordType {
A, A,
AAAA, AAAA,
@ -24,52 +24,56 @@ pub enum RecordType {
DS, DS,
CAA, CAA,
} }
/*
impl Serialize for RecordType { impl ToString for RecordType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn to_string(&self) -> String {
where match self {
S: Serializer, RecordType::A => "A".to_string(),
{ RecordType::AAAA => "AAAA".to_string(),
serializer.serialize_str(match self { RecordType::NS => "NS".to_string(),
RecordType::A => "A", RecordType::MX => "MX".to_string(),
RecordType::AAAA => "AAAA", RecordType::CNAME => "CNAME".to_string(),
RecordType::NS => "NS", RecordType::RP => "RP".to_string(),
RecordType::MX => "MX", RecordType::TXT => "TXT".to_string(),
RecordType::CNAME => "CNAME", RecordType::SOA => "SOA".to_string(),
RecordType::RP => "RP", RecordType::HINFO => "HINFO".to_string(),
RecordType::TXT => "TXT", RecordType::SRV => "SRV".to_string(),
RecordType::SOA => "SOA", RecordType::DANE => "DANE".to_string(),
RecordType::HINFO => "HINFO", RecordType::TLSA => "TLSA".to_string(),
RecordType::SRV => "SRV", RecordType::DS => "DS".to_string(),
RecordType::DANE => "DANE", RecordType::CAA => "CAA".to_string(),
RecordType::TLSA => "TLSA", }
RecordType::DS => "DS", }
RecordType::CAA => "CAA", }
impl TryFrom<&str> for RecordType {
type Error = &'static str;
fn try_from(value: &str) -> Result<Self, Self::Error> {
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 { #[derive(Serialize)]
type Value = &'static str; struct _RecordQuery {
records: Vec<RecordPayload>,
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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de> {
deserializer.deserialize_str(RecordType)
}
fn deserialize_in_place<D>(deserializer: D, place: &mut Self) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>, {
deserializer.deserialize_str()
}
}
*/
pub struct HetznerDNSAPIClient { pub struct HetznerDNSAPIClient {
token: String, token: String,
host: Url, host: Url,
@ -112,9 +116,18 @@ impl HetznerDNSAPIClient {
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).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) .execute(req)
.await .await
.map_err(|e| { .map_err(|e| {
@ -122,10 +135,20 @@ impl HetznerDNSAPIClient {
() ()
})? })?
.error_for_status() .error_for_status()
.map_err(|e| { println!("request error: {}", e); ()})? .map_err(|e| {
.text().await println!("request error: {}", e);
.map_err(|e| { println!("request decoding error: {}", e); ()})?; ()
Ok(serde_json::from_str::<T>(&t).map_err(|e| {println!("json response parsing 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);
()
})?)
} }
pub async fn get_zones<'a>( pub async fn get_zones<'a>(
@ -134,19 +157,21 @@ impl HetznerDNSAPIClient {
page: Option<u32>, page: Option<u32>,
per_page: Option<u32>, per_page: Option<u32>,
search_name: Option<&'a str>, search_name: Option<&'a str>,
) -> Result<ZoneResult, ()> { ) -> Result<Vec<Zone>, ()> {
self.api_call( let result: ZoneResult = self
"zones", .api_call(
Method::GET, "zones",
Some(&[ Method::GET,
("name", name.unwrap_or_default()), Some(&[
("page", page.unwrap_or(1).to_string().as_str()), ("name", name.unwrap_or_default()),
("per_page", per_page.unwrap_or(100).to_string().as_str()), ("page", page.unwrap_or(1).to_string().as_str()),
("search_name", search_name.unwrap_or_default()), ("per_page", per_page.unwrap_or(100).to_string().as_str()),
]), ("search_name", search_name.unwrap_or_default()),
None::<String>, ]),
) None::<String>,
.await )
.await?;
Ok(result.zones)
} }
pub 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, ()> {
@ -285,14 +310,17 @@ impl HetznerDNSAPIClient {
"records/bulk", "records/bulk",
Method::POST, Method::POST,
None::<[(&str, &str); 0]>, None::<[(&str, &str); 0]>,
Some(payloads), Some(_RecordQuery { records: payloads }),
) )
.await .await
.map_err(|_| ())?; .map_err(|_| ())?;
Ok(result.records) Ok(result.records)
} }
pub async fn update_records(&self, payloads: Vec<RecordPayload>) -> Result<Vec<Record>, ()> { pub async fn update_records(
&self,
payloads: Vec<(String, RecordPayload)>,
) -> Result<Vec<Record>, ()> {
let result: RecordsResult = self let result: RecordsResult = self
.api_call( .api_call(
"records/bulk", "records/bulk",

View file

@ -1,9 +1,8 @@
#![allow(dead_code, unused)] #![allow(dead_code, unused)]
use serde::{Deserialize, Serialize};
use crate::models::*; use crate::models::*;
use serde::{Deserialize, Serialize};
use std::error::Error; use std::error::Error;
pub mod models;
pub mod client; pub mod client;
pub mod models;

View file

@ -1,12 +1,12 @@
#![allow(dead_code, unused)] #![allow(dead_code, unused)]
use core::panic;
use std::ops::Sub;
use crate::client::*; use crate::client::*;
use crate::models::*; use crate::models::*;
use serde::{Serialize, Deserialize}; use core::panic;
use serde::{Deserialize, Serialize};
use std::ops::Sub;
mod models;
mod client; mod client;
mod models;
#[derive(PartialEq, Eq, Debug)] #[derive(PartialEq, Eq, Debug)]
enum Mode { enum Mode {
@ -35,27 +35,18 @@ struct ZoneContext {
} }
#[derive(Debug)] #[derive(Debug)]
struct RecordProto<'a> { struct RecordContext {
r#type: RecordType,
name: &'a str,
value: &'a str,
ttl: Option<u64>,
}
#[derive(Debug)]
struct RecordContext<'a> {
all: bool, all: bool,
zone: &'a str, records: Vec<RecordPayload>,
records: Vec<RecordProto<'a>>,
} }
#[derive(Debug)] #[derive(Debug)]
struct Context<'a> { struct Context {
mode: Mode, mode: Mode,
submode: SubMode, submode: SubMode,
token: Option<String>, token: Option<String>,
zone_context: ZoneContext, zone_context: ZoneContext,
record_context: RecordContext<'a>, record_context: RecordContext,
} }
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
@ -72,8 +63,13 @@ async fn main() {
}, },
record_context: RecordContext { record_context: RecordContext {
all: false, all: false,
zone: "", records: vec![RecordPayload {
records: vec![], zone_id: String::new(),
r#type: RecordType::A,
name: String::new(),
value: String::new(),
ttl: 0,
}],
}, },
}; };
let mut _continue = false; let mut _continue = false;
@ -135,7 +131,32 @@ async fn main() {
if arg.starts_with("-") { if arg.starts_with("-") {
match arg.as_str() { match arg.as_str() {
"--all" => ctx.record_context.all = true, "--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() 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 => { SubMode::Get => {
if ctx.record_context.all { if ctx.record_context.all {
println!("{:#?}", client.get_records(None, None, None).await.unwrap()); 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 => { Mode::PrimaryServer => {
todo!() todo!()
} }

View file

@ -1,11 +1,12 @@
use serde::{Serialize, Deserialize};
use chrono::{DateTime, Utc};
use crate::client::RecordType; use crate::client::RecordType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::borrow::Borrow;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TxtVerification { pub struct TxtVerification {
pub name: String, pub name: String,
pub token: String pub token: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -13,12 +14,12 @@ pub struct Pagination {
pub page: u32, pub page: u32,
pub per_page: u32, pub per_page: u32,
pub last_page: u32, pub last_page: u32,
pub total_entries: u32 pub total_entries: u32,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Meta { pub struct Meta {
pub pagination: Pagination pub pagination: Pagination,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -42,59 +43,56 @@ pub struct Zone {
//pub verified: Option<DateTime<Utc>>, //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: TxtVerification pub txt_verification: TxtVerification,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ZoneResult { pub struct ZoneResult {
pub zones: Vec<Zone>, pub zones: Vec<Zone>,
pub meta: Meta pub meta: Meta,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordPayload { pub struct RecordPayload {
zone_id: String, pub zone_id: String,
r#type: RecordType, pub r#type: RecordType,
name: String, pub name: String,
value: String, pub value: String,
ttl: u64 pub ttl: u64,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Record { pub struct Record {
id: String, pub id: String,
#[serde(with = "hetzner_date")] #[serde(with = "hetzner_date")]
created: DateTime<Utc>, pub created: DateTime<Utc>,
#[serde(with = "hetzner_date")] #[serde(with = "hetzner_date")]
modified: DateTime<Utc>, pub modified: DateTime<Utc>,
zone_id: String, pub zone_id: String,
r#type: String, pub r#type: RecordType,
name: String, pub name: String,
value: String, pub value: String,
ttl: Option<u64> pub ttl: Option<u64>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct RecordResult { pub struct RecordResult {
pub record: Record pub record: Record,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct RecordsResult { pub struct RecordsResult {
pub records: Vec<Record> pub records: Vec<Record>,
} }
mod hetzner_date { mod hetzner_date {
use chrono::{DateTime, Utc, NaiveDateTime}; use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{self, Deserialize, Serializer, Deserializer}; use serde::{self, Deserialize, Deserializer, Serializer};
// 2025-01-06 02:18:34.674 +0000 UTC // 2025-01-06 02:18:34.674 +0000 UTC
const FORMAT: &str = "%F %T.%-f"; const FORMAT: &str = "%F %T.%-f";
pub fn serialize<S>( pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
date: &DateTime<Utc>,
serializer: S,
) -> Result<S::Ok, S::Error>
where where
S: Serializer, S: Serializer,
{ {
@ -102,14 +100,13 @@ mod hetzner_date {
serializer.serialize_str(&s) serializer.serialize_str(&s)
} }
pub fn deserialize<'de, D>( pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
deserializer: D,
) -> Result<DateTime<Utc>, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let s = String::deserialize(deserializer)?; 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::<Utc>::from_naive_utc_and_offset(dt, Utc)) Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
} }
} }