Skip to main content

zone_update/dnsimple/
mod.rs

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