zone_edit/dnsmadeeasy/
mod.rs

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