zone_edit/dnsimple/
mod.rs

1// FIXME
2#![allow(unused)]
3
4mod types;
5
6use std::{net::Ipv4Addr, sync::Arc};
7use async_lock::Mutex;
8use cfg_if::cfg_if;
9use hyper::Uri;
10use serde::de::DeserializeOwned;
11use tracing::{error, info, warn};
12
13
14use crate::{dnsimple::types::{Accounts, CreateRecord, GetRecord, Records, UpdateRecord}, errors::{Error, Result}, http, Config, DnsProvider, RecordType};
15
16
17const API_BASE: &str = "https:://api.dnsimple.com/v2";
18
19pub struct Auth {
20    key: String,
21}
22
23impl Auth {
24    fn get_header(&self) -> String {
25        format!("Bearer {}", self.key)
26    }
27}
28
29struct DnSimple {
30    config: Config,
31    endpoint: &'static str,
32    auth: Auth,
33    acc_id: Mutex<Option<u32>>,
34}
35
36impl DnSimple {
37    pub fn new(config: Config, auth: Auth, acc: Option<u32>) -> Self {
38        Self::new_with_endpoint(config, auth, acc, API_BASE)
39    }
40
41    fn new_with_endpoint(config: Config, auth: Auth, acc: Option<u32>, endpoint: &'static str) -> Self {
42        let acc_id = Mutex::new(acc);
43        DnSimple {
44            config,
45            endpoint,
46            auth,
47            acc_id,
48        }
49    }
50
51    async fn get_upstream_id(&self) -> Result<u32> {
52        info!("Fetching account ID from upstream");
53        let endpoint = format!("{}/accounts", self.endpoint);
54        let uri = endpoint.parse()
55            .map_err(|e| Error::UrlError(format!("Error: {endpoint} -> {e}")))?;
56
57        let accounts_p = http::get::<Accounts>(uri, Some(self.auth.get_header())).await?;
58
59        match accounts_p {
60            Some(accounts) if accounts.accounts.len() == 1 => {
61                Ok(accounts.accounts[0].id)
62            }
63            Some(accounts) if accounts.accounts.len() > 1 => {
64                Err(Error::ApiError("More than one account returned; you must specify the account ID to use".to_string()))
65            }
66            // None or 0 accounts => {
67            _ => {
68                Err(Error::ApiError("No accounts returned from upstream".to_string()))
69            }
70        }
71    }
72
73    async fn get_id(&self) -> Result<u32> {
74        // This is roughly equivalent to OnceLock.get_or_init(), but
75        // is simpler than dealing with closure->Result and is more
76        // portable.
77        let mut id_p = self.acc_id.lock().await;
78
79        if let Some(id) = *id_p {
80            return Ok(id);
81        }
82
83        let id = self.get_upstream_id().await?;
84        *id_p = Some(id);
85
86        Ok(id)
87    }
88
89    async fn get_record(&self, host: &str, rtype: RecordType) -> Result<Option<GetRecord>>
90    {
91        let acc_id = self.get_id().await?;
92
93        let url = format!("{}/{acc_id}/zones/{}/records?name={host}&type={rtype}", self.endpoint, self.config.domain)
94            .parse()
95            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
96
97        let auth = self.auth.get_header();
98        let recs: Records = match http::get(url, Some(auth)).await? {
99            Some(rec) => rec,
100            None => return Ok(None)
101        };
102
103        // FIXME: Assumes no or single address (which probably makes sense
104        // for DDNS, but may cause issues with malformed zones.
105        let nr = recs.records.len();
106        if nr > 1 {
107            error!("Returned number of IPs is {}, should be 1", nr);
108            return Err(Error::UnexpectedRecord(format!("Returned number of IPs is {nr}, should be 1")));
109        } else if nr == 0 {
110            warn!("No IP returned for {host}, continuing");
111            return Ok(None);
112        }
113
114        Ok(Some(recs.records[0].clone()))
115    }
116}
117
118
119impl DnsProvider for DnSimple {
120
121    async  fn get_v4_record(&self, host: &str) -> Result<Option<Ipv4Addr> > {
122        let rec: GetRecord = match self.get_record(host, RecordType::A).await? {
123            Some(recs) => recs,
124            None => return Ok(None)
125        };
126
127
128        Ok(Some(rec.content))
129    }
130
131    async  fn create_v4_record(&self, host: &str, ip: &Ipv4Addr) -> Result<()> {
132        let acc_id = self.get_id().await?;
133
134        let url = format!("{}/{acc_id}/zones/{}/records", self.endpoint, self.config.domain)
135            .parse()
136            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
137        let auth = self.auth.get_header();
138
139        let rec = CreateRecord {
140            name: host.to_string(),
141            rtype: RecordType::A,
142            content: ip.to_string(),
143            ttl: 300,
144        };
145        if self.config.dry_run {
146            info!("DRY-RUN: Would have sent {rec:?} to {url}");
147            return Ok(())
148        }
149        http::post::<CreateRecord>(url, &rec, Some(auth)).await?;
150
151        Ok(())
152    }
153
154    async  fn update_v4_record(&self, host: &str, ip: &Ipv4Addr) -> Result<()> {
155        let rec = match self.get_record(host, RecordType::A).await? {
156            Some(rec) => rec,
157            None => {
158                warn!("DELETE: Record {host} doesn't exist");
159                return Ok(());
160            }
161        };
162
163        let acc_id = self.get_id().await?;
164        let rid = rec.id;
165
166        let update = UpdateRecord {
167            content: ip.to_string(),
168        };
169
170        let url = format!("{}/{acc_id}/zones/{}/records/{rid}", self.endpoint, self.config.domain)
171            .parse()
172            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
173        if self.config.dry_run {
174            info!("DRY-RUN: Would have sent PATCH to {url}");
175            return Ok(())
176        }
177
178        let auth = self.auth.get_header();
179        http::patch(url, &update, Some(auth)).await?;
180
181        Ok(())
182    }
183
184    async  fn delete_v4_record(&self, host: &str) -> Result<()> {
185        let rec = match self.get_record(host, RecordType::A).await? {
186            Some(rec) => rec,
187            None => {
188                warn!("DELETE: Record {host} doesn't exist");
189                return Ok(());
190            }
191        };
192
193        let acc_id = self.get_id().await?;
194        let rid = rec.id;
195
196        let url = format!("{}/{acc_id}/zones/{}/records/{rid}", self.endpoint, self.config.domain)
197            .parse()
198            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
199        if self.config.dry_run {
200            info!("DRY-RUN: Would have sent DELETE to {url}");
201            return Ok(())
202        }
203
204        let auth = self.auth.get_header();
205        http::delete(url, Some(auth)).await?;
206
207        Ok(())
208    }
209}
210
211
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::env;
217    use random_string::charsets::ALPHANUMERIC;
218    use tracing_test::traced_test;
219
220    const TEST_API: &str = "https://api.sandbox.dnsimple.com/v2";
221
222    fn get_client() -> DnSimple {
223        let auth = Auth { key: env::var("DNSIMPLE_TOKEN").unwrap() };
224        let config = Config {
225            domain: env::var("DNSIMPLE_TEST_DOMAIN").unwrap(),
226            dry_run: false,
227        };
228        DnSimple::new_with_endpoint(config, auth, None, TEST_API)
229    }
230
231    async fn test_id_fetch() -> Result<()> {
232        let client = get_client();
233
234        let id = client.get_upstream_id().await?;
235        assert_eq!(2602, id);
236
237        Ok(())
238    }
239
240    // TODO: This is generic, we could move it up to top-level testing.
241    async fn test_create_update_delete_ipv4() -> Result<()> {
242        let client = get_client();
243
244        let host = random_string::generate(16, ALPHANUMERIC);
245
246        // Create
247        let ip = "1.1.1.1".parse()?;
248        client.create_v4_record(&host, &ip).await?;
249        let cur = client.get_v4_record(&host).await?;
250        assert_eq!(Some(ip), cur);
251
252
253        // Update
254        let ip = "2.2.2.2".parse()?;
255        client.update_v4_record(&host, &ip).await?;
256        let cur = client.get_v4_record(&host).await?;
257        assert_eq!(Some(ip), cur);
258
259
260        // Delete
261        client.delete_v4_record(&host).await?;
262        let del = client.get_v4_record(&host).await?;
263        assert!(del.is_none());
264
265        Ok(())
266    }
267
268
269    #[cfg(feature = "smol")]
270    mod smol {
271        use super::*;
272        use macro_rules_attribute::apply;
273        use smol_macros::test;
274
275        #[apply(test!)]
276        #[traced_test]
277        #[cfg_attr(not(feature = "test_dnsimple"), ignore = "DnSimple API test")]
278        async fn smol_id_fetch() -> Result<()> {
279            test_id_fetch().await
280        }
281
282
283        #[apply(test!)]
284        #[traced_test]
285        #[cfg_attr(not(feature = "test_dnsimple"), ignore = "DnSimple API test")]
286        async fn smol_create_update() -> Result<()> {
287            test_create_update_delete_ipv4().await
288        }
289    }
290
291    #[cfg(feature = "tokio")]
292    mod smol {
293        use super::*;
294
295        #[tokio::test]
296        #[traced_test]
297        #[cfg_attr(not(feature = "test_dnsimple"), ignore = "DnSimple API test")]
298        async fn tokio_id_fetch() -> Result<()> {
299            test_id_fetch().await
300        }
301
302        #[tokio::test]
303        #[traced_test]
304        #[cfg_attr(not(feature = "test_dnsimple"), ignore = "DnSimple API test")]
305        async fn tokio_create_update() -> Result<()> {
306            test_create_update_delete_ipv4().await
307        }
308    }
309
310
311}
312