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