1use crate::{DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder};
13use serde::{Deserialize, Serialize};
14use std::{
15 net::{Ipv4Addr, Ipv6Addr},
16 time::Duration,
17};
18
19#[derive(Clone)]
20pub struct BunnyProvider {
21 client: HttpClientBuilder,
22}
23
24impl BunnyProvider {
25 pub(crate) fn new(api_key: impl AsRef<str>, timeout: Option<Duration>) -> crate::Result<Self> {
26 Ok(Self {
27 client: HttpClientBuilder::default()
28 .with_header("AccessKey", api_key.as_ref())
29 .with_timeout(timeout),
30 })
31 }
32
33 pub(crate) async fn create(
37 &self,
38 name: impl IntoFqdn<'_>,
39 record: DnsRecord,
40 ttl: u32,
41 origin: impl IntoFqdn<'_>,
42 ) -> crate::Result<()> {
43 let zone_id = self.get_zone_data(origin).await?.id;
44 let name = name.into_name();
45 let (flags, tag) = extract_caa_fields(&record);
46 let body = DnsRecordData {
47 name: name.into(),
48 record_type: (&record).into(),
49 ttl: Some(ttl),
50 flags,
51 tag,
52 };
53
54 self.client
55 .put(format!("https://api.bunny.net/dnszone/{zone_id}/records"))
56 .with_body(&body)?
57 .send_with_retry::<BunnyDnsRecord>(3)
58 .await
59 .map(|_| ())
60 }
61
62 pub(crate) async fn update(
63 &self,
64 name: impl IntoFqdn<'_>,
65 record: DnsRecord,
66 ttl: u32,
67 origin: impl IntoFqdn<'_>,
68 ) -> crate::Result<()> {
69 let name = name.into_name();
70
71 let zone_data = self.get_zone_data(origin).await?;
72 let zone_id = zone_data.id;
73 let bunny_record = zone_data
74 .records
75 .iter()
76 .find(|r| r.record.name == name && r.record.record_type.eq_type(&record))
77 .ok_or(Error::NotFound)?;
78
79 self.client
80 .post(format!(
81 "https://api.bunny.net/dnszone/{zone_id}/records/{}",
82 bunny_record.id
83 ))
84 .with_body({
85 let (flags, tag) = extract_caa_fields(&record);
86 BunnyDnsRecord {
87 id: bunny_record.id,
88 record: DnsRecordData {
89 name: bunny_record.record.name.clone(),
90 record_type: (&record).into(),
91 ttl: Some(ttl),
92 flags,
93 tag,
94 },
95 }
96 })?
97 .send_with_retry::<serde_json::Value>(3)
98 .await
99 .map(|_| ())
100 }
101
102 pub(crate) async fn delete(
103 &self,
104 name: impl IntoFqdn<'_>,
105 origin: impl IntoFqdn<'_>,
106 record: DnsRecordType,
107 ) -> crate::Result<()> {
108 let name = name.into_name();
109
110 let zone_data = self.get_zone_data(origin).await?;
111 let zone_id = zone_data.id;
112 let record_id = zone_data
113 .records
114 .iter()
115 .find(|r| r.record.name == name && r.record.record_type == record)
116 .map(|r| r.id)
117 .ok_or(Error::NotFound)?;
118
119 self.client
120 .delete(format!(
121 "https://api.bunny.net/dnszone/{zone_id}/records/{record_id}",
122 ))
123 .send_with_retry::<serde_json::Value>(3)
124 .await
125 .map(|_| ())
126 }
127
128 async fn get_zone_data(&self, origin: impl IntoFqdn<'_>) -> crate::Result<PartialDnsZone> {
132 let origin = origin.into_name();
133
134 let query_string = serde_urlencoded::to_string([("search", origin.as_ref())])
135 .expect("Unable to convert DNS origin into HTTP query string");
136 self.client
137 .get(format!("https://api.bunny.net/dnszone?{query_string}"))
138 .send_with_retry::<ApiItems<PartialDnsZone>>(3)
139 .await
140 .and_then(|r| {
141 r.items
142 .into_iter()
143 .find(|z| z.domain == origin.as_ref())
144 .ok_or_else(|| Error::Api(format!("DNS Record {origin} not found")))
145 })
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "Type")]
154#[repr(u8)]
155pub enum BunnyDnsRecordType {
156 #[serde(rename_all = "PascalCase")]
157 A {
158 value: Ipv4Addr,
159 },
160 #[serde(rename_all = "PascalCase")]
161 AAAA {
162 value: Ipv6Addr,
163 },
164 #[serde(rename_all = "PascalCase")]
165 CNAME {
166 value: String,
167 },
168 #[serde(rename_all = "PascalCase")]
169 TXT {
170 value: String,
171 },
172 #[serde(rename_all = "PascalCase")]
173 MX {
174 value: String,
175 priority: u16,
176 },
177 Redirect,
178 Flatten,
179 PullZone,
180 #[serde(rename_all = "PascalCase")]
181 SRV {
182 value: String,
183 priority: u16,
184 port: u16,
185 weight: u16,
186 },
187 #[serde(rename_all = "PascalCase")]
188 CAA {
189 value: String,
190 },
191 PTR,
192 Script,
193 #[serde(rename_all = "PascalCase")]
194 NS {
195 value: String,
196 },
197 SVCB,
198 HTTPS,
199 #[serde(rename_all = "PascalCase")]
200 TLSA {
201 value: String,
202 },
203}
204
205impl From<&DnsRecord> for BunnyDnsRecordType {
206 fn from(record: &DnsRecord) -> Self {
207 match record {
208 DnsRecord::A(content) => BunnyDnsRecordType::A { value: *content },
209 DnsRecord::AAAA(content) => BunnyDnsRecordType::AAAA { value: *content },
210 DnsRecord::CNAME(content) => BunnyDnsRecordType::CNAME {
211 value: content.to_string(),
212 },
213 DnsRecord::NS(content) => BunnyDnsRecordType::NS {
214 value: content.to_string(),
215 },
216 DnsRecord::MX(mx) => BunnyDnsRecordType::MX {
217 value: mx.exchange.to_string(),
218 priority: mx.priority,
219 },
220 DnsRecord::TXT(content) => BunnyDnsRecordType::TXT {
221 value: content.to_string(),
222 },
223 DnsRecord::SRV(srv) => BunnyDnsRecordType::SRV {
224 value: srv.target.to_string(),
225 priority: srv.priority,
226 port: srv.port,
227 weight: srv.weight,
228 },
229 DnsRecord::TLSA(tlsa) => BunnyDnsRecordType::TLSA {
230 value: tlsa.to_string(),
231 },
232 DnsRecord::CAA(caa) => {
233 let (_flags, _tag, value) = caa.clone().decompose();
234 BunnyDnsRecordType::CAA { value }
235 }
236 }
237 }
238}
239
240impl BunnyDnsRecordType {
241 fn eq_type(&self, other: &DnsRecord) -> bool {
243 match other {
244 DnsRecord::A(..) => matches!(self, BunnyDnsRecordType::A { .. }),
245 DnsRecord::AAAA(..) => matches!(self, BunnyDnsRecordType::AAAA { .. }),
246 DnsRecord::CNAME(..) => matches!(self, BunnyDnsRecordType::CNAME { .. }),
247 DnsRecord::NS(..) => matches!(self, BunnyDnsRecordType::NS { .. }),
248 DnsRecord::MX(..) => matches!(self, BunnyDnsRecordType::MX { .. }),
249 DnsRecord::TXT(..) => matches!(self, BunnyDnsRecordType::TXT { .. }),
250 DnsRecord::SRV(..) => matches!(self, BunnyDnsRecordType::SRV { .. }),
251 DnsRecord::TLSA(..) => matches!(self, BunnyDnsRecordType::TLSA { .. }),
252 DnsRecord::CAA(..) => matches!(self, BunnyDnsRecordType::CAA { .. }),
253 }
254 }
255}
256
257impl PartialEq<DnsRecordType> for BunnyDnsRecordType {
258 fn eq(&self, other: &DnsRecordType) -> bool {
259 match other {
260 DnsRecordType::A => matches!(self, BunnyDnsRecordType::A { .. }),
261 DnsRecordType::AAAA => matches!(self, BunnyDnsRecordType::AAAA { .. }),
262 DnsRecordType::CNAME => matches!(self, BunnyDnsRecordType::CNAME { .. }),
263 DnsRecordType::NS => matches!(self, BunnyDnsRecordType::NS { .. }),
264 DnsRecordType::MX => matches!(self, BunnyDnsRecordType::MX { .. }),
265 DnsRecordType::TXT => matches!(self, BunnyDnsRecordType::TXT { .. }),
266 DnsRecordType::SRV => matches!(self, BunnyDnsRecordType::SRV { .. }),
267 DnsRecordType::TLSA => matches!(self, BunnyDnsRecordType::TLSA { .. }),
268 DnsRecordType::CAA => matches!(self, BunnyDnsRecordType::CAA { .. }),
269 }
270 }
271}
272
273#[derive(Deserialize, Clone, Debug)]
277#[serde(rename_all = "PascalCase")]
278pub struct ApiItems<T> {
279 pub items: Vec<T>,
280
281 pub current_page: u32,
282 pub total_items: u32,
283
284 pub has_more_items: bool,
285}
286
287#[derive(Serialize, Deserialize, Clone, Debug)]
288#[serde(rename_all = "PascalCase")]
289pub struct PartialDnsZone {
290 pub id: u32,
291 pub domain: String,
292 pub records: Vec<BunnyDnsRecord>,
293}
294
295#[derive(Serialize, Deserialize, Clone, Debug)]
296#[serde(rename_all = "PascalCase")]
297pub struct BunnyDnsRecord {
298 pub id: u32,
299 #[serde(flatten)]
300 pub record: DnsRecordData,
301}
302
303#[derive(Serialize, Deserialize, Clone, Debug)]
304#[serde(rename_all = "PascalCase")]
305pub struct DnsRecordData {
306 pub name: String,
307
308 #[serde(flatten)]
309 pub record_type: BunnyDnsRecordType,
310
311 #[serde(skip_serializing_if = "Option::is_none")]
312 pub ttl: Option<u32>,
313
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub flags: Option<u8>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub tag: Option<String>,
319}
320
321fn extract_caa_fields(record: &DnsRecord) -> (Option<u8>, Option<String>) {
322 if let DnsRecord::CAA(caa) = record {
323 let (flags, tag, _value) = caa.clone().decompose();
324 (Some(flags), Some(tag))
325 } else {
326 (None, None)
327 }
328}