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    DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use serde::{Deserialize, Serialize};
17use std::{
18    net::{Ipv4Addr, Ipv6Addr},
19    time::Duration,
20};
21
22#[derive(Clone)]
23pub struct DigitalOceanProvider {
24    client: HttpClientBuilder,
25}
26
27#[derive(Deserialize, Serialize, Clone, Debug)]
28pub struct ListDomainRecord {
29    domain_records: Vec<DomainRecord>,
30}
31
32#[derive(Deserialize, Serialize, Clone, Debug)]
33pub struct UpdateDomainRecord<'a> {
34    ttl: u32,
35    name: &'a str,
36    #[serde(flatten)]
37    data: RecordData,
38}
39
40#[derive(Deserialize, Serialize, Clone, Debug)]
41pub struct DomainRecord {
42    id: i64,
43    ttl: u32,
44    name: String,
45    #[serde(flatten)]
46    data: RecordData,
47}
48
49#[derive(Deserialize, Serialize, Clone, Debug)]
50#[serde(tag = "type")]
51#[allow(clippy::upper_case_acronyms)]
52pub enum RecordData {
53    A {
54        data: Ipv4Addr,
55    },
56    AAAA {
57        data: Ipv6Addr,
58    },
59    CNAME {
60        data: String,
61    },
62    NS {
63        data: String,
64    },
65    MX {
66        data: String,
67        priority: u16,
68    },
69    TXT {
70        data: String,
71    },
72    SRV {
73        data: String,
74        priority: u16,
75        port: u16,
76        weight: u16,
77    },
78    CAA {
79        data: String,
80        flags: u8,
81        tag: String,
82    },
83}
84
85#[derive(Serialize, Debug)]
86pub struct Query<'a> {
87    name: &'a str,
88    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
89    record_type: Option<&'static str>,
90}
91
92impl DigitalOceanProvider {
93    pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
94        let client = HttpClientBuilder::default()
95            .with_header("Authorization", format!("Bearer {}", auth_token.as_ref()))
96            .with_timeout(timeout);
97        Self { client }
98    }
99
100    pub(crate) async fn create(
101        &self,
102        name: impl IntoFqdn<'_>,
103        record: DnsRecord,
104        ttl: u32,
105        origin: impl IntoFqdn<'_>,
106    ) -> crate::Result<()> {
107        let name = name.into_name();
108        let domain = origin.into_name();
109        let subdomain = strip_origin_from_name(&name, &domain, None);
110
111        self.client
112            .post(format!(
113                "https://api.digitalocean.com/v2/domains/{domain}/records",
114            ))
115            .with_body(UpdateDomainRecord {
116                ttl,
117                name: &subdomain,
118                data: RecordData::try_from(record).map_err(|err| Error::Api(err.to_string()))?,
119            })?
120            .send_raw()
121            .await
122            .map(|_| ())
123    }
124
125    pub(crate) async fn update(
126        &self,
127        name: impl IntoFqdn<'_>,
128        record: DnsRecord,
129        ttl: u32,
130        origin: impl IntoFqdn<'_>,
131    ) -> crate::Result<()> {
132        let name = name.into_name();
133        let domain = origin.into_name();
134        let subdomain = strip_origin_from_name(&name, &domain, None);
135        let record_type = record.as_type();
136        let record_id = self.obtain_record_id(&name, &domain, record_type).await?;
137
138        self.client
139            .put(format!(
140                "https://api.digitalocean.com/v2/domains/{domain}/records/{record_id}",
141            ))
142            .with_body(UpdateDomainRecord {
143                ttl,
144                name: &subdomain,
145                data: RecordData::try_from(record).map_err(|err| Error::Api(err.to_string()))?,
146            })?
147            .send_raw()
148            .await
149            .map(|_| ())
150    }
151
152    pub(crate) async fn delete(
153        &self,
154        name: impl IntoFqdn<'_>,
155        origin: impl IntoFqdn<'_>,
156        record_type: DnsRecordType,
157    ) -> crate::Result<()> {
158        let name = name.into_name();
159        let domain = origin.into_name();
160        let record_id = self.obtain_record_id(&name, &domain, record_type).await?;
161
162        self.client
163            .delete(format!(
164                "https://api.digitalocean.com/v2/domains/{domain}/records/{record_id}",
165            ))
166            .send_raw()
167            .await
168            .map(|_| ())
169    }
170
171    async fn obtain_record_id(
172        &self,
173        name: &str,
174        domain: &str,
175        record_type: DnsRecordType,
176    ) -> crate::Result<i64> {
177        let subdomain = strip_origin_from_name(name, domain, None);
178        self.client
179            .get(format!(
180                "https://api.digitalocean.com/v2/domains/{domain}/records?{}",
181                Query::name_and_type(name, record_type).serialize()
182            ))
183            .send_with_retry::<ListDomainRecord>(3)
184            .await
185            .and_then(|result| {
186                result
187                    .domain_records
188                    .into_iter()
189                    .find(|record| record.name == subdomain && record.data.is_type(record_type))
190                    .map(|record| record.id)
191                    .ok_or_else(|| {
192                        Error::Api(format!(
193                            "DNS Record {} of type {} not found",
194                            subdomain,
195                            record_type.as_str()
196                        ))
197                    })
198            })
199    }
200}
201
202impl RecordData {
203    fn is_type(&self, record_type: DnsRecordType) -> bool {
204        matches!(
205            (self, record_type),
206            (RecordData::A { .. }, DnsRecordType::A)
207                | (RecordData::AAAA { .. }, DnsRecordType::AAAA)
208                | (RecordData::CNAME { .. }, DnsRecordType::CNAME)
209                | (RecordData::NS { .. }, DnsRecordType::NS)
210                | (RecordData::MX { .. }, DnsRecordType::MX)
211                | (RecordData::TXT { .. }, DnsRecordType::TXT)
212                | (RecordData::SRV { .. }, DnsRecordType::SRV)
213                | (RecordData::CAA { .. }, DnsRecordType::CAA)
214        )
215    }
216}
217
218impl<'a> Query<'a> {
219    pub fn name(name: impl Into<&'a str>) -> Self {
220        Self {
221            name: name.into(),
222            record_type: None,
223        }
224    }
225
226    pub fn name_and_type(name: impl Into<&'a str>, record_type: DnsRecordType) -> Self {
227        Self {
228            name: name.into(),
229            record_type: Some(record_type.as_str()),
230        }
231    }
232
233    pub fn serialize(&self) -> String {
234        serde_urlencoded::to_string(self).unwrap()
235    }
236}
237
238impl TryFrom<DnsRecord> for RecordData {
239    type Error = &'static str;
240
241    fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
242        match record {
243            DnsRecord::A(content) => Ok(RecordData::A { data: content }),
244            DnsRecord::AAAA(content) => Ok(RecordData::AAAA { data: content }),
245            DnsRecord::CNAME(content) => Ok(RecordData::CNAME { data: content }),
246            DnsRecord::NS(content) => Ok(RecordData::NS { data: content }),
247            DnsRecord::MX(mx) => Ok(RecordData::MX {
248                data: mx.exchange,
249                priority: mx.priority,
250            }),
251            DnsRecord::TXT(content) => Ok(RecordData::TXT { data: content }),
252            DnsRecord::SRV(srv) => Ok(RecordData::SRV {
253                data: srv.target,
254                priority: srv.priority,
255                weight: srv.weight,
256                port: srv.port,
257            }),
258            DnsRecord::TLSA(_) => Err("TLSA records are not supported by DigitalOcean"),
259            DnsRecord::CAA(caa) => {
260                let (flags, tag, value) = caa.decompose();
261                Ok(RecordData::CAA {
262                    data: value,
263                    flags,
264                    tag,
265                })
266            }
267        }
268    }
269}