pub mod api;
use std::error::Error as StdErr;
use std::sync::Arc;
use crate::{
CreateRecord, CreateRecordError, CreateZone, CreateZoneError, DeleteRecord, DeleteRecordError,
DeleteZone, DeleteZoneError, HttpClientConfig, Provider, Record, RecordData,
RetrieveRecordError, RetrieveZoneError, Zone,
};
const SUPPORTED_RECORD_TYPES: &[&str; 16] = &[
"A", "AAAA", "NS", "MX", "CNAME", "RP", "TXT", "SOA", "HINFO", "SRV", "TLSA", "DS", "CAA",
"PTR", "HTTPS", "SVCB",
];
#[derive(Debug)]
pub struct HetznerProvider {
api_client: Arc<api::Client>,
}
impl Clone for HetznerProvider {
fn clone(&self) -> Self {
HetznerProvider {
api_client: Arc::clone(&self.api_client),
}
}
}
impl HetznerProvider {
pub fn new(api_key: &str) -> Result<Self, Box<dyn StdErr>> {
let api_client = api::Client::new(api_key)?;
Ok(Self {
api_client: Arc::new(api_client),
})
}
pub fn with_config(api_key: &str, config: HttpClientConfig) -> Result<Self, Box<dyn StdErr>> {
let api_client = api::Client::with_config(api_key, config)?;
Ok(Self {
api_client: Arc::new(api_client),
})
}
pub fn with_base_url(api_key: &str, base_url: &str) -> Result<Self, Box<dyn StdErr>> {
let api_client =
api::Client::with_base_url_and_config(api_key, base_url, HttpClientConfig::default())?;
Ok(Self {
api_client: Arc::new(api_client),
})
}
}
impl Provider for HetznerProvider {
type Zone = HetznerZone;
type CustomRetrieveError = reqwest::Error;
async fn get_zone(
&self,
zone_id: &str,
) -> Result<Self::Zone, RetrieveZoneError<Self::CustomRetrieveError>> {
let response = self
.api_client
.retrieve_zone(zone_id)
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::NOT_FOUND => RetrieveZoneError::NotFound,
reqwest::StatusCode::UNAUTHORIZED => RetrieveZoneError::Unauthorized,
_ => RetrieveZoneError::Custom(err),
};
}
RetrieveZoneError::Custom(err)
})?;
Ok(HetznerZone::from_api(
self.api_client.clone(),
response.zone,
))
}
async fn list_zones(
&self,
) -> Result<Vec<Self::Zone>, RetrieveZoneError<Self::CustomRetrieveError>> {
let mut zones = Vec::new();
let mut total: Option<usize> = None;
let mut page = 1;
loop {
let result =
self.api_client
.retrieve_zones(page, 100)
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::NOT_FOUND => RetrieveZoneError::NotFound,
reqwest::StatusCode::UNAUTHORIZED
| reqwest::StatusCode::FORBIDDEN => RetrieveZoneError::Unauthorized,
_ => RetrieveZoneError::Custom(err),
};
}
RetrieveZoneError::Custom(err)
});
match result {
Ok(response) => {
if total.is_none() {
total = Some(response.meta.pagination.total_entries as usize);
}
zones.extend(
response
.zones
.into_iter()
.map(|zone| HetznerZone::from_api(self.api_client.clone(), zone)),
);
}
Err(err) => {
if let RetrieveZoneError::NotFound = err {
break;
}
return Err(err);
}
}
if total.is_some_and(|t| zones.len() == t) {
break;
}
page += 1;
}
Ok(zones)
}
}
impl CreateZone for HetznerProvider {
type CustomCreateError = reqwest::Error;
async fn create_zone(
&self,
domain: &str,
) -> Result<Self::Zone, CreateZoneError<Self::CustomCreateError>> {
let response = self
.api_client
.create_zone(domain, None)
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::UNAUTHORIZED => CreateZoneError::Unauthorized,
reqwest::StatusCode::UNPROCESSABLE_ENTITY => {
CreateZoneError::InvalidDomainName
}
_ => CreateZoneError::Custom(err),
};
}
CreateZoneError::Custom(err)
})?;
Ok(HetznerZone::from_api(
self.api_client.clone(),
response.zone,
))
}
}
impl DeleteZone for HetznerProvider {
type CustomDeleteError = reqwest::Error;
async fn delete_zone(
&self,
zone_id: &str,
) -> Result<(), DeleteZoneError<Self::CustomDeleteError>> {
self.api_client.delete_zone(zone_id).await.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::NOT_FOUND => DeleteZoneError::NotFound,
reqwest::StatusCode::UNAUTHORIZED => DeleteZoneError::Unauthorized,
_ => DeleteZoneError::Custom(err),
};
}
DeleteZoneError::Custom(err)
})
}
}
#[derive(Debug, Clone)]
pub struct HetznerZone {
api_client: Arc<api::Client>,
repr: api::Zone,
zone_id_str: String,
}
impl HetznerZone {
fn from_api(api_client: Arc<api::Client>, zone: api::Zone) -> Self {
let zone_id_str = zone.id.to_string();
Self {
api_client,
repr: zone,
zone_id_str,
}
}
}
impl Zone for HetznerZone {
type CustomRetrieveError = reqwest::Error;
fn id(&self) -> &str {
&self.zone_id_str
}
fn domain(&self) -> &str {
&self.repr.name
}
async fn list_records(
&self,
) -> Result<Vec<Record>, RetrieveRecordError<Self::CustomRetrieveError>> {
let mut records = Vec::new();
let mut total: Option<usize> = None;
let mut page = 1;
loop {
let result = self
.api_client
.retrieve_rrsets(&self.zone_id_str, page, 100)
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::NOT_FOUND => RetrieveRecordError::NotFound,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
RetrieveRecordError::Unauthorized
}
_ => RetrieveRecordError::Custom(err),
};
}
RetrieveRecordError::Custom(err)
});
match result {
Ok(response) => {
if total.is_none() {
total = Some(response.meta.pagination.total_entries as usize);
}
let is_last_page =
response.meta.pagination.page >= response.meta.pagination.last_page;
for rrset in &response.rrsets {
let ttl = rrset.ttl.unwrap_or(self.repr.ttl);
for record_value in &rrset.records {
let record_id =
format!("{}/{}/{}", rrset.name, rrset.typ, record_value.value);
records.push(Record {
id: record_id,
host: rrset.name.clone(),
data: RecordData::from_raw(&rrset.typ, &record_value.value),
ttl,
});
}
}
if is_last_page {
break;
}
}
Err(err) => {
if let RetrieveRecordError::NotFound = err {
break;
}
return Err(err);
}
}
page += 1;
}
Ok(records)
}
async fn get_record(
&self,
record_id: &str,
) -> Result<Record, RetrieveRecordError<Self::CustomRetrieveError>> {
let parts: Vec<&str> = record_id.splitn(3, '/').collect();
if parts.len() != 3 {
return Err(RetrieveRecordError::NotFound);
}
let (name, typ, value) = (parts[0], parts[1], parts[2]);
let response = self
.api_client
.retrieve_rrset(&self.zone_id_str, name, typ)
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::NOT_FOUND => RetrieveRecordError::NotFound,
reqwest::StatusCode::UNAUTHORIZED => RetrieveRecordError::Unauthorized,
_ => RetrieveRecordError::Custom(err),
};
}
RetrieveRecordError::Custom(err)
})?;
let rrset = &response.rrset;
let record_value = rrset
.records
.iter()
.find(|r| r.value == value)
.ok_or(RetrieveRecordError::NotFound)?;
Ok(Record {
id: record_id.to_string(),
host: rrset.name.clone(),
data: RecordData::from_raw(&rrset.typ, &record_value.value),
ttl: rrset.ttl.unwrap_or(self.repr.ttl),
})
}
}
fn format_value_for_api(data: &RecordData) -> String {
match data {
RecordData::TXT(val) => {
if val.starts_with('"') && val.ends_with('"') {
val.clone()
} else {
format!("\"{}\"", val)
}
}
_ => data.get_value(),
}
}
impl CreateRecord for HetznerZone {
type CustomCreateError = reqwest::Error;
async fn create_record(
&self,
host: &str,
data: &RecordData,
ttl: u64,
) -> Result<Record, CreateRecordError<Self::CustomCreateError>> {
let typ = data.get_type();
if !SUPPORTED_RECORD_TYPES.contains(&typ) {
return Err(CreateRecordError::UnsupportedType);
}
let value = format_value_for_api(data);
let record_value = api::RecordValue::new(&value);
let opt_ttl = if ttl != self.repr.ttl {
Some(ttl)
} else {
None
};
let _response = self
.api_client
.add_records_to_rrset(&self.zone_id_str, host, typ, vec![record_value], opt_ttl)
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::UNAUTHORIZED => CreateRecordError::Unauthorized,
reqwest::StatusCode::UNPROCESSABLE_ENTITY => {
CreateRecordError::InvalidRecord
}
_ => CreateRecordError::Custom(err),
};
}
CreateRecordError::Custom(err)
})?;
let record_id = format!("{}/{}/{}", host, typ, value);
Ok(Record {
id: record_id,
host: host.to_string(),
data: data.clone(),
ttl,
})
}
}
impl DeleteRecord for HetznerZone {
type CustomDeleteError = reqwest::Error;
async fn delete_record(
&self,
record_id: &str,
) -> Result<(), DeleteRecordError<Self::CustomDeleteError>> {
let parts: Vec<&str> = record_id.splitn(3, '/').collect();
if parts.len() != 3 {
return Err(DeleteRecordError::NotFound);
}
let (name, typ, value) = (parts[0], parts[1], parts[2]);
let record_value = api::RecordValue::new(value);
self.api_client
.remove_records_from_rrset(&self.zone_id_str, name, typ, vec![record_value])
.await
.map_err(|err| {
if err.is_status() {
return match err.status().unwrap() {
reqwest::StatusCode::NOT_FOUND => DeleteRecordError::NotFound,
reqwest::StatusCode::UNAUTHORIZED => DeleteRecordError::Unauthorized,
_ => DeleteRecordError::Custom(err),
};
}
DeleteRecordError::Custom(err)
})?;
Ok(())
}
}