Skip to main content

dns_update/providers/
bunny.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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    // ---
37    // Library functions
38
39    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    // ---
134    // Utility functions
135
136    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// -----------
155// Data types
156
157#[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    /// Tests `self` and `other`'s DNS record type to be equal
247    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// -----------
279// API Responses
280
281#[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}