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