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