Skip to main content

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