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