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