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