1use crate::{
13 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
14 http::{HttpClient, HttpClientBuilder},
15 utils::strip_origin_from_name,
16};
17use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
18use serde::{Deserialize, Serialize};
19use std::time::Duration;
20
21const DEFAULT_API_ENDPOINT: &str = "https://api.domeneshop.no/v0";
22
23#[derive(Clone)]
24pub struct DomeneshopProvider {
25 client: HttpClient,
26 endpoint: String,
27}
28
29#[derive(Deserialize, Debug, Clone)]
30pub struct Domain {
31 pub id: i64,
32 pub domain: String,
33}
34
35#[derive(Serialize, Deserialize, Debug, Clone)]
36pub struct DnsRecordPayload {
37 pub host: String,
38 #[serde(rename = "type")]
39 pub record_type: String,
40 pub data: String,
41 pub ttl: u32,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub priority: Option<u16>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub weight: Option<u16>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub port: Option<u16>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub flags: Option<u8>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub tag: Option<String>,
52}
53
54#[derive(Deserialize, Debug, Clone)]
55pub struct ExistingDnsRecord {
56 pub id: i64,
57 pub host: String,
58 #[serde(rename = "type")]
59 pub record_type: String,
60 #[serde(default)]
61 pub data: String,
62 #[serde(default)]
63 pub priority: Option<u16>,
64 #[serde(default)]
65 pub weight: Option<u16>,
66 #[serde(default)]
67 pub port: Option<u16>,
68 #[serde(default)]
69 pub flags: Option<u8>,
70 #[serde(default)]
71 pub tag: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct DomeneshopRecordContent {
76 pub record_type: &'static str,
77 pub data: String,
78 pub priority: Option<u16>,
79 pub weight: Option<u16>,
80 pub port: Option<u16>,
81 pub flags: Option<u8>,
82 pub tag: Option<String>,
83}
84
85impl DomeneshopProvider {
86 pub(crate) fn new(
87 api_token: impl AsRef<str>,
88 api_secret: impl AsRef<str>,
89 timeout: Option<Duration>,
90 ) -> Self {
91 let credentials = format!("{}:{}", api_token.as_ref(), api_secret.as_ref());
92 let encoded = BASE64.encode(credentials.as_bytes());
93 let client = HttpClientBuilder::default()
94 .with_header("Authorization", format!("Basic {encoded}"))
95 .with_timeout(timeout)
96 .build();
97 Self {
98 client,
99 endpoint: DEFAULT_API_ENDPOINT.to_string(),
100 }
101 }
102
103 #[cfg(test)]
104 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
105 Self {
106 endpoint: endpoint.as_ref().to_string(),
107 ..self
108 }
109 }
110
111 pub(crate) async fn set_rrset(
112 &self,
113 name: impl IntoFqdn<'_>,
114 record_type: DnsRecordType,
115 ttl: u32,
116 records: Vec<DnsRecord>,
117 origin: impl IntoFqdn<'_>,
118 ) -> crate::Result<()> {
119 let name = name.into_name().into_owned();
120 let domain = origin.into_name().into_owned();
121 let host = strip_origin_from_name(&name, &domain, Some("@"));
122 let desired = build_contents(record_type, records)?;
123 let domain_id = self.find_domain_id(&domain).await?;
124 let existing = self.list_at(domain_id, &host, record_type).await?;
125
126 let mut existing_pool = existing;
127 let mut to_add: Vec<DomeneshopRecordContent> = Vec::new();
128
129 for content in desired {
130 if let Some(idx) = existing_pool
131 .iter()
132 .position(|r| record_matches(r, &content))
133 {
134 existing_pool.swap_remove(idx);
135 } else {
136 to_add.push(content);
137 }
138 }
139
140 for entry in existing_pool {
141 self.delete_record(domain_id, entry.id).await?;
142 }
143 for content in to_add {
144 self.create_record(domain_id, &host, ttl, &content).await?;
145 }
146 Ok(())
147 }
148
149 pub(crate) async fn add_to_rrset(
150 &self,
151 name: impl IntoFqdn<'_>,
152 record_type: DnsRecordType,
153 ttl: u32,
154 records: Vec<DnsRecord>,
155 origin: impl IntoFqdn<'_>,
156 ) -> crate::Result<()> {
157 if records.is_empty() {
158 return Ok(());
159 }
160 let name = name.into_name().into_owned();
161 let domain = origin.into_name().into_owned();
162 let host = strip_origin_from_name(&name, &domain, Some("@"));
163 let desired = build_contents(record_type, records)?;
164 let domain_id = self.find_domain_id(&domain).await?;
165 let existing = self.list_at(domain_id, &host, record_type).await?;
166
167 for content in desired {
168 if existing.iter().any(|r| record_matches(r, &content)) {
169 continue;
170 }
171 self.create_record(domain_id, &host, ttl, &content).await?;
172 }
173 Ok(())
174 }
175
176 pub(crate) async fn remove_from_rrset(
177 &self,
178 name: impl IntoFqdn<'_>,
179 record_type: DnsRecordType,
180 records: Vec<DnsRecord>,
181 origin: impl IntoFqdn<'_>,
182 ) -> crate::Result<()> {
183 if records.is_empty() {
184 return Ok(());
185 }
186 let name = name.into_name().into_owned();
187 let domain = origin.into_name().into_owned();
188 let host = strip_origin_from_name(&name, &domain, Some("@"));
189 let to_remove = build_contents(record_type, records)?;
190 let domain_id = self.find_domain_id(&domain).await?;
191 let existing = self.list_at(domain_id, &host, record_type).await?;
192
193 for content in to_remove {
194 if let Some(entry) = existing.iter().find(|r| record_matches(r, &content)) {
195 self.delete_record(domain_id, entry.id).await?;
196 }
197 }
198 Ok(())
199 }
200
201 pub(crate) async fn list_rrset(
202 &self,
203 name: impl IntoFqdn<'_>,
204 record_type: DnsRecordType,
205 origin: impl IntoFqdn<'_>,
206 ) -> crate::Result<Vec<DnsRecord>> {
207 let name = name.into_name().into_owned();
208 let domain = origin.into_name().into_owned();
209 let host = strip_origin_from_name(&name, &domain, Some("@"));
210 let domain_id = self.find_domain_id(&domain).await?;
211 let existing = self.list_at(domain_id, &host, record_type).await?;
212 existing.into_iter().map(DnsRecord::try_from).collect()
213 }
214
215 async fn find_domain_id(&self, domain: &str) -> crate::Result<i64> {
216 let domains: Vec<Domain> = self
217 .client
218 .get(format!("{endpoint}/domains", endpoint = self.endpoint))
219 .send()
220 .await?;
221 domains
222 .into_iter()
223 .find(|d| d.domain == domain)
224 .map(|d| d.id)
225 .ok_or_else(|| Error::Api(format!("Domain {domain} not found")))
226 }
227
228 async fn list_at(
229 &self,
230 domain_id: i64,
231 host: &str,
232 record_type: DnsRecordType,
233 ) -> crate::Result<Vec<ExistingDnsRecord>> {
234 let query = serde_urlencoded::to_string([("host", host), ("type", record_type.as_str())])
235 .unwrap_or_default();
236 let records: Vec<ExistingDnsRecord> = self
237 .client
238 .get(format!(
239 "{endpoint}/domains/{domain_id}/dns?{query}",
240 endpoint = self.endpoint
241 ))
242 .send()
243 .await?;
244 let type_str = record_type.as_str();
245 Ok(records
246 .into_iter()
247 .filter(|r| r.host == host && r.record_type == type_str)
248 .collect())
249 }
250
251 async fn create_record(
252 &self,
253 domain_id: i64,
254 host: &str,
255 ttl: u32,
256 content: &DomeneshopRecordContent,
257 ) -> crate::Result<()> {
258 let body = build_payload(host, ttl, content);
259 self.client
260 .post(format!(
261 "{endpoint}/domains/{domain_id}/dns",
262 endpoint = self.endpoint
263 ))
264 .with_body(&body)?
265 .send_with_retry::<serde_json::Value>(3)
266 .await
267 .map(|_| ())
268 }
269
270 async fn delete_record(&self, domain_id: i64, record_id: i64) -> crate::Result<()> {
271 self.client
272 .delete(format!(
273 "{endpoint}/domains/{domain_id}/dns/{record_id}",
274 endpoint = self.endpoint
275 ))
276 .send_with_retry::<serde_json::Value>(3)
277 .await
278 .map(|_| ())
279 }
280}
281
282fn build_payload(host: &str, ttl: u32, content: &DomeneshopRecordContent) -> DnsRecordPayload {
283 DnsRecordPayload {
284 host: host.to_string(),
285 record_type: content.record_type.to_string(),
286 data: content.data.clone(),
287 ttl,
288 priority: content.priority,
289 weight: content.weight,
290 port: content.port,
291 flags: content.flags,
292 tag: content.tag.clone(),
293 }
294}
295
296fn build_contents(
297 expected_type: DnsRecordType,
298 records: Vec<DnsRecord>,
299) -> crate::Result<Vec<DomeneshopRecordContent>> {
300 let mut out = Vec::with_capacity(records.len());
301 for record in records {
302 if record.as_type() != expected_type {
303 return Err(Error::Api(format!(
304 "RRSet record type mismatch: expected {}, got {}",
305 expected_type.as_str(),
306 record.as_type().as_str(),
307 )));
308 }
309 out.push(DomeneshopRecordContent::try_from(record)?);
310 }
311 Ok(out)
312}
313
314fn record_matches(existing: &ExistingDnsRecord, desired: &DomeneshopRecordContent) -> bool {
315 existing.record_type == desired.record_type
316 && existing.data == desired.data
317 && existing.priority == desired.priority
318 && existing.weight == desired.weight
319 && existing.port == desired.port
320 && existing.flags == desired.flags
321 && existing.tag == desired.tag
322}
323
324impl TryFrom<DnsRecord> for DomeneshopRecordContent {
325 type Error = Error;
326
327 fn try_from(record: DnsRecord) -> Result<Self, Self::Error> {
328 match record {
329 DnsRecord::A(addr) => Ok(DomeneshopRecordContent {
330 record_type: "A",
331 data: addr.to_string(),
332 priority: None,
333 weight: None,
334 port: None,
335 flags: None,
336 tag: None,
337 }),
338 DnsRecord::AAAA(addr) => Ok(DomeneshopRecordContent {
339 record_type: "AAAA",
340 data: addr.to_string(),
341 priority: None,
342 weight: None,
343 port: None,
344 flags: None,
345 tag: None,
346 }),
347 DnsRecord::CNAME(target) => Ok(DomeneshopRecordContent {
348 record_type: "CNAME",
349 data: target,
350 priority: None,
351 weight: None,
352 port: None,
353 flags: None,
354 tag: None,
355 }),
356 DnsRecord::NS(target) => Ok(DomeneshopRecordContent {
357 record_type: "NS",
358 data: target,
359 priority: None,
360 weight: None,
361 port: None,
362 flags: None,
363 tag: None,
364 }),
365 DnsRecord::MX(mx) => Ok(DomeneshopRecordContent {
366 record_type: "MX",
367 data: mx.exchange,
368 priority: Some(mx.priority),
369 weight: None,
370 port: None,
371 flags: None,
372 tag: None,
373 }),
374 DnsRecord::TXT(text) => Ok(DomeneshopRecordContent {
375 record_type: "TXT",
376 data: text,
377 priority: None,
378 weight: None,
379 port: None,
380 flags: None,
381 tag: None,
382 }),
383 DnsRecord::SRV(srv) => Ok(DomeneshopRecordContent {
384 record_type: "SRV",
385 data: srv.target,
386 priority: Some(srv.priority),
387 weight: Some(srv.weight),
388 port: Some(srv.port),
389 flags: None,
390 tag: None,
391 }),
392 DnsRecord::TLSA(_) => Err(Error::Unsupported(
393 "TLSA records are not supported by Domeneshop".to_string(),
394 )),
395 DnsRecord::CAA(caa) => {
396 let (flags, tag, value) = caa.decompose();
397 Ok(DomeneshopRecordContent {
398 record_type: "CAA",
399 data: value,
400 priority: None,
401 weight: None,
402 port: None,
403 flags: Some(flags),
404 tag: Some(tag),
405 })
406 }
407 }
408 }
409}
410
411impl TryFrom<ExistingDnsRecord> for DnsRecord {
412 type Error = Error;
413
414 fn try_from(record: ExistingDnsRecord) -> Result<Self, Self::Error> {
415 match record.record_type.as_str() {
416 "A" => record
417 .data
418 .parse()
419 .map(DnsRecord::A)
420 .map_err(|e| Error::Parse(format!("invalid A data: {e}"))),
421 "AAAA" => record
422 .data
423 .parse()
424 .map(DnsRecord::AAAA)
425 .map_err(|e| Error::Parse(format!("invalid AAAA data: {e}"))),
426 "CNAME" => Ok(DnsRecord::CNAME(record.data)),
427 "NS" => Ok(DnsRecord::NS(record.data)),
428 "MX" => Ok(DnsRecord::MX(MXRecord {
429 exchange: record.data,
430 priority: record.priority.unwrap_or_default(),
431 })),
432 "TXT" => Ok(DnsRecord::TXT(record.data)),
433 "SRV" => Ok(DnsRecord::SRV(SRVRecord {
434 priority: record.priority.unwrap_or_default(),
435 weight: record.weight.unwrap_or_default(),
436 port: record.port.unwrap_or_default(),
437 target: record.data,
438 })),
439 "CAA" => {
440 let flags = record.flags.unwrap_or_default();
441 let tag = record.tag.unwrap_or_default();
442 Ok(DnsRecord::CAA(build_caa(flags, tag, record.data)?))
443 }
444 other => Err(Error::Parse(format!(
445 "Unsupported Domeneshop record type: {other}"
446 ))),
447 }
448 }
449}
450
451fn build_caa(flags: u8, tag: String, value: String) -> crate::Result<CAARecord> {
452 let issuer_critical = flags & 0x80 != 0;
453 match tag.as_str() {
454 "issue" => {
455 let (name, options) = parse_caa_value(&value);
456 Ok(CAARecord::Issue {
457 issuer_critical,
458 name,
459 options,
460 })
461 }
462 "issuewild" => {
463 let (name, options) = parse_caa_value(&value);
464 Ok(CAARecord::IssueWild {
465 issuer_critical,
466 name,
467 options,
468 })
469 }
470 "iodef" => Ok(CAARecord::Iodef {
471 issuer_critical,
472 url: value,
473 }),
474 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
475 }
476}
477
478fn parse_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
479 let mut parts = value.split(';').map(str::trim);
480 let name_part = parts.next().unwrap_or("").trim().to_string();
481 let name = if name_part.is_empty() {
482 None
483 } else {
484 Some(name_part)
485 };
486 let options = parts
487 .filter(|p| !p.is_empty())
488 .map(|p| match p.split_once('=') {
489 Some((k, v)) => KeyValue {
490 key: k.trim().to_string(),
491 value: v.trim().to_string(),
492 },
493 None => KeyValue {
494 key: p.trim().to_string(),
495 value: String::new(),
496 },
497 })
498 .collect();
499 (name, options)
500}