1use crate::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::{
16 net::{Ipv4Addr, Ipv6Addr},
17 time::Duration,
18};
19
20#[derive(Clone)]
21pub struct CloudflareProvider {
22 client: HttpClientBuilder,
23}
24
25#[derive(Deserialize, Debug)]
26pub struct IdMap {
27 pub id: String,
28 pub name: String,
29}
30
31#[derive(Serialize, Debug)]
32pub struct Query {
33 name: String,
34 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
35 record_type: Option<&'static str>,
36 #[serde(rename = "match", skip_serializing_if = "Option::is_none")]
37 match_mode: Option<&'static str>,
38}
39
40#[derive(Serialize, Clone, Debug)]
41pub struct CreateDnsRecordParams<'a> {
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub ttl: Option<u32>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub priority: Option<u16>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub proxied: Option<bool>,
48 pub name: &'a str,
49 #[serde(flatten)]
50 pub content: DnsContent,
51}
52
53#[derive(Serialize, Clone, Debug)]
54pub struct UpdateDnsRecordParams<'a> {
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub ttl: Option<u32>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub proxied: Option<bool>,
59 pub name: &'a str,
60 #[serde(flatten)]
61 pub content: DnsContent,
62}
63
64#[derive(Deserialize, Serialize, Clone, Debug)]
65#[serde(tag = "type")]
66#[allow(clippy::upper_case_acronyms)]
67pub enum DnsContent {
68 A { content: Ipv4Addr },
69 AAAA { content: Ipv6Addr },
70 CNAME { content: String },
71 NS { content: String },
72 MX { content: String, priority: u16 },
73 TXT { content: String },
74 SRV { data: SrvData },
75 TLSA { data: TlsaData },
76 CAA { data: CaaData },
77}
78
79#[derive(Deserialize, Serialize, Clone, Debug)]
80pub struct SrvData {
81 pub priority: u16,
82 pub weight: u16,
83 pub port: u16,
84 pub target: String,
85}
86
87#[derive(Deserialize, Serialize, Clone, Debug)]
88pub struct TlsaData {
89 pub usage: u8,
90 pub selector: u8,
91 pub matching_type: u8,
92 pub certificate: String,
93}
94
95#[derive(Deserialize, Serialize, Clone, Debug)]
96pub struct CaaData {
97 pub flags: u8,
98 pub tag: String,
99 pub value: String,
100}
101
102#[derive(Deserialize, Serialize, Debug)]
103struct ApiResult<T> {
104 errors: Vec<ApiError>,
105 success: bool,
106 result: T,
107}
108
109#[derive(Deserialize, Serialize, Debug)]
110pub struct ApiError {
111 pub code: u16,
112 pub message: String,
113}
114
115impl CloudflareProvider {
116 pub(crate) fn new(
117 secret: impl AsRef<str>,
118 email: Option<impl AsRef<str>>,
119 timeout: Option<Duration>,
120 ) -> crate::Result<Self> {
121 let client = if let Some(email) = email {
122 HttpClientBuilder::default()
123 .with_header("X-Auth-Email", email.as_ref())
124 .with_header("X-Auth-Key", secret.as_ref())
125 } else {
126 HttpClientBuilder::default()
127 .with_header("Authorization", format!("Bearer {}", secret.as_ref()))
128 }
129 .with_timeout(timeout);
130
131 Ok(Self { client })
132 }
133
134 async fn obtain_zone_id(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
135 let origin = origin.into_name();
136 let mut candidate: &str = origin.as_ref();
137 loop {
138 let zones = self
139 .client
140 .get(format!(
141 "https://api.cloudflare.com/client/v4/zones?{}",
142 Query::name(candidate).serialize()
143 ))
144 .send_with_retry::<ApiResult<Vec<IdMap>>>(3)
145 .await
146 .and_then(|r| r.unwrap_response("list zones"))?;
147 if let Some(zone) = zones.into_iter().find(|zone| zone.name == candidate) {
148 return Ok(zone.id);
149 }
150 match candidate.split_once('.') {
151 Some((_, rest)) if rest.contains('.') => candidate = rest,
152 _ => {
153 return Err(Error::Api(format!(
154 "No Cloudflare zone found for {}",
155 origin.as_ref()
156 )));
157 }
158 }
159 }
160 }
161
162 async fn obtain_record_id(
163 &self,
164 zone_id: &str,
165 name: impl IntoFqdn<'_>,
166 record_type: DnsRecordType,
167 ) -> crate::Result<String> {
168 let name = name.into_name();
169 self.client
170 .get(format!(
171 "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?{}",
172 Query::name_and_type(name.as_ref(), record_type).serialize()
173 ))
174 .send_with_retry::<ApiResult<Vec<IdMap>>>(3)
175 .await
176 .and_then(|r| r.unwrap_response("list DNS records"))
177 .and_then(|result| {
178 result
179 .into_iter()
180 .find(|record| record.name == name.as_ref())
181 .map(|record| record.id)
182 .ok_or_else(|| {
183 Error::Api(format!(
184 "DNS Record {} of type {} not found",
185 name.as_ref(),
186 record_type.as_str()
187 ))
188 })
189 })
190 }
191
192 pub(crate) async fn create(
193 &self,
194 name: impl IntoFqdn<'_>,
195 record: DnsRecord,
196 ttl: u32,
197 origin: impl IntoFqdn<'_>,
198 ) -> crate::Result<()> {
199 self.client
200 .post(format!(
201 "https://api.cloudflare.com/client/v4/zones/{}/dns_records",
202 self.obtain_zone_id(origin).await?
203 ))
204 .with_body(CreateDnsRecordParams {
205 ttl: ttl.into(),
206 priority: record.priority(),
207 proxied: false.into(),
208 name: name.into_name().as_ref(),
209 content: record.into(),
210 })?
211 .send_with_retry::<ApiResult<Value>>(3)
212 .await
213 .map(|_| ())
214 }
215
216 pub(crate) async fn update(
217 &self,
218 name: impl IntoFqdn<'_>,
219 record: DnsRecord,
220 ttl: u32,
221 origin: impl IntoFqdn<'_>,
222 ) -> crate::Result<()> {
223 let name = name.into_name();
224 self.client
225 .patch(format!(
226 "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}",
227 self.obtain_zone_id(origin).await?,
228 name.as_ref()
229 ))
230 .with_body(UpdateDnsRecordParams {
231 ttl: ttl.into(),
232 proxied: None,
233 name: name.as_ref(),
234 content: record.into(),
235 })?
236 .send_with_retry::<ApiResult<Value>>(3)
237 .await
238 .map(|_| ())
239 }
240
241 pub(crate) async fn delete(
242 &self,
243 name: impl IntoFqdn<'_>,
244 origin: impl IntoFqdn<'_>,
245 record_type: DnsRecordType,
246 ) -> crate::Result<()> {
247 let zone_id = self.obtain_zone_id(origin).await?;
248 let record_id = self.obtain_record_id(&zone_id, name, record_type).await?;
249
250 self.client
251 .delete(format!(
252 "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
253 ))
254 .send_with_retry::<ApiResult<Value>>(3)
255 .await
256 .map(|_| ())
257 }
258}
259
260impl<T> ApiResult<T> {
261 fn unwrap_response(self, action_name: &str) -> crate::Result<T> {
262 if self.success {
263 Ok(self.result)
264 } else {
265 Err(Error::Api(format!(
266 "Failed to {action_name}: {:?}",
267 self.errors
268 )))
269 }
270 }
271}
272
273impl Query {
274 pub fn name(name: impl Into<String>) -> Self {
275 Self {
276 name: name.into(),
277 record_type: None,
278 match_mode: None,
279 }
280 }
281
282 pub fn name_and_type(name: impl Into<String>, record_type: DnsRecordType) -> Self {
283 Self {
284 name: name.into(),
285 record_type: Some(record_type.as_str()),
286 match_mode: Some("all"),
287 }
288 }
289
290 pub fn serialize(&self) -> String {
291 serde_urlencoded::to_string(self).unwrap()
292 }
293}
294
295impl From<DnsRecord> for DnsContent {
296 fn from(record: DnsRecord) -> Self {
297 match record {
298 DnsRecord::A(content) => DnsContent::A { content },
299 DnsRecord::AAAA(content) => DnsContent::AAAA { content },
300 DnsRecord::CNAME(content) => DnsContent::CNAME { content },
301 DnsRecord::NS(content) => DnsContent::NS { content },
302 DnsRecord::MX(mx) => DnsContent::MX {
303 content: mx.exchange,
304 priority: mx.priority,
305 },
306 DnsRecord::TXT(content) => DnsContent::TXT { content },
307 DnsRecord::SRV(srv) => DnsContent::SRV {
308 data: SrvData {
309 priority: srv.priority,
310 weight: srv.weight,
311 port: srv.port,
312 target: srv.target,
313 },
314 },
315 DnsRecord::TLSA(tlsa) => DnsContent::TLSA {
316 data: TlsaData {
317 usage: u8::from(tlsa.cert_usage),
318 selector: u8::from(tlsa.selector),
319 matching_type: u8::from(tlsa.matching),
320 certificate: tlsa
321 .cert_data
322 .iter()
323 .map(|b| format!("{b:02x}"))
324 .collect(),
325 },
326 },
327 DnsRecord::CAA(caa) => {
328 let (flags, tag, value) = caa.decompose();
329 DnsContent::CAA {
330 data: CaaData { flags, tag, value },
331 }
332 }
333 }
334 }
335}