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