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::utils::build_caa;
13use crate::{
14    DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord,
15    http::{HttpClient, HttpClientBuilder},
16    utils::strip_origin_from_name,
17};
18use encodify::base64::STANDARD as BASE64;
19use serde::{Deserialize, Serialize};
20use std::time::Duration;
21
22const DEFAULT_API_ENDPOINT: &str = "https://api.domeneshop.no/v0";
23
24#[derive(Clone)]
25pub struct DomeneshopProvider {
26    client: HttpClient,
27    endpoint: String,
28}
29
30#[derive(Deserialize, Debug, Clone)]
31pub struct Domain {
32    pub id: i64,
33    pub domain: String,
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone)]
37pub struct DnsRecordPayload {
38    pub host: String,
39    #[serde(rename = "type")]
40    pub record_type: String,
41    pub data: String,
42    pub ttl: u32,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub priority: Option<u16>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub weight: Option<u16>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub port: Option<u16>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub flags: Option<u8>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub tag: Option<String>,
53}
54
55#[derive(Deserialize, Debug, Clone)]
56pub struct ExistingDnsRecord {
57    pub id: i64,
58    pub host: String,
59    #[serde(rename = "type")]
60    pub record_type: String,
61    #[serde(default)]
62    pub data: String,
63    #[serde(default)]
64    pub priority: Option<u16>,
65    #[serde(default)]
66    pub weight: Option<u16>,
67    #[serde(default)]
68    pub port: Option<u16>,
69    #[serde(default)]
70    pub flags: Option<u8>,
71    #[serde(default)]
72    pub tag: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct DomeneshopRecordContent {
77    pub record_type: &'static str,
78    pub data: String,
79    pub priority: Option<u16>,
80    pub weight: Option<u16>,
81    pub port: Option<u16>,
82    pub flags: Option<u8>,
83    pub tag: Option<String>,
84}
85
86impl DomeneshopProvider {
87    pub(crate) fn new(
88        api_token: impl AsRef<str>,
89        api_secret: impl AsRef<str>,
90        timeout: Option<Duration>,
91    ) -> Self {
92        let credentials = format!("{}:{}", api_token.as_ref(), api_secret.as_ref());
93        let mut authorization = String::from("Basic ");
94        BASE64.encode_append(credentials, &mut authorization);
95        let client = HttpClientBuilder::default()
96            .with_header("Authorization", authorization)
97            .with_timeout(timeout)
98            .build();
99        Self {
100            client,
101            endpoint: DEFAULT_API_ENDPOINT.to_string(),
102        }
103    }
104
105    #[cfg(test)]
106    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
107        Self {
108            endpoint: endpoint.as_ref().to_string(),
109            ..self
110        }
111    }
112
113    pub(crate) async fn set_rrset(
114        &self,
115        name: impl IntoFqdn<'_>,
116        record_type: DnsRecordType,
117        ttl: u32,
118        records: Vec<DnsRecord>,
119        origin: impl IntoFqdn<'_>,
120    ) -> crate::Result<()> {
121        let name = name.into_name().into_owned();
122        let domain = origin.into_name().into_owned();
123        let host = strip_origin_from_name(&name, &domain, Some("@"));
124        let desired = build_contents(record_type, records)?;
125        let domain_id = self.find_domain_id(&domain).await?;
126        let existing = self.list_at(domain_id, &host, record_type).await?;
127
128        let mut existing_pool = existing;
129        let mut to_add: Vec<DomeneshopRecordContent> = Vec::new();
130
131        for content in desired {
132            if let Some(idx) = existing_pool
133                .iter()
134                .position(|r| record_matches(r, &content))
135            {
136                existing_pool.swap_remove(idx);
137            } else {
138                to_add.push(content);
139            }
140        }
141
142        for entry in existing_pool {
143            self.delete_record(domain_id, entry.id).await?;
144        }
145        for content in to_add {
146            self.create_record(domain_id, &host, ttl, &content).await?;
147        }
148        Ok(())
149    }
150
151    pub(crate) async fn add_to_rrset(
152        &self,
153        name: impl IntoFqdn<'_>,
154        record_type: DnsRecordType,
155        ttl: u32,
156        records: Vec<DnsRecord>,
157        origin: impl IntoFqdn<'_>,
158    ) -> crate::Result<()> {
159        if records.is_empty() {
160            return Ok(());
161        }
162        let name = name.into_name().into_owned();
163        let domain = origin.into_name().into_owned();
164        let host = strip_origin_from_name(&name, &domain, Some("@"));
165        let desired = build_contents(record_type, records)?;
166        let domain_id = self.find_domain_id(&domain).await?;
167        let existing = self.list_at(domain_id, &host, record_type).await?;
168
169        for content in desired {
170            if existing.iter().any(|r| record_matches(r, &content)) {
171                continue;
172            }
173            self.create_record(domain_id, &host, ttl, &content).await?;
174        }
175        Ok(())
176    }
177
178    pub(crate) async fn remove_from_rrset(
179        &self,
180        name: impl IntoFqdn<'_>,
181        record_type: DnsRecordType,
182        records: Vec<DnsRecord>,
183        origin: impl IntoFqdn<'_>,
184    ) -> crate::Result<()> {
185        if records.is_empty() {
186            return Ok(());
187        }
188        let name = name.into_name().into_owned();
189        let domain = origin.into_name().into_owned();
190        let host = strip_origin_from_name(&name, &domain, Some("@"));
191        let to_remove = build_contents(record_type, records)?;
192        let domain_id = self.find_domain_id(&domain).await?;
193        let existing = self.list_at(domain_id, &host, record_type).await?;
194
195        for content in to_remove {
196            if let Some(entry) = existing.iter().find(|r| record_matches(r, &content)) {
197                self.delete_record(domain_id, entry.id).await?;
198            }
199        }
200        Ok(())
201    }
202
203    pub(crate) async fn list_rrset(
204        &self,
205        name: impl IntoFqdn<'_>,
206        record_type: DnsRecordType,
207        origin: impl IntoFqdn<'_>,
208    ) -> crate::Result<Vec<DnsRecord>> {
209        let name = name.into_name().into_owned();
210        let domain = origin.into_name().into_owned();
211        let host = strip_origin_from_name(&name, &domain, Some("@"));
212        let domain_id = self.find_domain_id(&domain).await?;
213        let existing = self.list_at(domain_id, &host, record_type).await?;
214        existing.into_iter().map(DnsRecord::try_from).collect()
215    }
216
217    async fn find_domain_id(&self, domain: &str) -> crate::Result<i64> {
218        let domains: Vec<Domain> = self
219            .client
220            .get(format!("{endpoint}/domains", endpoint = self.endpoint))
221            .send()
222            .await?;
223        domains
224            .into_iter()
225            .find(|d| d.domain == domain)
226            .map(|d| d.id)
227            .ok_or_else(|| Error::Api(format!("Domain {domain} not found")))
228    }
229
230    async fn list_at(
231        &self,
232        domain_id: i64,
233        host: &str,
234        record_type: DnsRecordType,
235    ) -> crate::Result<Vec<ExistingDnsRecord>> {
236        let query = serde_urlencoded::to_string([("host", host), ("type", record_type.as_str())])
237            .unwrap_or_default();
238        let records: Vec<ExistingDnsRecord> = self
239            .client
240            .get(format!(
241                "{endpoint}/domains/{domain_id}/dns?{query}",
242                endpoint = self.endpoint
243            ))
244            .send()
245            .await?;
246        let type_str = record_type.as_str();
247        Ok(records
248            .into_iter()
249            .filter(|r| r.host == host && r.record_type == type_str)
250            .collect())
251    }
252
253    async fn create_record(
254        &self,
255        domain_id: i64,
256        host: &str,
257        ttl: u32,
258        content: &DomeneshopRecordContent,
259    ) -> crate::Result<()> {
260        let body = build_payload(host, ttl, content);
261        self.client
262            .post(format!(
263                "{endpoint}/domains/{domain_id}/dns",
264                endpoint = self.endpoint
265            ))
266            .with_body(&body)?
267            .send_with_retry::<serde_json::Value>(3)
268            .await
269            .map(|_| ())
270    }
271
272    async fn delete_record(&self, domain_id: i64, record_id: i64) -> crate::Result<()> {
273        self.client
274            .delete(format!(
275                "{endpoint}/domains/{domain_id}/dns/{record_id}",
276                endpoint = self.endpoint
277            ))
278            .send_with_retry::<serde_json::Value>(3)
279            .await
280            .map(|_| ())
281    }
282}
283
284fn build_payload(host: &str, ttl: u32, content: &DomeneshopRecordContent) -> DnsRecordPayload {
285    DnsRecordPayload {
286        host: host.to_string(),
287        record_type: content.record_type.to_string(),
288        data: content.data.clone(),
289        ttl,
290        priority: content.priority,
291        weight: content.weight,
292        port: content.port,
293        flags: content.flags,
294        tag: content.tag.clone(),
295    }
296}
297
298fn build_contents(
299    expected_type: DnsRecordType,
300    records: Vec<DnsRecord>,
301) -> crate::Result<Vec<DomeneshopRecordContent>> {
302    let mut out = Vec::with_capacity(records.len());
303    for record in records {
304        if record.as_type() != expected_type {
305            return Err(Error::Api(format!(
306                "RRSet record type mismatch: expected {}, got {}",
307                expected_type.as_str(),
308                record.as_type().as_str(),
309            )));
310        }
311        out.push(DomeneshopRecordContent::try_from(record)?);
312    }
313    Ok(out)
314}
315
316fn record_matches(existing: &ExistingDnsRecord, desired: &DomeneshopRecordContent) -> bool {
317    existing.record_type == desired.record_type
318        && existing.data == desired.data
319        && existing.priority == desired.priority
320        && existing.weight == desired.weight
321        && existing.port == desired.port
322        && existing.flags == desired.flags
323        && existing.tag == desired.tag
324}
325
326impl TryFrom<DnsRecord> for DomeneshopRecordContent {
327    type Error = Error;
328
329    fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
330        match record {
331            DnsRecord::A(addr) => Ok(DomeneshopRecordContent {
332                record_type: "A",
333                data: addr.to_string(),
334                priority: None,
335                weight: None,
336                port: None,
337                flags: None,
338                tag: None,
339            }),
340            DnsRecord::AAAA(addr) => Ok(DomeneshopRecordContent {
341                record_type: "AAAA",
342                data: addr.to_string(),
343                priority: None,
344                weight: None,
345                port: None,
346                flags: None,
347                tag: None,
348            }),
349            DnsRecord::CNAME(target) => Ok(DomeneshopRecordContent {
350                record_type: "CNAME",
351                data: target,
352                priority: None,
353                weight: None,
354                port: None,
355                flags: None,
356                tag: None,
357            }),
358            DnsRecord::NS(target) => Ok(DomeneshopRecordContent {
359                record_type: "NS",
360                data: target,
361                priority: None,
362                weight: None,
363                port: None,
364                flags: None,
365                tag: None,
366            }),
367            DnsRecord::MX(mx) => Ok(DomeneshopRecordContent {
368                record_type: "MX",
369                data: mx.exchange,
370                priority: Some(mx.priority),
371                weight: None,
372                port: None,
373                flags: None,
374                tag: None,
375            }),
376            DnsRecord::TXT(text) => Ok(DomeneshopRecordContent {
377                record_type: "TXT",
378                data: text,
379                priority: None,
380                weight: None,
381                port: None,
382                flags: None,
383                tag: None,
384            }),
385            DnsRecord::SRV(srv) => Ok(DomeneshopRecordContent {
386                record_type: "SRV",
387                data: srv.target,
388                priority: Some(srv.priority),
389                weight: Some(srv.weight),
390                port: Some(srv.port),
391                flags: None,
392                tag: None,
393            }),
394            DnsRecord::TLSA(_) => Err(Error::Unsupported(
395                "TLSA records are not supported by Domeneshop".to_string(),
396            )),
397            DnsRecord::CAA(caa) => {
398                let (flags, tag, value) = caa.decompose();
399                Ok(DomeneshopRecordContent {
400                    record_type: "CAA",
401                    data: value,
402                    priority: None,
403                    weight: None,
404                    port: None,
405                    flags: Some(flags),
406                    tag: Some(tag),
407                })
408            }
409        }
410    }
411}
412
413impl TryFrom<ExistingDnsRecord> for DnsRecord {
414    type Error = Error;
415
416    fn try_from(record: ExistingDnsRecord) -> Result<Self, Self::Error> {
417        match record.record_type.as_str() {
418            "A" => record
419                .data
420                .parse()
421                .map(DnsRecord::A)
422                .map_err(|e| Error::Parse(format!("invalid A data: {e}"))),
423            "AAAA" => record
424                .data
425                .parse()
426                .map(DnsRecord::AAAA)
427                .map_err(|e| Error::Parse(format!("invalid AAAA data: {e}"))),
428            "CNAME" => Ok(DnsRecord::CNAME(record.data)),
429            "NS" => Ok(DnsRecord::NS(record.data)),
430            "MX" => Ok(DnsRecord::MX(MXRecord {
431                exchange: record.data,
432                priority: record.priority.unwrap_or_default(),
433            })),
434            "TXT" => Ok(DnsRecord::TXT(record.data)),
435            "SRV" => Ok(DnsRecord::SRV(SRVRecord {
436                priority: record.priority.unwrap_or_default(),
437                weight: record.weight.unwrap_or_default(),
438                port: record.port.unwrap_or_default(),
439                target: record.data,
440            })),
441            "CAA" => {
442                let flags = record.flags.unwrap_or_default();
443                let tag = record.tag.unwrap_or_default();
444                Ok(DnsRecord::CAA(build_caa(flags, &tag, &record.data)?))
445            }
446            other => Err(Error::Parse(format!(
447                "Unsupported Domeneshop record type: {other}"
448            ))),
449        }
450    }
451}