zone_update/dnsmadeeasy/
mod.rs

1mod types;
2
3use std::{fmt::Display, sync::Mutex};
4
5use chrono::Utc;
6use hmac::{Hmac, Mac};
7use serde::{de::DeserializeOwned, Deserialize, Serialize};
8use sha1::Sha1;
9use tracing::{error, info, warn};
10
11use crate::{
12    dnsmadeeasy::types::{Domain, Record, Records}, errors::{Error, Result}, generate_helpers, http::{self, ResponseToOption, WithHeaders}, Config, DnsProvider, RecordType
13};
14
15
16pub(crate) const API_BASE: &str = "https://api.dnsmadeeasy.com/V2.0";
17
18#[derive(Clone, Debug, Deserialize)]
19pub struct Auth {
20    pub key: String,
21    pub secret: String,
22}
23
24// See https://api-docs.dnsmadeeasy.com/
25const KEY_HEADER: &str = "x-dnsme-apiKey";
26const SECRET_HEADER: &str = "x-dnsme-hmac";
27const TIME_HEADER: &str = "x-dnsme-requestDate";
28
29
30impl Auth {
31    fn get_headers(&self) -> Result<Vec<(&str, String)>> {
32        // See https://api-docs.dnsmadeeasy.com/
33        let time = Utc::now()
34            .to_rfc2822();
35        let hmac = {
36            let secret = self.secret.clone().into_bytes();
37            let mut mac = Hmac::<Sha1>::new_from_slice(&secret)
38                .map_err(|e| Error::AuthError(format!("Error generating HMAC: {e}")))?;
39            mac.update(&time.clone().into_bytes());
40            hex::encode(mac.finalize().into_bytes())
41        };
42        let headers = vec![
43            (KEY_HEADER, self.key.clone()),
44            (SECRET_HEADER, hmac),
45            (TIME_HEADER, time),
46        ];
47
48        Ok(headers)
49    }
50}
51
52pub struct DnsMadeEasy {
53    config: Config,
54    endpoint: &'static str,
55    auth: Auth,
56    domain_id: Mutex<Option<u32>>,
57}
58
59impl DnsMadeEasy {
60    pub fn new(config: Config, auth: Auth) -> Self {
61        Self::new_with_endpoint(config, auth, API_BASE)
62    }
63
64    pub fn new_with_endpoint(config: Config, auth: Auth, endpoint: &'static str) -> Self {
65        Self {
66            config,
67            endpoint,
68            auth,
69            domain_id: Mutex::new(None),
70        }
71    }
72
73    fn get_domain(&self) -> Result<Domain>
74    {
75        let url = format!("{}/dns/managed/name?domainname={}", self.endpoint, self.config.domain);
76
77        let domain = http::client().get(url)
78            .with_headers(self.auth.get_headers()?)?
79            .call()?
80            .to_option::<Domain>()?
81            .ok_or(Error::ApiError("No domain returned from upstream".to_string()))?;
82
83        Ok(domain)
84    }
85
86    fn get_domain_id(&self) -> Result<u32> {
87        // This is roughly equivalent to OnceLock.get_or_init(), but
88        // is simpler than dealing with closure->Result and is more
89        // portable.
90        let mut id_p = self.domain_id.lock()
91            .map_err(|e| Error::LockingError(e.to_string()))?;
92
93        if let Some(id) = *id_p {
94            return Ok(id);
95        }
96
97        let domain = self.get_domain()?;
98        let id = domain.id;
99        *id_p = Some(id);
100
101        Ok(id)
102    }
103
104
105    fn get_upstream_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<Record<T>>>
106    where
107        T: DeserializeOwned
108    {
109        let domain_id = self.get_domain_id()?;
110        let url = format!("{}/dns/managed/{domain_id}/records?recordName={host}&type={rtype}", self.endpoint);
111
112        let response = http::client().get(url)
113            .with_json_headers()
114            .with_headers(self.auth.get_headers()?)?
115            .call()?
116            .to_option::<Records<T>>()?;
117
118        // FIXME: Similar to the dnsimple impl, can dedup?
119        let mut recs: Records<T> = match response {
120            Some(rec) => rec,
121            None => return Ok(None)
122        };
123
124        // FIXME: Assumes no or single address (which probably makes
125        // sense for DDNS and DNS-01, but may cause issues with
126        // malformed zones).
127        let nr = recs.records.len();
128        if nr > 1 {
129            error!("Returned number of IPs is {}, should be 1", nr);
130            return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
131        } else if nr == 0 {
132            warn!("No record returned for {host}, continuing");
133            return Ok(None);
134        }
135
136        Ok(Some(recs.records.remove(0)))
137    }
138}
139
140
141impl DnsProvider for DnsMadeEasy {
142
143    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T> >
144    where
145        T: DeserializeOwned
146    {
147
148        let rec: Record<T> = match self.get_upstream_record(&rtype, host)? {
149            Some(recs) => recs,
150            None => return Ok(None)
151        };
152
153        Ok(Some(rec.value))
154    }
155
156    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
157    where
158        T: Serialize + DeserializeOwned + Display + Clone
159    {
160        let domain_id = self.get_domain_id()?;
161        let url = format!("{}/dns/managed/{domain_id}/records", self.endpoint);
162
163        let record = Record {
164            id: 0,
165            name: host.to_string(),
166            value: record.to_string(),
167            rtype,
168            source_id: 0,
169            ttl: 300,
170        };
171        if self.config.dry_run {
172            info!("DRY-RUN: Would have sent {record:?} to {url}");
173            return Ok(())
174        }
175
176        let body = serde_json::to_string(&record)?;
177        let _response = http::client().post(url)
178            .with_json_headers()
179            .with_headers(self.auth.get_headers()?)?
180            .send(body)?
181            .check_error()?;
182
183        Ok(())
184    }
185
186    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
187    where
188        T: Serialize + DeserializeOwned + Display + Clone
189    {
190        let rec: Record<String> = match self.get_upstream_record(&rtype, host)? {
191            Some(rec) => rec,
192            None => {
193                warn!("DELETE: Record {host} doesn't exist");
194                return Ok(());
195            }
196        };
197
198        let rid = rec.id;
199        let domain_id = self.get_domain_id()?;
200        let url = format!("{}/dns/managed/{domain_id}/records/{rid}", self.endpoint);
201
202        let record = Record {
203            id: 0,
204            name: host.to_string(),
205            value: urec.to_string(),
206            rtype,
207            source_id: 0,
208            ttl: 300,
209        };
210
211        if self.config.dry_run {
212            info!("DRY-RUN: Would have sent {record:?} to {url}");
213            return Ok(())
214        }
215
216        let body = serde_json::to_string(&record)?;
217        let _response = http::client().put(url)
218            .with_json_headers()
219            .with_headers(self.auth.get_headers()?)?
220            .send(body)?
221            .check_error()?;
222
223        Ok(())
224    }
225
226    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()> {
227
228        let rec: Record<String> = match self.get_upstream_record(&rtype, host)? {
229            Some(rec) => rec,
230            None => {
231                warn!("DELETE: Record {host} doesn't exist");
232                return Ok(());
233            }
234        };
235
236        let rid = rec.id;
237        let domain_id = self.get_domain_id()?;
238        let url = format!("{}/dns/managed/{domain_id}/records/{rid}", self.endpoint);
239        if self.config.dry_run {
240            info!("DRY-RUN: Would have sent DELETE to {url}");
241            return Ok(())
242        }
243
244        let _response = http::client().delete(url)
245            .with_json_headers()
246            .with_headers(self.auth.get_headers()?)?
247            .call()?
248            .check_error()?;
249
250        Ok(())
251    }
252
253
254    generate_helpers!();
255}
256
257
258
259
260#[cfg(test)]
261pub(crate) mod tests {
262    use super::*;
263    use crate::{generate_tests, tests::*};
264    use std::env;
265
266    pub(crate) const TEST_API: &str = "https://api.sandbox.dnsmadeeasy.com/V2.0";
267
268    fn get_client() -> DnsMadeEasy {
269        let auth = Auth {
270            key: env::var("DNSMADEEASY_KEY").unwrap(),
271            secret: env::var("DNSMADEEASY_SECRET").unwrap(),
272        };
273        let config = Config {
274            domain: env::var("DNSMADEEASY_TEST_DOMAIN").unwrap(),
275            dry_run: false,
276        };
277        DnsMadeEasy::new_with_endpoint(config, auth, TEST_API)
278    }
279
280    #[test_log::test]
281    #[cfg_attr(not(feature = "test_dnsmadeeasy"), ignore = "Dnsmadeeasy API test")]
282    fn test_get_domain() -> Result<()> {
283        let client = get_client();
284
285        let domain = client.get_domain()?;
286        assert_eq!("testcondition.net".to_string(), domain.name);
287
288        Ok(())
289    }
290
291
292    generate_tests!("test_dnsmadeeasy");
293}