1use 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}