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        self.client
94            .post(format!(
95                "{endpoint}/domains/{domain}/rrsets/",
96                endpoint = self.endpoint,
97                domain = domain
98            ))
99            .with_body(DnsRecordParams {
100                subname: &subdomain,
101                rr_type: &desec_record.record_type,
102                ttl: Some(ttl),
103                records: vec![desec_record.content],
104            })?
105            .send_with_retry::<DesecApiResponse>(3)
106            .await
107            .map(|_| ())
108    }
109
110    pub(crate) async fn update(
111        &self,
112        name: impl IntoFqdn<'_>,
113        record: DnsRecord,
114        ttl: u32,
115        origin: impl IntoFqdn<'_>,
116    ) -> crate::Result<()> {
117        let name = name.into_name().to_ascii_lowercase();
118        let domain = origin.into_name().to_ascii_lowercase();
119        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
120        let ttl = ttl.max(DESEC_MIN_TTL);
121
122        let desec_record = DesecDnsRecordRepresentation::from(record);
123        self.client
124            .put(format!(
125                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
126                endpoint = self.endpoint,
127                domain = &domain,
128                subdomain = &subdomain,
129                rr_type = &desec_record.record_type,
130            ))
131            .with_body(DnsRecordParams {
132                subname: &subdomain,
133                rr_type: desec_record.record_type.as_str(),
134                ttl: Some(ttl),
135                records: vec![desec_record.content],
136            })?
137            .send_with_retry::<DesecApiResponse>(3)
138            .await
139            .map(|_| ())
140    }
141
142    pub(crate) async fn delete(
143        &self,
144        name: impl IntoFqdn<'_>,
145        origin: impl IntoFqdn<'_>,
146        record_type: DnsRecordType,
147    ) -> crate::Result<()> {
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
152        let rr_type = &record_type.to_string();
153        self.client
154            .delete(format!(
155                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/",
156                endpoint = self.endpoint,
157                domain = &domain,
158                subdomain = &subdomain,
159                rtype = &rr_type.to_string(),
160            ))
161            .send_with_retry::<DesecEmptyResponse>(3)
162            .await
163            .map(|_| ())
164    }
165}
166
167fn ensure_fqdn(name: String) -> String {
168    if name.ends_with('.') {
169        name
170    } else {
171        format!("{name}.")
172    }
173}
174
175/// Converts a DNS record into a representation that can be sent to the desec API.
176impl From<DnsRecord> for DesecDnsRecordRepresentation {
177    fn from(record: DnsRecord) -> Self {
178        match record {
179            DnsRecord::A(content) => DesecDnsRecordRepresentation {
180                record_type: "A".to_string(),
181                content: content.to_string(),
182            },
183            DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
184                record_type: "AAAA".to_string(),
185                content: content.to_string(),
186            },
187            DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
188                record_type: "CNAME".to_string(),
189                content: ensure_fqdn(content),
190            },
191            DnsRecord::NS(content) => DesecDnsRecordRepresentation {
192                record_type: "NS".to_string(),
193                content: ensure_fqdn(content),
194            },
195            DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
196                record_type: "MX".to_string(),
197                content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
198            },
199            DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
200                record_type: "TXT".to_string(),
201                content: format!("\"{content}\""),
202            },
203            DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
204                record_type: "SRV".to_string(),
205                content: format!(
206                    "{} {} {} {}",
207                    srv.priority,
208                    srv.weight,
209                    srv.port,
210                    ensure_fqdn(srv.target)
211                ),
212            },
213            DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
214                record_type: "TLSA".to_string(),
215                content: tlsa.to_string(),
216            },
217            DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
218                record_type: "CAA".to_string(),
219                content: caa.to_string(),
220            },
221        }
222    }
223}