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
29const 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 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 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 let mut recs: Records<T> = match response {
125 Some(rec) => rec,
126 None => return Ok(None)
127 };
128
129 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}