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