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