1use crate::{
13 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
14 TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector,
15 http::{HttpClient, HttpClientBuilder},
16 utils::strip_origin_from_name,
17};
18use serde::{Deserialize, Deserializer, Serialize};
19use std::{
20 net::{Ipv4Addr, Ipv6Addr},
21 time::Duration,
22};
23
24#[derive(Clone)]
25pub struct PorkBunProvider {
26 client: HttpClient,
27 api_key: String,
28 secret_api_key: String,
29 endpoint: String,
30}
31
32#[derive(Serialize, Debug)]
33pub struct AuthParams<'a> {
34 pub secretapikey: &'a str,
35 pub apikey: &'a str,
36}
37
38#[derive(Serialize, Debug)]
39pub struct DnsRecordParams<'a> {
40 #[serde(flatten)]
41 pub auth: AuthParams<'a>,
42 pub name: &'a str,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub ttl: Option<u32>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub notes: Option<&'a str>,
47 #[serde(flatten)]
48 content: RecordData,
49}
50
51#[derive(Serialize, Debug)]
52pub struct EditByNameTypeParams<'a> {
53 #[serde(flatten)]
54 pub auth: AuthParams<'a>,
55 pub content: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub ttl: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub prio: Option<u16>,
60}
61
62#[derive(Deserialize, Debug)]
63pub struct ApiResponse {
64 pub status: String,
65 pub message: Option<String>,
66}
67
68#[derive(Deserialize, Debug)]
69struct RetrieveResponse {
70 status: String,
71 #[serde(default)]
72 message: Option<String>,
73 #[serde(default)]
74 records: Vec<ListedRecord>,
75}
76
77#[derive(Deserialize, Debug, Clone)]
78struct ListedRecord {
79 id: String,
80 #[serde(rename = "type")]
81 record_type: String,
82 content: String,
83 #[serde(default, deserialize_with = "deserialize_opt_u16_from_string")]
84 prio: Option<u16>,
85}
86
87#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
88#[serde(tag = "type")]
89#[allow(clippy::upper_case_acronyms)]
90pub enum RecordData {
91 A { content: Ipv4Addr },
92 MX { content: String, prio: u16 },
93 CNAME { content: String },
94 ALIAS { content: String },
95 TXT { content: String },
96 NS { content: String },
97 AAAA { content: Ipv6Addr },
98 SRV { content: String, prio: u16 },
99 TLSA { content: String },
100 CAA { content: String },
101 HTTPS { content: String },
102 SVCB { content: String },
103 SSHFP { content: String },
104}
105
106const DEFAULT_API_ENDPOINT: &str = "https://api.porkbun.com/api/json/v3";
107
108impl PorkBunProvider {
109 pub(crate) fn new(
110 api_key: impl AsRef<str>,
111 secret_api_key: impl AsRef<str>,
112 timeout: Option<Duration>,
113 ) -> Self {
114 let client = HttpClientBuilder::default().with_timeout(timeout).build();
115
116 Self {
117 client,
118 api_key: api_key.as_ref().to_string(),
119 secret_api_key: secret_api_key.as_ref().to_string(),
120 endpoint: DEFAULT_API_ENDPOINT.to_string(),
121 }
122 }
123
124 #[cfg(test)]
125 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
126 Self {
127 endpoint: endpoint.as_ref().to_string(),
128 ..self
129 }
130 }
131
132 pub(crate) async fn set_rrset(
133 &self,
134 name: impl IntoFqdn<'_>,
135 record_type: DnsRecordType,
136 ttl: u32,
137 records: Vec<DnsRecord>,
138 origin: impl IntoFqdn<'_>,
139 ) -> crate::Result<()> {
140 let name = name.into_name().into_owned();
141 let domain = origin.into_name().into_owned();
142 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
143
144 if records.is_empty() {
145 return self
146 .delete_by_name_type(&domain, record_type.as_str(), &subdomain)
147 .await;
148 }
149
150 let desired = build_record_data(record_type, records)?;
151
152 if desired.len() == 1 {
153 let data = desired.into_iter().next().unwrap();
154 return self.edit_by_name_type(&domain, &subdomain, ttl, data).await;
155 }
156
157 let existing = self
158 .retrieve_by_name_type(&domain, record_type.as_str(), &subdomain)
159 .await?;
160
161 let mut existing_pool = existing;
162 let mut to_add: Vec<RecordData> = Vec::new();
163
164 for data in desired {
165 if let Some(idx) = existing_pool.iter().position(|r| listed_matches(r, &data)) {
166 existing_pool.swap_remove(idx);
167 } else {
168 to_add.push(data);
169 }
170 }
171
172 for entry in existing_pool {
173 self.delete_record(&domain, &entry.id).await?;
174 }
175 for data in to_add {
176 self.create_record(&domain, &subdomain, ttl, data).await?;
177 }
178 Ok(())
179 }
180
181 pub(crate) async fn add_to_rrset(
182 &self,
183 name: impl IntoFqdn<'_>,
184 record_type: DnsRecordType,
185 ttl: u32,
186 records: Vec<DnsRecord>,
187 origin: impl IntoFqdn<'_>,
188 ) -> crate::Result<()> {
189 if records.is_empty() {
190 return Ok(());
191 }
192 let name = name.into_name().into_owned();
193 let domain = origin.into_name().into_owned();
194 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
195 let desired = build_record_data(record_type, records)?;
196 let existing = self
197 .retrieve_by_name_type(&domain, record_type.as_str(), &subdomain)
198 .await?;
199
200 for data in desired {
201 if existing.iter().any(|r| listed_matches(r, &data)) {
202 continue;
203 }
204 self.create_record(&domain, &subdomain, ttl, data).await?;
205 }
206 Ok(())
207 }
208
209 pub(crate) async fn remove_from_rrset(
210 &self,
211 name: impl IntoFqdn<'_>,
212 record_type: DnsRecordType,
213 records: Vec<DnsRecord>,
214 origin: impl IntoFqdn<'_>,
215 ) -> crate::Result<()> {
216 if records.is_empty() {
217 return Ok(());
218 }
219 let name = name.into_name().into_owned();
220 let domain = origin.into_name().into_owned();
221 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
222 let to_remove = build_record_data(record_type, records)?;
223 let existing = self
224 .retrieve_by_name_type(&domain, record_type.as_str(), &subdomain)
225 .await?;
226
227 for data in to_remove {
228 if let Some(entry) = existing.iter().find(|r| listed_matches(r, &data)) {
229 self.delete_record(&domain, &entry.id).await?;
230 }
231 }
232 Ok(())
233 }
234
235 pub(crate) async fn list_rrset(
236 &self,
237 name: impl IntoFqdn<'_>,
238 record_type: DnsRecordType,
239 origin: impl IntoFqdn<'_>,
240 ) -> crate::Result<Vec<DnsRecord>> {
241 let name = name.into_name().into_owned();
242 let domain = origin.into_name().into_owned();
243 let subdomain = strip_origin_from_name(&name, &domain, Some(""));
244 let listed = self
245 .retrieve_by_name_type(&domain, record_type.as_str(), &subdomain)
246 .await?;
247 listed
248 .into_iter()
249 .map(|r| listed_to_dns_record(r, record_type))
250 .collect()
251 }
252
253 fn auth(&self) -> AuthParams<'_> {
254 AuthParams {
255 secretapikey: &self.secret_api_key,
256 apikey: &self.api_key,
257 }
258 }
259
260 async fn retrieve_by_name_type(
261 &self,
262 domain: &str,
263 record_type: &str,
264 subdomain: &str,
265 ) -> crate::Result<Vec<ListedRecord>> {
266 let url = retrieve_by_name_type_url(&self.endpoint, domain, record_type, subdomain);
267 let response: RetrieveResponse = self
268 .client
269 .post(url)
270 .with_body(self.auth())?
271 .send_with_retry(3)
272 .await?;
273 if response.status == "SUCCESS" {
274 Ok(response
275 .records
276 .into_iter()
277 .filter(|r| r.record_type.eq_ignore_ascii_case(record_type))
278 .collect())
279 } else {
280 Err(Error::Api(response.status_message()))
281 }
282 }
283
284 async fn create_record(
285 &self,
286 domain: &str,
287 subdomain: &str,
288 ttl: u32,
289 content: RecordData,
290 ) -> crate::Result<()> {
291 self.client
292 .post(format!(
293 "{endpoint}/dns/create/{domain}",
294 endpoint = self.endpoint,
295 ))
296 .with_body(DnsRecordParams {
297 auth: self.auth(),
298 name: subdomain,
299 ttl: Some(ttl),
300 notes: None,
301 content,
302 })?
303 .send_with_retry::<ApiResponse>(3)
304 .await?
305 .into_result()
306 }
307
308 async fn delete_record(&self, domain: &str, record_id: &str) -> crate::Result<()> {
309 self.client
310 .post(format!(
311 "{endpoint}/dns/delete/{domain}/{record_id}",
312 endpoint = self.endpoint,
313 ))
314 .with_body(self.auth())?
315 .send_with_retry::<ApiResponse>(3)
316 .await?
317 .into_result()
318 }
319
320 async fn delete_by_name_type(
321 &self,
322 domain: &str,
323 record_type: &str,
324 subdomain: &str,
325 ) -> crate::Result<()> {
326 self.client
327 .post(delete_by_name_type_url(
328 &self.endpoint,
329 domain,
330 record_type,
331 subdomain,
332 ))
333 .with_body(self.auth())?
334 .send_with_retry::<ApiResponse>(3)
335 .await?
336 .into_result()
337 }
338
339 async fn edit_by_name_type(
340 &self,
341 domain: &str,
342 subdomain: &str,
343 ttl: u32,
344 data: RecordData,
345 ) -> crate::Result<()> {
346 let variant = data.variant_name();
347 let (content, prio) = data.into_content_prio();
348 self.client
349 .post(edit_by_name_type_url(
350 &self.endpoint,
351 domain,
352 variant,
353 subdomain,
354 ))
355 .with_body(EditByNameTypeParams {
356 auth: self.auth(),
357 content,
358 ttl: Some(ttl),
359 prio,
360 })?
361 .send_with_retry::<ApiResponse>(3)
362 .await?
363 .into_result()
364 }
365}
366
367fn retrieve_by_name_type_url(
368 endpoint: &str,
369 domain: &str,
370 record_type: &str,
371 subdomain: &str,
372) -> String {
373 if subdomain.is_empty() {
374 format!("{endpoint}/dns/retrieveByNameType/{domain}/{record_type}")
375 } else {
376 format!("{endpoint}/dns/retrieveByNameType/{domain}/{record_type}/{subdomain}")
377 }
378}
379
380fn edit_by_name_type_url(
381 endpoint: &str,
382 domain: &str,
383 record_type: &str,
384 subdomain: &str,
385) -> String {
386 if subdomain.is_empty() {
387 format!("{endpoint}/dns/editByNameType/{domain}/{record_type}")
388 } else {
389 format!("{endpoint}/dns/editByNameType/{domain}/{record_type}/{subdomain}")
390 }
391}
392
393fn delete_by_name_type_url(
394 endpoint: &str,
395 domain: &str,
396 record_type: &str,
397 subdomain: &str,
398) -> String {
399 if subdomain.is_empty() {
400 format!("{endpoint}/dns/deleteByNameType/{domain}/{record_type}")
401 } else {
402 format!("{endpoint}/dns/deleteByNameType/{domain}/{record_type}/{subdomain}")
403 }
404}
405
406fn build_record_data(
407 expected_type: DnsRecordType,
408 records: Vec<DnsRecord>,
409) -> crate::Result<Vec<RecordData>> {
410 let mut out = Vec::with_capacity(records.len());
411 for record in records {
412 if record.as_type() != expected_type {
413 return Err(Error::Api(format!(
414 "RRSet record type mismatch: expected {}, got {}",
415 expected_type.as_str(),
416 record.as_type().as_str(),
417 )));
418 }
419 out.push(record.into());
420 }
421 Ok(out)
422}
423
424fn listed_matches(listed: &ListedRecord, data: &RecordData) -> bool {
425 if !listed.record_type.eq_ignore_ascii_case(data.variant_name()) {
426 return false;
427 }
428 let (expected_content, expected_prio) = data.as_content_prio();
429 if listed.content.trim_end_matches('.') != expected_content.trim_end_matches('.') {
430 return false;
431 }
432 match expected_prio {
433 Some(p) => listed.prio == Some(p),
434 None => true,
435 }
436}
437
438fn listed_to_dns_record(
439 listed: ListedRecord,
440 record_type: DnsRecordType,
441) -> crate::Result<DnsRecord> {
442 let content = listed.content;
443 let prio = listed.prio;
444 Ok(match record_type {
445 DnsRecordType::A => DnsRecord::A(
446 content
447 .parse()
448 .map_err(|e| Error::Parse(format!("invalid A record content {content}: {e}")))?,
449 ),
450 DnsRecordType::AAAA => DnsRecord::AAAA(
451 content
452 .parse()
453 .map_err(|e| Error::Parse(format!("invalid AAAA record content {content}: {e}")))?,
454 ),
455 DnsRecordType::CNAME => DnsRecord::CNAME(content.trim_end_matches('.').to_string()),
456 DnsRecordType::NS => DnsRecord::NS(content.trim_end_matches('.').to_string()),
457 DnsRecordType::MX => DnsRecord::MX(MXRecord {
458 exchange: content.trim_end_matches('.').to_string(),
459 priority: prio.unwrap_or(0),
460 }),
461 DnsRecordType::TXT => DnsRecord::TXT(content),
462 DnsRecordType::SRV => DnsRecord::SRV(parse_srv_content(&content, prio.unwrap_or(0))?),
463 DnsRecordType::TLSA => DnsRecord::TLSA(parse_tlsa_content(&content)?),
464 DnsRecordType::CAA => DnsRecord::CAA(parse_caa_content(&content)?),
465 })
466}
467
468fn parse_srv_content(content: &str, priority: u16) -> crate::Result<SRVRecord> {
469 let parts: Vec<&str> = content.split_whitespace().collect();
470 if parts.len() != 3 {
471 return Err(Error::Parse(format!(
472 "invalid SRV content {content}: expected 'weight port target'"
473 )));
474 }
475 let weight: u16 = parts[0]
476 .parse()
477 .map_err(|e| Error::Parse(format!("invalid SRV weight {}: {e}", parts[0])))?;
478 let port: u16 = parts[1]
479 .parse()
480 .map_err(|e| Error::Parse(format!("invalid SRV port {}: {e}", parts[1])))?;
481 Ok(SRVRecord {
482 priority,
483 weight,
484 port,
485 target: parts[2].trim_end_matches('.').to_string(),
486 })
487}
488
489fn parse_tlsa_content(content: &str) -> crate::Result<TLSARecord> {
490 let parts: Vec<&str> = content.split_whitespace().collect();
491 if parts.len() != 4 {
492 return Err(Error::Parse(format!(
493 "invalid TLSA content {content}: expected 'usage selector matching hex'"
494 )));
495 }
496 let usage: u8 = parts[0]
497 .parse()
498 .map_err(|e| Error::Parse(format!("invalid TLSA usage: {e}")))?;
499 let selector: u8 = parts[1]
500 .parse()
501 .map_err(|e| Error::Parse(format!("invalid TLSA selector: {e}")))?;
502 let matching: u8 = parts[2]
503 .parse()
504 .map_err(|e| Error::Parse(format!("invalid TLSA matching: {e}")))?;
505 Ok(TLSARecord {
506 cert_usage: tlsa_cert_usage_from_u8(usage)?,
507 selector: tlsa_selector_from_u8(selector)?,
508 matching: tlsa_matching_from_u8(matching)?,
509 cert_data: decode_hex(parts[3])?,
510 })
511}
512
513fn parse_caa_content(content: &str) -> crate::Result<CAARecord> {
514 let trimmed = content.trim();
515 let (flags_str, rest) = trimmed
516 .split_once(char::is_whitespace)
517 .ok_or_else(|| Error::Parse(format!("invalid CAA content {content}: missing tag")))?;
518 let (tag, raw_value) = rest
519 .trim_start()
520 .split_once(char::is_whitespace)
521 .ok_or_else(|| Error::Parse(format!("invalid CAA content {content}: missing value")))?;
522 let flags: u8 = flags_str
523 .parse()
524 .map_err(|e| Error::Parse(format!("invalid CAA flags {flags_str}: {e}")))?;
525 let value = raw_value.trim().trim_matches('"').to_string();
526 build_caa(flags, tag.to_string(), value)
527}
528
529fn build_caa(flags: u8, tag: String, value: String) -> crate::Result<CAARecord> {
530 let issuer_critical = flags & 0x80 != 0;
531 match tag.as_str() {
532 "issue" => {
533 let (name, options) = parse_caa_value(&value);
534 Ok(CAARecord::Issue {
535 issuer_critical,
536 name,
537 options,
538 })
539 }
540 "issuewild" => {
541 let (name, options) = parse_caa_value(&value);
542 Ok(CAARecord::IssueWild {
543 issuer_critical,
544 name,
545 options,
546 })
547 }
548 "iodef" => Ok(CAARecord::Iodef {
549 issuer_critical,
550 url: value,
551 }),
552 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
553 }
554}
555
556fn parse_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
557 let mut parts = value.split(';').map(str::trim);
558 let name_part = parts.next().unwrap_or("").trim().to_string();
559 let name = if name_part.is_empty() {
560 None
561 } else {
562 Some(name_part)
563 };
564 let options = parts
565 .filter(|p| !p.is_empty())
566 .map(|p| match p.split_once('=') {
567 Some((k, v)) => KeyValue {
568 key: k.trim().to_string(),
569 value: v.trim().to_string(),
570 },
571 None => KeyValue {
572 key: p.trim().to_string(),
573 value: String::new(),
574 },
575 })
576 .collect();
577 (name, options)
578}
579
580fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
581 if !hex.len().is_multiple_of(2) {
582 return Err(Error::Parse(format!("invalid hex string: {hex}")));
583 }
584 (0..hex.len())
585 .step_by(2)
586 .map(|i| {
587 u8::from_str_radix(&hex[i..i + 2], 16)
588 .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
589 })
590 .collect()
591}
592
593fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
594 Ok(match value {
595 0 => TlsaCertUsage::PkixTa,
596 1 => TlsaCertUsage::PkixEe,
597 2 => TlsaCertUsage::DaneTa,
598 3 => TlsaCertUsage::DaneEe,
599 255 => TlsaCertUsage::Private,
600 _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
601 })
602}
603
604fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
605 Ok(match value {
606 0 => TlsaSelector::Full,
607 1 => TlsaSelector::Spki,
608 255 => TlsaSelector::Private,
609 _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
610 })
611}
612
613fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
614 Ok(match value {
615 0 => TlsaMatching::Raw,
616 1 => TlsaMatching::Sha256,
617 2 => TlsaMatching::Sha512,
618 255 => TlsaMatching::Private,
619 _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
620 })
621}
622
623fn deserialize_opt_u16_from_string<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
624where
625 D: Deserializer<'de>,
626{
627 #[derive(Deserialize)]
628 #[serde(untagged)]
629 enum Either {
630 Str(String),
631 Num(u16),
632 None,
633 }
634 match Option::<Either>::deserialize(deserializer)? {
635 None | Some(Either::None) => Ok(None),
636 Some(Either::Num(n)) => Ok(Some(n)),
637 Some(Either::Str(s)) => {
638 if s.is_empty() {
639 Ok(None)
640 } else {
641 s.parse::<u16>().map(Some).map_err(serde::de::Error::custom)
642 }
643 }
644 }
645}
646
647impl ApiResponse {
648 fn into_result(self) -> crate::Result<()> {
649 if self.status == "SUCCESS" {
650 Ok(())
651 } else {
652 Err(Error::Api(self.message.unwrap_or(self.status)))
653 }
654 }
655}
656
657impl RetrieveResponse {
658 fn status_message(self) -> String {
659 self.message.unwrap_or(self.status)
660 }
661}
662
663impl RecordData {
664 pub fn variant_name(&self) -> &'static str {
665 match self {
666 RecordData::A { .. } => "A",
667 RecordData::MX { .. } => "MX",
668 RecordData::CNAME { .. } => "CNAME",
669 RecordData::ALIAS { .. } => "ALIAS",
670 RecordData::TXT { .. } => "TXT",
671 RecordData::NS { .. } => "NS",
672 RecordData::AAAA { .. } => "AAAA",
673 RecordData::SRV { .. } => "SRV",
674 RecordData::TLSA { .. } => "TLSA",
675 RecordData::CAA { .. } => "CAA",
676 RecordData::HTTPS { .. } => "HTTPS",
677 RecordData::SVCB { .. } => "SVCB",
678 RecordData::SSHFP { .. } => "SSHFP",
679 }
680 }
681
682 fn as_content_prio(&self) -> (String, Option<u16>) {
683 match self {
684 RecordData::A { content } => (content.to_string(), None),
685 RecordData::AAAA { content } => (content.to_string(), None),
686 RecordData::CNAME { content }
687 | RecordData::ALIAS { content }
688 | RecordData::NS { content }
689 | RecordData::TXT { content }
690 | RecordData::TLSA { content }
691 | RecordData::CAA { content }
692 | RecordData::HTTPS { content }
693 | RecordData::SVCB { content }
694 | RecordData::SSHFP { content } => (content.clone(), None),
695 RecordData::MX { content, prio } => (content.clone(), Some(*prio)),
696 RecordData::SRV { content, prio } => (content.clone(), Some(*prio)),
697 }
698 }
699
700 fn into_content_prio(self) -> (String, Option<u16>) {
701 match self {
702 RecordData::A { content } => (content.to_string(), None),
703 RecordData::AAAA { content } => (content.to_string(), None),
704 RecordData::CNAME { content }
705 | RecordData::ALIAS { content }
706 | RecordData::NS { content }
707 | RecordData::TXT { content }
708 | RecordData::TLSA { content }
709 | RecordData::CAA { content }
710 | RecordData::HTTPS { content }
711 | RecordData::SVCB { content }
712 | RecordData::SSHFP { content } => (content, None),
713 RecordData::MX { content, prio } => (content, Some(prio)),
714 RecordData::SRV { content, prio } => (content, Some(prio)),
715 }
716 }
717}
718
719fn strip_trailing_dot(value: String) -> String {
720 if value.ends_with('.') {
721 value.trim_end_matches('.').to_string()
722 } else {
723 value
724 }
725}
726
727impl From<DnsRecord> for RecordData {
728 fn from(record: DnsRecord) -> Self {
729 match record {
730 DnsRecord::A(content) => RecordData::A { content },
731 DnsRecord::AAAA(content) => RecordData::AAAA { content },
732 DnsRecord::CNAME(content) => RecordData::CNAME {
733 content: strip_trailing_dot(content),
734 },
735 DnsRecord::NS(content) => RecordData::NS {
736 content: strip_trailing_dot(content),
737 },
738 DnsRecord::MX(mx) => RecordData::MX {
739 content: strip_trailing_dot(mx.exchange),
740 prio: mx.priority,
741 },
742 DnsRecord::TXT(content) => RecordData::TXT { content },
743 DnsRecord::SRV(srv) => RecordData::SRV {
744 content: format!(
745 "{} {} {}",
746 srv.weight,
747 srv.port,
748 strip_trailing_dot(srv.target)
749 ),
750 prio: srv.priority,
751 },
752 DnsRecord::TLSA(tlsa) => RecordData::TLSA {
753 content: tlsa.to_string(),
754 },
755 DnsRecord::CAA(caa) => RecordData::CAA {
756 content: caa.to_string(),
757 },
758 }
759 }
760}