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_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<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 mut recs: Records<T> = match response {
124            Some(rec) => rec,
125            None => return Ok(None)
126        };
127
128        // FIXME: Assumes no or single address (which probably makes
129        // sense for DDNS and DNS-01, but may cause issues with
130        // malformed zones).
131        let nr = recs.records.len();
132        if nr > 1 {
133            error!("Returned number of IPs is {}, should be 1", nr);
134            return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
135        } else if nr == 0 {
136            warn!("No IP returned for {host}, continuing");
137            return Ok(None);
138        }
139
140        Ok(Some(recs.records.remove(0)))
141    }
142
143}
144
145
146impl DnsProvider for Dnsimple {
147
148    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T> >
149    where
150        T: DeserializeOwned
151    {
152        let rec: GetRecord<T> = match self.get_upstream_record(rtype, host)? {
153            Some(recs) => recs,
154            None => return Ok(None)
155        };
156
157
158        Ok(Some(rec.content))
159    }
160
161    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
162    where
163        T: Display,
164    {
165        let acc_id = self.get_id()?;
166
167        let url = format!("{}/{acc_id}/zones/{}/records", self.endpoint, self.config.domain);
168
169        let rec = CreateRecord {
170            name: host.to_string(),
171            rtype,
172            content: record.to_string(),
173            ttl: 300,
174        };
175
176        if self.config.dry_run {
177            info!("DRY-RUN: Would have sent {rec:?} to {url}");
178            return Ok(())
179        }
180
181        let body = serde_json::to_string(&rec)?;
182        http::client().post(url)
183            .with_json_headers()
184            .with_auth(self.auth.get_header())
185            .send(body)?;
186
187        Ok(())
188    }
189
190    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
191    where
192        T: DeserializeOwned + Display,
193    {
194        let rec: GetRecord<T> = match self.get_upstream_record(rtype, host)? {
195            Some(rec) => rec,
196            None => {
197                warn!("DELETE: Record {host} doesn't exist");
198                return Ok(());
199            }
200        };
201
202        let acc_id = self.get_id()?;
203        let rid = rec.id;
204
205        let update = UpdateRecord {
206            content: urec.to_string(),
207        };
208
209        let url = format!("{}/{acc_id}/zones/{}/records/{rid}", self.endpoint, self.config.domain);
210        if self.config.dry_run {
211            info!("DRY-RUN: Would have sent PATCH to {url}");
212            return Ok(())
213        }
214
215
216        let body = serde_json::to_string(&update)?;
217        http::client().patch(url)
218            .with_json_headers()
219            .with_auth(self.auth.get_header())
220            .send(body)?;
221
222        Ok(())
223    }
224
225    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()> {
226        let rec: GetRecord<String> = match self.get_upstream_record(rtype, host)? {
227            Some(rec) => rec,
228            None => {
229                warn!("DELETE: Record {host} doesn't exist");
230                return Ok(());
231            }
232        };
233
234        let acc_id = self.get_id()?;
235        let rid = rec.id;
236
237        let url = format!("{}/{acc_id}/zones/{}/records/{rid}", self.endpoint, self.config.domain);
238        if self.config.dry_run {
239            info!("DRY-RUN: Would have sent DELETE to {url}");
240            return Ok(())
241        }
242
243        http::client().delete(url)
244            .with_json_headers()
245            .with_auth(self.auth.get_header())
246            .call()?;
247
248        Ok(())
249    }
250
251    generate_helpers!();
252}
253
254
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::{generate_tests, tests::*};
260    use std::env;
261
262    const TEST_API: &str = "https://api.sandbox.dnsimple.com/v2";
263
264    fn get_client() -> Dnsimple {
265        let auth = Auth { key: env::var("DNSIMPLE_TOKEN").unwrap() };
266        let config = Config {
267            domain: env::var("DNSIMPLE_TEST_DOMAIN").unwrap(),
268            dry_run: false,
269        };
270        Dnsimple::new_with_endpoint(config, auth, None, TEST_API)
271    }
272
273    #[test_log::test]
274    #[cfg_attr(not(feature = "test_dnsimple"), ignore = "Dnsimple API test")]
275    fn test_id_fetch() -> Result<()> {
276        let client = get_client();
277
278        let id = client.get_upstream_id()?;
279        assert_eq!(2602, id);
280
281        Ok(())
282    }
283
284    generate_tests!("test_dnsimple");
285}