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