zone_update/
lib.rs

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