Skip to main content

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