zone_update/dnsimple/
mod.rs

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