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