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 = "bunny")]
10pub mod bunny;
11#[cfg(feature = "cloudflare")]
12pub mod cloudflare;
13#[cfg(feature = "desec")]
14pub mod desec;
15#[cfg(feature = "digitalocean")]
16pub mod digitalocean;
17#[cfg(feature = "dnsimple")]
18pub mod dnsimple;
19#[cfg(feature = "dnsmadeeasy")]
20pub mod dnsmadeeasy;
21#[cfg(feature = "gandi")]
22pub mod gandi;
23#[cfg(feature = "linode")]
24pub mod linode;
25#[cfg(feature = "porkbun")]
26pub mod porkbun;
27
28use std::{fmt::{self, Debug, Display, Formatter}, net::Ipv4Addr};
29
30use serde::{de::DeserializeOwned, Deserialize, Serialize};
31use tracing::warn;
32
33use crate::errors::Result;
34
35
36pub struct Config {
41 pub domain: String,
42 pub dry_run: bool,
43}
44
45#[derive(Clone, Debug, Deserialize)]
54#[serde(rename_all = "lowercase", tag = "name")]
55#[non_exhaustive]
56pub enum Provider {
57 #[cfg(feature = "bunny")]
58 Bunny(bunny::Auth),
59 #[cfg(feature = "cloudflare")]
60 Cloudflare(cloudflare::Auth),
61 #[cfg(feature = "desec")]
62 DeSec(desec::Auth),
63 #[cfg(feature = "digitalocean")]
64 DigitalOcean(digitalocean::Auth),
65 #[cfg(feature = "dnsmadeeasy")]
66 DnsMadeEasy(dnsmadeeasy::Auth),
67 #[cfg(feature = "dnsimple")]
68 Dnsimple(dnsimple::Auth),
69 #[cfg(feature = "gandi")]
70 Gandi(gandi::Auth),
71 #[cfg(feature = "linode")]
72 Linode(linode::Auth),
73 #[cfg(feature = "porkbun")]
74 PorkBun(porkbun::Auth),
75}
76
77impl Provider {
78
79 pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
83 match self {
84 #[cfg(feature = "bunny")]
85 Provider::Bunny(auth) => Box::new(bunny::Bunny::new(dns_conf, auth.clone())),
86 #[cfg(feature = "cloudflare")]
87 Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
88 #[cfg(feature = "desec")]
89 Provider::DeSec(auth) => Box::new(desec::DeSec::new(dns_conf, auth.clone())),
90 #[cfg(feature = "digitalocean")]
91 Provider::DigitalOcean(auth) => Box::new(digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
92 #[cfg(feature = "gandi")]
93 Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
94 #[cfg(feature = "dnsimple")]
95 Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
96 #[cfg(feature = "dnsmadeeasy")]
97 Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
98 #[cfg(feature = "porkbun")]
99 Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
100 #[cfg(feature = "linode")]
101 Provider::Linode(auth) => Box::new(linode::Linode::new(dns_conf, auth.clone())),
102 }
103 }
104
105 #[cfg(feature = "async")]
109 pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
110 match self {
111 #[cfg(feature = "bunny")]
112 Provider::Bunny(auth) => Box::new(async_impl::bunny::Bunny::new(dns_conf, auth.clone())),
113 #[cfg(feature = "cloudflare")]
114 Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
115 #[cfg(feature = "desec")]
116 Provider::DeSec(auth) => Box::new(async_impl::desec::DeSec::new(dns_conf, auth.clone())),
117 #[cfg(feature = "digitalocean")]
118 Provider::DigitalOcean(auth) => Box::new(async_impl::digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
119 #[cfg(feature = "gandi")]
120 Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
121 #[cfg(feature = "dnsimple")]
122 Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
123 #[cfg(feature = "dnsmadeeasy")]
124 Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
125 #[cfg(feature = "porkbun")]
126 Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
127 #[cfg(feature = "linode")]
128 Provider::Linode(auth) => Box::new(async_impl::linode::Linode::new(dns_conf, auth.clone())),
129 }
130 }
131}
132
133
134
135#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
136#[non_exhaustive]
137pub enum RecordType {
138 A,
139 AAAA,
140 CAA,
141 CNAME,
142 MX,
143 NS,
144 PTR,
145 SRV,
146 TXT,
147 SVCB,
148 HTTPS,
149}
150
151impl Display for RecordType {
152 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
153 write!(f, "{:?}", self)
154 }
155}
156
157pub trait DnsProvider {
165 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
167 where T: DeserializeOwned,
168 Self: Sized;
169
170 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
172 where T: Serialize + DeserializeOwned + Display + Clone,
173 Self: Sized;
174
175 fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
177 where T: Serialize + DeserializeOwned + Display + Clone,
178 Self: Sized;
179
180 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>;
182
183 fn delete_all_records(&self, rtype: RecordType, host: &str) -> Result<()>;
185
186
187 fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
191
192 fn create_txt_record(&self, host: &str, record: &str) -> Result<()>;
196
197 fn update_txt_record(&self, host: &str, record: &str) -> Result<()>;
201
202 fn delete_txt_record(&self, host: &str) -> Result<()>;
206
207 fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
211
212 fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
216
217 fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
221
222 fn delete_a_record(&self, host: &str) -> Result<()>;
226}
227
228#[macro_export]
238macro_rules! generate_helpers {
239 () => {
240
241 fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
242 self.get_record::<String>(RecordType::TXT, host)
243 .map(|opt| opt.map(|s| $crate::strip_quotes(&s)))
244 }
245
246 fn create_txt_record(&self, host: &str, record: &str) -> Result<()> {
247 self.create_record(RecordType::TXT, host, &$crate::ensure_quotes(record))
248 }
249
250 fn update_txt_record(&self, host: &str, record: &str) -> Result<()> {
251 self.update_record(RecordType::TXT, host, &$crate::ensure_quotes(record))
252 }
253
254 fn delete_txt_record(&self, host: &str) -> Result<()> {
255 self.delete_record(RecordType::TXT, host)
256 }
257
258 fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
259 self.get_record(RecordType::A, host)
260 }
261
262 fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
263 self.create_record(RecordType::A, host, record)
264 }
265
266 fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
267 self.update_record(RecordType::A, host, record)
268 }
269
270 fn delete_a_record(&self, host: &str) -> Result<()> {
271 self.delete_record(RecordType::A, host)
272 }
273 }
274}
275
276fn ensure_quotes(record: &str) -> String {
277 let starts = record.starts_with('"');
278 let ends = record.ends_with('"');
279
280 match (starts, ends) {
281 (true, true) => record.to_string(),
282 (true, false) => format!("{}\"", record),
283 (false, true) => format!("\"{}", record),
284 (false, false) => format!("\"{}\"", record),
285 }
286}
287
288fn strip_quotes(record: &str) -> String {
289 let chars = record.chars();
290 let mut check = chars.clone();
291
292 let first = check.next();
293 let last = check.last();
294
295 if let Some('"') = first && let Some('"') = last {
296 chars.skip(1)
297 .take(record.len() - 2)
298 .collect()
299
300 } else {
301 warn!("Double quotes not found in record string, using whole record.");
302 record.to_string()
303 }
304}
305
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use std::net::Ipv4Addr;
311 use random_string::charsets::ALPHA_LOWER;
312 use tracing::info;
313
314 #[test]
315 fn test_strip_quotes() {
316 assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
317 assert_eq!("abc123\"", strip_quotes("abc123\""));
318 assert_eq!("\"abc123", strip_quotes("\"abc123"));
319 assert_eq!("abc123", strip_quotes("abc123"));
320 }
321
322 #[test]
323 fn test_already_quoted() {
324 assert_eq!(ensure_quotes(&"\"hello\"".to_string()), "\"hello\"");
325 assert_eq!(ensure_quotes(&"\"\"".to_string()), "\"\"");
326 assert_eq!(ensure_quotes(&"\"a\"".to_string()), "\"a\"");
327 assert_eq!(ensure_quotes(&"\"quoted \" string\"".to_string()), "\"quoted \" string\"");
328 }
329
330 #[test]
331 fn test_no_quotes() {
332 assert_eq!(ensure_quotes(&"hello".to_string()), "\"hello\"");
333 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
334 assert_eq!(ensure_quotes(&"a".to_string()), "\"a\"");
335 assert_eq!(ensure_quotes(&"hello world".to_string()), "\"hello world\"");
336 }
337
338 #[test]
339 fn test_only_starting_quote() {
340 assert_eq!(ensure_quotes(&"\"hello".to_string()), "\"hello\"");
341 assert_eq!(ensure_quotes(&"\"test case".to_string()), "\"test case\"");
342 }
343
344 #[test]
345 fn test_only_ending_quote() {
346 assert_eq!(ensure_quotes(&"hello\"".to_string()), "\"hello\"");
347 assert_eq!(ensure_quotes(&"test case\"".to_string()), "\"test case\"");
348 }
349
350 #[test]
351 fn test_whitespace_handling() {
352 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
354 assert_eq!(ensure_quotes(&" ".to_string()), "\" \"");
355 assert_eq!(ensure_quotes(&"\t\n".to_string()), "\"\t\n\"");
356 assert_eq!(ensure_quotes(&" hello ".to_string()), "\" hello \"");
358 assert_eq!(ensure_quotes(&"\" hello ".to_string()), "\" hello \"");
359 assert_eq!(ensure_quotes(&" hello \"".to_string()), "\" hello \"");
360 }
361
362 #[test]
363 fn test_special_characters() {
364 assert_eq!(ensure_quotes(&"hello\nworld".to_string()), "\"hello\nworld\"");
365 assert_eq!(ensure_quotes(&"hello\tworld".to_string()), "\"hello\tworld\"");
366 assert_eq!(ensure_quotes(&"123!@#$%^&*()".to_string()), "\"123!@#$%^&*()\"");
367 }
368
369 pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
370
371 let host = random_string::generate(16, ALPHA_LOWER);
372
373 info!("Creating IPv4 {host}");
375 let ip: Ipv4Addr = "10.9.8.7".parse()?;
376 client.create_record(RecordType::A, &host, &ip)?;
377 info!("Checking IPv4 {host}");
378 let cur = client.get_record(RecordType::A, &host)?;
379 assert_eq!(Some(ip), cur);
380
381
382 info!("Updating IPv4 {host}");
384 let ip: Ipv4Addr = "10.10.9.8".parse()?;
385 client.update_record(RecordType::A, &host, &ip)?;
386 info!("Checking IPv4 {host}");
387 let cur = client.get_record(RecordType::A, &host)?;
388 assert_eq!(Some(ip), cur);
389
390
391 info!("Deleting IPv4 {host}");
393 client.delete_record(RecordType::A, &host)?;
394 let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
395 assert!(del.is_none());
396
397 Ok(())
398 }
399
400 pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
401
402 let host = random_string::generate(16, ALPHA_LOWER);
403
404 let txt = "\"a text reference\"".to_string();
406 client.create_record(RecordType::TXT, &host, &txt)?;
407 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
408 assert_eq!(txt, cur.unwrap());
409
410
411 let txt = "\"another text reference\"".to_string();
413 client.update_record(RecordType::TXT, &host, &txt)?;
414 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
415 assert_eq!(txt, cur.unwrap());
416
417
418 client.delete_record(RecordType::TXT, &host)?;
420 let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
421 assert!(del.is_none());
422
423 Ok(())
424 }
425
426 pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
427
428 let host = random_string::generate(16, ALPHA_LOWER);
429
430 let txt = "a text reference".to_string();
432 client.create_txt_record(&host, &txt)?;
433 let cur = client.get_txt_record(&host)?;
434 assert_eq!(txt, strip_quotes(&cur.unwrap()));
435
436
437 let txt = "another text reference".to_string();
439 client.update_txt_record(&host, &txt)?;
440 let cur = client.get_txt_record(&host)?;
441 assert_eq!(txt, strip_quotes(&cur.unwrap()));
442
443
444 client.delete_txt_record(&host)?;
446 let del = client.get_txt_record(&host)?;
447 assert!(del.is_none());
448
449 Ok(())
450 }
451
452 pub(crate) fn test_delete_all_records(client: impl DnsProvider) -> Result<()> {
453
454 let host = random_string::generate(16, ALPHA_LOWER);
455
456 let txt = "\"first text reference\"".to_string();
458 client.create_record(RecordType::TXT, &host, &txt)?;
459 let txt = "\"second text reference\"".to_string();
460 client.create_record(RecordType::TXT, &host, &txt)?;
461
462
463 client.delete_all_records(RecordType::TXT, &host)?;
465 let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
466 assert!(del.is_none());
467
468 Ok(())
469 }
470
471 #[macro_export]
505 macro_rules! generate_tests {
506 ($feat:literal) => {
507 use serial_test::serial;
508
509 #[test_log::test]
510 #[serial]
511 #[cfg_attr(not(feature = $feat), ignore = "API test")]
512 fn create_update_v4() -> Result<()> {
513 test_create_update_delete_ipv4(get_client())?;
514 Ok(())
515 }
516
517 #[test_log::test]
518 #[serial]
519 #[cfg_attr(not(feature = $feat), ignore = "API test")]
520 fn create_update_txt() -> Result<()> {
521 test_create_update_delete_txt(get_client())?;
522 Ok(())
523 }
524
525 #[test_log::test]
526 #[serial]
527 #[cfg_attr(not(feature = $feat), ignore = "API test")]
528 fn create_update_default() -> Result<()> {
529 test_create_update_delete_txt_default(get_client())?;
530 Ok(())
531 }
532
533 #[test_log::test]
534 #[serial]
535 #[cfg_attr(not(feature = $feat), ignore = "API test")]
536 fn delete_all_records() -> Result<()> {
537 test_delete_all_records(get_client())?;
538 Ok(())
539 }
540 }
541 }
542
543
544}