zone_update/dnsimple/
mod.rs

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