1use crate::{
13 DnsRecord, DnsRecordType, Error, IntoFqdn, http::HttpClientBuilder,
14 utils::strip_origin_from_name,
15};
16use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
17use serde::{Deserialize, Serialize};
18use std::time::Duration;
19
20const DEFAULT_API_ENDPOINT: &str = "https://api.domeneshop.no/v0";
21
22#[derive(Clone)]
23pub struct DomeneshopProvider {
24 client: HttpClientBuilder,
25 endpoint: String,
26}
27
28#[derive(Deserialize, Debug, Clone)]
29pub struct Domain {
30 pub id: i64,
31 pub domain: String,
32}
33
34#[derive(Serialize, Deserialize, Debug, Clone)]
35pub struct DnsRecordPayload {
36 pub host: String,
37 #[serde(rename = "type")]
38 pub record_type: String,
39 pub data: String,
40 pub ttl: u32,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub priority: Option<u16>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub weight: Option<u16>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub port: Option<u16>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub flags: Option<u8>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub tag: Option<String>,
51}
52
53#[derive(Deserialize, Debug, Clone)]
54pub struct ExistingDnsRecord {
55 pub id: i64,
56 pub host: String,
57 #[serde(rename = "type")]
58 pub record_type: String,
59}
60
61pub struct DomeneshopRecordContent {
62 pub record_type: &'static str,
63 pub data: String,
64 pub priority: Option<u16>,
65 pub weight: Option<u16>,
66 pub port: Option<u16>,
67 pub flags: Option<u8>,
68 pub tag: Option<String>,
69}
70
71impl DomeneshopProvider {
72 pub(crate) fn new(
73 api_token: impl AsRef<str>,
74 api_secret: impl AsRef<str>,
75 timeout: Option<Duration>,
76 ) -> Self {
77 let credentials = format!("{}:{}", api_token.as_ref(), api_secret.as_ref());
78 let encoded = BASE64.encode(credentials.as_bytes());
79 let client = HttpClientBuilder::default()
80 .with_header("Authorization", format!("Basic {encoded}"))
81 .with_timeout(timeout);
82 Self {
83 client,
84 endpoint: DEFAULT_API_ENDPOINT.to_string(),
85 }
86 }
87
88 #[cfg(test)]
89 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
90 Self {
91 endpoint: endpoint.as_ref().to_string(),
92 ..self
93 }
94 }
95
96 pub(crate) async fn create(
97 &self,
98 name: impl IntoFqdn<'_>,
99 record: DnsRecord,
100 ttl: u32,
101 origin: impl IntoFqdn<'_>,
102 ) -> crate::Result<()> {
103 let name = name.into_name();
104 let domain = origin.into_name();
105 let host = strip_origin_from_name(&name, &domain, Some("@"));
106 let domain_id = self.find_domain_id(&domain).await?;
107 let content = DomeneshopRecordContent::try_from(record)?;
108 let body = DnsRecordPayload {
109 host,
110 record_type: content.record_type.to_string(),
111 data: content.data,
112 ttl,
113 priority: content.priority,
114 weight: content.weight,
115 port: content.port,
116 flags: content.flags,
117 tag: content.tag,
118 };
119
120 self.client
121 .post(format!(
122 "{endpoint}/domains/{domain_id}/dns",
123 endpoint = self.endpoint
124 ))
125 .with_body(&body)?
126 .send_raw()
127 .await
128 .map(|_| ())
129 }
130
131 pub(crate) async fn update(
132 &self,
133 name: impl IntoFqdn<'_>,
134 record: DnsRecord,
135 ttl: u32,
136 origin: impl IntoFqdn<'_>,
137 ) -> crate::Result<()> {
138 let name = name.into_name();
139 let domain = origin.into_name();
140 let host = strip_origin_from_name(&name, &domain, Some("@"));
141 let record_type = record.as_type();
142 let domain_id = self.find_domain_id(&domain).await?;
143 let record_id = self
144 .find_record_id(domain_id, &host, record_type)
145 .await?;
146 let content = DomeneshopRecordContent::try_from(record)?;
147 let body = DnsRecordPayload {
148 host,
149 record_type: content.record_type.to_string(),
150 data: content.data,
151 ttl,
152 priority: content.priority,
153 weight: content.weight,
154 port: content.port,
155 flags: content.flags,
156 tag: content.tag,
157 };
158
159 self.client
160 .put(format!(
161 "{endpoint}/domains/{domain_id}/dns/{record_id}",
162 endpoint = self.endpoint
163 ))
164 .with_body(&body)?
165 .send_raw()
166 .await
167 .map(|_| ())
168 }
169
170 pub(crate) async fn delete(
171 &self,
172 name: impl IntoFqdn<'_>,
173 origin: impl IntoFqdn<'_>,
174 record_type: DnsRecordType,
175 ) -> crate::Result<()> {
176 let name = name.into_name();
177 let domain = origin.into_name();
178 let host = strip_origin_from_name(&name, &domain, Some("@"));
179 let domain_id = self.find_domain_id(&domain).await?;
180 let record_id = self.find_record_id(domain_id, &host, record_type).await?;
181
182 self.client
183 .delete(format!(
184 "{endpoint}/domains/{domain_id}/dns/{record_id}",
185 endpoint = self.endpoint
186 ))
187 .send_raw()
188 .await
189 .map(|_| ())
190 }
191
192 async fn find_domain_id(&self, domain: &str) -> crate::Result<i64> {
193 let domains: Vec<Domain> = self
194 .client
195 .get(format!("{endpoint}/domains", endpoint = self.endpoint))
196 .send()
197 .await?;
198 domains
199 .into_iter()
200 .find(|d| d.domain == domain)
201 .map(|d| d.id)
202 .ok_or_else(|| Error::Api(format!("Domain {domain} not found")))
203 }
204
205 async fn find_record_id(
206 &self,
207 domain_id: i64,
208 host: &str,
209 record_type: DnsRecordType,
210 ) -> crate::Result<i64> {
211 let records: Vec<ExistingDnsRecord> = self
212 .client
213 .get(format!(
214 "{endpoint}/domains/{domain_id}/dns",
215 endpoint = self.endpoint
216 ))
217 .send()
218 .await?;
219 let type_str = record_type.as_str();
220 records
221 .into_iter()
222 .find(|r| r.host == host && r.record_type == type_str)
223 .map(|r| r.id)
224 .ok_or_else(|| {
225 Error::Api(format!(
226 "DNS Record {host} of type {type_str} not found"
227 ))
228 })
229 }
230}
231
232impl TryFrom<DnsRecord> for DomeneshopRecordContent {
233 type Error = Error;
234
235 fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
236 match record {
237 DnsRecord::A(addr) => Ok(DomeneshopRecordContent {
238 record_type: "A",
239 data: addr.to_string(),
240 priority: None,
241 weight: None,
242 port: None,
243 flags: None,
244 tag: None,
245 }),
246 DnsRecord::AAAA(addr) => Ok(DomeneshopRecordContent {
247 record_type: "AAAA",
248 data: addr.to_string(),
249 priority: None,
250 weight: None,
251 port: None,
252 flags: None,
253 tag: None,
254 }),
255 DnsRecord::CNAME(target) => Ok(DomeneshopRecordContent {
256 record_type: "CNAME",
257 data: target,
258 priority: None,
259 weight: None,
260 port: None,
261 flags: None,
262 tag: None,
263 }),
264 DnsRecord::NS(target) => Ok(DomeneshopRecordContent {
265 record_type: "NS",
266 data: target,
267 priority: None,
268 weight: None,
269 port: None,
270 flags: None,
271 tag: None,
272 }),
273 DnsRecord::MX(mx) => Ok(DomeneshopRecordContent {
274 record_type: "MX",
275 data: mx.exchange,
276 priority: Some(mx.priority),
277 weight: None,
278 port: None,
279 flags: None,
280 tag: None,
281 }),
282 DnsRecord::TXT(text) => Ok(DomeneshopRecordContent {
283 record_type: "TXT",
284 data: text,
285 priority: None,
286 weight: None,
287 port: None,
288 flags: None,
289 tag: None,
290 }),
291 DnsRecord::SRV(srv) => Ok(DomeneshopRecordContent {
292 record_type: "SRV",
293 data: srv.target,
294 priority: Some(srv.priority),
295 weight: Some(srv.weight),
296 port: Some(srv.port),
297 flags: None,
298 tag: None,
299 }),
300 DnsRecord::TLSA(_) => Err(Error::Api(
301 "TLSA records are not supported by Domeneshop".to_string(),
302 )),
303 DnsRecord::CAA(caa) => {
304 let (flags, tag, value) = caa.decompose();
305 Ok(DomeneshopRecordContent {
306 record_type: "CAA",
307 data: value,
308 priority: None,
309 weight: None,
310 port: None,
311 flags: Some(flags),
312 tag: Some(tag),
313 })
314 }
315 }
316 }
317}