zone_update/
lib.rs

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