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