zone_update/
lib.rs

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
34/// Configuration for DNS operations.
35///
36/// Contains the domain to operate on and a `dry_run` flag to avoid
37/// making changes during testing.
38pub struct Config {
39    pub domain: String,
40    pub dry_run: bool,
41}
42
43/// DNS provider selection used by this crate.
44///
45/// Each variant contains the authentication information for the
46/// selected provider.
47///
48/// This can be used by dependents of this project as part of their
49/// config-file, or directly. See the `netlink-ddns` project for an
50/// example.
51#[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    /// Return a blocking (synchronous) implementation of the selected provider.
68    ///
69    /// The returned boxed trait object implements `DnsProvider`.
70    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    /// Return an async implementation of the selected provider.
92    ///
93    /// The returned boxed trait object implements `async_impl::AsyncDnsProvider`.
94    #[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
142/// A trait for a DNS provider.
143///
144/// This trait defines the basic operations that a DNS provider must support.
145///
146/// The trait provides methods for creating, reading, updating, and
147/// deleting DNS records. It also provides default implementations for
148/// TXT and A records.
149pub trait DnsProvider {
150    /// Get a DNS record by host and record type.
151    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
152    where T: DeserializeOwned,
153          Self: Sized;
154
155    /// Create a new DNS record by host and record type.
156    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
157    where T: Serialize + DeserializeOwned + Display + Clone,
158          Self: Sized;
159
160    /// Update a DNS record by host and record type.
161    fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
162    where T: Serialize + DeserializeOwned + Display + Clone,
163          Self: Sized;
164
165    /// Delete a DNS record by host and record type.
166    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
167    where Self: Sized;
168
169    /// Get a TXT record.
170    ///
171    /// This is a helper method that calls `get_record` with the `TXT` record type.
172    fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
173
174    /// Create a new TXT record.
175    ///
176    /// This is a helper method that calls `create_record` with the `TXT` record type.
177    fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
178
179    /// Update a TXT record.
180    ///
181    /// This is a helper method that calls `update_record` with the `TXT` record type.
182    fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
183
184    /// Delete a TXT record.
185    ///
186    /// This is a helper method that calls `delete_record` with the `TXT` record type.
187    fn delete_txt_record(&self, host: &str) -> Result<()>;
188
189    /// Get an A record.
190    ///
191    /// This is a helper method that calls `get_record` with the `A` record type.
192    fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
193
194    /// Create a new A record.
195    ///
196    /// This is a helper method that calls `create_record` with the `A` record type.
197    fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
198
199    /// Update an A record.
200    ///
201    /// This is a helper method that calls `update_record` with the `A` record type.
202    fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
203
204    /// Delete an A record.
205    ///
206    /// This is a helper method that calls `delete_record` with the `A` record type.
207    fn delete_a_record(&self, host: &str) -> Result<()>;
208}
209
210/// A macro to generate default helper implementations for provider impls.
211///
212/// The reason for this macro is that traits don't play well with
213/// generics and Sized, preventing us from providing default
214/// implementations in the trait. There are ways around this, but they
215/// either involve messy downcasting or lots of match arms that need
216/// to be updated as providers are added. As we want to keep the
217/// process of adding providers as self-contained as possible this is
218/// the simplest method for now.
219#[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        // Empty and whitespace-only strings become empty quoted strings
335        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        // Whitespace within content is preserved
339        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        // Create
356        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        // Update
365        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        // Delete
374        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        // Create
387        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        // Update
394        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        // Delete
401        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        // Create
413        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        // Update
420        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        // Delete
427        client.delete_txt_record(&host)?;
428        let del = client.get_txt_record(&host)?;
429        assert!(del.is_none());
430
431        Ok(())
432    }
433
434    /// A macro to generate a standard set of tests for a DNS provider.
435    ///
436    /// This macro generates three tests:
437    /// - `create_update_v4`: tests creating, updating, and deleting an A record.
438    /// - `create_update_txt`: tests creating, updating, and deleting a TXT record.
439    /// - `create_update_default`: tests creating, updating, and deleting a TXT record using the default provider methods.
440    ///
441    /// The tests are conditionally compiled based on the feature flag passed as an argument.
442    ///
443    /// # Requirements
444    ///
445    /// The module that uses this macro must define a `get_client()` function that returns a type
446    /// that implements the `DnsProvider` trait. This function is used by the tests to get a client
447    /// for the DNS provider.
448    ///
449    /// # Arguments
450    ///
451    /// * `$feat` - A string literal representing the feature flag that enables these tests.
452    ///
453    /// # Example
454    ///
455    /// ```
456    /// // In your test module
457    /// use zone_update::{generate_tests, DnsProvider};
458    ///
459    /// fn get_client() -> impl DnsProvider {
460    ///     // ... your client implementation
461    /// }
462    ///
463    /// // This will generate the tests, but they will only run if the "my_provider" feature is enabled.
464    /// generate_tests!("my_provider");
465    /// ```
466    #[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}