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:
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

View file

@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<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 {
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<RecordPayload>,
}
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 {
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>(&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>(&t).map_err(|e| {
println!("json response parsing error: {}", e);
()
})?)
}
pub async fn get_zones<'a>(
@ -134,19 +157,21 @@ impl HetznerDNSAPIClient {
page: Option<u32>,
per_page: Option<u32>,
search_name: Option<&'a str>,
) -> 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::<String>,
)
.await
) -> Result<Vec<Zone>, ()> {
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::<String>,
)
.await?;
Ok(result.zones)
}
pub async fn create_zone(&self, name: String, ttl: Option<u64>) -> Result<Zone, ()> {
@ -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<RecordPayload>) -> Result<Vec<Record>, ()> {
pub async fn update_records(
&self,
payloads: Vec<(String, RecordPayload)>,
) -> Result<Vec<Record>, ()> {
let result: RecordsResult = self
.api_call(
"records/bulk",

View file

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

View file

@ -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<u64>,
}
#[derive(Debug)]
struct RecordContext<'a> {
struct RecordContext {
all: bool,
zone: &'a str,
records: Vec<RecordProto<'a>>,
records: Vec<RecordPayload>,
}
#[derive(Debug)]
struct Context<'a> {
struct Context {
mode: Mode,
submode: SubMode,
token: Option<String>,
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!()
}

View file

@ -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<DateTime<Utc>>,
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<Zone>,
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<Utc>,
pub created: DateTime<Utc>,
#[serde(with = "hetzner_date")]
modified: DateTime<Utc>,
zone_id: String,
r#type: String,
name: String,
value: String,
ttl: Option<u64>
pub modified: DateTime<Utc>,
pub zone_id: String,
pub r#type: RecordType,
pub name: String,
pub value: String,
pub ttl: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RecordResult {
pub record: Record
pub record: Record,
}
#[derive(Debug, Serialize, Deserialize)]
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};
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<S>(
date: &DateTime<Utc>,
serializer: S,
) -> Result<S::Ok, S::Error>
pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
@ -102,14 +100,13 @@ mod hetzner_date {
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(
deserializer: D,
) -> Result<DateTime<Utc>, D::Error>
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)?;
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))
}
}