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    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    /// Return a blocking (synchronous) implementation of the selected provider.
71    ///
72    /// The returned boxed trait object implements `DnsProvider`.
73    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    /// Return an async implementation of the selected provider.
97    ///
98    /// The returned boxed trait object implements `async_impl::AsyncDnsProvider`.
99    #[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
148/// A trait for a DNS provider.
149///
150/// This trait defines the basic operations that a DNS provider must support.
151///
152/// The trait provides methods for creating, reading, updating, and
153/// deleting DNS records. It also provides default implementations for
154/// TXT and A records.
155pub trait DnsProvider {
156    /// Get a DNS record by host and record type.
157    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
158    where T: DeserializeOwned,
159          Self: Sized;
160
161    /// Create a new DNS record by host and record type.
162    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
163    where T: Serialize + DeserializeOwned + Display + Clone,
164          Self: Sized;
165
166    /// Update a DNS record by host and record type.
167    fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
168    where T: Serialize + DeserializeOwned + Display + Clone,
169          Self: Sized;
170
171    /// Delete a DNS record by host and record type.
172    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
173    where Self: Sized;
174
175    /// Get a TXT record.
176    ///
177    /// This is a helper method that calls `get_record` with the `TXT` record type.
178    fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
179
180    /// Create a new TXT record.
181    ///
182    /// This is a helper method that calls `create_record` with the `TXT` record type.
183    fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
184
185    /// Update a TXT record.
186    ///
187    /// This is a helper method that calls `update_record` with the `TXT` record type.
188    fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
189
190    /// Delete a TXT record.
191    ///
192    /// This is a helper method that calls `delete_record` with the `TXT` record type.
193    fn delete_txt_record(&self, host: &str) -> Result<()>;
194
195    /// Get an A record.
196    ///
197    /// This is a helper method that calls `get_record` with the `A` record type.
198    fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
199
200    /// Create a new A record.
201    ///
202    /// This is a helper method that calls `create_record` with the `A` record type.
203    fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
204
205    /// Update an A record.
206    ///
207    /// This is a helper method that calls `update_record` with the `A` record type.
208    fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
209
210    /// Delete an A record.
211    ///
212    /// This is a helper method that calls `delete_record` with the `A` record type.
213    fn delete_a_record(&self, host: &str) -> Result<()>;
214}
215
216/// A macro to generate default helper implementations for provider impls.
217///
218/// The reason for this macro is that traits don't play well with
219/// generics and Sized, preventing us from providing default
220/// implementations in the trait. There are ways around this, but they
221/// either involve messy downcasting or lots of match arms that need
222/// to be updated as providers are added. As we want to keep the
223/// process of adding providers as self-contained as possible this is
224/// the simplest method for now.
225#[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        // Empty and whitespace-only strings become empty quoted strings
341        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        // Whitespace within content is preserved
345        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        // Create
362        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        // Update
371        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        // Delete
380        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        // Create
393        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        // Update
400        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        // Delete
407        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        // Create
419        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        // Update
426        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        // Delete
433        client.delete_txt_record(&host)?;
434        let del = client.get_txt_record(&host)?;
435        assert!(del.is_none());
436
437        Ok(())
438    }
439
440    /// A macro to generate a standard set of tests for a DNS provider.
441    ///
442    /// This macro generates three tests:
443    /// - `create_update_v4`: tests creating, updating, and deleting an A record.
444    /// - `create_update_txt`: tests creating, updating, and deleting a TXT record.
445    /// - `create_update_default`: tests creating, updating, and deleting a TXT record using the default provider methods.
446    ///
447    /// The tests are conditionally compiled based on the feature flag passed as an argument.
448    ///
449    /// # Requirements
450    ///
451    /// The module that uses this macro must define a `get_client()` function that returns a type
452    /// that implements the `DnsProvider` trait. This function is used by the tests to get a client
453    /// for the DNS provider.
454    ///
455    /// # Arguments
456    ///
457    /// * `$feat` - A string literal representing the feature flag that enables these tests.
458    ///
459    /// # Example
460    ///
461    /// ```
462    /// // In your test module
463    /// use zone_update::{generate_tests, DnsProvider};
464    ///
465    /// fn get_client() -> impl DnsProvider {
466    ///     // ... your client implementation
467    /// }
468    ///
469    /// // This will generate the tests, but they will only run if the "my_provider" feature is enabled.
470    /// generate_tests!("my_provider");
471    /// ```
472    #[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}