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