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#[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
34pub struct Bunny {
38 config: Config,
39 auth: Auth,
40 zone_id: Mutex<Option<u64>>,
41}
42
43impl Bunny {
44
45 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 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 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}