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