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 = "porkbun")]
22pub mod porkbun;
23
24use std::{fmt::{self, Debug, Display, Formatter}, net::Ipv4Addr};
25
26use serde::{de::DeserializeOwned, Deserialize, Serialize};
27use tracing::warn;
28
29use crate::errors::Result;
30
31
32pub struct Config {
37 pub domain: String,
38 pub dry_run: bool,
39}
40
41#[derive(Clone, Debug, Deserialize)]
50#[serde(rename_all = "lowercase", tag = "name")]
51#[non_exhaustive]
52pub enum Provider {
53 Cloudflare(cloudflare::Auth),
54 DeSec(desec::Auth),
55 DigitalOcean(digitalocean::Auth),
56 Gandi(gandi::Auth),
57 Dnsimple(dnsimple::Auth),
58 DnsMadeEasy(dnsmadeeasy::Auth),
59 PorkBun(porkbun::Auth),
60}
61
62impl Provider {
63
64 pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
68 match self {
69 #[cfg(feature = "cloudflare")]
70 Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
71 #[cfg(feature = "desec")]
72 Provider::DeSec(auth) => Box::new(desec::DeSec::new(dns_conf, auth.clone())),
73 #[cfg(feature = "digitalocean")]
74 Provider::DigitalOcean(auth) => Box::new(digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
75 #[cfg(feature = "gandi")]
76 Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
77 #[cfg(feature = "dnsimple")]
78 Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
79 #[cfg(feature = "dnsmadeeasy")]
80 Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
81 #[cfg(feature = "porkbun")]
82 Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
83 }
84 }
85
86 #[cfg(feature = "async")]
90 pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
91 match self {
92 #[cfg(feature = "cloudflare")]
93 Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
94 #[cfg(feature = "desec")]
95 Provider::DeSec(auth) => Box::new(async_impl::desec::DeSec::new(dns_conf, auth.clone())),
96 #[cfg(feature = "digitalocean")]
97 Provider::DigitalOcean(auth) => Box::new(async_impl::digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
98 #[cfg(feature = "gandi")]
99 Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
100 #[cfg(feature = "dnsimple")]
101 Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
102 #[cfg(feature = "dnsmadeeasy")]
103 Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
104 #[cfg(feature = "porkbun")]
105 Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
106 }
107 }
108}
109
110
111
112#[derive(Serialize, Deserialize, Clone, Debug)]
113pub enum RecordType {
114 A,
115 AAAA,
116 CAA,
117 CNAME,
118 HINFO,
119 MX,
120 NAPTR,
121 NS,
122 PTR,
123 SRV,
124 SPF,
125 SSHFP,
126 TXT,
127}
128
129impl Display for RecordType {
130 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
131 write!(f, "{:?}", self)
132 }
133}
134
135pub trait DnsProvider {
143 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
145 where T: DeserializeOwned,
146 Self: Sized;
147
148 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
150 where T: Serialize + DeserializeOwned + Display + Clone,
151 Self: Sized;
152
153 fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
155 where T: Serialize + DeserializeOwned + Display + Clone,
156 Self: Sized;
157
158 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
160 where Self: Sized;
161
162 fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
166
167 fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
171
172 fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
176
177 fn delete_txt_record(&self, host: &str) -> Result<()>;
181
182 fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
186
187 fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
191
192 fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
196
197 fn delete_a_record(&self, host: &str) -> Result<()>;
201}
202
203#[macro_export]
213macro_rules! generate_helpers {
214 () => {
215
216 fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
217 self.get_record::<String>(RecordType::TXT, host)
218 .map(|opt| opt.map(|s| crate::strip_quotes(&s)))
219 }
220
221 fn create_txt_record(&self, host: &str, record: &String) -> Result<()> {
222 self.create_record(RecordType::TXT, host, &crate::ensure_quotes(record))
223 }
224
225 fn update_txt_record(&self, host: &str, record: &String) -> Result<()> {
226 self.update_record(RecordType::TXT, host, &crate::ensure_quotes(record))
227 }
228
229 fn delete_txt_record(&self, host: &str) -> Result<()> {
230 self.delete_record(RecordType::TXT, host)
231 }
232
233 fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
234 self.get_record(RecordType::A, host)
235 }
236
237 fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
238 self.create_record(RecordType::A, host, record)
239 }
240
241 fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
242 self.update_record(RecordType::A, host, record)
243 }
244
245 fn delete_a_record(&self, host: &str) -> Result<()> {
246 self.delete_record(RecordType::A, host)
247 }
248 }
249}
250
251fn ensure_quotes(record: &String) -> String {
252 let starts = record.starts_with('"');
253 let ends = record.ends_with('"');
254
255 match (starts, ends) {
256 (true, true) => record.clone(),
257 (true, false) => format!("{}\"", record),
258 (false, true) => format!("\"{}", record),
259 (false, false) => format!("\"{}\"", record),
260 }
261}
262
263fn strip_quotes(record: &str) -> String {
264 let chars = record.chars();
265 let mut check = chars.clone();
266
267 let first = check.next();
268 let last = check.last();
269
270 if let Some('"') = first && let Some('"') = last {
271 chars.skip(1)
272 .take(record.len() - 2)
273 .collect()
274
275 } else {
276 warn!("Double quotes not found in record string, using whole record.");
277 record.to_string()
278 }
279}
280
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use std::net::Ipv4Addr;
286 use random_string::charsets::ALPHA_LOWER;
287 use tracing::info;
288
289 #[test]
290 fn test_strip_quotes() {
291 assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
292 assert_eq!("abc123\"", strip_quotes("abc123\""));
293 assert_eq!("\"abc123", strip_quotes("\"abc123"));
294 assert_eq!("abc123", strip_quotes("abc123"));
295 }
296
297 #[test]
298 fn test_already_quoted() {
299 assert_eq!(ensure_quotes(&"\"hello\"".to_string()), "\"hello\"");
300 assert_eq!(ensure_quotes(&"\"\"".to_string()), "\"\"");
301 assert_eq!(ensure_quotes(&"\"a\"".to_string()), "\"a\"");
302 assert_eq!(ensure_quotes(&"\"quoted \" string\"".to_string()), "\"quoted \" string\"");
303 }
304
305 #[test]
306 fn test_no_quotes() {
307 assert_eq!(ensure_quotes(&"hello".to_string()), "\"hello\"");
308 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
309 assert_eq!(ensure_quotes(&"a".to_string()), "\"a\"");
310 assert_eq!(ensure_quotes(&"hello world".to_string()), "\"hello world\"");
311 }
312
313 #[test]
314 fn test_only_starting_quote() {
315 assert_eq!(ensure_quotes(&"\"hello".to_string()), "\"hello\"");
316 assert_eq!(ensure_quotes(&"\"test case".to_string()), "\"test case\"");
317 }
318
319 #[test]
320 fn test_only_ending_quote() {
321 assert_eq!(ensure_quotes(&"hello\"".to_string()), "\"hello\"");
322 assert_eq!(ensure_quotes(&"test case\"".to_string()), "\"test case\"");
323 }
324
325 #[test]
326 fn test_whitespace_handling() {
327 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
329 assert_eq!(ensure_quotes(&" ".to_string()), "\" \"");
330 assert_eq!(ensure_quotes(&"\t\n".to_string()), "\"\t\n\"");
331 assert_eq!(ensure_quotes(&" hello ".to_string()), "\" hello \"");
333 assert_eq!(ensure_quotes(&"\" hello ".to_string()), "\" hello \"");
334 assert_eq!(ensure_quotes(&" hello \"".to_string()), "\" hello \"");
335 }
336
337 #[test]
338 fn test_special_characters() {
339 assert_eq!(ensure_quotes(&"hello\nworld".to_string()), "\"hello\nworld\"");
340 assert_eq!(ensure_quotes(&"hello\tworld".to_string()), "\"hello\tworld\"");
341 assert_eq!(ensure_quotes(&"123!@#$%^&*()".to_string()), "\"123!@#$%^&*()\"");
342 }
343
344 pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
345
346 let host = random_string::generate(16, ALPHA_LOWER);
347
348 info!("Creating IPv4 {host}");
350 let ip: Ipv4Addr = "10.9.8.7".parse()?;
351 client.create_record(RecordType::A, &host, &ip)?;
352 let cur = client.get_record(RecordType::A, &host)?;
353 assert_eq!(Some(ip), cur);
354
355
356 info!("Updating IPv4 {host}");
358 let ip: Ipv4Addr = "10.10.9.8".parse()?;
359 client.update_record(RecordType::A, &host, &ip)?;
360 let cur = client.get_record(RecordType::A, &host)?;
361 assert_eq!(Some(ip), cur);
362
363
364 info!("Deleting IPv4 {host}");
366 client.delete_record(RecordType::A, &host)?;
367 let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
368 assert!(del.is_none());
369
370 Ok(())
371 }
372
373 pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
374
375 let host = random_string::generate(16, ALPHA_LOWER);
376
377 let txt = "\"a text reference\"".to_string();
379 println!("CREATE");
380 client.create_record(RecordType::TXT, &host, &txt)?;
381 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
382 assert_eq!(txt, cur.unwrap());
383
384
385 let txt = "\"another text reference\"".to_string();
387 println!("UPDATE");
388 client.update_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 println!("DELETE");
395 client.delete_record(RecordType::TXT, &host)?;
396 let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
397 assert!(del.is_none());
398
399 Ok(())
400 }
401
402 pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
403
404 let host = random_string::generate(16, ALPHA_LOWER);
405
406 let txt = "a text reference".to_string();
408 client.create_txt_record(&host, &txt)?;
409 let cur = client.get_txt_record(&host)?;
410 assert_eq!(txt, strip_quotes(&cur.unwrap()));
411
412
413 let txt = "another text reference".to_string();
415 client.update_txt_record(&host, &txt)?;
416 let cur = client.get_txt_record(&host)?;
417 assert_eq!(txt, strip_quotes(&cur.unwrap()));
418
419
420 client.delete_txt_record(&host)?;
422 let del = client.get_txt_record(&host)?;
423 assert!(del.is_none());
424
425 Ok(())
426 }
427
428 #[macro_export]
461 macro_rules! generate_tests {
462 ($feat:literal) => {
463 use serial_test::serial;
464
465 #[test_log::test]
466 #[serial]
467 #[cfg_attr(not(feature = $feat), ignore = "API test")]
468 fn create_update_v4() -> Result<()> {
469 test_create_update_delete_ipv4(get_client())?;
470 Ok(())
471 }
472
473 #[test_log::test]
474 #[serial]
475 #[cfg_attr(not(feature = $feat), ignore = "API test")]
476 fn create_update_txt() -> Result<()> {
477 test_create_update_delete_txt(get_client())?;
478 Ok(())
479 }
480
481 #[test_log::test]
482 #[serial]
483 #[cfg_attr(not(feature = $feat), ignore = "API test")]
484 fn create_update_default() -> Result<()> {
485 test_create_update_delete_txt_default(get_client())?;
486 Ok(())
487 }
488 }
489 }
490
491
492}