1#![doc = include_str!("../README.md")]
2
3pub mod errors;
4mod http;
5
6#[cfg(feature = "async")]
7pub mod async_impl;
8
9#[cfg(feature = "cloudflare")]
10pub mod cloudflare;
11#[cfg(feature = "desec")]
12pub mod desec;
13#[cfg(feature = "digitalocean")]
14pub mod digitalocean;
15#[cfg(feature = "dnsimple")]
16pub mod dnsimple;
17#[cfg(feature = "dnsmadeeasy")]
18pub mod dnsmadeeasy;
19#[cfg(feature = "gandi")]
20pub mod gandi;
21#[cfg(feature = "linode")]
22pub mod linode;
23#[cfg(feature = "porkbun")]
24pub mod porkbun;
25
26use std::{fmt::{self, Debug, Display, Formatter}, net::Ipv4Addr};
27
28use serde::{de::DeserializeOwned, Deserialize, Serialize};
29use tracing::warn;
30
31use crate::errors::Result;
32
33
34pub struct Config {
39 pub domain: String,
40 pub dry_run: bool,
41}
42
43#[derive(Clone, Debug, Deserialize)]
52#[serde(rename_all = "lowercase", tag = "name")]
53#[non_exhaustive]
54pub enum Provider {
55 Cloudflare(cloudflare::Auth),
56 DeSec(desec::Auth),
57 DigitalOcean(digitalocean::Auth),
58 DnsMadeEasy(dnsmadeeasy::Auth),
59 Dnsimple(dnsimple::Auth),
60 Gandi(gandi::Auth),
61 Linode(linode::Auth),
62 PorkBun(porkbun::Auth),
63}
64
65impl Provider {
66
67 pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
71 match self {
72 #[cfg(feature = "cloudflare")]
73 Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
74 #[cfg(feature = "desec")]
75 Provider::DeSec(auth) => Box::new(desec::DeSec::new(dns_conf, auth.clone())),
76 #[cfg(feature = "digitalocean")]
77 Provider::DigitalOcean(auth) => Box::new(digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
78 #[cfg(feature = "gandi")]
79 Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
80 #[cfg(feature = "dnsimple")]
81 Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
82 #[cfg(feature = "dnsmadeeasy")]
83 Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
84 #[cfg(feature = "porkbun")]
85 Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
86 #[cfg(feature = "linode")]
87 Provider::Linode(auth) => Box::new(linode::Linode::new(dns_conf, auth.clone())),
88 }
89 }
90
91 #[cfg(feature = "async")]
95 pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
96 match self {
97 #[cfg(feature = "cloudflare")]
98 Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
99 #[cfg(feature = "desec")]
100 Provider::DeSec(auth) => Box::new(async_impl::desec::DeSec::new(dns_conf, auth.clone())),
101 #[cfg(feature = "digitalocean")]
102 Provider::DigitalOcean(auth) => Box::new(async_impl::digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
103 #[cfg(feature = "gandi")]
104 Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
105 #[cfg(feature = "dnsimple")]
106 Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
107 #[cfg(feature = "dnsmadeeasy")]
108 Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
109 #[cfg(feature = "porkbun")]
110 Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
111 #[cfg(feature = "linode")]
112 Provider::Linode(auth) => Box::new(async_impl::linode::Linode::new(dns_conf, auth.clone())),
113 }
114 }
115}
116
117
118
119#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
120pub enum RecordType {
121 A,
122 AAAA,
123 CAA,
124 CNAME,
125 HINFO,
126 MX,
127 NAPTR,
128 NS,
129 PTR,
130 SRV,
131 SPF,
132 SSHFP,
133 TXT,
134}
135
136impl Display for RecordType {
137 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
138 write!(f, "{:?}", self)
139 }
140}
141
142pub trait DnsProvider {
150 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
152 where T: DeserializeOwned,
153 Self: Sized;
154
155 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
157 where T: Serialize + DeserializeOwned + Display + Clone,
158 Self: Sized;
159
160 fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
162 where T: Serialize + DeserializeOwned + Display + Clone,
163 Self: Sized;
164
165 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
167 where Self: Sized;
168
169 fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
173
174 fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
178
179 fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
183
184 fn delete_txt_record(&self, host: &str) -> Result<()>;
188
189 fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
193
194 fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
198
199 fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
203
204 fn delete_a_record(&self, host: &str) -> Result<()>;
208}
209
210#[macro_export]
220macro_rules! generate_helpers {
221 () => {
222
223 fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
224 self.get_record::<String>(RecordType::TXT, host)
225 .map(|opt| opt.map(|s| crate::strip_quotes(&s)))
226 }
227
228 fn create_txt_record(&self, host: &str, record: &String) -> Result<()> {
229 self.create_record(RecordType::TXT, host, &crate::ensure_quotes(record))
230 }
231
232 fn update_txt_record(&self, host: &str, record: &String) -> Result<()> {
233 self.update_record(RecordType::TXT, host, &crate::ensure_quotes(record))
234 }
235
236 fn delete_txt_record(&self, host: &str) -> Result<()> {
237 self.delete_record(RecordType::TXT, host)
238 }
239
240 fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
241 self.get_record(RecordType::A, host)
242 }
243
244 fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
245 self.create_record(RecordType::A, host, record)
246 }
247
248 fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
249 self.update_record(RecordType::A, host, record)
250 }
251
252 fn delete_a_record(&self, host: &str) -> Result<()> {
253 self.delete_record(RecordType::A, host)
254 }
255 }
256}
257
258fn ensure_quotes(record: &String) -> String {
259 let starts = record.starts_with('"');
260 let ends = record.ends_with('"');
261
262 match (starts, ends) {
263 (true, true) => record.clone(),
264 (true, false) => format!("{}\"", record),
265 (false, true) => format!("\"{}", record),
266 (false, false) => format!("\"{}\"", record),
267 }
268}
269
270fn strip_quotes(record: &str) -> String {
271 let chars = record.chars();
272 let mut check = chars.clone();
273
274 let first = check.next();
275 let last = check.last();
276
277 if let Some('"') = first && let Some('"') = last {
278 chars.skip(1)
279 .take(record.len() - 2)
280 .collect()
281
282 } else {
283 warn!("Double quotes not found in record string, using whole record.");
284 record.to_string()
285 }
286}
287
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::net::Ipv4Addr;
293 use random_string::charsets::ALPHA_LOWER;
294 use tracing::info;
295
296 #[test]
297 fn test_strip_quotes() {
298 assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
299 assert_eq!("abc123\"", strip_quotes("abc123\""));
300 assert_eq!("\"abc123", strip_quotes("\"abc123"));
301 assert_eq!("abc123", strip_quotes("abc123"));
302 }
303
304 #[test]
305 fn test_already_quoted() {
306 assert_eq!(ensure_quotes(&"\"hello\"".to_string()), "\"hello\"");
307 assert_eq!(ensure_quotes(&"\"\"".to_string()), "\"\"");
308 assert_eq!(ensure_quotes(&"\"a\"".to_string()), "\"a\"");
309 assert_eq!(ensure_quotes(&"\"quoted \" string\"".to_string()), "\"quoted \" string\"");
310 }
311
312 #[test]
313 fn test_no_quotes() {
314 assert_eq!(ensure_quotes(&"hello".to_string()), "\"hello\"");
315 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
316 assert_eq!(ensure_quotes(&"a".to_string()), "\"a\"");
317 assert_eq!(ensure_quotes(&"hello world".to_string()), "\"hello world\"");
318 }
319
320 #[test]
321 fn test_only_starting_quote() {
322 assert_eq!(ensure_quotes(&"\"hello".to_string()), "\"hello\"");
323 assert_eq!(ensure_quotes(&"\"test case".to_string()), "\"test case\"");
324 }
325
326 #[test]
327 fn test_only_ending_quote() {
328 assert_eq!(ensure_quotes(&"hello\"".to_string()), "\"hello\"");
329 assert_eq!(ensure_quotes(&"test case\"".to_string()), "\"test case\"");
330 }
331
332 #[test]
333 fn test_whitespace_handling() {
334 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
336 assert_eq!(ensure_quotes(&" ".to_string()), "\" \"");
337 assert_eq!(ensure_quotes(&"\t\n".to_string()), "\"\t\n\"");
338 assert_eq!(ensure_quotes(&" hello ".to_string()), "\" hello \"");
340 assert_eq!(ensure_quotes(&"\" hello ".to_string()), "\" hello \"");
341 assert_eq!(ensure_quotes(&" hello \"".to_string()), "\" hello \"");
342 }
343
344 #[test]
345 fn test_special_characters() {
346 assert_eq!(ensure_quotes(&"hello\nworld".to_string()), "\"hello\nworld\"");
347 assert_eq!(ensure_quotes(&"hello\tworld".to_string()), "\"hello\tworld\"");
348 assert_eq!(ensure_quotes(&"123!@#$%^&*()".to_string()), "\"123!@#$%^&*()\"");
349 }
350
351 pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
352
353 let host = random_string::generate(16, ALPHA_LOWER);
354
355 info!("Creating IPv4 {host}");
357 let ip: Ipv4Addr = "10.9.8.7".parse()?;
358 client.create_record(RecordType::A, &host, &ip)?;
359 info!("Checking IPv4 {host}");
360 let cur = client.get_record(RecordType::A, &host)?;
361 assert_eq!(Some(ip), cur);
362
363
364 info!("Updating IPv4 {host}");
366 let ip: Ipv4Addr = "10.10.9.8".parse()?;
367 client.update_record(RecordType::A, &host, &ip)?;
368 info!("Checking IPv4 {host}");
369 let cur = client.get_record(RecordType::A, &host)?;
370 assert_eq!(Some(ip), cur);
371
372
373 info!("Deleting IPv4 {host}");
375 client.delete_record(RecordType::A, &host)?;
376 let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
377 assert!(del.is_none());
378
379 Ok(())
380 }
381
382 pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
383
384 let host = random_string::generate(16, ALPHA_LOWER);
385
386 let txt = "\"a text reference\"".to_string();
388 client.create_record(RecordType::TXT, &host, &txt)?;
389 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
390 assert_eq!(txt, cur.unwrap());
391
392
393 let txt = "\"another text reference\"".to_string();
395 client.update_record(RecordType::TXT, &host, &txt)?;
396 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
397 assert_eq!(txt, cur.unwrap());
398
399
400 client.delete_record(RecordType::TXT, &host)?;
402 let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
403 assert!(del.is_none());
404
405 Ok(())
406 }
407
408 pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
409
410 let host = random_string::generate(16, ALPHA_LOWER);
411
412 let txt = "a text reference".to_string();
414 client.create_txt_record(&host, &txt)?;
415 let cur = client.get_txt_record(&host)?;
416 assert_eq!(txt, strip_quotes(&cur.unwrap()));
417
418
419 let txt = "another text reference".to_string();
421 client.update_txt_record(&host, &txt)?;
422 let cur = client.get_txt_record(&host)?;
423 assert_eq!(txt, strip_quotes(&cur.unwrap()));
424
425
426 client.delete_txt_record(&host)?;
428 let del = client.get_txt_record(&host)?;
429 assert!(del.is_none());
430
431 Ok(())
432 }
433
434 #[macro_export]
467 macro_rules! generate_tests {
468 ($feat:literal) => {
469 use serial_test::serial;
470
471 #[test_log::test]
472 #[serial]
473 #[cfg_attr(not(feature = $feat), ignore = "API test")]
474 fn create_update_v4() -> Result<()> {
475 test_create_update_delete_ipv4(get_client())?;
476 Ok(())
477 }
478
479 #[test_log::test]
480 #[serial]
481 #[cfg_attr(not(feature = $feat), ignore = "API test")]
482 fn create_update_txt() -> Result<()> {
483 test_create_update_delete_txt(get_client())?;
484 Ok(())
485 }
486
487 #[test_log::test]
488 #[serial]
489 #[cfg_attr(not(feature = $feat), ignore = "API test")]
490 fn create_update_default() -> Result<()> {
491 test_create_update_delete_txt_default(get_client())?;
492 Ok(())
493 }
494 }
495 }
496
497
498}