1use crate::{
13 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue as DnsKeyValue, MXRecord,
14 SRVRecord, TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector,
15 http::{HttpClient, HttpClientBuilder},
16 utils::strip_origin_from_name,
17};
18use serde::{Deserialize, Serialize};
19use std::time::Duration;
20
21pub struct DesecDnsRecordRepresentation {
22 pub record_type: String,
23 pub content: String,
24}
25
26#[derive(Clone)]
27pub struct DesecProvider {
28 client: HttpClient,
29 endpoint: String,
30}
31
32#[derive(Serialize, Clone, Debug)]
33pub struct DnsRecordParams<'a> {
34 pub subname: &'a str,
35 #[serde(rename = "type")]
36 pub rr_type: &'a str,
37 pub ttl: Option<u32>,
38 pub records: Vec<String>,
39}
40
41#[derive(Deserialize, Debug)]
42pub struct DesecApiResponse {
43 pub created: String,
44 pub domain: String,
45 pub subname: String,
46 pub name: String,
47 pub records: Vec<String>,
48 pub ttl: u32,
49 #[serde(rename = "type")]
50 pub record_type: String,
51 pub touched: String,
52}
53
54#[derive(Deserialize)]
55struct DesecEmptyResponse {}
56
57const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1";
58
59const DESEC_MIN_TTL: u32 = 3600;
60
61fn url_subname(subname: &str) -> &str {
62 if subname.is_empty() { "@" } else { subname }
63}
64
65impl DesecProvider {
66 pub(crate) fn new(auth_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
67 let client = HttpClientBuilder::default()
68 .with_header("Authorization", format!("Token {}", auth_token.as_ref()))
69 .with_timeout(timeout)
70 .build();
71
72 Self {
73 client,
74 endpoint: DEFAULT_API_ENDPOINT.to_string(),
75 }
76 }
77
78 #[cfg(test)]
79 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
80 Self {
81 endpoint: endpoint.as_ref().to_string(),
82 ..self
83 }
84 }
85
86 pub(crate) async fn set_rrset(
87 &self,
88 name: impl IntoFqdn<'_>,
89 record_type: DnsRecordType,
90 ttl: u32,
91 records: Vec<DnsRecord>,
92 origin: impl IntoFqdn<'_>,
93 ) -> crate::Result<()> {
94 let name = name.into_name().to_ascii_lowercase();
95 let domain = origin.into_name().to_ascii_lowercase();
96 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
97 let rr_type = record_type.as_str();
98
99 if records.is_empty() {
100 let rrset_url = format!(
101 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
102 endpoint = self.endpoint,
103 domain = &domain,
104 subdomain = url_subname(&subdomain),
105 rr_type = rr_type,
106 );
107
108 return self
109 .client
110 .delete(rrset_url)
111 .send_with_retry::<DesecEmptyResponse>(3)
112 .await
113 .map(|_| ())
114 .or_else(|err| match err {
115 crate::Error::NotFound => Ok(()),
116 err => Err(err),
117 });
118 }
119
120 let contents = build_contents(record_type, records)?;
121 let ttl = ttl.max(DESEC_MIN_TTL);
122
123 let rrsets_url = format!(
124 "{endpoint}/domains/{domain}/rrsets/",
125 endpoint = self.endpoint,
126 domain = &domain,
127 );
128
129 self.client
130 .put(rrsets_url)
131 .with_body(vec![DnsRecordParams {
132 subname: &subdomain,
133 rr_type,
134 ttl: Some(ttl),
135 records: contents,
136 }])?
137 .send_with_retry::<Vec<DesecApiResponse>>(3)
138 .await
139 .map(|_| ())
140 }
141
142 pub(crate) async fn add_to_rrset(
143 &self,
144 name: impl IntoFqdn<'_>,
145 record_type: DnsRecordType,
146 ttl: u32,
147 records: Vec<DnsRecord>,
148 origin: impl IntoFqdn<'_>,
149 ) -> crate::Result<()> {
150 if records.is_empty() {
151 return Ok(());
152 }
153
154 let name = name.into_name().to_ascii_lowercase();
155 let domain = origin.into_name().to_ascii_lowercase();
156 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
157 let rr_type = record_type.as_str();
158 let ttl = ttl.max(DESEC_MIN_TTL);
159
160 let to_add = build_contents(record_type, records)?;
161
162 let rrset_url = format!(
163 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
164 endpoint = self.endpoint,
165 domain = &domain,
166 subdomain = url_subname(&subdomain),
167 rr_type = rr_type,
168 );
169
170 let (mut current, existed) = match self
171 .client
172 .get(rrset_url.clone())
173 .send_with_retry::<DesecApiResponse>(3)
174 .await
175 {
176 Ok(existing) => (existing.records, true),
177 Err(crate::Error::NotFound) => (Vec::new(), false),
178 Err(err) => return Err(err),
179 };
180
181 let before = current.len();
182 for content in to_add {
183 if !current.iter().any(|r| r == &content) {
184 current.push(content);
185 }
186 }
187
188 if existed && current.len() == before {
189 return Ok(());
190 }
191
192 let params = DnsRecordParams {
193 subname: &subdomain,
194 rr_type,
195 ttl: Some(ttl),
196 records: current,
197 };
198
199 if existed {
200 self.client.put(rrset_url)
201 } else {
202 self.client.post(format!(
203 "{endpoint}/domains/{domain}/rrsets/",
204 endpoint = self.endpoint,
205 domain = domain
206 ))
207 }
208 .with_body(params)?
209 .send_with_retry::<DesecApiResponse>(3)
210 .await
211 .map(|_| ())
212 }
213
214 pub(crate) async fn remove_from_rrset(
215 &self,
216 name: impl IntoFqdn<'_>,
217 record_type: DnsRecordType,
218 records: Vec<DnsRecord>,
219 origin: impl IntoFqdn<'_>,
220 ) -> crate::Result<()> {
221 if records.is_empty() {
222 return Ok(());
223 }
224
225 let name = name.into_name().to_ascii_lowercase();
226 let domain = origin.into_name().to_ascii_lowercase();
227 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
228 let rr_type = record_type.as_str();
229
230 let to_remove = build_contents(record_type, records)?;
231
232 let rrset_url = format!(
233 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
234 endpoint = self.endpoint,
235 domain = &domain,
236 subdomain = url_subname(&subdomain),
237 rr_type = rr_type,
238 );
239
240 let existing = match self
241 .client
242 .get(rrset_url.clone())
243 .send_with_retry::<DesecApiResponse>(3)
244 .await
245 {
246 Ok(existing) => existing,
247 Err(crate::Error::NotFound) => return Ok(()),
248 Err(err) => return Err(err),
249 };
250
251 let original_len = existing.records.len();
252 let filtered: Vec<String> = existing
253 .records
254 .into_iter()
255 .filter(|content| !to_remove.iter().any(|r| r == content))
256 .collect();
257
258 if filtered.len() == original_len {
259 return Ok(());
260 }
261
262 if filtered.is_empty() {
263 return self
264 .client
265 .delete(rrset_url)
266 .send_with_retry::<DesecEmptyResponse>(3)
267 .await
268 .map(|_| ())
269 .or_else(|err| match err {
270 crate::Error::NotFound => Ok(()),
271 err => Err(err),
272 });
273 }
274
275 self.client
276 .put(rrset_url)
277 .with_body(DnsRecordParams {
278 subname: &subdomain,
279 rr_type,
280 ttl: Some(existing.ttl),
281 records: filtered,
282 })?
283 .send_with_retry::<DesecApiResponse>(3)
284 .await
285 .map(|_| ())
286 }
287
288 pub(crate) async fn list_rrset(
289 &self,
290 name: impl IntoFqdn<'_>,
291 record_type: DnsRecordType,
292 origin: impl IntoFqdn<'_>,
293 ) -> crate::Result<Vec<DnsRecord>> {
294 let name = name.into_name().to_ascii_lowercase();
295 let domain = origin.into_name().to_ascii_lowercase();
296 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
297 let rr_type = record_type.as_str();
298
299 let rrset_url = format!(
300 "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/",
301 endpoint = self.endpoint,
302 domain = &domain,
303 subdomain = url_subname(&subdomain),
304 rr_type = rr_type,
305 );
306
307 let response = match self
308 .client
309 .get(rrset_url)
310 .send_with_retry::<DesecApiResponse>(3)
311 .await
312 {
313 Ok(response) => response,
314 Err(crate::Error::NotFound) => return Ok(Vec::new()),
315 Err(err) => return Err(err),
316 };
317
318 response
319 .records
320 .into_iter()
321 .map(|content| parse_record(record_type, &content))
322 .collect()
323 }
324}
325
326fn build_contents(
327 expected_type: DnsRecordType,
328 records: Vec<DnsRecord>,
329) -> crate::Result<Vec<String>> {
330 let mut out = Vec::with_capacity(records.len());
331 for record in records {
332 if record.as_type() != expected_type {
333 return Err(Error::Api(format!(
334 "RRSet record type mismatch: expected {}, got {}",
335 expected_type.as_str(),
336 record.as_type().as_str(),
337 )));
338 }
339 out.push(DesecDnsRecordRepresentation::from(record).content);
340 }
341 Ok(out)
342}
343
344fn parse_record(record_type: DnsRecordType, content: &str) -> crate::Result<DnsRecord> {
345 match record_type {
346 DnsRecordType::A => content
347 .parse()
348 .map(DnsRecord::A)
349 .map_err(|e| Error::Parse(format!("invalid A record: {e}"))),
350 DnsRecordType::AAAA => content
351 .parse()
352 .map(DnsRecord::AAAA)
353 .map_err(|e| Error::Parse(format!("invalid AAAA record: {e}"))),
354 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(content).to_string())),
355 DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(content).to_string())),
356 DnsRecordType::MX => parse_mx(content),
357 DnsRecordType::TXT => Ok(DnsRecord::TXT(unquote_txt(content))),
358 DnsRecordType::SRV => parse_srv(content),
359 DnsRecordType::TLSA => parse_tlsa(content),
360 DnsRecordType::CAA => parse_caa(content),
361 }
362}
363
364fn strip_trailing_dot(s: &str) -> &str {
365 s.strip_suffix('.').unwrap_or(s)
366}
367
368fn unquote_txt(content: &str) -> String {
369 let trimmed = content
370 .strip_prefix('"')
371 .and_then(|s| s.strip_suffix('"'))
372 .unwrap_or(content);
373 trimmed.replace("\\\"", "\"")
374}
375
376fn parse_mx(content: &str) -> crate::Result<DnsRecord> {
377 let (prio, exchange) = content
378 .split_once(' ')
379 .ok_or_else(|| Error::Parse(format!("invalid MX record: {content}")))?;
380 let priority: u16 = prio
381 .parse()
382 .map_err(|e| Error::Parse(format!("invalid MX priority {prio}: {e}")))?;
383 Ok(DnsRecord::MX(MXRecord {
384 priority,
385 exchange: strip_trailing_dot(exchange.trim()).to_string(),
386 }))
387}
388
389fn parse_srv(content: &str) -> crate::Result<DnsRecord> {
390 let mut parts = content.split_whitespace();
391 let priority: u16 = parts
392 .next()
393 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?
394 .parse()
395 .map_err(|e| Error::Parse(format!("invalid SRV priority: {e}")))?;
396 let weight: u16 = parts
397 .next()
398 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?
399 .parse()
400 .map_err(|e| Error::Parse(format!("invalid SRV weight: {e}")))?;
401 let port: u16 = parts
402 .next()
403 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?
404 .parse()
405 .map_err(|e| Error::Parse(format!("invalid SRV port: {e}")))?;
406 let target = parts
407 .next()
408 .ok_or_else(|| Error::Parse(format!("invalid SRV record: {content}")))?;
409 Ok(DnsRecord::SRV(SRVRecord {
410 priority,
411 weight,
412 port,
413 target: strip_trailing_dot(target).to_string(),
414 }))
415}
416
417fn parse_tlsa(content: &str) -> crate::Result<DnsRecord> {
418 let mut parts = content.split_whitespace();
419 let usage: u8 = parts
420 .next()
421 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {content}")))?
422 .parse()
423 .map_err(|e| Error::Parse(format!("invalid TLSA usage: {e}")))?;
424 let selector: u8 = parts
425 .next()
426 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {content}")))?
427 .parse()
428 .map_err(|e| Error::Parse(format!("invalid TLSA selector: {e}")))?;
429 let matching: u8 = parts
430 .next()
431 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {content}")))?
432 .parse()
433 .map_err(|e| Error::Parse(format!("invalid TLSA matching: {e}")))?;
434 let hex: String = parts.collect::<Vec<_>>().join("");
435 Ok(DnsRecord::TLSA(TLSARecord {
436 cert_usage: tlsa_cert_usage_from_u8(usage)?,
437 selector: tlsa_selector_from_u8(selector)?,
438 matching: tlsa_matching_from_u8(matching)?,
439 cert_data: decode_hex(&hex)?,
440 }))
441}
442
443fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
444 Ok(match value {
445 0 => TlsaCertUsage::PkixTa,
446 1 => TlsaCertUsage::PkixEe,
447 2 => TlsaCertUsage::DaneTa,
448 3 => TlsaCertUsage::DaneEe,
449 255 => TlsaCertUsage::Private,
450 _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
451 })
452}
453
454fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
455 Ok(match value {
456 0 => TlsaSelector::Full,
457 1 => TlsaSelector::Spki,
458 255 => TlsaSelector::Private,
459 _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
460 })
461}
462
463fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
464 Ok(match value {
465 0 => TlsaMatching::Raw,
466 1 => TlsaMatching::Sha256,
467 2 => TlsaMatching::Sha512,
468 255 => TlsaMatching::Private,
469 _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
470 })
471}
472
473fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
474 if !hex.len().is_multiple_of(2) {
475 return Err(Error::Parse(format!("invalid hex string: {hex}")));
476 }
477 (0..hex.len())
478 .step_by(2)
479 .map(|i| {
480 u8::from_str_radix(&hex[i..i + 2], 16)
481 .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
482 })
483 .collect()
484}
485
486fn parse_caa(content: &str) -> crate::Result<DnsRecord> {
487 let mut parts = content.splitn(3, ' ');
488 let flags: u8 = parts
489 .next()
490 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
491 .parse()
492 .map_err(|e| Error::Parse(format!("invalid CAA flags: {e}")))?;
493 let tag = parts
494 .next()
495 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?
496 .to_string();
497 let raw_value = parts
498 .next()
499 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {content}")))?;
500 let value = raw_value
501 .strip_prefix('"')
502 .and_then(|s| s.strip_suffix('"'))
503 .unwrap_or(raw_value)
504 .to_string();
505
506 let issuer_critical = flags & 0x80 != 0;
507 match tag.as_str() {
508 "issue" => {
509 let (name, options) = parse_caa_value(&value);
510 Ok(DnsRecord::CAA(CAARecord::Issue {
511 issuer_critical,
512 name,
513 options,
514 }))
515 }
516 "issuewild" => {
517 let (name, options) = parse_caa_value(&value);
518 Ok(DnsRecord::CAA(CAARecord::IssueWild {
519 issuer_critical,
520 name,
521 options,
522 }))
523 }
524 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
525 issuer_critical,
526 url: value,
527 })),
528 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
529 }
530}
531
532fn parse_caa_value(value: &str) -> (Option<String>, Vec<DnsKeyValue>) {
533 let mut parts = value.split(';').map(str::trim);
534 let name_part = parts.next().unwrap_or("").trim().to_string();
535 let name = if name_part.is_empty() {
536 None
537 } else {
538 Some(name_part)
539 };
540 let options = parts
541 .filter(|p| !p.is_empty())
542 .map(|p| match p.split_once('=') {
543 Some((k, v)) => DnsKeyValue {
544 key: k.trim().to_string(),
545 value: v.trim().to_string(),
546 },
547 None => DnsKeyValue {
548 key: p.trim().to_string(),
549 value: String::new(),
550 },
551 })
552 .collect();
553 (name, options)
554}
555
556fn ensure_fqdn(name: String) -> String {
557 if name.ends_with('.') {
558 name
559 } else {
560 format!("{name}.")
561 }
562}
563
564impl From<DnsRecord> for DesecDnsRecordRepresentation {
565 fn from(record: DnsRecord) -> Self {
566 match record {
567 DnsRecord::A(content) => DesecDnsRecordRepresentation {
568 record_type: "A".to_string(),
569 content: content.to_string(),
570 },
571 DnsRecord::AAAA(content) => DesecDnsRecordRepresentation {
572 record_type: "AAAA".to_string(),
573 content: content.to_string(),
574 },
575 DnsRecord::CNAME(content) => DesecDnsRecordRepresentation {
576 record_type: "CNAME".to_string(),
577 content: ensure_fqdn(content),
578 },
579 DnsRecord::NS(content) => DesecDnsRecordRepresentation {
580 record_type: "NS".to_string(),
581 content: ensure_fqdn(content),
582 },
583 DnsRecord::MX(mx) => DesecDnsRecordRepresentation {
584 record_type: "MX".to_string(),
585 content: format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
586 },
587 DnsRecord::TXT(content) => DesecDnsRecordRepresentation {
588 record_type: "TXT".to_string(),
589 content: format!("\"{content}\""),
590 },
591 DnsRecord::SRV(srv) => DesecDnsRecordRepresentation {
592 record_type: "SRV".to_string(),
593 content: format!(
594 "{} {} {} {}",
595 srv.priority,
596 srv.weight,
597 srv.port,
598 ensure_fqdn(srv.target)
599 ),
600 },
601 DnsRecord::TLSA(tlsa) => DesecDnsRecordRepresentation {
602 record_type: "TLSA".to_string(),
603 content: tlsa.to_string(),
604 },
605 DnsRecord::CAA(caa) => DesecDnsRecordRepresentation {
606 record_type: "CAA".to_string(),
607 content: caa.to_string(),
608 },
609 }
610 }
611}