Skip to main content

dns_update/providers/
domeneshop.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, Error, IntoFqdn, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
17use serde::{Deserialize, Serialize};
18use std::time::Duration;
19
20const DEFAULT_API_ENDPOINT: &str = "https://api.domeneshop.no/v0";
21
22#[derive(Clone)]
23pub struct DomeneshopProvider {
24    client: HttpClientBuilder,
25    endpoint: String,
26}
27
28#[derive(Deserialize, Debug, Clone)]
29pub struct Domain {
30    pub id: i64,
31    pub domain: String,
32}
33
34#[derive(Serialize, Deserialize, Debug, Clone)]
35pub struct DnsRecordPayload {
36    pub host: String,
37    #[serde(rename = "type")]
38    pub record_type: String,
39    pub data: String,
40    pub ttl: u32,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub priority: Option<u16>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub weight: Option<u16>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub port: Option<u16>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub flags: Option<u8>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub tag: Option<String>,
51}
52
53#[derive(Deserialize, Debug, Clone)]
54pub struct ExistingDnsRecord {
55    pub id: i64,
56    pub host: String,
57    #[serde(rename = "type")]
58    pub record_type: String,
59}
60
61pub struct DomeneshopRecordContent {
62    pub record_type: &'static str,
63    pub data: String,
64    pub priority: Option<u16>,
65    pub weight: Option<u16>,
66    pub port: Option<u16>,
67    pub flags: Option<u8>,
68    pub tag: Option<String>,
69}
70
71impl DomeneshopProvider {
72    pub(crate) fn new(
73        api_token: impl AsRef<str>,
74        api_secret: impl AsRef<str>,
75        timeout: Option<Duration>,
76    ) -> Self {
77        let credentials = format!("{}:{}", api_token.as_ref(), api_secret.as_ref());
78        let encoded = BASE64.encode(credentials.as_bytes());
79        let client = HttpClientBuilder::default()
80            .with_header("Authorization", format!("Basic {encoded}"))
81            .with_timeout(timeout);
82        Self {
83            client,
84            endpoint: DEFAULT_API_ENDPOINT.to_string(),
85        }
86    }
87
88    #[cfg(test)]
89    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
90        Self {
91            endpoint: endpoint.as_ref().to_string(),
92            ..self
93        }
94    }
95
96    pub(crate) async fn create(
97        &self,
98        name: impl IntoFqdn<'_>,
99        record: DnsRecord,
100        ttl: u32,
101        origin: impl IntoFqdn<'_>,
102    ) -> crate::Result<()> {
103        let name = name.into_name();
104        let domain = origin.into_name();
105        let host = strip_origin_from_name(&name, &domain, Some("@"));
106        let domain_id = self.find_domain_id(&domain).await?;
107        let content = DomeneshopRecordContent::try_from(record)?;
108        let body = DnsRecordPayload {
109            host,
110            record_type: content.record_type.to_string(),
111            data: content.data,
112            ttl,
113            priority: content.priority,
114            weight: content.weight,
115            port: content.port,
116            flags: content.flags,
117            tag: content.tag,
118        };
119
120        self.client
121            .post(format!(
122                "{endpoint}/domains/{domain_id}/dns",
123                endpoint = self.endpoint
124            ))
125            .with_body(&body)?
126            .send_raw()
127            .await
128            .map(|_| ())
129    }
130
131    pub(crate) async fn update(
132        &self,
133        name: impl IntoFqdn<'_>,
134        record: DnsRecord,
135        ttl: u32,
136        origin: impl IntoFqdn<'_>,
137    ) -> crate::Result<()> {
138        let name = name.into_name();
139        let domain = origin.into_name();
140        let host = strip_origin_from_name(&name, &domain, Some("@"));
141        let record_type = record.as_type();
142        let domain_id = self.find_domain_id(&domain).await?;
143        let record_id = self
144            .find_record_id(domain_id, &host, record_type)
145            .await?;
146        let content = DomeneshopRecordContent::try_from(record)?;
147        let body = DnsRecordPayload {
148            host,
149            record_type: content.record_type.to_string(),
150            data: content.data,
151            ttl,
152            priority: content.priority,
153            weight: content.weight,
154            port: content.port,
155            flags: content.flags,
156            tag: content.tag,
157        };
158
159        self.client
160            .put(format!(
161                "{endpoint}/domains/{domain_id}/dns/{record_id}",
162                endpoint = self.endpoint
163            ))
164            .with_body(&body)?
165            .send_raw()
166            .await
167            .map(|_| ())
168    }
169
170    pub(crate) async fn delete(
171        &self,
172        name: impl IntoFqdn<'_>,
173        origin: impl IntoFqdn<'_>,
174        record_type: DnsRecordType,
175    ) -> crate::Result<()> {
176        let name = name.into_name();
177        let domain = origin.into_name();
178        let host = strip_origin_from_name(&name, &domain, Some("@"));
179        let domain_id = self.find_domain_id(&domain).await?;
180        let record_id = self.find_record_id(domain_id, &host, record_type).await?;
181
182        self.client
183            .delete(format!(
184                "{endpoint}/domains/{domain_id}/dns/{record_id}",
185                endpoint = self.endpoint
186            ))
187            .send_raw()
188            .await
189            .map(|_| ())
190    }
191
192    async fn find_domain_id(&self, domain: &str) -> crate::Result<i64> {
193        let domains: Vec<Domain> = self
194            .client
195            .get(format!("{endpoint}/domains", endpoint = self.endpoint))
196            .send()
197            .await?;
198        domains
199            .into_iter()
200            .find(|d| d.domain == domain)
201            .map(|d| d.id)
202            .ok_or_else(|| Error::Api(format!("Domain {domain} not found")))
203    }
204
205    async fn find_record_id(
206        &self,
207        domain_id: i64,
208        host: &str,
209        record_type: DnsRecordType,
210    ) -> crate::Result<i64> {
211        let records: Vec<ExistingDnsRecord> = self
212            .client
213            .get(format!(
214                "{endpoint}/domains/{domain_id}/dns",
215                endpoint = self.endpoint
216            ))
217            .send()
218            .await?;
219        let type_str = record_type.as_str();
220        records
221            .into_iter()
222            .find(|r| r.host == host && r.record_type == type_str)
223            .map(|r| r.id)
224            .ok_or_else(|| {
225                Error::Api(format!(
226                    "DNS Record {host} of type {type_str} not found"
227                ))
228            })
229    }
230}
231
232impl TryFrom<DnsRecord> for DomeneshopRecordContent {
233    type Error = Error;
234
235    fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
236        match record {
237            DnsRecord::A(addr) => Ok(DomeneshopRecordContent {
238                record_type: "A",
239                data: addr.to_string(),
240                priority: None,
241                weight: None,
242                port: None,
243                flags: None,
244                tag: None,
245            }),
246            DnsRecord::AAAA(addr) => Ok(DomeneshopRecordContent {
247                record_type: "AAAA",
248                data: addr.to_string(),
249                priority: None,
250                weight: None,
251                port: None,
252                flags: None,
253                tag: None,
254            }),
255            DnsRecord::CNAME(target) => Ok(DomeneshopRecordContent {
256                record_type: "CNAME",
257                data: target,
258                priority: None,
259                weight: None,
260                port: None,
261                flags: None,
262                tag: None,
263            }),
264            DnsRecord::NS(target) => Ok(DomeneshopRecordContent {
265                record_type: "NS",
266                data: target,
267                priority: None,
268                weight: None,
269                port: None,
270                flags: None,
271                tag: None,
272            }),
273            DnsRecord::MX(mx) => Ok(DomeneshopRecordContent {
274                record_type: "MX",
275                data: mx.exchange,
276                priority: Some(mx.priority),
277                weight: None,
278                port: None,
279                flags: None,
280                tag: None,
281            }),
282            DnsRecord::TXT(text) => Ok(DomeneshopRecordContent {
283                record_type: "TXT",
284                data: text,
285                priority: None,
286                weight: None,
287                port: None,
288                flags: None,
289                tag: None,
290            }),
291            DnsRecord::SRV(srv) => Ok(DomeneshopRecordContent {
292                record_type: "SRV",
293                data: srv.target,
294                priority: Some(srv.priority),
295                weight: Some(srv.weight),
296                port: Some(srv.port),
297                flags: None,
298                tag: None,
299            }),
300            DnsRecord::TLSA(_) => Err(Error::Api(
301                "TLSA records are not supported by Domeneshop".to_string(),
302            )),
303            DnsRecord::CAA(caa) => {
304                let (flags, tag, value) = caa.decompose();
305                Ok(DomeneshopRecordContent {
306                    record_type: "CAA",
307                    data: value,
308                    priority: None,
309                    weight: None,
310                    port: None,
311                    flags: Some(flags),
312                    tag: Some(tag),
313                })
314            }
315        }
316    }
317}