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