use crate::{
DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
utils::strip_origin_from_name,
};
use serde::{Deserialize, Serialize};
use std::{
net::{Ipv4Addr, Ipv6Addr},
time::Duration,
};
#[derive(Clone)]
pub struct DigitalOceanProvider {
client: HttpClientBuilder,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ListDomainRecord {
domain_records: Vec<DomainRecord>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct UpdateDomainRecord<'a> {
ttl: u32,
name: &'a str,
#[serde(flatten)]
data: RecordData,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DomainRecord {
id: i64,
ttl: u32,
name: String,
#[serde(flatten)]
data: RecordData,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(tag = "type")]
#[allow(clippy::upper_case_acronyms)]
pub enum RecordData {
A {
data: Ipv4Addr,
},
AAAA {
data: Ipv6Addr,
},
CNAME {
data: String,
},
NS {
data: String,
},
MX {
data: String,
priority: u16,
},
TXT {
data: String,
},
SRV {
data: String,
priority: u16,
port: u16,
weight: u16,
},
CAA {
data: String,
flags: u8,
tag: String,
},
}
#[derive(Serialize, Debug)]
pub struct Query<'a> {
name: &'a str,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
record_type: Option<&'static str>,
}
impl DigitalOceanProvider {
pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
let client = HttpClientBuilder::default()
.with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
.with_timeout(timeout);
Self { client }
}
pub(crate) async fn create(
&self,
name: impl IntoFqdn<'_>,
record: DnsRecord,
ttl: u32,
origin: impl IntoFqdn<'_>,
) -> crate::Result<()> {
let name = name.into_name();
let domain = origin.into_name();
let subdomain = strip_origin_from_name(&name, &domain, None);
self.client
.post(format!(
"https://api.digitalocean.com/v2/domains/{domain}/records",
))
.with_body(UpdateDomainRecord {
ttl,
name: &subdomain,
data: RecordData::try_from(record).map_err(|err| Error::Api(err.to_string()))?,
})?
.send_raw()
.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();
let domain = origin.into_name();
let subdomain = strip_origin_from_name(&name, &domain, None);
let record_type = record.as_type();
let record_id = self.obtain_record_id(&name, &domain, record_type).await?;
self.client
.put(format!(
"https://api.digitalocean.com/v2/domains/{domain}/records/{record_id}",
))
.with_body(UpdateDomainRecord {
ttl,
name: &subdomain,
data: RecordData::try_from(record).map_err(|err| Error::Api(err.to_string()))?,
})?
.send_raw()
.await
.map(|_| ())
}
pub(crate) async fn delete(
&self,
name: impl IntoFqdn<'_>,
origin: impl IntoFqdn<'_>,
record_type: DnsRecordType,
) -> crate::Result<()> {
let name = name.into_name();
let domain = origin.into_name();
let record_id = self.obtain_record_id(&name, &domain, record_type).await?;
self.client
.delete(format!(
"https://api.digitalocean.com/v2/domains/{domain}/records/{record_id}",
))
.send_raw()
.await
.map(|_| ())
}
async fn obtain_record_id(
&self,
name: &str,
domain: &str,
record_type: DnsRecordType,
) -> crate::Result<i64> {
let subdomain = strip_origin_from_name(name, domain, None);
self.client
.get(format!(
"https://api.digitalocean.com/v2/domains/{domain}/records?{}",
Query::name_and_type(name, record_type).serialize()
))
.send_with_retry::<ListDomainRecord>(3)
.await
.and_then(|result| {
result
.domain_records
.into_iter()
.find(|record| record.name == subdomain && record.data.is_type(record_type))
.map(|record| record.id)
.ok_or_else(|| {
Error::Api(format!(
"DNS Record {} of type {} not found",
subdomain,
record_type.as_str()
))
})
})
}
}
impl RecordData {
fn is_type(&self, record_type: DnsRecordType) -> bool {
matches!(
(self, record_type),
(RecordData::A { .. }, DnsRecordType::A)
| (RecordData::AAAA { .. }, DnsRecordType::AAAA)
| (RecordData::CNAME { .. }, DnsRecordType::CNAME)
| (RecordData::NS { .. }, DnsRecordType::NS)
| (RecordData::MX { .. }, DnsRecordType::MX)
| (RecordData::TXT { .. }, DnsRecordType::TXT)
| (RecordData::SRV { .. }, DnsRecordType::SRV)
| (RecordData::CAA { .. }, DnsRecordType::CAA)
)
}
}
impl<'a> Query<'a> {
pub fn name(name: impl Into<&'a str>) -> Self {
Self {
name: name.into(),
record_type: None,
}
}
pub fn name_and_type(name: impl Into<&'a str>, record_type: DnsRecordType) -> Self {
Self {
name: name.into(),
record_type: Some(record_type.as_str()),
}
}
pub fn serialize(&self) -> String {
serde_urlencoded::to_string(self).unwrap()
}
}
impl TryFrom<DnsRecord> for RecordData {
type Error = &'static str;
fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
match record {
DnsRecord::A(content) => Ok(RecordData::A { data: content }),
DnsRecord::AAAA(content) => Ok(RecordData::AAAA { data: content }),
DnsRecord::CNAME(content) => Ok(RecordData::CNAME { data: content }),
DnsRecord::NS(content) => Ok(RecordData::NS { data: content }),
DnsRecord::MX(mx) => Ok(RecordData::MX {
data: mx.exchange,
priority: mx.priority,
}),
DnsRecord::TXT(content) => Ok(RecordData::TXT { data: content }),
DnsRecord::SRV(srv) => Ok(RecordData::SRV {
data: srv.target,
priority: srv.priority,
weight: srv.weight,
port: srv.port,
}),
DnsRecord::TLSA(_) => Err("TLSA records are not supported by DigitalOcean"),
DnsRecord::CAA(caa) => {
let (flags, tag, value) = caa.decompose();
Ok(RecordData::CAA {
data: value,
flags,
tag,
})
}
}
}
}