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 = "porkbun")]
22pub mod porkbun;
23
24use std::{fmt::{self, Debug, Display, Formatter}, net::Ipv4Addr};
25
26use serde::{de::DeserializeOwned, Deserialize, Serialize};
27use tracing::warn;
28
29use crate::errors::Result;
30
31
32/// Configuration for DNS operations.
33///
34/// Contains the domain to operate on and a `dry_run` flag to avoid
35/// making changes during testing.
36pub struct Config {
37    pub domain: String,
38    pub dry_run: bool,
39}
40
41/// DNS provider selection used by this crate.
42///
43/// Each variant contains the authentication information for the
44/// selected provider.
45///
46/// This can be used by dependents of this project as part of their
47/// config-file, or directly. See the `netlink-ddns` project for an
48/// example.
49#[derive(Clone, Debug, Deserialize)]
50#[serde(rename_all = "lowercase", tag = "name")]
51#[non_exhaustive]
52pub enum Provider {
53    Cloudflare(cloudflare::Auth),
54    DeSec(desec::Auth),
55    DigitalOcean(digitalocean::Auth),
56    Gandi(gandi::Auth),
57    Dnsimple(dnsimple::Auth),
58    DnsMadeEasy(dnsmadeeasy::Auth),
59    PorkBun(porkbun::Auth),
60}
61
62impl Provider {
63
64    /// Return a blocking (synchronous) implementation of the selected provider.
65    ///
66    /// The returned boxed trait object implements `DnsProvider`.
67    pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
68        match self {
69            #[cfg(feature = "cloudflare")]
70            Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
71            #[cfg(feature = "desec")]
72            Provider::DeSec(auth) => Box::new(desec::DeSec::new(dns_conf, auth.clone())),
73            #[cfg(feature = "digitalocean")]
74            Provider::DigitalOcean(auth) => Box::new(digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
75            #[cfg(feature = "gandi")]
76            Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
77            #[cfg(feature = "dnsimple")]
78            Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
79            #[cfg(feature = "dnsmadeeasy")]
80            Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
81            #[cfg(feature = "porkbun")]
82            Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
83        }
84    }
85
86    /// Return an async implementation of the selected provider.
87    ///
88    /// The returned boxed trait object implements `async_impl::AsyncDnsProvider`.
89    #[cfg(feature = "async")]
90    pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
91        match self {
92            #[cfg(feature = "cloudflare")]
93            Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
94            #[cfg(feature = "desec")]
95            Provider::DeSec(auth) => Box::new(async_impl::desec::DeSec::new(dns_conf, auth.clone())),
96            #[cfg(feature = "digitalocean")]
97            Provider::DigitalOcean(auth) => Box::new(async_impl::digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
98            #[cfg(feature = "gandi")]
99            Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
100            #[cfg(feature = "dnsimple")]
101            Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
102            #[cfg(feature = "dnsmadeeasy")]
103            Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
104            #[cfg(feature = "porkbun")]
105            Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
106        }
107    }
108}
109
110
111
112#[derive(Serialize, Deserialize, Clone, Debug)]
113pub enum RecordType {
114    A,
115    AAAA,
116    CAA,
117    CNAME,
118    HINFO,
119    MX,
120    NAPTR,
121    NS,
122    PTR,
123    SRV,
124    SPF,
125    SSHFP,
126    TXT,
127}
128
129impl Display for RecordType {
130    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
131        write!(f, "{:?}", self)
132    }
133}
134
135/// A trait for a DNS provider.
136///
137/// This trait defines the basic operations that a DNS provider must support.
138///
139/// The trait provides methods for creating, reading, updating, and
140/// deleting DNS records. It also provides default implementations for
141/// TXT and A records.
142pub trait DnsProvider {
143    /// Get a DNS record by host and record type.
144    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
145    where T: DeserializeOwned,
146          Self: Sized;
147
148    /// Create a new DNS record by host and record type.
149    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
150    where T: Serialize + DeserializeOwned + Display + Clone,
151          Self: Sized;
152
153    /// Update a DNS record by host and record type.
154    fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
155    where T: Serialize + DeserializeOwned + Display + Clone,
156          Self: Sized;
157
158    /// Delete a DNS record by host and record type.
159    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
160    where Self: Sized;
161
162    /// Get a TXT record.
163    ///
164    /// This is a helper method that calls `get_record` with the `TXT` record type.
165    fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
166
167    /// Create a new TXT record.
168    ///
169    /// This is a helper method that calls `create_record` with the `TXT` record type.
170    fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
171
172    /// Update a TXT record.
173    ///
174    /// This is a helper method that calls `update_record` with the `TXT` record type.
175    fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
176
177    /// Delete a TXT record.
178    ///
179    /// This is a helper method that calls `delete_record` with the `TXT` record type.
180    fn delete_txt_record(&self, host: &str) -> Result<()>;
181
182    /// Get an A record.
183    ///
184    /// This is a helper method that calls `get_record` with the `A` record type.
185    fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
186
187    /// Create a new A record.
188    ///
189    /// This is a helper method that calls `create_record` with the `A` record type.
190    fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
191
192    /// Update an A record.
193    ///
194    /// This is a helper method that calls `update_record` with the `A` record type.
195    fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
196
197    /// Delete an A record.
198    ///
199    /// This is a helper method that calls `delete_record` with the `A` record type.
200    fn delete_a_record(&self, host: &str) -> Result<()>;
201}
202
203/// A macro to generate default helper implementations for provider impls.
204///
205/// The reason for this macro is that traits don't play well with
206/// generics and Sized, preventing us from providing default
207/// implementations in the trait. There are ways around this, but they
208/// either involve messy downcasting or lots of match arms that need
209/// to be updated as providers are added. As we want to keep the
210/// process of adding providers as self-contained as possible this is
211/// the simplest method for now.
212#[macro_export]
213macro_rules! generate_helpers {
214    () => {
215
216        fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
217            self.get_record::<String>(RecordType::TXT, host)
218                .map(|opt| opt.map(|s| crate::strip_quotes(&s)))
219        }
220
221        fn create_txt_record(&self, host: &str, record: &String) -> Result<()> {
222            self.create_record(RecordType::TXT, host, &crate::ensure_quotes(record))
223        }
224
225        fn update_txt_record(&self, host: &str, record: &String) -> Result<()> {
226            self.update_record(RecordType::TXT, host, &crate::ensure_quotes(record))
227        }
228
229        fn delete_txt_record(&self, host: &str) -> Result<()> {
230            self.delete_record(RecordType::TXT, host)
231        }
232
233        fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
234            self.get_record(RecordType::A, host)
235        }
236
237        fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
238            self.create_record(RecordType::A, host, record)
239        }
240
241        fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
242            self.update_record(RecordType::A, host, record)
243        }
244
245        fn delete_a_record(&self, host: &str) -> Result<()> {
246            self.delete_record(RecordType::A, host)
247        }
248    }
249}
250
251fn ensure_quotes(record: &String) -> String {
252    let starts = record.starts_with('"');
253    let ends = record.ends_with('"');
254
255    match (starts, ends) {
256        (true, true)   => record.clone(),
257        (true, false)  => format!("{}\"", record),
258        (false, true)  => format!("\"{}", record),
259        (false, false) => format!("\"{}\"", record),
260    }
261}
262
263fn strip_quotes(record: &str) -> String {
264    let chars = record.chars();
265    let mut check = chars.clone();
266
267    let first = check.next();
268    let last = check.last();
269
270    if let Some('"') = first && let Some('"') = last {
271        chars.skip(1)
272            .take(record.len() - 2)
273            .collect()
274
275    } else {
276        warn!("Double quotes not found in record string, using whole record.");
277        record.to_string()
278    }
279}
280
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::net::Ipv4Addr;
286    use random_string::charsets::ALPHA_LOWER;
287    use tracing::info;
288
289    #[test]
290    fn test_strip_quotes() {
291        assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
292        assert_eq!("abc123\"", strip_quotes("abc123\""));
293        assert_eq!("\"abc123", strip_quotes("\"abc123"));
294        assert_eq!("abc123", strip_quotes("abc123"));
295    }
296
297    #[test]
298    fn test_already_quoted() {
299        assert_eq!(ensure_quotes(&"\"hello\"".to_string()), "\"hello\"");
300        assert_eq!(ensure_quotes(&"\"\"".to_string()), "\"\"");
301        assert_eq!(ensure_quotes(&"\"a\"".to_string()), "\"a\"");
302        assert_eq!(ensure_quotes(&"\"quoted \" string\"".to_string()), "\"quoted \" string\"");
303    }
304
305    #[test]
306    fn test_no_quotes() {
307        assert_eq!(ensure_quotes(&"hello".to_string()), "\"hello\"");
308        assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
309        assert_eq!(ensure_quotes(&"a".to_string()), "\"a\"");
310        assert_eq!(ensure_quotes(&"hello world".to_string()), "\"hello world\"");
311    }
312
313    #[test]
314    fn test_only_starting_quote() {
315        assert_eq!(ensure_quotes(&"\"hello".to_string()), "\"hello\"");
316        assert_eq!(ensure_quotes(&"\"test case".to_string()), "\"test case\"");
317    }
318
319    #[test]
320    fn test_only_ending_quote() {
321        assert_eq!(ensure_quotes(&"hello\"".to_string()), "\"hello\"");
322        assert_eq!(ensure_quotes(&"test case\"".to_string()), "\"test case\"");
323    }
324
325    #[test]
326    fn test_whitespace_handling() {
327        // Empty and whitespace-only strings become empty quoted strings
328        assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
329        assert_eq!(ensure_quotes(&"   ".to_string()), "\"   \"");
330        assert_eq!(ensure_quotes(&"\t\n".to_string()), "\"\t\n\"");
331        // Whitespace within content is preserved
332        assert_eq!(ensure_quotes(&" hello ".to_string()), "\" hello \"");
333        assert_eq!(ensure_quotes(&"\" hello ".to_string()), "\" hello \"");
334        assert_eq!(ensure_quotes(&" hello \"".to_string()), "\" hello \"");
335    }
336
337    #[test]
338    fn test_special_characters() {
339        assert_eq!(ensure_quotes(&"hello\nworld".to_string()), "\"hello\nworld\"");
340        assert_eq!(ensure_quotes(&"hello\tworld".to_string()), "\"hello\tworld\"");
341        assert_eq!(ensure_quotes(&"123!@#$%^&*()".to_string()), "\"123!@#$%^&*()\"");
342    }
343
344    pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
345
346        let host = random_string::generate(16, ALPHA_LOWER);
347
348        // Create
349        info!("Creating IPv4 {host}");
350        let ip: Ipv4Addr = "10.9.8.7".parse()?;
351        client.create_record(RecordType::A, &host, &ip)?;
352        let cur = client.get_record(RecordType::A, &host)?;
353        assert_eq!(Some(ip), cur);
354
355
356        // Update
357        info!("Updating IPv4 {host}");
358        let ip: Ipv4Addr = "10.10.9.8".parse()?;
359        client.update_record(RecordType::A, &host, &ip)?;
360        let cur = client.get_record(RecordType::A, &host)?;
361        assert_eq!(Some(ip), cur);
362
363
364        // Delete
365        info!("Deleting IPv4 {host}");
366        client.delete_record(RecordType::A, &host)?;
367        let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
368        assert!(del.is_none());
369
370        Ok(())
371    }
372
373    pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
374
375        let host = random_string::generate(16, ALPHA_LOWER);
376
377        // Create
378        let txt = "\"a text reference\"".to_string();
379        println!("CREATE");
380        client.create_record(RecordType::TXT, &host, &txt)?;
381        let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
382        assert_eq!(txt, cur.unwrap());
383
384
385        // Update
386        let txt = "\"another text reference\"".to_string();
387        println!("UPDATE");
388        client.update_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        // Delete
394        println!("DELETE");
395        client.delete_record(RecordType::TXT, &host)?;
396        let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
397        assert!(del.is_none());
398
399        Ok(())
400    }
401
402    pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
403
404        let host = random_string::generate(16, ALPHA_LOWER);
405
406        // Create
407        let txt = "a text reference".to_string();
408        client.create_txt_record(&host, &txt)?;
409        let cur = client.get_txt_record(&host)?;
410        assert_eq!(txt, strip_quotes(&cur.unwrap()));
411
412
413        // Update
414        let txt = "another text reference".to_string();
415        client.update_txt_record(&host, &txt)?;
416        let cur = client.get_txt_record(&host)?;
417        assert_eq!(txt, strip_quotes(&cur.unwrap()));
418
419
420        // Delete
421        client.delete_txt_record(&host)?;
422        let del = client.get_txt_record(&host)?;
423        assert!(del.is_none());
424
425        Ok(())
426    }
427
428    /// A macro to generate a standard set of tests for a DNS provider.
429    ///
430    /// This macro generates three tests:
431    /// - `create_update_v4`: tests creating, updating, and deleting an A record.
432    /// - `create_update_txt`: tests creating, updating, and deleting a TXT record.
433    /// - `create_update_default`: tests creating, updating, and deleting a TXT record using the default provider methods.
434    ///
435    /// The tests are conditionally compiled based on the feature flag passed as an argument.
436    ///
437    /// # Requirements
438    ///
439    /// The module that uses this macro must define a `get_client()` function that returns a type
440    /// that implements the `DnsProvider` trait. This function is used by the tests to get a client
441    /// for the DNS provider.
442    ///
443    /// # Arguments
444    ///
445    /// * `$feat` - A string literal representing the feature flag that enables these tests.
446    ///
447    /// # Example
448    ///
449    /// ```
450    /// // In your test module
451    /// use zone_update::{generate_tests, DnsProvider};
452    ///
453    /// fn get_client() -> impl DnsProvider {
454    ///     // ... your client implementation
455    /// }
456    ///
457    /// // This will generate the tests, but they will only run if the "my_provider" feature is enabled.
458    /// generate_tests!("my_provider");
459    /// ```
460    #[macro_export]
461    macro_rules! generate_tests {
462        ($feat:literal) => {
463            use serial_test::serial;
464
465            #[test_log::test]
466            #[serial]
467            #[cfg_attr(not(feature = $feat), ignore = "API test")]
468            fn create_update_v4() -> Result<()> {
469                test_create_update_delete_ipv4(get_client())?;
470                Ok(())
471            }
472
473            #[test_log::test]
474            #[serial]
475            #[cfg_attr(not(feature = $feat), ignore = "API test")]
476            fn create_update_txt() -> Result<()> {
477                test_create_update_delete_txt(get_client())?;
478                Ok(())
479            }
480
481            #[test_log::test]
482            #[serial]
483            #[cfg_attr(not(feature = $feat), ignore = "API test")]
484            fn create_update_default() -> Result<()> {
485                test_create_update_delete_txt_default(get_client())?;
486                Ok(())
487            }
488        }
489    }
490
491
492}