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    DnsRecord, DnsRecordType, IntoFqdn, http::HttpClientBuilder, utils::strip_origin_from_name,
14};
15use serde::{Deserialize, Serialize};
16use std::time::Duration;
17
18pub struct DesecDnsRecordRepresentation {
19    pub record_type: String,
20    pub content: String,
21}
22
23#[derive(Clone)]
24pub struct DesecProvider {
25    client: HttpClientBuilder,
26    endpoint: String,
27}
28
29/// The parameters for creation and modification requests of the desec API.
30#[derive(Serialize, Clone, Debug)]
31pub struct DnsRecordParams<'a> {
32    pub subname: &'a str,
33    #[serde(rename = "type")]
34    pub rr_type: &'a str,
35    pub ttl: Option<u32>,
36    pub records: Vec<String>,
37}
38
39/// The response for creation and modification requests of the desec API.
40#[derive(Deserialize, Debug)]
41pub struct DesecApiResponse {
42    pub created: String,
43    pub domain: String,
44    pub subname: String,
45    pub name: String,
46    pub records: Vec<String>,
47    pub ttl: u32,
48    #[serde(rename = "type")]
49    pub record_type: String,
50    pub touched: String,
51}
52
53#[derive(Deserialize)]
54struct DesecEmptyResponse {}
55
56const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1";
57
58const DESEC_MIN_TTL: u32 = 3600;
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
66        Self {
67            client,
68            endpoint: DEFAULT_API_ENDPOINT.to_string(),
69        }
70    }
71
72    #[cfg(test)]
73    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
74        Self {
75            endpoint: endpoint.as_ref().to_string(),
76            ..self
77        }
78    }
79
80    pub(crate) async fn create(
81        &self,
82        name: impl IntoFqdn<'_>,
83        record: DnsRecord,
84        ttl: u32,
85        origin: impl IntoFqdn<'_>,
86    ) -> crate::Result<()> {
87        let name = name.into_name().to_ascii_lowercase();
88        let domain = origin.into_name().to_ascii_lowercase();
89        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
90        let ttl = ttl.max(DESEC_MIN_TTL);
91
92        let desec_record = DesecDnsRecordRepresentation::from(record);
93
94        let rrset_url = format!(
95            "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
96            endpoint = self.endpoint,
97            domain = &domain,
98            subdomain = &subdomain,
99            rr_type = &desec_record.record_type,
100        );
101
102        let (mut records, existed) = match self
103            .client
104            .get(rrset_url.clone())
105            .send_with_retry::<DesecApiResponse>(3)
106            .await
107        {
108            Ok(existing) => (existing.records, true),
109            Err(crate::Error::NotFound) => (Vec::new(), false),
110            Err(err) => return Err(err),
111        };
112
113        if !records.iter().any(|r| r == &desec_record.content) {
114            records.push(desec_record.content);
115        }
116
117        let params = DnsRecordParams {
118            subname: &subdomain,
119            rr_type: &desec_record.record_type,
120            ttl: Some(ttl),
121            records,
122        };
123
124        if existed {
125            self.client.put(rrset_url)
126        } else {
127            self.client.post(format!(
128                "{endpoint}/domains/{domain}/rrsets/",
129                endpoint = self.endpoint,
130                domain = domain
131            ))
132        }
133        .with_body(params)?
134        .send_with_retry::<DesecApiResponse>(3)
135        .await
136        .map(|_| ())
137    }
138
139    pub(crate) async fn update(
140        &self,
141        name: impl IntoFqdn<'_>,
142        record: DnsRecord,
143        ttl: u32,
144        origin: impl IntoFqdn<'_>,
145    ) -> crate::Result<()> {
146        let name = name.into_name().to_ascii_lowercase();
147        let domain = origin.into_name().to_ascii_lowercase();
148        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
149        let ttl = ttl.max(DESEC_MIN_TTL);
150
151        let desec_record = DesecDnsRecordRepresentation::from(record);
152        self.client
153            .put(format!(
154                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
155                endpoint = self.endpoint,
156                domain = &domain,
157                subdomain = &subdomain,
158                rr_type = &desec_record.record_type,
159            ))
160            .with_body(DnsRecordParams {
161                subname: &subdomain,
162                rr_type: desec_record.record_type.as_str(),
163                ttl: Some(ttl),
164                records: vec![desec_record.content],
165            })?
166            .send_with_retry::<DesecApiResponse>(3)
167            .await
168            .map(|_| ())
169    }
170
171    pub(crate) async fn delete(
172        &self,
173        name: impl IntoFqdn<'_>,
174        origin: impl IntoFqdn<'_>,
175        record_type: DnsRecordType,
176    ) -> crate::Result<()> {
177        let name = name.into_name().to_ascii_lowercase();
178        let domain = origin.into_name().to_ascii_lowercase();
179        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
180
181        let rr_type = &record_type.to_string();
182        self.client
183            .delete(format!(
184                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/",
185                endpoint = self.endpoint,
186                domain = &domain,
187                subdomain = &subdomain,
188                rtype = &rr_type.to_string(),
189            ))
190            .send_with_retry::<DesecEmptyResponse>(3)
191            .await
192            .map(|_| ())
193            .or_else(|err| match err {
194                crate::Error::NotFound => Ok(()),
195                err => Err(err),
196            })
197    }
198}
199
200fn ensure_fqdn(name: String) -> String {
201    if name.ends_with('.') {
202        name
203    } else {
204        format!("{name}.")
205    }
206}
207
208/// Converts a DNS record into a representation that can be sent to the desec API.
209impl From<DnsRecord> for DesecDnsRecordRepresentation {
210    fn from(record: DnsRecord) -> Self {
211        match record {
212            DnsRecord::A(content) => DesecDnsRecordRepresentation {
213                record_type: "A".to_string(),
214                content: content.to_string(),
215            },
216            DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
217                record_type: "AAAA".to_string(),
218                content: content.to_string(),
219            },
220            DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
221                record_type: "CNAME".to_string(),
222                content: ensure_fqdn(content),
223            },
224            DnsRecord::NS(content) => DesecDnsRecordRepresentation {
225                record_type: "NS".to_string(),
226                content: ensure_fqdn(content),
227            },
228            DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
229                record_type: "MX".to_string(),
230                content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
231            },
232            DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
233                record_type: "TXT".to_string(),
234                content: format!("\"{content}\""),
235            },
236            DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
237                record_type: "SRV".to_string(),
238                content: format!(
239                    "{} {} {} {}",
240                    srv.priority,
241                    srv.weight,
242                    srv.port,
243                    ensure_fqdn(srv.target)
244                ),
245            },
246            DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
247                record_type: "TLSA".to_string(),
248                content: tlsa.to_string(),
249            },
250            DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
251                record_type: "CAA".to_string(),
252                content: caa.to_string(),
253            },
254        }
255    }
256}