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