Skip to main content

zone_update/bunny/
mod.rs

1mod types;
2
3use std::{fmt::{Debug, Display}, sync::Mutex};
4
5use serde::{de::DeserializeOwned, Deserialize};
6use tracing::{error, info, warn};
7
8use crate::{
9    Config, DnsProvider, RecordType,
10    bunny::types::{CreateUpdate, Record, ZoneInfo, ZoneList},
11    errors::{Error, Result},
12    generate_helpers,
13    http::{self, ResponseToOption, WithHeaders},
14};
15
16const API_BASE: &str = "https://api.bunny.net/dnszone";
17
18
19/// Authentication credentials for the Bunny API.
20///
21/// Contains the API key and secret required for requests.
22#[derive(Clone, Debug, Deserialize)]
23pub struct Auth {
24    pub key: String,
25}
26
27impl Auth {
28    fn get_header(&self) -> String {
29         self.key.clone()
30    }
31}
32
33
34/// Synchronous Bunny DNS provider implementation.
35///
36/// Holds configuration and authentication state for performing API calls.
37pub struct Bunny {
38    config: Config,
39    auth: Auth,
40    zone_id: Mutex<Option<u64>>,
41}
42
43impl Bunny {
44
45    /// Create a new `Bunny` provider instance.
46    pub fn new(config: Config, auth: Auth) -> Self {
47        Self {
48            config,
49            auth,
50            zone_id: Mutex::new(None),
51        }
52    }
53
54
55    fn get_zone_id(&self) -> Result<u64> {
56        let mut id_p = self.zone_id.lock()
57            .map_err(|e| Error::LockingError(e.to_string()))?;
58
59        if let Some(id) = id_p.as_ref() {
60            return Ok(*id);
61        }
62
63        let zone = self.get_zone_info()?;
64        let id = zone.id;
65        *id_p = Some(id);
66
67        Ok(id)
68    }
69
70    fn get_zone_info(&self) -> Result<ZoneInfo> {
71        let uri = format!("{API_BASE}?search={}", self.config.domain);
72        let zones = http::client()
73            .get(uri)
74            .with_json_headers()
75            .header("AccessKey", self.auth.get_header())
76            .call()?
77            .to_option::<ZoneList>()?
78            .ok_or(Error::RecordNotFound(format!("Couldn't fetch zone info for {}", self.config.domain)))?
79            .items;
80        let zone = zones.into_iter()
81            .find(|z| z.domain == self.config.domain)
82            .ok_or(Error::RecordNotFound(format!("Couldn't fetch zone info for {}", self.config.domain)))?;
83
84        Ok(zone)
85    }
86
87    fn get_upstream_records<T>(&self, rtype: RecordType, host: &str) -> Result<Vec<Record<T>>>
88    where
89        T: DeserializeOwned
90    {
91        println!("GET UPSTREAM {rtype}, {host}");
92        let zone_id = self.get_zone_id()?;
93        let url = format!("{API_BASE}/{zone_id}");
94
95        let mut response = http::client().get(url)
96            .header("AccessKey", self.auth.get_header())
97            .with_json_headers()
98            .call()?;
99
100        // Bunny returns *all* records, with no ability to filter by
101        // type, resulting in a mixed-type array. To work around this
102        // we filter on the raw json values before deserialising
103        // properly.
104        let body = response.body_mut().read_to_string()?;
105        let u64rtype = u64::from(rtype);
106
107        let values: serde_json::Value = serde_json::from_str(&body)?;
108        let data = values["Records"].as_array()
109            .ok_or(Error::ApiError("Data field not found".to_string()))?;
110        let records = data.iter()
111            .filter_map(|obj| match &obj["Type"] {
112                serde_json::Value::Number(n)
113                    if n.as_u64().is_some_and(|v| v == u64rtype) && obj["Name"] == host
114                    => Some(serde_json::from_value(obj.clone())),
115                _ => None,
116            })
117            .collect::<std::result::Result<Vec<Record<T>>, _>>()?;
118        println!("DONE");
119
120        Ok(records)
121    }
122
123    fn get_upstream_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<Record<T>>>
124    where
125        T: DeserializeOwned
126    {
127        let mut recs = self.get_upstream_records(rtype, host)?;
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.len();
133        if nr > 1 {
134            error!("Returned number of records 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 record returned for {host}, continuing");
138            return Ok(None);
139        }
140
141        Ok(Some(recs.remove(0)))
142    }
143
144    fn do_delete(&self, rec: Record<String>) -> Result<()> {
145        let zone_id = self.get_zone_id()?;
146        let url = format!("{API_BASE}/{zone_id}/records/{}", rec.id);
147        if self.config.dry_run {
148            info!("DRY-RUN: Would have sent DELETE to {url}");
149            return Ok(())
150        }
151
152        info!("Deleting DNS {} record {}", rec.rtype, rec.name);
153        http::client().delete(url)
154            .with_json_headers()
155            .header("AccessKey", self.auth.get_header())
156            .call()?;
157
158        Ok(())
159    }
160
161}
162
163
164impl DnsProvider for Bunny {
165
166    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
167    where
168        T: DeserializeOwned
169    {
170        let resp = self.get_upstream_record(rtype, host)?;
171        let rec: Record<T> = match resp {
172            Some(recs) => recs,
173            None => return Ok(None)
174        };
175        Ok(Some(rec.value))
176    }
177
178    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
179    where
180        T: Display,
181    {
182        let zone_id = self.get_zone_id()?;
183        let url = format!("{API_BASE}/{zone_id}/records");
184
185        let rec = CreateUpdate {
186            name: host.to_string(),
187            rtype,
188            value: record.to_string(),
189            ttl: 300,
190        };
191
192        let body = serde_json::to_string(&rec)?;
193
194        if self.config.dry_run {
195            info!("DRY-RUN: Would have sent {body} to {url}");
196            return Ok(())
197        }
198
199        let _response = http::client().put(url)
200            .with_json_headers()
201            .header("AccessKey", self.auth.get_header())
202            .send(body)?;
203
204        Ok(())
205    }
206
207    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
208    where
209        T: DeserializeOwned + Display,
210    {
211        let rec: Record<T> = match self.get_upstream_record(rtype, host)? {
212            Some(rec) => rec,
213            None => {
214                warn!("UPDATE: Record {host} doesn't exist");
215                return Ok(())
216            }
217        };
218
219        let rec_id = rec.id;
220        let zone_id = self.get_zone_id()?;
221        let url = format!("{API_BASE}/{zone_id}/records/{rec_id}");
222
223        let record = CreateUpdate {
224            name: host.to_string(),
225            rtype,
226            value: urec.to_string(),
227            ttl: 300,
228        };
229
230        if self.config.dry_run {
231            info!("DRY-RUN: Would have sent PUT to {url}");
232            return Ok(())
233        }
234
235        let body = serde_json::to_string(&record)?;
236        http::client().post(url)
237            .with_json_headers()
238            .header("AccessKey", self.auth.get_header())
239            .send(body)?;
240
241        Ok(())
242    }
243
244    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
245    {
246        let rec = match self.get_upstream_record(rtype, host)? {
247            Some(rec) => rec,
248            None => {
249                warn!("DELETE: Record {host} doesn't exist");
250                return Ok(())
251            }
252        };
253
254        self.do_delete(rec)
255    }
256
257    fn delete_all_records(&self, rtype: RecordType, host: &str) -> Result<()>
258    {
259        let recs: Vec<Record<String>> = self.get_upstream_records(rtype, host)?;
260        for rec in recs {
261            self.do_delete(rec)?;
262        }
263
264        Ok(())
265    }
266
267    generate_helpers!();
268
269}
270
271#[cfg(test)]
272pub(crate) mod tests {
273    use super::*;
274    use crate::{generate_tests, tests::*};
275    use std::env;
276
277    fn get_client() -> Bunny {
278        let auth = Auth {
279            key: env::var("BUNNY_API_KEY").unwrap(),
280        };
281        let config = Config {
282            domain: env::var("BUNNY_TEST_DOMAIN").unwrap(),
283            dry_run: false,
284        };
285        Bunny::new(config, auth)
286    }
287
288    generate_tests!("test_bunny");
289}