zone_update/digitalocean/
mod.rs

1mod types;
2
3use std::fmt::Display;
4
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6use tracing::{error, info, warn};
7
8use crate::{
9    Config, DnsProvider, RecordType,
10    digitalocean::types::{CreateUpdate, Record, Records},
11    errors::{Error, Result},
12    generate_helpers,
13    http::{self, ResponseToOption, WithHeaders},
14};
15
16const API_BASE: &'static str = "https://api.digitalocean.com/v2/domains";
17
18/// Authentication credentials for the Digital Ocean API.
19///
20/// Contains the API key and secret required for requests.
21#[derive(Clone, Debug, Deserialize)]
22pub struct Auth {
23    pub key: String,
24}
25
26impl Auth {
27    fn get_header(&self) -> String {
28        format!("Bearer {}", self.key)
29    }
30}
31
32/// Synchronous DigitalOcean DNS provider implementation.
33///
34/// Holds configuration and authentication state for performing API calls.
35pub struct DigitalOcean {
36    config: Config,
37    auth: Auth,
38}
39
40impl DigitalOcean {
41    /// Create a new `Digital Ocean` provider instance.
42    pub fn new(config: Config, auth: Auth) -> Self {
43        Self {
44            config,
45            auth,
46        }
47    }
48
49    fn get_upstream_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<Record<T>>>
50    where
51        T: DeserializeOwned
52    {
53        let url = format!("{API_BASE}/{}/records?type={rtype}&name={host}.{}", self.config.domain, self.config.domain);
54
55        let response = http::client().get(url)
56            .with_json_headers()
57            .with_auth(self.auth.get_header())
58            .call()?
59            .to_option()?;
60
61        // FIXME: Similar to other impls, can dedup?
62        let mut recs: Records<T> = match response {
63            Some(rec) => rec,
64            None => return Ok(None)
65        };
66
67        // FIXME: Assumes no or single address (which probably makes
68        // sense for DDNS and DNS-01, but may cause issues with
69        // malformed zones).
70        let nr = recs.domain_records.len();
71        if nr > 1 {
72            error!("Returned number of records is {}, should be 1", nr);
73            return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
74        } else if nr == 0 {
75            warn!("No IP returned for {host}, continuing");
76            return Ok(None);
77        }
78
79        Ok(Some(recs.domain_records.remove(0)))
80    }
81
82    fn get_record_id(&self, rtype: &RecordType, host: &str) -> Result<Option<u64>> {
83        let id_p = self.get_upstream_record::<String>(rtype, host)?
84            .map(|r| r.id);
85        Ok(id_p)
86    }
87}
88
89impl DnsProvider for DigitalOcean {
90
91    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T> >
92    where
93        T: DeserializeOwned
94    {
95         let rec: Record<T> = match self.get_upstream_record(&rtype, host)? {
96            Some(rec) => rec,
97            None => return Ok(None)
98        };
99
100        Ok(Some(rec.data))
101    }
102
103    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
104    where
105        T: Serialize + DeserializeOwned + Display + Clone
106    {
107        let url = format!("{API_BASE}/{}/records", self.config.domain);
108
109        let record = CreateUpdate {
110            name: host.to_string(),
111            rtype,
112            data: record.to_string(),
113            ttl: 300,
114        };
115        if self.config.dry_run {
116            info!("DRY-RUN: Would have sent {record:?} to {url}");
117            return Ok(())
118        }
119
120        let body = serde_json::to_string(&record)?;
121        let _response = http::client().post(url)
122            .with_auth(self.auth.get_header())
123            .with_json_headers()
124            .send(body)?
125            .check_error()?;
126
127        Ok(())
128    }
129
130    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
131    where
132        T: Serialize + DeserializeOwned + Display + Clone
133    {
134        let id = self.get_record_id(&rtype, host)?
135            .ok_or(Error::RecordNotFound(host.to_string()))?;
136        let url = format!("{API_BASE}/{}/records/{id}", self.config.domain);
137
138        let record = CreateUpdate {
139            name: host.to_string(),
140            rtype,
141            data: urec.to_string(),
142            ttl: 300,
143        };
144
145        if self.config.dry_run {
146            info!("DRY-RUN: Would have sent {record:?} to {url}");
147            return Ok(())
148        }
149
150        let body = serde_json::to_string(&record)?;
151        let _response = http::client().put(url)
152            .with_auth(self.auth.get_header())
153            .with_json_headers()
154            .send(body)?
155            .check_error()?;
156
157        Ok(())
158    }
159
160    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
161    {
162        let id = match self.get_record_id(&rtype, host)? {
163            Some(id) => id,
164            None => {
165                warn!("No {rtype} record to delete for {host}");
166                return Ok(());
167            }
168        };
169
170        let url = format!("{API_BASE}/{}/records/{id}", self.config.domain);
171        if self.config.dry_run {
172            info!("DRY-RUN: Would have sent DELETE to {url}");
173            return Ok(())
174        }
175
176        http::client().delete(url)
177            .with_auth(self.auth.get_header())
178            .with_json_headers()
179            .call()?;
180
181        Ok(())
182    }
183
184    generate_helpers!();
185
186}
187
188
189#[cfg(test)]
190pub(crate) mod tests {
191    use super::*;
192    use crate::{generate_tests, tests::*};
193    use std::env;
194
195    fn get_client() -> DigitalOcean {
196        let auth = Auth {
197            key: env::var("DIGITALOCEAN_API_KEY").unwrap(),
198        };
199        let config = Config {
200            domain: env::var("DIGITALOCEAN_TEST_DOMAIN").unwrap(),
201            dry_run: false,
202        };
203        DigitalOcean::new(config, auth)
204    }
205
206    generate_tests!("test_digitalocean");
207}