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