Skip to main content

dns_update/providers/
desec.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::utils::split_caa_value;
13use crate::utils::strip_trailing_dot;
14use crate::utils::unquote_txt;
15use crate::utils::{parse_mx, parse_srv, parse_tlsa};
16use crate::{
17    CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn,
18    http::{HttpClient, HttpClientBuilder},
19    utils::strip_origin_from_name,
20};
21use serde::{Deserialize, Serialize};
22use std::{
23    collections::HashMap,
24    sync::{Arc, Mutex},
25    time::{Duration, Instant},
26};
27
28pub struct DesecDnsRecordRepresentation {
29    pub record_type: String,
30    pub content: String,
31}
32
33#[derive(Clone)]
34pub struct DesecProvider {
35    client: HttpClient,
36    endpoint: String,
37    zones: Arc<Mutex<HashMap<String, (String, Instant)>>>,
38}
39
40#[derive(Serialize, Clone, Debug)]
41pub struct DnsRecordParams<'a> {
42    pub subname: &'a str,
43    #[serde(rename = "type")]
44    pub rr_type: &'a str,
45    pub ttl: Option<u32>,
46    pub records: Vec<String>,
47}
48
49#[derive(Deserialize, Debug)]
50pub struct DesecApiResponse {
51    pub created: String,
52    pub domain: String,
53    pub subname: String,
54    pub name: String,
55    pub records: Vec<String>,
56    pub ttl: u32,
57    #[serde(rename = "type")]
58    pub record_type: String,
59    pub touched: String,
60}
61
62#[derive(Deserialize)]
63struct DesecEmptyResponse {}
64
65const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1";
66
67const DESEC_MIN_TTL: u32 = 3600;
68
69fn url_subname(subname: &str) -> &str {
70    if subname.is_empty() { "@" } else { subname }
71}
72
73impl DesecProvider {
74    pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
75        let client = HttpClientBuilder::default()
76            .with_header("Authorization", format!("Token {}", auth_token.as_ref()))
77            .with_timeout(timeout)
78            .build();
79
80        Self {
81            client,
82            endpoint: DEFAULT_API_ENDPOINT.to_string(),
83            zones: Arc::new(Mutex::new(HashMap::new())),
84        }
85    }
86
87    async fn resolve_domain(&self, origin: &str) -> crate::Result<String> {
88        let origin = origin.trim_end_matches('.');
89
90        if let Ok(guard) = self.zones.lock()
91            && let Some((resolved, expiry)) = guard.get(origin)
92            && Instant::now() < *expiry
93        {
94            return Ok(resolved.clone());
95        }
96
97        let resolved = self.discover_domain(origin).await;
98
99        if let Ok(mut guard) = self.zones.lock() {
100            guard.insert(
101                origin.to_string(),
102                (resolved.clone(), Instant::now() + Duration::from_secs(300)),
103            );
104        }
105
106        Ok(resolved)
107    }
108
109    async fn discover_domain(&self, origin: &str) -> String {
110        let mut candidate = origin;
111        loop {
112            let domain_url = format!(
113                "{endpoint}/domains/{candidate}/",
114                endpoint = self.endpoint,
115                candidate = candidate,
116            );
117
118            if self
119                .client
120                .get(domain_url)
121                .send_with_retry::<DesecEmptyResponse>(3)
122                .await
123                .is_ok()
124            {
125                return candidate.to_string();
126            }
127
128            match candidate.split_once('.') {
129                Some((_, rest)) if rest.contains('.') => candidate = rest,
130                _ => return origin.to_string(),
131            }
132        }
133    }
134
135    #[cfg(test)]
136    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
137        Self {
138            endpoint: endpoint.as_ref().to_string(),
139            ..self
140        }
141    }
142
143    pub(crate) async fn set_rrset(
144        &self,
145        name: impl IntoFqdn<'_>,
146        record_type: DnsRecordType,
147        ttl: u32,
148        records: Vec<DnsRecord>,
149        origin: impl IntoFqdn<'_>,
150    ) -> crate::Result<()> {
151        let name = name.into_name().to_ascii_lowercase();
152        let origin = origin.into_name().to_ascii_lowercase();
153        let domain = self.resolve_domain(&origin).await?;
154        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
155        let rr_type = record_type.as_str();
156
157        if records.is_empty() {
158            let rrset_url = format!(
159                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
160                endpoint = self.endpoint,
161                domain = domain,
162                subdomain = url_subname(&subdomain),
163                rr_type = rr_type,
164            );
165
166            return self
167                .client
168                .delete(rrset_url)
169                .send_with_retry::<DesecEmptyResponse>(3)
170                .await
171                .map(|_| ())
172                .or_else(|err| match err {
173                    crate::Error::NotFound => Ok(()),
174                    err => Err(err),
175                });
176        }
177
178        let contents = build_contents(record_type, records)?;
179        let ttl = ttl.max(DESEC_MIN_TTL);
180
181        let rrsets_url = format!(
182            "{endpoint}/domains/{domain}/rrsets/",
183            endpoint = self.endpoint,
184            domain = domain,
185        );
186
187        self.client
188            .put(rrsets_url)
189            .with_body(vec![DnsRecordParams {
190                subname: &subdomain,
191                rr_type,
192                ttl: Some(ttl),
193                records: contents,
194            }])?
195            .send_with_retry::<Vec<DesecApiResponse>>(3)
196            .await
197            .map(|_| ())
198    }
199
200    pub(crate) async fn add_to_rrset(
201        &self,
202        name: impl IntoFqdn<'_>,
203        record_type: DnsRecordType,
204        ttl: u32,
205        records: Vec<DnsRecord>,
206        origin: impl IntoFqdn<'_>,
207    ) -> crate::Result<()> {
208        if records.is_empty() {
209            return Ok(());
210        }
211
212        let name = name.into_name().to_ascii_lowercase();
213        let origin = origin.into_name().to_ascii_lowercase();
214        let domain = self.resolve_domain(&origin).await?;
215        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
216        let rr_type = record_type.as_str();
217        let ttl = ttl.max(DESEC_MIN_TTL);
218
219        let to_add = build_contents(record_type, records)?;
220
221        let rrset_url = format!(
222            "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
223            endpoint = self.endpoint,
224            domain = domain,
225            subdomain = url_subname(&subdomain),
226            rr_type = rr_type,
227        );
228
229        let (mut current, existed) = match self
230            .client
231            .get(rrset_url.clone())
232            .send_with_retry::<DesecApiResponse>(3)
233            .await
234        {
235            Ok(existing) => (existing.records, true),
236            Err(crate::Error::NotFound) => (Vec::new(), false),
237            Err(err) => return Err(err),
238        };
239
240        let before = current.len();
241        for content in to_add {
242            if !current.iter().any(|r| r == &content) {
243                current.push(content);
244            }
245        }
246
247        if existed && current.len() == before {
248            return Ok(());
249        }
250
251        let params = DnsRecordParams {
252            subname: &subdomain,
253            rr_type,
254            ttl: Some(ttl),
255            records: current,
256        };
257
258        if existed {
259            self.client.put(rrset_url)
260        } else {
261            self.client.post(format!(
262                "{endpoint}/domains/{domain}/rrsets/",
263                endpoint = self.endpoint,
264                domain = domain
265            ))
266        }
267        .with_body(params)?
268        .send_with_retry::<DesecApiResponse>(3)
269        .await
270        .map(|_| ())
271    }
272
273    pub(crate) async fn remove_from_rrset(
274        &self,
275        name: impl IntoFqdn<'_>,
276        record_type: DnsRecordType,
277        records: Vec<DnsRecord>,
278        origin: impl IntoFqdn<'_>,
279    ) -> crate::Result<()> {
280        if records.is_empty() {
281            return Ok(());
282        }
283
284        let name = name.into_name().to_ascii_lowercase();
285        let origin = origin.into_name().to_ascii_lowercase();
286        let domain = self.resolve_domain(&origin).await?;
287        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
288        let rr_type = record_type.as_str();
289
290        let to_remove = build_contents(record_type, records)?;
291
292        let rrset_url = format!(
293            "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
294            endpoint = self.endpoint,
295            domain = domain,
296            subdomain = url_subname(&subdomain),
297            rr_type = rr_type,
298        );
299
300        let existing = match self
301            .client
302            .get(rrset_url.clone())
303            .send_with_retry::<DesecApiResponse>(3)
304            .await
305        {
306            Ok(existing) => existing,
307            Err(crate::Error::NotFound) => return Ok(()),
308            Err(err) => return Err(err),
309        };
310
311        let original_len = existing.records.len();
312        let filtered: Vec<String> = existing
313            .records
314            .into_iter()
315            .filter(|content| !to_remove.iter().any(|r| r == content))
316            .collect();
317
318        if filtered.len() == original_len {
319            return Ok(());
320        }
321
322        if filtered.is_empty() {
323            return self
324                .client
325                .delete(rrset_url)
326                .send_with_retry::<DesecEmptyResponse>(3)
327                .await
328                .map(|_| ())
329                .or_else(|err| match err {
330                    crate::Error::NotFound => Ok(()),
331                    err => Err(err),
332                });
333        }
334
335        self.client
336            .put(rrset_url)
337            .with_body(DnsRecordParams {
338                subname: &subdomain,
339                rr_type,
340                ttl: Some(existing.ttl),
341                records: filtered,
342            })?
343            .send_with_retry::<DesecApiResponse>(3)
344            .await
345            .map(|_| ())
346    }
347
348    pub(crate) async fn list_rrset(
349        &self,
350        name: impl IntoFqdn<'_>,
351        record_type: DnsRecordType,
352        origin: impl IntoFqdn<'_>,
353    ) -> crate::Result<Vec<DnsRecord>> {
354        let name = name.into_name().to_ascii_lowercase();
355        let origin = origin.into_name().to_ascii_lowercase();
356        let domain = self.resolve_domain(&origin).await?;
357        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
358        let rr_type = record_type.as_str();
359
360        let rrset_url = format!(
361            "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
362            endpoint = self.endpoint,
363            domain = domain,
364            subdomain = url_subname(&subdomain),
365            rr_type = rr_type,
366        );
367
368        let response = match self
369            .client
370            .get(rrset_url)
371            .send_with_retry::<DesecApiResponse>(3)
372            .await
373        {
374            Ok(response) => response,
375            Err(crate::Error::NotFound) => return Ok(Vec::new()),
376            Err(err) => return Err(err),
377        };
378
379        response
380            .records
381            .into_iter()
382            .map(|content| parse_record(record_type, &content))
383            .collect()
384    }
385}
386
387fn build_contents(
388    expected_type: DnsRecordType,
389    records: Vec<DnsRecord>,
390) -> crate::Result<Vec<String>> {
391    let mut out = Vec::with_capacity(records.len());
392    for record in records {
393        if record.as_type() != expected_type {
394            return Err(Error::Api(format!(
395                "RRSet record type mismatch: expected {}, got {}",
396                expected_type.as_str(),
397                record.as_type().as_str(),
398            )));
399        }
400        out.push(DesecDnsRecordRepresentation::from(record).content);
401    }
402    Ok(out)
403}
404
405fn parse_record(record_type: DnsRecordType, content: &str) -> crate::Result<DnsRecord> {
406    match record_type {
407        DnsRecordType::A => content
408            .parse()
409            .map(DnsRecord::A)
410            .map_err(|e| Error::Parse(format!("invalid A record: {e}"))),
411        DnsRecordType::AAAA => content
412            .parse()
413            .map(DnsRecord::AAAA)
414            .map_err(|e| Error::Parse(format!("invalid AAAA record: {e}"))),
415        DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(content).to_string())),
416        DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(content).to_string())),
417        DnsRecordType::MX => parse_mx(content),
418        DnsRecordType::TXT => Ok(DnsRecord::TXT(unquote_txt(content))),
419        DnsRecordType::SRV => parse_srv(content),
420        DnsRecordType::TLSA => parse_tlsa(content),
421        DnsRecordType::CAA => parse_caa(content),
422    }
423}
424
425fn parse_caa(content: &str) -> crate::Result<DnsRecord> {
426    let mut parts = content.splitn(3, ' ');
427    let flags: u8 = parts
428        .next()
429        .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
430        .parse()
431        .map_err(|e| Error::Parse(format!("invalid CAA flags: {e}")))?;
432    let tag = parts
433        .next()
434        .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
435        .to_string();
436    let raw_value = parts
437        .next()
438        .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?;
439    let value = raw_value
440        .strip_prefix('"')
441        .and_then(|s| s.strip_suffix('"'))
442        .unwrap_or(raw_value)
443        .to_string();
444
445    let issuer_critical = flags & 0x80 != 0;
446    match tag.as_str() {
447        "issue" => {
448            let (name, options) = split_caa_value(&value);
449            Ok(DnsRecord::CAA(CAARecord::Issue {
450                issuer_critical,
451                name,
452                options,
453            }))
454        }
455        "issuewild" => {
456            let (name, options) = split_caa_value(&value);
457            Ok(DnsRecord::CAA(CAARecord::IssueWild {
458                issuer_critical,
459                name,
460                options,
461            }))
462        }
463        "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
464            issuer_critical,
465            url: value,
466        })),
467        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
468    }
469}
470
471impl From<DnsRecord> for DesecDnsRecordRepresentation {
472    fn from(record: DnsRecord) -> Self {
473        match record {
474            DnsRecord::A(content) => DesecDnsRecordRepresentation {
475                record_type: "A".to_string(),
476                content: content.to_string(),
477            },
478            DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
479                record_type: "AAAA".to_string(),
480                content: content.to_string(),
481            },
482            DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
483                record_type: "CNAME".to_string(),
484                content: content.into_fqdn().into_owned(),
485            },
486            DnsRecord::NS(content) => DesecDnsRecordRepresentation {
487                record_type: "NS".to_string(),
488                content: content.into_fqdn().into_owned(),
489            },
490            DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
491                record_type: "MX".to_string(),
492                content: format!("{} {}", mx.priority, mx.exchange.into_fqdn().into_owned()),
493            },
494            DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
495                record_type: "TXT".to_string(),
496                content: format!("\"{content}\""),
497            },
498            DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
499                record_type: "SRV".to_string(),
500                content: format!(
501                    "{} {} {} {}",
502                    srv.priority,
503                    srv.weight,
504                    srv.port,
505                    srv.target.into_fqdn().into_owned()
506                ),
507            },
508            DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
509                record_type: "TLSA".to_string(),
510                content: tlsa.to_string(),
511            },
512            DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
513                record_type: "CAA".to_string(),
514                content: caa.to_string(),
515            },
516        }
517    }
518}