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)]
22pub struct Auth {
23 pub key: String,
24 pub secret: String,
25}
26
27const 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 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
55pub struct DnsMadeEasy {
59 config: Config,
60 endpoint: &'static str,
61 auth: Auth,
62 domain_id: Mutex<Option<u32>>,
63}
64
65impl DnsMadeEasy {
66 pub fn new(config: Config, auth: Auth) -> Self {
68 Self::new_with_endpoint(config, auth, API_BASE)
69 }
70
71 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 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 let mut recs: Records<T> = match response {
128 Some(rec) => rec,
129 None => return Ok(None)
130 };
131
132 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}