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
56/// The default endpoint for the desec API.
57const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1";
58
59impl DesecProvider {
60    pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
61        let client = HttpClientBuilder::default()
62            .with_header("Authorization", format!("Token {}", auth_token.as_ref()))
63            .with_timeout(timeout);
64
65        Self {
66            client,
67            endpoint: DEFAULT_API_ENDPOINT.to_string(),
68        }
69    }
70
71    #[cfg(test)]
72    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
73        Self {
74            endpoint: endpoint.as_ref().to_string(),
75            ..self
76        }
77    }
78
79    pub(crate) async fn create(
80        &self,
81        name: impl IntoFqdn<'_>,
82        record: DnsRecord,
83        ttl: u32,
84        origin: impl IntoFqdn<'_>,
85    ) -> crate::Result<()> {
86        let name = name.into_name();
87        let domain = origin.into_name();
88        let subdomain = strip_origin_from_name(&name, &domain, None);
89
90        let desec_record = DesecDnsRecordRepresentation::from(record);
91        self.client
92            .post(format!(
93                "{endpoint}/domains/{domain}/rrsets/",
94                endpoint = self.endpoint,
95                domain = domain
96            ))
97            .with_body(DnsRecordParams {
98                subname: &subdomain,
99                rr_type: &desec_record.record_type,
100                ttl: Some(ttl),
101                records: vec![desec_record.content],
102            })?
103            .send_with_retry::<DesecApiResponse>(3)
104            .await
105            .map(|_| ())
106    }
107
108    pub(crate) async fn update(
109        &self,
110        name: impl IntoFqdn<'_>,
111        record: DnsRecord,
112        ttl: u32,
113        origin: impl IntoFqdn<'_>,
114    ) -> crate::Result<()> {
115        let name = name.into_name();
116        let domain = origin.into_name();
117        let subdomain = strip_origin_from_name(&name, &domain, None);
118
119        let desec_record = DesecDnsRecordRepresentation::from(record);
120        self.client
121            .put(format!(
122                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
123                endpoint = self.endpoint,
124                domain = &domain,
125                subdomain = &subdomain,
126                rr_type = &desec_record.record_type,
127            ))
128            .with_body(DnsRecordParams {
129                subname: &subdomain,
130                rr_type: desec_record.record_type.as_str(),
131                ttl: Some(ttl),
132                records: vec![desec_record.content],
133            })?
134            .send_with_retry::<DesecApiResponse>(3)
135            .await
136            .map(|_| ())
137    }
138
139    pub(crate) async fn delete(
140        &self,
141        name: impl IntoFqdn<'_>,
142        origin: impl IntoFqdn<'_>,
143        record_type: DnsRecordType,
144    ) -> crate::Result<()> {
145        let name = name.into_name();
146        let domain = origin.into_name();
147        let subdomain = strip_origin_from_name(&name, &domain, None);
148
149        let rr_type = &record_type.to_string();
150        self.client
151            .delete(format!(
152                "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/",
153                endpoint = self.endpoint,
154                domain = &domain,
155                subdomain = &subdomain,
156                rtype = &rr_type.to_string(),
157            ))
158            .send_with_retry::<DesecEmptyResponse>(3)
159            .await
160            .map(|_| ())
161    }
162}
163
164fn ensure_fqdn(name: String) -> String {
165    if name.ends_with('.') {
166        name
167    } else {
168        format!("{name}.")
169    }
170}
171
172/// Converts a DNS record into a representation that can be sent to the desec API.
173impl From<DnsRecord> for DesecDnsRecordRepresentation {
174    fn from(record: DnsRecord) -> Self {
175        match record {
176            DnsRecord::A(content) => DesecDnsRecordRepresentation {
177                record_type: "A".to_string(),
178                content: content.to_string(),
179            },
180            DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
181                record_type: "AAAA".to_string(),
182                content: content.to_string(),
183            },
184            DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
185                record_type: "CNAME".to_string(),
186                content: ensure_fqdn(content),
187            },
188            DnsRecord::NS(content) => DesecDnsRecordRepresentation {
189                record_type: "NS".to_string(),
190                content: ensure_fqdn(content),
191            },
192            DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
193                record_type: "MX".to_string(),
194                content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
195            },
196            DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
197                record_type: "TXT".to_string(),
198                content: format!("\"{content}\""),
199            },
200            DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
201                record_type: "SRV".to_string(),
202                content: format!(
203                    "{} {} {} {}",
204                    srv.priority,
205                    srv.weight,
206                    srv.port,
207                    ensure_fqdn(srv.target)
208                ),
209            },
210            DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
211                record_type: "TLSA".to_string(),
212                content: tlsa.to_string(),
213            },
214            DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
215                record_type: "CAA".to_string(),
216                content: caa.to_string(),
217            },
218        }
219    }
220}