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::{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    // ---
34    // Library functions
35
36    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    // ---
129    // Utility functions
130
131    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// -----------
150// Data types
151
152#[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    /// Tests `self` and `other`'s DNS record type to be equal
242    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// -----------
274// API Responses
275
276#[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}