Skip to main content

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: &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_records<T>(&self, rtype: &RecordType, host: &str) -> Result<Vec<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 recs: Records<T> = match response {
63            Some(rec) => rec,
64            None => return Ok(vec![])
65        };
66
67        Ok(recs.domain_records)
68    }
69
70    fn get_upstream_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<Record<T>>>
71    where
72        T: DeserializeOwned
73    {
74        let mut recs = self.get_upstream_records(rtype, host)?;
75
76        // FIXME: Assumes no or single address (which probably makes
77        // sense for DDNS and DNS-01, but may cause issues with
78        // malformed zones).
79        let nr = recs.len();
80        if nr > 1 {
81            error!("Returned number of records is {}, should be 1", nr);
82            return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
83        } else if nr == 0 {
84            warn!("No IP returned for {host}, continuing");
85            return Ok(None);
86        }
87
88        Ok(Some(recs.remove(0)))
89    }
90
91    fn do_delete(&self, rec: Record<String>) -> Result<()> {
92
93        let url = format!("{API_BASE}/{}/records/{}", self.config.domain, rec.id);
94        if self.config.dry_run {
95            info!("DRY-RUN: Would have sent DELETE to {url}");
96            return Ok(())
97        }
98
99        info!("Deleting DNS {} record {}", rec.rtype, rec.name);
100        http::client().delete(url)
101            .with_auth(self.auth.get_header())
102            .with_json_headers()
103            .call()?;
104
105        Ok(())
106    }
107
108}
109
110impl DnsProvider for DigitalOcean {
111
112    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T> >
113    where
114        T: DeserializeOwned
115    {
116         let rec: Record<T> = match self.get_upstream_record(&rtype, host)? {
117            Some(rec) => rec,
118            None => return Ok(None)
119        };
120
121        Ok(Some(rec.data))
122    }
123
124    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
125    where
126        T: Serialize + DeserializeOwned + Display + Clone
127    {
128        let url = format!("{API_BASE}/{}/records", self.config.domain);
129
130        let record = CreateUpdate {
131            name: host.to_string(),
132            rtype,
133            data: record.to_string(),
134            ttl: 300,
135        };
136        if self.config.dry_run {
137            info!("DRY-RUN: Would have sent {record:?} to {url}");
138            return Ok(())
139        }
140
141        let body = serde_json::to_string(&record)?;
142        let _response = http::client().post(url)
143            .with_auth(self.auth.get_header())
144            .with_json_headers()
145            .send(body)?
146            .check_error()?;
147
148        Ok(())
149    }
150
151    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
152    where
153        T: Serialize + DeserializeOwned + Display + Clone
154    {
155        let rec: Record<T> = self.get_upstream_record(&rtype, host)?
156            .ok_or(Error::RecordNotFound(host.to_string()))?;
157        let url = format!("{API_BASE}/{}/records/{}", self.config.domain, rec.id);
158
159        let record = CreateUpdate {
160            name: host.to_string(),
161            rtype,
162            data: urec.to_string(),
163            ttl: 300,
164        };
165
166        if self.config.dry_run {
167            info!("DRY-RUN: Would have sent {record:?} to {url}");
168            return Ok(())
169        }
170
171        let body = serde_json::to_string(&record)?;
172        let _response = http::client().put(url)
173            .with_auth(self.auth.get_header())
174            .with_json_headers()
175            .send(body)?
176            .check_error()?;
177
178        Ok(())
179    }
180
181    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
182    {
183        let rec = match self.get_upstream_record(&rtype, host)? {
184            Some(rec) => rec,
185            None => {
186                warn!("No {rtype} record to delete for {host}");
187                return Ok(());
188            }
189        };
190
191        self.do_delete(rec)
192    }
193
194    fn delete_all_records(&self, rtype: RecordType, host: &str) -> Result<()>
195    where Self: Sized
196    {
197        let recs: Vec<Record<String>> = self.get_upstream_records(&rtype, host)?;
198        for rec in recs {
199            self.do_delete(rec)?;
200        }
201
202        Ok(())
203    }
204
205    generate_helpers!();
206
207}
208
209
210#[cfg(test)]
211pub(crate) mod tests {
212    use super::*;
213    use crate::{generate_tests, tests::*};
214    use std::env;
215
216    fn get_client() -> DigitalOcean {
217        let auth = Auth {
218            key: env::var("DIGITALOCEAN_API_KEY").unwrap(),
219        };
220        let config = Config {
221            domain: env::var("DIGITALOCEAN_TEST_DOMAIN").unwrap(),
222            dry_run: false,
223        };
224        DigitalOcean::new(config, auth)
225    }
226
227    generate_tests!("test_digitalocean");
228}