use crate::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
net::{Ipv4Addr, Ipv6Addr},
time::Duration,
};
#[derive(Clone)]
pub struct CloudflareProvider {
client: HttpClientBuilder,
}
#[derive(Deserialize, Debug)]
pub struct IdMap {
pub id: String,
pub name: String,
}
#[derive(Serialize, Debug)]
pub struct Query {
name: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
record_type: Option<&'static str>,
#[serde(rename = "match", skip_serializing_if = "Option::is_none")]
match_mode: Option<&'static str>,
}
#[derive(Serialize, Clone, Debug)]
pub struct CreateDnsRecordParams<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxied: Option<bool>,
pub name: &'a str,
#[serde(flatten)]
pub content: DnsContent,
}
#[derive(Serialize, Clone, Debug)]
pub struct UpdateDnsRecordParams<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxied: Option<bool>,
pub name: &'a str,
#[serde(flatten)]
pub content: DnsContent,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(tag = "type")]
#[allow(clippy::upper_case_acronyms)]
pub enum DnsContent {
A { content: Ipv4Addr },
AAAA { content: Ipv6Addr },
CNAME { content: String },
NS { content: String },
MX { content: String, priority: u16 },
TXT { content: String },
SRV { data: SrvData },
TLSA { data: TlsaData },
CAA { content: String },
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct SrvData {
pub priority: u16,
pub weight: u16,
pub port: u16,
pub target: String,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct TlsaData {
pub usage: u8,
pub selector: u8,
pub matching_type: u8,
pub certificate: String,
}
#[derive(Deserialize, Serialize, Debug)]
struct ApiResult<T> {
errors: Vec<ApiError>,
success: bool,
result: T,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct ApiError {
pub code: u16,
pub message: String,
}
impl CloudflareProvider {
pub(crate) fn new(
secret: impl AsRef<str>,
email: Option<impl AsRef<str>>,
timeout: Option<Duration>,
) -> crate::Result<Self> {
let client = if let Some(email) = email {
HttpClientBuilder::default()
.with_header("X-Auth-Email", email.as_ref())
.with_header("X-Auth-Key", secret.as_ref())
} else {
HttpClientBuilder::default()
.with_header("Authorization", format!("Bearer {}", secret.as_ref()))
}
.with_timeout(timeout);
Ok(Self { client })
}
async fn obtain_zone_id(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
let origin = origin.into_name();
self.client
.get(format!(
"https://api.cloudflare.com/client/v4/zones?{}",
Query::name(origin.as_ref()).serialize()
))
.send_with_retry::<ApiResult<Vec<IdMap>>>(3)
.await
.and_then(|r| r.unwrap_response("list zones"))
.and_then(|result| {
result
.into_iter()
.find(|zone| zone.name == origin.as_ref())
.map(|zone| zone.id)
.ok_or_else(|| Error::Api(format!("Zone {} not found", origin.as_ref())))
})
}
async fn obtain_record_id(
&self,
zone_id: &str,
name: impl IntoFqdn<'_>,
record_type: DnsRecordType,
) -> crate::Result<String> {
let name = name.into_name();
self.client
.get(format!(
"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?{}",
Query::name_and_type(name.as_ref(), record_type).serialize()
))
.send_with_retry::<ApiResult<Vec<IdMap>>>(3)
.await
.and_then(|r| r.unwrap_response("list DNS records"))
.and_then(|result| {
result
.into_iter()
.find(|record| record.name == name.as_ref())
.map(|record| record.id)
.ok_or_else(|| {
Error::Api(format!(
"DNS Record {} of type {} not found",
name.as_ref(),
record_type.as_str()
))
})
})
}
pub(crate) async fn create(
&self,
name: impl IntoFqdn<'_>,
record: DnsRecord,
ttl: u32,
origin: impl IntoFqdn<'_>,
) -> crate::Result<()> {
self.client
.post(format!(
"https://api.cloudflare.com/client/v4/zones/{}/dns_records",
self.obtain_zone_id(origin).await?
))
.with_body(CreateDnsRecordParams {
ttl: ttl.into(),
priority: record.priority(),
proxied: false.into(),
name: name.into_name().as_ref(),
content: record.into(),
})?
.send_with_retry::<ApiResult<Value>>(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();
self.client
.patch(format!(
"https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}",
self.obtain_zone_id(origin).await?,
name.as_ref()
))
.with_body(UpdateDnsRecordParams {
ttl: ttl.into(),
proxied: None,
name: name.as_ref(),
content: record.into(),
})?
.send_with_retry::<ApiResult<Value>>(3)
.await
.map(|_| ())
}
pub(crate) async fn delete(
&self,
name: impl IntoFqdn<'_>,
origin: impl IntoFqdn<'_>,
record_type: DnsRecordType,
) -> crate::Result<()> {
let zone_id = self.obtain_zone_id(origin).await?;
let record_id = self.obtain_record_id(&zone_id, name, record_type).await?;
self.client
.delete(format!(
"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
))
.send_with_retry::<ApiResult<Value>>(3)
.await
.map(|_| ())
}
}
impl<T> ApiResult<T> {
fn unwrap_response(self, action_name: &str) -> crate::Result<T> {
if self.success {
Ok(self.result)
} else {
Err(Error::Api(format!(
"Failed to {action_name}: {:?}",
self.errors
)))
}
}
}
impl Query {
pub fn name(name: impl Into<String>) -> Self {
Self {
name: name.into(),
record_type: None,
match_mode: None,
}
}
pub fn name_and_type(name: impl Into<String>, record_type: DnsRecordType) -> Self {
Self {
name: name.into(),
record_type: Some(record_type.as_str()),
match_mode: Some("all"),
}
}
pub fn serialize(&self) -> String {
serde_urlencoded::to_string(self).unwrap()
}
}
impl From<DnsRecord> for DnsContent {
fn from(record: DnsRecord) -> Self {
match record {
DnsRecord::A(content) => DnsContent::A { content },
DnsRecord::AAAA(content) => DnsContent::AAAA { content },
DnsRecord::CNAME(content) => DnsContent::CNAME { content },
DnsRecord::NS(content) => DnsContent::NS { content },
DnsRecord::MX(mx) => DnsContent::MX {
content: mx.exchange,
priority: mx.priority,
},
DnsRecord::TXT(content) => DnsContent::TXT { content },
DnsRecord::SRV(srv) => DnsContent::SRV {
data: SrvData {
priority: srv.priority,
weight: srv.weight,
port: srv.port,
target: srv.target,
},
},
DnsRecord::TLSA(tlsa) => DnsContent::TLSA {
data: TlsaData {
usage: u8::from(tlsa.cert_usage),
selector: u8::from(tlsa.selector),
matching_type: u8::from(tlsa.matching),
certificate: tlsa
.cert_data
.iter()
.map(|b| format!("{b:02x}"))
.collect(),
},
},
DnsRecord::CAA(caa) => DnsContent::CAA {
content: caa.to_string(),
},
}
}
}