Skip to main content

dns_update_lite/providers/
cloudflare.rs

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}