Skip to main content

dns_update_lite/providers/
desec.rs

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