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