Skip to main content

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 = "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
36/// Configuration for DNS operations.
37///
38/// Contains the domain to operate on and a `dry_run` flag to avoid
39/// making changes during testing.
40pub struct Config {
41    pub domain: String,
42    pub dry_run: bool,
43}
44
45/// DNS provider selection used by this crate.
46///
47/// Each variant contains the authentication information for the
48/// selected provider.
49///
50/// This can be used by dependents of this project as part of their
51/// config-file, or directly. See the `netlink-ddns` project for an
52/// example.
53#[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    /// Return a blocking (synchronous) implementation of the selected provider.
80    ///
81    /// The returned boxed trait object implements `DnsProvider`.
82    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    /// Return an async implementation of the selected provider.
106    ///
107    /// The returned boxed trait object implements `async_impl::AsyncDnsProvider`.
108    #[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
157/// A trait for a DNS provider.
158///
159/// This trait defines the basic operations that a DNS provider must support.
160///
161/// The trait provides methods for creating, reading, updating, and
162/// deleting DNS records. It also provides default implementations for
163/// TXT and A records.
164pub trait DnsProvider {
165    /// Get a DNS record by host and record type.
166    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
167    where T: DeserializeOwned,
168          Self: Sized;
169
170    /// Create a new DNS record by host and record type.
171    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
172    where T: Serialize + DeserializeOwned + Display + Clone,
173          Self: Sized;
174
175    /// Update a DNS record by host and record type.
176    fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
177    where T: Serialize + DeserializeOwned + Display + Clone,
178          Self: Sized;
179
180    /// Delete a DNS record by host and record type.
181    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>;
182
183    /// Delete all DNS records matching host and record type.
184    fn delete_all_records(&self, rtype: RecordType, host: &str) -> Result<()>;
185
186
187    /// Get a TXT record.
188    ///
189    /// This is a helper method that calls `get_record` with the `TXT` record type.
190    fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
191
192    /// Create a new TXT record.
193    ///
194    /// This is a helper method that calls `create_record` with the `TXT` record type.
195    fn create_txt_record(&self, host: &str, record: &str) -> Result<()>;
196
197    /// Update a TXT record.
198    ///
199    /// This is a helper method that calls `update_record` with the `TXT` record type.
200    fn update_txt_record(&self, host: &str, record: &str) -> Result<()>;
201
202    /// Delete a TXT record.
203    ///
204    /// This is a helper method that calls `delete_record` with the `TXT` record type.
205    fn delete_txt_record(&self, host: &str) -> Result<()>;
206
207    /// Get an A record.
208    ///
209    /// This is a helper method that calls `get_record` with the `A` record type.
210    fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
211
212    /// Create a new A record.
213    ///
214    /// This is a helper method that calls `create_record` with the `A` record type.
215    fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
216
217    /// Update an A record.
218    ///
219    /// This is a helper method that calls `update_record` with the `A` record type.
220    fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
221
222    /// Delete an A record.
223    ///
224    /// This is a helper method that calls `delete_record` with the `A` record type.
225    fn delete_a_record(&self, host: &str) -> Result<()>;
226}
227
228/// A macro to generate default helper implementations for provider impls.
229///
230/// The reason for this macro is that traits don't play well with
231/// generics and Sized, preventing us from providing default
232/// implementations in the trait. There are ways around this, but they
233/// either involve messy downcasting or lots of match arms that need
234/// to be updated as providers are added. As we want to keep the
235/// process of adding providers as self-contained as possible this is
236/// the simplest method for now.
237#[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        // Empty and whitespace-only strings become empty quoted strings
353        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        // Whitespace within content is preserved
357        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        // Create
374        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        // Update
383        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        // Delete
392        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        // Create
405        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        // Update
412        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        // Delete
419        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        // Create
431        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        // Update
438        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        // Delete
445        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        // Create
457        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        // Delete all
464        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    /// A macro to generate a standard set of tests for a DNS provider.
472    ///
473    /// This macro generates four tests:
474    /// - `create_update_v4`: tests creating, updating, and deleting an A record.
475    /// - `create_update_txt`: tests creating, updating, and deleting a TXT record.
476    /// - `create_update_default`: tests creating, updating, and deleting a TXT record using the default provider methods.
477    /// - `delete_all_records`: tests deleting all TXT records for a host.
478    ///
479    /// The tests are conditionally compiled based on the feature flag passed as an argument.
480    ///
481    /// # Requirements
482    ///
483    /// The module that uses this macro must define a `get_client()` function that returns a type
484    /// that implements the `DnsProvider` trait. This function is used by the tests to get a client
485    /// for the DNS provider.
486    ///
487    /// # Arguments
488    ///
489    /// * `$feat` - A string literal representing the feature flag that enables these tests.
490    ///
491    /// # Example
492    ///
493    /// ```
494    /// // In your test module
495    /// use zone_update::{generate_tests, DnsProvider};
496    ///
497    /// fn get_client() -> impl DnsProvider {
498    ///     // ... your client implementation
499    /// }
500    ///
501    /// // This will generate the tests, but they will only run if the "my_provider" feature is enabled.
502    /// generate_tests!("my_provider");
503    /// ```
504    #[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}