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