zone_update/
lib.rs

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