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