Skip to main content

dns_update/providers/
safedns.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::time::Duration;
15
16const DEFAULT_API_ENDPOINT: &str = "https://api.ukfast.io/safedns/v1";
17
18#[derive(Clone)]
19pub struct SafeDnsProvider {
20    client: HttpClientBuilder,
21    endpoint: String,
22}
23
24#[derive(Serialize, Debug, Clone)]
25pub struct SafeDnsRecordPayload<'a> {
26    pub name: &'a str,
27    #[serde(rename = "type")]
28    pub record_type: &'a str,
29    pub content: String,
30    pub ttl: u32,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub priority: Option<u16>,
33}
34
35#[derive(Deserialize, Debug, Clone)]
36pub struct SafeDnsRecord {
37    pub id: i64,
38    pub name: String,
39    #[serde(rename = "type")]
40    pub record_type: String,
41}
42
43#[derive(Deserialize, Debug)]
44pub struct ListRecordsResponse {
45    pub data: Vec<SafeDnsRecord>,
46}
47
48#[derive(Deserialize, Debug)]
49pub struct AddRecordResponse {
50    #[allow(dead_code)]
51    pub data: SafeDnsRecord,
52}
53
54pub struct SafeDnsRecordContent {
55    pub record_type: &'static str,
56    pub content: String,
57    pub priority: Option<u16>,
58}
59
60impl SafeDnsProvider {
61    pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
62        let client = HttpClientBuilder::default()
63            .with_header("Authorization", auth_token.as_ref())
64            .with_timeout(timeout);
65        Self {
66            client,
67            endpoint: DEFAULT_API_ENDPOINT.to_string(),
68        }
69    }
70
71    #[cfg(test)]
72    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
73        Self {
74            endpoint: endpoint.as_ref().to_string(),
75            ..self
76        }
77    }
78
79    pub(crate) async fn create(
80        &self,
81        name: impl IntoFqdn<'_>,
82        record: DnsRecord,
83        ttl: u32,
84        origin: impl IntoFqdn<'_>,
85    ) -> crate::Result<()> {
86        let fqdn = name.into_name().to_string();
87        let zone = origin.into_name().to_string();
88        let content = SafeDnsRecordContent::try_from(record)?;
89        let body = SafeDnsRecordPayload {
90            name: &fqdn,
91            record_type: content.record_type,
92            content: content.content,
93            ttl,
94            priority: content.priority,
95        };
96
97        self.client
98            .post(format!(
99                "{endpoint}/zones/{zone}/records",
100                endpoint = self.endpoint
101            ))
102            .with_body(&body)?
103            .send_raw()
104            .await
105            .map(|_| ())
106    }
107
108    pub(crate) async fn update(
109        &self,
110        name: impl IntoFqdn<'_>,
111        record: DnsRecord,
112        ttl: u32,
113        origin: impl IntoFqdn<'_>,
114    ) -> crate::Result<()> {
115        let fqdn = name.into_name().to_string();
116        let zone = origin.into_name().to_string();
117        let record_type = record.as_type();
118        let record_id = self.find_record_id(&zone, &fqdn, record_type).await?;
119        let content = SafeDnsRecordContent::try_from(record)?;
120        let body = SafeDnsRecordPayload {
121            name: &fqdn,
122            record_type: content.record_type,
123            content: content.content,
124            ttl,
125            priority: content.priority,
126        };
127
128        self.client
129            .patch(format!(
130                "{endpoint}/zones/{zone}/records/{record_id}",
131                endpoint = self.endpoint
132            ))
133            .with_body(&body)?
134            .send_raw()
135            .await
136            .map(|_| ())
137    }
138
139    pub(crate) async fn delete(
140        &self,
141        name: impl IntoFqdn<'_>,
142        origin: impl IntoFqdn<'_>,
143        record_type: DnsRecordType,
144    ) -> crate::Result<()> {
145        let fqdn = name.into_name().to_string();
146        let zone = origin.into_name().to_string();
147        let record_id = self.find_record_id(&zone, &fqdn, record_type).await?;
148
149        self.client
150            .delete(format!(
151                "{endpoint}/zones/{zone}/records/{record_id}",
152                endpoint = self.endpoint
153            ))
154            .send_raw()
155            .await
156            .map(|_| ())
157    }
158
159    async fn find_record_id(
160        &self,
161        zone: &str,
162        name: &str,
163        record_type: DnsRecordType,
164    ) -> crate::Result<i64> {
165        let response: ListRecordsResponse = self
166            .client
167            .get(format!(
168                "{endpoint}/zones/{zone}/records",
169                endpoint = self.endpoint
170            ))
171            .send()
172            .await?;
173        let type_str = record_type.as_str();
174        response
175            .data
176            .into_iter()
177            .find(|r| r.name == name && r.record_type == type_str)
178            .map(|r| r.id)
179            .ok_or_else(|| {
180                Error::Api(format!(
181                    "DNS Record {name} of type {type_str} not found"
182                ))
183            })
184    }
185}
186
187impl TryFrom<DnsRecord> for SafeDnsRecordContent {
188    type Error = Error;
189
190    fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
191        match record {
192            DnsRecord::A(addr) => Ok(SafeDnsRecordContent {
193                record_type: "A",
194                content: addr.to_string(),
195                priority: None,
196            }),
197            DnsRecord::AAAA(addr) => Ok(SafeDnsRecordContent {
198                record_type: "AAAA",
199                content: addr.to_string(),
200                priority: None,
201            }),
202            DnsRecord::CNAME(target) => Ok(SafeDnsRecordContent {
203                record_type: "CNAME",
204                content: target,
205                priority: None,
206            }),
207            DnsRecord::NS(target) => Ok(SafeDnsRecordContent {
208                record_type: "NS",
209                content: target,
210                priority: None,
211            }),
212            DnsRecord::MX(mx) => Ok(SafeDnsRecordContent {
213                record_type: "MX",
214                content: mx.exchange,
215                priority: Some(mx.priority),
216            }),
217            DnsRecord::TXT(text) => Ok(SafeDnsRecordContent {
218                record_type: "TXT",
219                content: format!("\"{text}\""),
220                priority: None,
221            }),
222            DnsRecord::SRV(srv) => Ok(SafeDnsRecordContent {
223                record_type: "SRV",
224                content: format!("{} {} {} {}", srv.priority, srv.weight, srv.port, srv.target),
225                priority: Some(srv.priority),
226            }),
227            DnsRecord::TLSA(tlsa) => Ok(SafeDnsRecordContent {
228                record_type: "TLSA",
229                content: tlsa.to_string(),
230                priority: None,
231            }),
232            DnsRecord::CAA(caa) => Ok(SafeDnsRecordContent {
233                record_type: "CAA",
234                content: caa.to_string(),
235                priority: None,
236            }),
237        }
238    }
239}