1use crate::utils::build_caa;
2use crate::utils::unquote_txt;
3use crate::utils::{
4 decode_hex, tlsa_cert_usage_from_u8, tlsa_matching_from_u8, tlsa_selector_from_u8,
5};
6use crate::{
7 DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord, TLSARecord,
8 http::{HttpClient, HttpClientBuilder},
9 utils::txt_chunks_to_text,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::{
14 borrow::Cow,
15 net::{Ipv4Addr, Ipv6Addr},
16 time::Duration,
17};
18
19#[derive(Clone)]
20pub struct CloudflareProvider {
21 client: HttpClient,
22 endpoint: Cow<'static, str>,
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(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
54#[serde(tag = "type")]
55#[allow(clippy::upper_case_acronyms)]
56pub enum DnsContent {
57 A { content: Ipv4Addr },
58 AAAA { content: Ipv6Addr },
59 CNAME { content: String },
60 NS { content: String },
61 MX { content: String, priority: u16 },
62 TXT { content: String },
63 SRV { data: SrvData },
64 TLSA { data: TlsaData },
65 CAA { data: CaaData },
66 HTTPS { data: HttpsData },
67}
68
69#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
70pub struct HttpsData {
71 pub priority: u16,
72 pub target: String,
73 pub value: String,
74}
75
76#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
77pub struct SrvData {
78 pub priority: u16,
79 pub weight: u16,
80 pub port: u16,
81 pub target: String,
82}
83
84#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
85pub struct TlsaData {
86 pub usage: u8,
87 pub selector: u8,
88 pub matching_type: u8,
89 pub certificate: String,
90}
91
92#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
93pub struct CaaData {
94 pub flags: u8,
95 pub tag: String,
96 pub value: String,
97}
98
99#[derive(Deserialize, Debug, Clone)]
100struct ListedRecord {
101 id: String,
102 #[serde(flatten)]
103 content: DnsContent,
104}
105
106#[derive(Deserialize, Serialize, Debug)]
107struct ApiResult<T> {
108 errors: Vec<ApiError>,
109 success: bool,
110 result: T,
111}
112
113#[derive(Deserialize, Serialize, Debug)]
114pub struct ApiError {
115 pub code: u16,
116 pub message: String,
117}
118
119impl CloudflareProvider {
120 pub(crate) fn new(secret: impl AsRef<str>, timeout: Option<Duration>) -> crate::Result<Self> {
121 let client = HttpClientBuilder::default()
122 .with_header("Authorization", format!("Bearer {}", secret.as_ref()))
123 .with_timeout(timeout)
124 .build();
125
126 Ok(Self {
127 client,
128 endpoint: crate::config::cloudflare_endpoint(),
129 })
130 }
131
132 #[cfg(test)]
133 pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
134 Self {
135 endpoint: endpoint.into(),
136 ..self
137 }
138 }
139
140 async fn obtain_zone_id(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
141 let origin = origin.into_name();
142 let mut candidate: &str = origin.as_ref();
143 loop {
144 let zones = self
145 .client
146 .get(format!(
147 "{}/zones?{}",
148 self.endpoint,
149 Query::name(candidate).serialize()
150 ))
151 .send_with_retry::<ApiResult<Vec<IdMap>>>(3)
152 .await
153 .and_then(|r| r.unwrap_response("list zones"))?;
154 if let Some(zone) = zones.into_iter().find(|zone| zone.name == candidate) {
155 return Ok(zone.id);
156 }
157 match candidate.split_once('.') {
158 Some((_, rest)) if rest.contains('.') => candidate = rest,
159 _ => {
160 return Err(Error::Api(format!(
161 "No Cloudflare zone found for {}",
162 origin.as_ref()
163 )));
164 }
165 }
166 }
167 }
168
169 pub(crate) async fn set_rrset(
170 &self,
171 name: impl IntoFqdn<'_>,
172 record_type: DnsRecordType,
173 ttl: u32,
174 records: Vec<DnsRecord>,
175 origin: impl IntoFqdn<'_>,
176 ) -> crate::Result<()> {
177 let zone_id = self.obtain_zone_id(origin).await?;
178 let name = name.into_name().into_owned();
179 let desired = build_contents(record_type, records)?;
180 let existing = self.list_at(&zone_id, &name, record_type).await?;
181
182 let mut to_add = Vec::new();
183 let mut existing_unmatched: Vec<ListedRecord> = Vec::new();
184 let mut existing_iter = existing.into_iter();
185 let mut existing_pool: Vec<ListedRecord> = existing_iter.by_ref().collect();
186
187 for content in desired {
188 if let Some(idx) = existing_pool.iter().position(|r| r.content == content) {
189 existing_pool.swap_remove(idx);
190 } else {
191 to_add.push(content);
192 }
193 }
194 existing_unmatched.append(&mut existing_pool);
195
196 for entry in existing_unmatched {
197 self.delete_record(&zone_id, &entry.id).await?;
198 }
199 for content in to_add {
200 self.create_record(&zone_id, &name, ttl, content).await?;
201 }
202 Ok(())
203 }
204
205 pub(crate) async fn add_to_rrset(
206 &self,
207 name: impl IntoFqdn<'_>,
208 record_type: DnsRecordType,
209 ttl: u32,
210 records: Vec<DnsRecord>,
211 origin: impl IntoFqdn<'_>,
212 ) -> crate::Result<()> {
213 if records.is_empty() {
214 return Ok(());
215 }
216 let zone_id = self.obtain_zone_id(origin).await?;
217 let name = name.into_name().into_owned();
218 let desired = build_contents(record_type, records)?;
219 let existing = self.list_at(&zone_id, &name, record_type).await?;
220
221 for content in desired {
222 if existing.iter().any(|r| r.content == content) {
223 continue;
224 }
225 self.create_record(&zone_id, &name, ttl, content).await?;
226 }
227 Ok(())
228 }
229
230 pub(crate) async fn remove_from_rrset(
231 &self,
232 name: impl IntoFqdn<'_>,
233 record_type: DnsRecordType,
234 records: Vec<DnsRecord>,
235 origin: impl IntoFqdn<'_>,
236 ) -> crate::Result<()> {
237 if records.is_empty() {
238 return Ok(());
239 }
240 let zone_id = self.obtain_zone_id(origin).await?;
241 let name = name.into_name().into_owned();
242 let to_remove = build_contents(record_type, records)?;
243 let existing = self.list_at(&zone_id, &name, record_type).await?;
244
245 for content in to_remove {
246 if let Some(entry) = existing.iter().find(|r| r.content == content) {
247 self.delete_record(&zone_id, &entry.id).await?;
248 }
249 }
250 Ok(())
251 }
252
253 pub(crate) async fn list_rrset(
254 &self,
255 name: impl IntoFqdn<'_>,
256 record_type: DnsRecordType,
257 origin: impl IntoFqdn<'_>,
258 ) -> crate::Result<Vec<DnsRecord>> {
259 let zone_id = self.obtain_zone_id(origin).await?;
260 let name = name.into_name().into_owned();
261 let listed = self.list_at(&zone_id, &name, record_type).await?;
262 listed.into_iter().map(|r| r.content.try_into()).collect()
263 }
264
265 #[cfg(test)]
266 pub(crate) async fn list_contents_for_tests(
267 &self,
268 name: impl IntoFqdn<'_>,
269 record_type: DnsRecordType,
270 origin: impl IntoFqdn<'_>,
271 ) -> crate::Result<Vec<DnsContent>> {
272 let zone_id = self.obtain_zone_id(origin).await?;
273 let name = name.into_name().into_owned();
274 let listed = self.list_at(&zone_id, &name, record_type).await?;
275 Ok(listed.into_iter().map(|r| r.content).collect())
276 }
277
278 async fn list_at(
279 &self,
280 zone_id: &str,
281 name: &str,
282 record_type: DnsRecordType,
283 ) -> crate::Result<Vec<ListedRecord>> {
284 let url = format!(
285 "{}/zones/{zone_id}/dns_records?{}&per_page=100",
286 self.endpoint,
287 Query::name_and_type(name, record_type).serialize()
288 );
289 let response: ApiResult<Vec<ListedRecord>> =
290 self.client.get(url).send_with_retry(3).await?;
291 response.unwrap_response("list DNS records")
292 }
293
294 async fn create_record(
295 &self,
296 zone_id: &str,
297 name: &str,
298 ttl: u32,
299 content: DnsContent,
300 ) -> crate::Result<()> {
301 let priority = match &content {
302 DnsContent::MX { priority, .. } => Some(*priority),
303 _ => None,
304 };
305 self.client
306 .post(format!("{}/zones/{zone_id}/dns_records", self.endpoint))
307 .with_body(CreateDnsRecordParams {
308 ttl: Some(ttl),
309 priority,
310 proxied: Some(false),
311 name,
312 content,
313 })?
314 .send_with_retry::<ApiResult<Value>>(3)
315 .await
316 .map(|_| ())
317 }
318
319 async fn delete_record(&self, zone_id: &str, record_id: &str) -> crate::Result<()> {
320 self.client
321 .delete(format!(
322 "{}/zones/{zone_id}/dns_records/{record_id}",
323 self.endpoint
324 ))
325 .send_with_retry::<ApiResult<Value>>(3)
326 .await
327 .map(|_| ())
328 }
329}
330
331fn build_contents(
332 expected_type: DnsRecordType,
333 records: Vec<DnsRecord>,
334) -> crate::Result<Vec<DnsContent>> {
335 let mut out = Vec::with_capacity(records.len());
336 for record in records {
337 if record.as_type() != expected_type {
338 return Err(Error::Api(format!(
339 "RRSet record type mismatch: expected {}, got {}",
340 expected_type.as_str(),
341 record.as_type().as_str(),
342 )));
343 }
344 out.push(record.into());
345 }
346 Ok(out)
347}
348
349impl<T> ApiResult<T> {
350 fn unwrap_response(self, action_name: &str) -> crate::Result<T> {
351 if self.success {
352 Ok(self.result)
353 } else {
354 Err(Error::Api(format!(
355 "Failed to {action_name}: {:?}",
356 self.errors
357 )))
358 }
359 }
360}
361
362impl Query {
363 pub fn name(name: impl Into<String>) -> Self {
364 Self {
365 name: name.into(),
366 record_type: None,
367 match_mode: None,
368 }
369 }
370
371 pub fn name_and_type(name: impl Into<String>, record_type: DnsRecordType) -> Self {
372 Self {
373 name: name.into(),
374 record_type: Some(record_type.as_str()),
375 match_mode: Some("all"),
376 }
377 }
378
379 pub fn serialize(&self) -> String {
380 serde_urlencoded::to_string(self)
381 .expect("query parameters are statically serializable to urlencoding")
382 }
383}
384
385impl From<DnsRecord> for DnsContent {
386 fn from(record: DnsRecord) -> Self {
387 match record {
388 DnsRecord::A(content) => DnsContent::A { content },
389 DnsRecord::AAAA(content) => DnsContent::AAAA { content },
390 DnsRecord::CNAME(content) => DnsContent::CNAME { content },
391 DnsRecord::NS(content) => DnsContent::NS { content },
392 DnsRecord::MX(mx) => DnsContent::MX {
393 content: mx.exchange,
394 priority: mx.priority,
395 },
396 DnsRecord::TXT(content) => {
397 let mut out = String::with_capacity(content.len() + 4);
398 txt_chunks_to_text(&mut out, &content, " ");
399 DnsContent::TXT { content: out }
400 }
401 DnsRecord::SRV(srv) => DnsContent::SRV {
402 data: SrvData {
403 priority: srv.priority,
404 weight: srv.weight,
405 port: srv.port,
406 target: srv.target,
407 },
408 },
409 DnsRecord::TLSA(tlsa) => DnsContent::TLSA {
410 data: TlsaData {
411 usage: u8::from(tlsa.cert_usage),
412 selector: u8::from(tlsa.selector),
413 matching_type: u8::from(tlsa.matching),
414 certificate: tlsa.cert_data.iter().map(|b| format!("{b:02x}")).collect(),
415 },
416 },
417 DnsRecord::CAA(caa) => {
418 let (flags, tag, value) = caa.decompose();
419 DnsContent::CAA {
420 data: CaaData { flags, tag, value },
421 }
422 }
423 DnsRecord::HTTPS(https) => DnsContent::HTTPS {
424 data: HttpsData {
425 priority: https.svc_priority,
426 target: https.target_name,
427 value: https
428 .svc_params
429 .into_iter()
430 .map(|v| v.to_string())
431 .collect::<Vec<_>>()
432 .join(" "),
433 },
434 },
435 }
436 }
437}
438
439impl TryFrom<DnsContent> for DnsRecord {
440 type Error = Error;
441
442 fn try_from(content: DnsContent) -> crate::Result<Self> {
443 Ok(match content {
444 DnsContent::A { content } => DnsRecord::A(content),
445 DnsContent::AAAA { content } => DnsRecord::AAAA(content),
446 DnsContent::CNAME { content } => DnsRecord::CNAME(content),
447 DnsContent::NS { content } => DnsRecord::NS(content),
448 DnsContent::MX { content, priority } => DnsRecord::MX(MXRecord {
449 exchange: content,
450 priority,
451 }),
452 DnsContent::TXT { content } => DnsRecord::TXT(unquote_txt(&content)),
453 DnsContent::SRV { data } => DnsRecord::SRV(SRVRecord {
454 priority: data.priority,
455 weight: data.weight,
456 port: data.port,
457 target: data.target,
458 }),
459 DnsContent::TLSA { data } => DnsRecord::TLSA(TLSARecord {
460 cert_usage: tlsa_cert_usage_from_u8(data.usage)?,
461 selector: tlsa_selector_from_u8(data.selector)?,
462 matching: tlsa_matching_from_u8(data.matching_type)?,
463 cert_data: decode_hex(&data.certificate)?,
464 }),
465 DnsContent::CAA { data } => {
466 DnsRecord::CAA(build_caa(data.flags, &data.tag, &data.value)?)
467 }
468 DnsContent::HTTPS { data } => DnsRecord::HTTPS(crate::HTTPSRecord {
469 svc_priority: data.priority,
470 target_name: data.target,
471 svc_params: data
472 .value
473 .split(' ')
474 .map(|v| {
475 let mut parts = v.splitn(2, '=');
476 crate::KeyValue {
477 key: parts.next().unwrap_or_default().to_string(),
478 value: parts
479 .next()
480 .unwrap_or_default()
481 .trim_matches('"')
482 .to_string(),
483 }
484 })
485 .collect::<Vec<_>>(),
486 }),
487 })
488 }
489}