use crate::{
DnsRecord, DnsRecordType, IntoFqdn, http::HttpClientBuilder, utils::strip_origin_from_name,
};
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub struct DesecDnsRecordRepresentation {
pub record_type: String,
pub content: String,
}
#[derive(Clone)]
pub struct DesecProvider {
client: HttpClientBuilder,
endpoint: String,
}
#[derive(Serialize, Clone, Debug)]
pub struct DnsRecordParams<'a> {
pub subname: &'a str,
#[serde(rename = "type")]
pub rr_type: &'a str,
pub ttl: Option<u32>,
pub records: Vec<String>,
}
#[derive(Deserialize, Debug)]
pub struct DesecApiResponse {
pub created: String,
pub domain: String,
pub subname: String,
pub name: String,
pub records: Vec<String>,
pub ttl: u32,
#[serde(rename = "type")]
pub record_type: String,
pub touched: String,
}
#[derive(Deserialize)]
struct DesecEmptyResponse {}
const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1";
const DESEC_MIN_TTL: u32 = 3600;
impl DesecProvider {
pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
let client = HttpClientBuilder::default()
.with_header("Authorization", format!("Token {}", auth_token.as_ref()))
.with_timeout(timeout);
Self {
client,
endpoint: DEFAULT_API_ENDPOINT.to_string(),
}
}
#[cfg(test)]
pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
Self {
endpoint: endpoint.as_ref().to_string(),
..self
}
}
pub(crate) async fn create(
&self,
name: impl IntoFqdn<'_>,
record: DnsRecord,
ttl: u32,
origin: impl IntoFqdn<'_>,
) -> crate::Result<()> {
let name = name.into_name().to_ascii_lowercase();
let domain = origin.into_name().to_ascii_lowercase();
let subdomain = strip_origin_from_name(&name, &domain, Some(""));
let ttl = ttl.max(DESEC_MIN_TTL);
let desec_record = DesecDnsRecordRepresentation::from(record);
let rrset_url = format!(
"{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
endpoint = self.endpoint,
domain = &domain,
subdomain = &subdomain,
rr_type = &desec_record.record_type,
);
let (mut records, existed) = match self
.client
.get(rrset_url.clone())
.send_with_retry::<DesecApiResponse>(3)
.await
{
Ok(existing) => (existing.records, true),
Err(crate::Error::NotFound) => (Vec::new(), false),
Err(err) => return Err(err),
};
if !records.iter().any(|r| r == &desec_record.content) {
records.push(desec_record.content);
}
let params = DnsRecordParams {
subname: &subdomain,
rr_type: &desec_record.record_type,
ttl: Some(ttl),
records,
};
if existed {
self.client.put(rrset_url)
} else {
self.client.post(format!(
"{endpoint}/domains/{domain}/rrsets/",
endpoint = self.endpoint,
domain = domain
))
}
.with_body(params)?
.send_with_retry::<DesecApiResponse>(3)
.await
.map(|_| ())
}
pub(crate) async fn update(
&self,
name: impl IntoFqdn<'_>,
record: DnsRecord,
ttl: u32,
origin: impl IntoFqdn<'_>,
) -> crate::Result<()> {
let name = name.into_name().to_ascii_lowercase();
let domain = origin.into_name().to_ascii_lowercase();
let subdomain = strip_origin_from_name(&name, &domain, Some(""));
let ttl = ttl.max(DESEC_MIN_TTL);
let desec_record = DesecDnsRecordRepresentation::from(record);
self.client
.put(format!(
"{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
endpoint = self.endpoint,
domain = &domain,
subdomain = &subdomain,
rr_type = &desec_record.record_type,
))
.with_body(DnsRecordParams {
subname: &subdomain,
rr_type: desec_record.record_type.as_str(),
ttl: Some(ttl),
records: vec![desec_record.content],
})?
.send_with_retry::<DesecApiResponse>(3)
.await
.map(|_| ())
}
pub(crate) async fn delete(
&self,
name: impl IntoFqdn<'_>,
origin: impl IntoFqdn<'_>,
record_type: DnsRecordType,
) -> crate::Result<()> {
let name = name.into_name().to_ascii_lowercase();
let domain = origin.into_name().to_ascii_lowercase();
let subdomain = strip_origin_from_name(&name, &domain, Some(""));
let rr_type = &record_type.to_string();
self.client
.delete(format!(
"{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/",
endpoint = self.endpoint,
domain = &domain,
subdomain = &subdomain,
rtype = &rr_type.to_string(),
))
.send_with_retry::<DesecEmptyResponse>(3)
.await
.map(|_| ())
.or_else(|err| match err {
crate::Error::NotFound => Ok(()),
err => Err(err),
})
}
}
fn ensure_fqdn(name: String) -> String {
if name.ends_with('.') {
name
} else {
format!("{name}.")
}
}
impl From<DnsRecord> for DesecDnsRecordRepresentation {
fn from(record: DnsRecord) -> Self {
match record {
DnsRecord::A(content) => DesecDnsRecordRepresentation {
record_type: "A".to_string(),
content: content.to_string(),
},
DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
record_type: "AAAA".to_string(),
content: content.to_string(),
},
DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
record_type: "CNAME".to_string(),
content: ensure_fqdn(content),
},
DnsRecord::NS(content) => DesecDnsRecordRepresentation {
record_type: "NS".to_string(),
content: ensure_fqdn(content),
},
DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
record_type: "MX".to_string(),
content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
},
DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
record_type: "TXT".to_string(),
content: format!("\"{content}\""),
},
DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
record_type: "SRV".to_string(),
content: format!(
"{} {} {} {}",
srv.priority,
srv.weight,
srv.port,
ensure_fqdn(srv.target)
),
},
DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
record_type: "TLSA".to_string(),
content: tlsa.to_string(),
},
DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
record_type: "CAA".to_string(),
content: caa.to_string(),
},
}
}
}