1use 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}