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