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