Skip to main content

dns_update/providers/
cloudflare.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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}