zone_update/bunny/
mod.rs

1mod types;
2
3use std::{fmt::{Debug, Display}, sync::Mutex};
4
5use serde::{de::DeserializeOwned, Deserialize};
6use tracing::{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.clone());
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            .filter(|z| z.domain == self.config.domain)
82            .next()
83            .ok_or(Error::RecordNotFound(format!("Couldn't fetch zone info for {}", self.config.domain)))?;
84
85        Ok(zone)
86    }
87
88    fn get_upstream_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<Record<T>>>
89    where
90        T: DeserializeOwned
91    {
92        println!("GET UPSTREAM {rtype}, {host}");
93        let zone_id = self.get_zone_id()?;
94        let url = format!("{API_BASE}/{zone_id}");
95
96        let mut response = http::client().get(url)
97            .header("AccessKey", self.auth.get_header())
98            .with_json_headers()
99            .call()?;
100
101        // Bunny returns *all* records, with no ability to filter by
102        // type, resulting in a mixed-type array. To work around this
103        // we filter on the raw json values before deserialising
104        // properly.
105        let body = response.body_mut().read_to_string()?;
106        let u64rtype = u64::from(rtype);
107
108        let values: serde_json::Value = serde_json::from_str(&body)?;
109        let data = values["Records"].as_array()
110            .ok_or(Error::ApiError("Data field not found".to_string()))?;
111        let record = data.into_iter()
112            .filter_map(|obj| match &obj["Type"] {
113                serde_json::Value::Number(n)
114                    if n.as_u64().is_some_and(|v| v == u64rtype) && obj["Name"] == host
115                    => Some(serde_json::from_value(obj.clone())),
116                _ => None,
117            })
118            .next()
119            .transpose()?;
120        println!("DONE");
121
122        Ok(record)
123    }
124
125}
126
127
128impl DnsProvider for Bunny {
129
130    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
131    where
132        T: DeserializeOwned
133    {
134        let resp = self.get_upstream_record(rtype, host)?;
135        let rec: Record<T> = match resp {
136            Some(recs) => recs,
137            None => return Ok(None)
138        };
139        Ok(Some(rec.value))
140    }
141
142    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
143    where
144        T: Display,
145    {
146        let zone_id = self.get_zone_id()?;
147        let url = format!("{API_BASE}/{zone_id}/records");
148
149        let rec = CreateUpdate {
150            name: host.to_string(),
151            rtype,
152            value: record.to_string(),
153            ttl: 300,
154        };
155
156        let body = serde_json::to_string(&rec)?;
157
158        if self.config.dry_run {
159            info!("DRY-RUN: Would have sent {body} to {url}");
160            return Ok(())
161        }
162
163        let _response = http::client().put(url)
164            .with_json_headers()
165            .header("AccessKey", self.auth.get_header())
166            .send(body)?;
167
168        Ok(())
169    }
170
171    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
172    where
173        T: DeserializeOwned + Display,
174    {
175        let rec: Record<T> = match self.get_upstream_record(rtype, host)? {
176            Some(rec) => rec,
177            None => {
178                warn!("UPDATE: Record {host} doesn't exist");
179                return Ok(())
180            }
181        };
182
183        let rec_id = rec.id;
184        let zone_id = self.get_zone_id()?;
185        let url = format!("{API_BASE}/{zone_id}/records/{rec_id}");
186
187        let record = CreateUpdate {
188            name: host.to_string(),
189            rtype: rtype,
190            value: urec.to_string(),
191            ttl: 300,
192        };
193
194        if self.config.dry_run {
195            info!("DRY-RUN: Would have sent PUT to {url}");
196            return Ok(())
197        }
198
199        let body = serde_json::to_string(&record)?;
200        http::client().post(url)
201            .with_json_headers()
202            .header("AccessKey", self.auth.get_header())
203            .send(body)?;
204
205        Ok(())
206    }
207
208    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
209    {
210        let rec: Record<String> = match self.get_upstream_record(rtype, host)? {
211            Some(rec) => rec,
212            None => {
213                warn!("DELETE: Record {host} doesn't exist");
214                return Ok(())
215            }
216        };
217
218        let rec_id = rec.id;
219        let zone_id = self.get_zone_id()?;
220        let url = format!("{API_BASE}/{zone_id}/records/{rec_id}");
221
222        if self.config.dry_run {
223            info!("DRY-RUN: Would have sent DELETE to {url}");
224            return Ok(())
225        }
226
227        http::client().delete(url)
228            .with_json_headers()
229            .header("AccessKey", self.auth.get_header())
230            .call()?;
231
232        Ok(())
233
234    }
235
236    generate_helpers!();
237
238}
239
240#[cfg(test)]
241pub(crate) mod tests {
242    use super::*;
243    use crate::{generate_tests, tests::*};
244    use std::env;
245
246    fn get_client() -> Bunny {
247        let auth = Auth {
248            key: env::var("BUNNY_API_KEY").unwrap(),
249        };
250        let config = Config {
251            domain: env::var("BUNNY_TEST_DOMAIN").unwrap(),
252            dry_run: false,
253        };
254        Bunny::new(config, auth)
255    }
256
257    generate_tests!("test_bunny");
258}