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