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