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#[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.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 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}