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