1pub mod errors;
2mod http;
3
4#[cfg(feature = "async")]
5pub mod async_impl;
6
7#[cfg(feature = "cloudflare")]
8pub mod cloudflare;
9#[cfg(feature = "dnsimple")]
10pub mod dnsimple;
11#[cfg(feature = "dnsmadeeasy")]
12pub mod dnsmadeeasy;
13#[cfg(feature = "gandi")]
14pub mod gandi;
15#[cfg(feature = "porkbun")]
16pub mod porkbun;
17
18use std::{fmt::{self, Debug, Display, Formatter}, net::Ipv4Addr};
19
20use serde::{de::DeserializeOwned, Deserialize, Serialize};
21use tracing::warn;
22
23use crate::errors::Result;
24
25
26pub struct Config {
31 pub domain: String,
32 pub dry_run: bool,
33}
34
35#[derive(Clone, Debug, Deserialize)]
43#[serde(rename_all = "lowercase", tag = "name")]
44pub enum Provider {
45 Cloudflare(cloudflare::Auth),
46 Gandi(gandi::Auth),
47 Dnsimple(dnsimple::Auth),
48 DnsMadeEasy(dnsmadeeasy::Auth),
49 PorkBun(porkbun::Auth),
50}
51
52impl Provider {
53
54 pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
58 match self {
59 #[cfg(feature = "cloudflare")]
60 Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
61 #[cfg(feature = "gandi")]
62 Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
63 #[cfg(feature = "dnsimple")]
64 Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
65 #[cfg(feature = "dnsmadeeasy")]
66 Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
67 #[cfg(feature = "porkbun")]
68 Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
69 }
70 }
71
72 #[cfg(feature = "async")]
76 pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
77 match self {
78 #[cfg(feature = "cloudflare")]
79 Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
80 #[cfg(feature = "gandi")]
81 Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
82 #[cfg(feature = "dnsimple")]
83 Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
84 #[cfg(feature = "dnsmadeeasy")]
85 Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
86 #[cfg(feature = "porkbun")]
87 Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
88 }
89 }
90}
91
92
93
94#[derive(Serialize, Deserialize, Clone, Debug)]
95pub enum RecordType {
96 A,
97 AAAA,
98 CAA,
99 CNAME,
100 HINFO,
101 MX,
102 NAPTR,
103 NS,
104 PTR,
105 SRV,
106 SPF,
107 SSHFP,
108 TXT,
109}
110
111impl Display for RecordType {
112 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
113 write!(f, "{:?}", self)
114 }
115}
116
117pub trait DnsProvider {
125 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
127 where T: DeserializeOwned,
128 Self: Sized;
129
130 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
132 where T: Serialize + DeserializeOwned + Display + Clone,
133 Self: Sized;
134
135 fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
137 where T: Serialize + DeserializeOwned + Display + Clone,
138 Self: Sized;
139
140 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
142 where Self: Sized;
143
144 fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
148
149 fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
153
154 fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
158
159 fn delete_txt_record(&self, host: &str) -> Result<()>;
163
164 fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
168
169 fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
173
174 fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
178
179 fn delete_a_record(&self, host: &str) -> Result<()>;
183}
184
185#[macro_export]
195macro_rules! generate_helpers {
196 () => {
197
198 fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
199 self.get_record::<String>(RecordType::TXT, host)
200 .map(|opt| opt.map(|s| crate::strip_quotes(&s)))
201 }
202
203 fn create_txt_record(&self, host: &str, record: &String) -> Result<()> {
204 self.create_record(RecordType::TXT, host, record)
205 }
206
207 fn update_txt_record(&self, host: &str, record: &String) -> Result<()> {
208 self.update_record(RecordType::TXT, host, record)
209 }
210
211 fn delete_txt_record(&self, host: &str) -> Result<()> {
212 self.delete_record(RecordType::TXT, host)
213 }
214
215 fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
216 self.get_record(RecordType::A, host)
217 }
218
219 fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
220 self.create_record(RecordType::A, host, record)
221 }
222
223 fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
224 self.update_record(RecordType::A, host, record)
225 }
226
227 fn delete_a_record(&self, host: &str) -> Result<()> {
228 self.delete_record(RecordType::A, host)
229 }
230 }
231}
232
233
234fn strip_quotes(record: &str) -> String {
235 let chars = record.chars();
236 let mut check = chars.clone();
237
238 let first = check.next();
239 let last = check.last();
240
241 if let Some('"') = first && let Some('"') = last {
242 chars.skip(1)
243 .take(record.len() - 2)
244 .collect()
245
246 } else {
247 warn!("Double quotes not found in record string, using whole record.");
248 record.to_string()
249 }
250}
251
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use std::net::Ipv4Addr;
257 use random_string::charsets::ALPHA_LOWER;
258 use tracing::info;
259
260 #[test]
261 fn test_strip_quotes() -> Result<()> {
262 assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
263 assert_eq!("abc123\"", strip_quotes("abc123\""));
264 assert_eq!("\"abc123", strip_quotes("\"abc123"));
265 assert_eq!("abc123", strip_quotes("abc123"));
266
267 Ok(())
268 }
269
270
271 pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
272
273 let host = random_string::generate(16, ALPHA_LOWER);
274
275 info!("Creating IPv4 {host}");
277 let ip: Ipv4Addr = "1.1.1.1".parse()?;
278 client.create_record(RecordType::A, &host, &ip)?;
279 let cur = client.get_record(RecordType::A, &host)?;
280 assert_eq!(Some(ip), cur);
281
282
283 info!("Updating IPv4 {host}");
285 let ip: Ipv4Addr = "2.2.2.2".parse()?;
286 client.update_record(RecordType::A, &host, &ip)?;
287 let cur = client.get_record(RecordType::A, &host)?;
288 assert_eq!(Some(ip), cur);
289
290
291 info!("Deleting IPv4 {host}");
293 client.delete_record(RecordType::A, &host)?;
294 let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
295 assert!(del.is_none());
296
297 Ok(())
298 }
299
300 pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
301
302 let host = random_string::generate(16, ALPHA_LOWER);
303
304 let txt = "a text reference".to_string();
306 client.create_record(RecordType::TXT, &host, &txt)?;
307 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
308 assert_eq!(txt, strip_quotes(&cur.unwrap()));
309
310
311 let txt = "another text reference".to_string();
313 client.update_record(RecordType::TXT, &host, &txt)?;
314 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
315 assert_eq!(txt, strip_quotes(&cur.unwrap()));
316
317
318 client.delete_record(RecordType::TXT, &host)?;
320 let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
321 assert!(del.is_none());
322
323 Ok(())
324 }
325
326 pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
327
328 let host = random_string::generate(16, ALPHA_LOWER);
329
330 let txt = "a text reference".to_string();
332 client.create_txt_record(&host, &txt)?;
333 let cur = client.get_txt_record(&host)?;
334 assert_eq!(txt, strip_quotes(&cur.unwrap()));
335
336
337 let txt = "another text reference".to_string();
339 client.update_txt_record(&host, &txt)?;
340 let cur = client.get_txt_record(&host)?;
341 assert_eq!(txt, strip_quotes(&cur.unwrap()));
342
343
344 client.delete_txt_record(&host)?;
346 let del = client.get_txt_record(&host)?;
347 assert!(del.is_none());
348
349 Ok(())
350 }
351
352 #[macro_export]
385 macro_rules! generate_tests {
386 ($feat:literal) => {
387
388 #[test_log::test]
389 #[cfg_attr(not(feature = $feat), ignore = "API test")]
390 fn create_update_v4() -> Result<()> {
391 test_create_update_delete_ipv4(get_client())?;
392 Ok(())
393 }
394
395 #[test_log::test]
396 #[cfg_attr(not(feature = $feat), ignore = "API test")]
397 fn create_update_txt() -> Result<()> {
398 test_create_update_delete_txt(get_client())?;
399 Ok(())
400 }
401
402 #[test_log::test]
403 #[cfg_attr(not(feature = $feat), ignore = "API test")]
404 fn create_update_default() -> Result<()> {
405 test_create_update_delete_txt_default(get_client())?;
406 Ok(())
407 }
408 }
409 }
410
411
412}