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 Bunny(bunny::Auth),
58 Cloudflare(cloudflare::Auth),
59 DeSec(desec::Auth),
60 DigitalOcean(digitalocean::Auth),
61 DnsMadeEasy(dnsmadeeasy::Auth),
62 Dnsimple(dnsimple::Auth),
63 Gandi(gandi::Auth),
64 Linode(linode::Auth),
65 PorkBun(porkbun::Auth),
66}
67
68impl Provider {
69
70 pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
74 match self {
75 #[cfg(feature = "bunny")]
76 Provider::Bunny(auth) => Box::new(bunny::Bunny::new(dns_conf, auth.clone())),
77 #[cfg(feature = "cloudflare")]
78 Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
79 #[cfg(feature = "desec")]
80 Provider::DeSec(auth) => Box::new(desec::DeSec::new(dns_conf, auth.clone())),
81 #[cfg(feature = "digitalocean")]
82 Provider::DigitalOcean(auth) => Box::new(digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
83 #[cfg(feature = "gandi")]
84 Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
85 #[cfg(feature = "dnsimple")]
86 Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
87 #[cfg(feature = "dnsmadeeasy")]
88 Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
89 #[cfg(feature = "porkbun")]
90 Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
91 #[cfg(feature = "linode")]
92 Provider::Linode(auth) => Box::new(linode::Linode::new(dns_conf, auth.clone())),
93 }
94 }
95
96 #[cfg(feature = "async")]
100 pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
101 match self {
102 #[cfg(feature = "bunny")]
103 Provider::Bunny(auth) => Box::new(async_impl::bunny::Bunny::new(dns_conf, auth.clone())),
104 #[cfg(feature = "cloudflare")]
105 Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
106 #[cfg(feature = "desec")]
107 Provider::DeSec(auth) => Box::new(async_impl::desec::DeSec::new(dns_conf, auth.clone())),
108 #[cfg(feature = "digitalocean")]
109 Provider::DigitalOcean(auth) => Box::new(async_impl::digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
110 #[cfg(feature = "gandi")]
111 Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
112 #[cfg(feature = "dnsimple")]
113 Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
114 #[cfg(feature = "dnsmadeeasy")]
115 Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
116 #[cfg(feature = "porkbun")]
117 Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
118 #[cfg(feature = "linode")]
119 Provider::Linode(auth) => Box::new(async_impl::linode::Linode::new(dns_conf, auth.clone())),
120 }
121 }
122}
123
124
125
126#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
127#[non_exhaustive]
128pub enum RecordType {
129 A,
130 AAAA,
131 CAA,
132 CNAME,
133 MX,
134 NS,
135 PTR,
136 SRV,
137 TXT,
138 SVCB,
139 HTTPS,
140}
141
142impl Display for RecordType {
143 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
144 write!(f, "{:?}", self)
145 }
146}
147
148pub trait DnsProvider {
156 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
158 where T: DeserializeOwned,
159 Self: Sized;
160
161 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
163 where T: Serialize + DeserializeOwned + Display + Clone,
164 Self: Sized;
165
166 fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
168 where T: Serialize + DeserializeOwned + Display + Clone,
169 Self: Sized;
170
171 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
173 where Self: Sized;
174
175 fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
179
180 fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
184
185 fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
189
190 fn delete_txt_record(&self, host: &str) -> Result<()>;
194
195 fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
199
200 fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
204
205 fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
209
210 fn delete_a_record(&self, host: &str) -> Result<()>;
214}
215
216#[macro_export]
226macro_rules! generate_helpers {
227 () => {
228
229 fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
230 self.get_record::<String>(RecordType::TXT, host)
231 .map(|opt| opt.map(|s| crate::strip_quotes(&s)))
232 }
233
234 fn create_txt_record(&self, host: &str, record: &String) -> Result<()> {
235 self.create_record(RecordType::TXT, host, &crate::ensure_quotes(record))
236 }
237
238 fn update_txt_record(&self, host: &str, record: &String) -> Result<()> {
239 self.update_record(RecordType::TXT, host, &crate::ensure_quotes(record))
240 }
241
242 fn delete_txt_record(&self, host: &str) -> Result<()> {
243 self.delete_record(RecordType::TXT, host)
244 }
245
246 fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
247 self.get_record(RecordType::A, host)
248 }
249
250 fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
251 self.create_record(RecordType::A, host, record)
252 }
253
254 fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
255 self.update_record(RecordType::A, host, record)
256 }
257
258 fn delete_a_record(&self, host: &str) -> Result<()> {
259 self.delete_record(RecordType::A, host)
260 }
261 }
262}
263
264fn ensure_quotes(record: &String) -> String {
265 let starts = record.starts_with('"');
266 let ends = record.ends_with('"');
267
268 match (starts, ends) {
269 (true, true) => record.clone(),
270 (true, false) => format!("{}\"", record),
271 (false, true) => format!("\"{}", record),
272 (false, false) => format!("\"{}\"", record),
273 }
274}
275
276fn strip_quotes(record: &str) -> String {
277 let chars = record.chars();
278 let mut check = chars.clone();
279
280 let first = check.next();
281 let last = check.last();
282
283 if let Some('"') = first && let Some('"') = last {
284 chars.skip(1)
285 .take(record.len() - 2)
286 .collect()
287
288 } else {
289 warn!("Double quotes not found in record string, using whole record.");
290 record.to_string()
291 }
292}
293
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use std::net::Ipv4Addr;
299 use random_string::charsets::ALPHA_LOWER;
300 use tracing::info;
301
302 #[test]
303 fn test_strip_quotes() {
304 assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
305 assert_eq!("abc123\"", strip_quotes("abc123\""));
306 assert_eq!("\"abc123", strip_quotes("\"abc123"));
307 assert_eq!("abc123", strip_quotes("abc123"));
308 }
309
310 #[test]
311 fn test_already_quoted() {
312 assert_eq!(ensure_quotes(&"\"hello\"".to_string()), "\"hello\"");
313 assert_eq!(ensure_quotes(&"\"\"".to_string()), "\"\"");
314 assert_eq!(ensure_quotes(&"\"a\"".to_string()), "\"a\"");
315 assert_eq!(ensure_quotes(&"\"quoted \" string\"".to_string()), "\"quoted \" string\"");
316 }
317
318 #[test]
319 fn test_no_quotes() {
320 assert_eq!(ensure_quotes(&"hello".to_string()), "\"hello\"");
321 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
322 assert_eq!(ensure_quotes(&"a".to_string()), "\"a\"");
323 assert_eq!(ensure_quotes(&"hello world".to_string()), "\"hello world\"");
324 }
325
326 #[test]
327 fn test_only_starting_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_only_ending_quote() {
334 assert_eq!(ensure_quotes(&"hello\"".to_string()), "\"hello\"");
335 assert_eq!(ensure_quotes(&"test case\"".to_string()), "\"test case\"");
336 }
337
338 #[test]
339 fn test_whitespace_handling() {
340 assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
342 assert_eq!(ensure_quotes(&" ".to_string()), "\" \"");
343 assert_eq!(ensure_quotes(&"\t\n".to_string()), "\"\t\n\"");
344 assert_eq!(ensure_quotes(&" hello ".to_string()), "\" hello \"");
346 assert_eq!(ensure_quotes(&"\" hello ".to_string()), "\" hello \"");
347 assert_eq!(ensure_quotes(&" hello \"".to_string()), "\" hello \"");
348 }
349
350 #[test]
351 fn test_special_characters() {
352 assert_eq!(ensure_quotes(&"hello\nworld".to_string()), "\"hello\nworld\"");
353 assert_eq!(ensure_quotes(&"hello\tworld".to_string()), "\"hello\tworld\"");
354 assert_eq!(ensure_quotes(&"123!@#$%^&*()".to_string()), "\"123!@#$%^&*()\"");
355 }
356
357 pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
358
359 let host = random_string::generate(16, ALPHA_LOWER);
360
361 info!("Creating IPv4 {host}");
363 let ip: Ipv4Addr = "10.9.8.7".parse()?;
364 client.create_record(RecordType::A, &host, &ip)?;
365 info!("Checking IPv4 {host}");
366 let cur = client.get_record(RecordType::A, &host)?;
367 assert_eq!(Some(ip), cur);
368
369
370 info!("Updating IPv4 {host}");
372 let ip: Ipv4Addr = "10.10.9.8".parse()?;
373 client.update_record(RecordType::A, &host, &ip)?;
374 info!("Checking IPv4 {host}");
375 let cur = client.get_record(RecordType::A, &host)?;
376 assert_eq!(Some(ip), cur);
377
378
379 info!("Deleting IPv4 {host}");
381 client.delete_record(RecordType::A, &host)?;
382 let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
383 assert!(del.is_none());
384
385 Ok(())
386 }
387
388 pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
389
390 let host = random_string::generate(16, ALPHA_LOWER);
391
392 let txt = "\"a text reference\"".to_string();
394 client.create_record(RecordType::TXT, &host, &txt)?;
395 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
396 assert_eq!(txt, cur.unwrap());
397
398
399 let txt = "\"another text reference\"".to_string();
401 client.update_record(RecordType::TXT, &host, &txt)?;
402 let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
403 assert_eq!(txt, cur.unwrap());
404
405
406 client.delete_record(RecordType::TXT, &host)?;
408 let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
409 assert!(del.is_none());
410
411 Ok(())
412 }
413
414 pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
415
416 let host = random_string::generate(16, ALPHA_LOWER);
417
418 let txt = "a text reference".to_string();
420 client.create_txt_record(&host, &txt)?;
421 let cur = client.get_txt_record(&host)?;
422 assert_eq!(txt, strip_quotes(&cur.unwrap()));
423
424
425 let txt = "another text reference".to_string();
427 client.update_txt_record(&host, &txt)?;
428 let cur = client.get_txt_record(&host)?;
429 assert_eq!(txt, strip_quotes(&cur.unwrap()));
430
431
432 client.delete_txt_record(&host)?;
434 let del = client.get_txt_record(&host)?;
435 assert!(del.is_none());
436
437 Ok(())
438 }
439
440 #[macro_export]
473 macro_rules! generate_tests {
474 ($feat:literal) => {
475 use serial_test::serial;
476
477 #[test_log::test]
478 #[serial]
479 #[cfg_attr(not(feature = $feat), ignore = "API test")]
480 fn create_update_v4() -> Result<()> {
481 test_create_update_delete_ipv4(get_client())?;
482 Ok(())
483 }
484
485 #[test_log::test]
486 #[serial]
487 #[cfg_attr(not(feature = $feat), ignore = "API test")]
488 fn create_update_txt() -> Result<()> {
489 test_create_update_delete_txt(get_client())?;
490 Ok(())
491 }
492
493 #[test_log::test]
494 #[serial]
495 #[cfg_attr(not(feature = $feat), ignore = "API test")]
496 fn create_update_default() -> Result<()> {
497 test_create_update_delete_txt_default(get_client())?;
498 Ok(())
499 }
500 }
501 }
502
503
504}