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