1use crate::crypto::{hmac_sha256, sha256_digest};
13use crate::http::{HttpClient, HttpClientBuilder};
14use crate::utils::txt_chunks_to_text;
15use crate::{
16 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue as DnsKeyValue, MXRecord,
17 Result, SRVRecord,
18};
19use base64::Engine;
20use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
21use chrono::Utc;
22use serde::{Deserialize, Serialize};
23use std::time::Duration;
24
25const DEFAULT_API_BASE: &str = "/config-dns/v2";
26const DEFAULT_MAX_BODY: usize = 131072;
27
28#[derive(Debug, Clone)]
29pub struct EdgeDnsConfig {
30 pub host: String,
31 pub client_token: String,
32 pub client_secret: String,
33 pub access_token: String,
34 pub account_switch_key: Option<String>,
35 pub request_timeout: Option<Duration>,
36}
37
38#[derive(Clone)]
39pub struct EdgeDnsProvider {
40 client: HttpClient,
41 host: String,
42 scheme: String,
43 base_path: String,
44 client_token: String,
45 client_secret: String,
46 access_token: String,
47 account_switch_key: Option<String>,
48 max_body: usize,
49}
50
51#[derive(Serialize, Debug)]
52struct RecordBody<'a> {
53 name: &'a str,
54 #[serde(rename = "type")]
55 record_type: &'a str,
56 ttl: u32,
57 rdata: Vec<String>,
58}
59
60impl EdgeDnsProvider {
61 pub(crate) fn new(config: EdgeDnsConfig) -> Result<Self> {
62 if config.host.is_empty() {
63 return Err(Error::Client("edgedns: host is required".to_string()));
64 }
65 if config.client_token.is_empty()
66 || config.client_secret.is_empty()
67 || config.access_token.is_empty()
68 {
69 return Err(Error::Client(
70 "edgedns: client_token, client_secret and access_token are required".to_string(),
71 ));
72 }
73 let client = HttpClientBuilder::default()
74 .with_timeout(config.request_timeout)
75 .build();
76 let (host, scheme) = parse_host(&config.host);
77 Ok(Self {
78 client,
79 host,
80 scheme,
81 base_path: DEFAULT_API_BASE.to_string(),
82 client_token: config.client_token,
83 client_secret: config.client_secret,
84 access_token: config.access_token,
85 account_switch_key: config.account_switch_key,
86 max_body: DEFAULT_MAX_BODY,
87 })
88 }
89
90 #[cfg(test)]
91 pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
92 let endpoint = endpoint.as_ref().trim_end_matches('/').to_string();
93 let (host, scheme) = parse_host(&endpoint);
94 self.host = host;
95 self.scheme = scheme;
96 self
97 }
98
99 fn base_url(&self) -> String {
100 format!("{}://{}{}", self.scheme, self.host, self.base_path)
101 }
102
103 pub(crate) async fn set_rrset(
104 &self,
105 name: impl IntoFqdn<'_>,
106 record_type: DnsRecordType,
107 ttl: u32,
108 records: Vec<DnsRecord>,
109 origin: impl IntoFqdn<'_>,
110 ) -> Result<()> {
111 check_record_types(record_type, &records)?;
112 let name = name.into_name().to_ascii_lowercase();
113 let zone = origin.into_name().to_ascii_lowercase();
114 if zone.is_empty() {
115 return Err(Error::Api("edgedns: origin zone is required".to_string()));
116 }
117 let type_str = edgedns_record_type(record_type)?;
118 let path = self.record_path(&zone, &name, type_str);
119 let url = format!("{}{}", self.base_url(), path);
120
121 if records.is_empty() {
122 match self.send("DELETE", &url, None).await {
123 Ok(_) => return Ok(()),
124 Err(Error::NotFound) => return Ok(()),
125 Err(e) => return Err(e),
126 }
127 }
128
129 let rdata = build_rdata(record_type, &records)?;
130 let body = RecordBody {
131 name: &name,
132 record_type: type_str,
133 ttl,
134 rdata,
135 };
136 let payload =
137 serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
138 match self.send("PUT", &url, Some(&payload)).await {
139 Ok(_) => Ok(()),
140 Err(Error::NotFound) => {
141 self.send("POST", &url, Some(&payload)).await?;
142 Ok(())
143 }
144 Err(e) => Err(e),
145 }
146 }
147
148 pub(crate) async fn add_to_rrset(
149 &self,
150 name: impl IntoFqdn<'_>,
151 record_type: DnsRecordType,
152 ttl: u32,
153 records: Vec<DnsRecord>,
154 origin: impl IntoFqdn<'_>,
155 ) -> Result<()> {
156 check_record_types(record_type, &records)?;
157 if records.is_empty() {
158 return Ok(());
159 }
160 let name = name.into_name().to_ascii_lowercase();
161 let zone = origin.into_name().to_ascii_lowercase();
162 if zone.is_empty() {
163 return Err(Error::Api("edgedns: origin zone is required".to_string()));
164 }
165 let type_str = edgedns_record_type(record_type)?;
166 let path = self.record_path(&zone, &name, type_str);
167 let url = format!("{}{}", self.base_url(), path);
168
169 let current = self.fetch_rdata(&url).await?;
170 let desired = build_rdata(record_type, &records)?;
171 let mut merged = current;
172 for entry in desired {
173 if !merged.iter().any(|existing| existing == &entry) {
174 merged.push(entry);
175 }
176 }
177
178 let body = RecordBody {
179 name: &name,
180 record_type: type_str,
181 ttl,
182 rdata: merged,
183 };
184 let payload =
185 serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
186 match self.send("PUT", &url, Some(&payload)).await {
187 Ok(_) => Ok(()),
188 Err(Error::NotFound) => {
189 self.send("POST", &url, Some(&payload)).await?;
190 Ok(())
191 }
192 Err(e) => Err(e),
193 }
194 }
195
196 pub(crate) async fn remove_from_rrset(
197 &self,
198 name: impl IntoFqdn<'_>,
199 record_type: DnsRecordType,
200 records: Vec<DnsRecord>,
201 origin: impl IntoFqdn<'_>,
202 ) -> Result<()> {
203 check_record_types(record_type, &records)?;
204 if records.is_empty() {
205 return Ok(());
206 }
207 let name = name.into_name().to_ascii_lowercase();
208 let zone = origin.into_name().to_ascii_lowercase();
209 if zone.is_empty() {
210 return Err(Error::Api("edgedns: origin zone is required".to_string()));
211 }
212 let type_str = edgedns_record_type(record_type)?;
213 let path = self.record_path(&zone, &name, type_str);
214 let url = format!("{}{}", self.base_url(), path);
215
216 let current = self.fetch_rrset(&url).await?;
217 let Some(existing) = current else {
218 return Ok(());
219 };
220 let to_remove = build_rdata(record_type, &records)?;
221 let remaining: Vec<String> = existing
222 .rdata
223 .into_iter()
224 .filter(|entry| !to_remove.iter().any(|drop| drop == entry))
225 .collect();
226
227 if remaining.is_empty() {
228 match self.send("DELETE", &url, None).await {
229 Ok(_) => return Ok(()),
230 Err(Error::NotFound) => return Ok(()),
231 Err(e) => return Err(e),
232 }
233 }
234
235 let body = RecordBody {
236 name: &name,
237 record_type: type_str,
238 ttl: existing.ttl,
239 rdata: remaining,
240 };
241 let payload =
242 serde_json::to_string(&body).map_err(|e| Error::Serialize(format!("edgedns: {e}")))?;
243 self.send("PUT", &url, Some(&payload)).await?;
244 Ok(())
245 }
246
247 pub(crate) async fn list_rrset(
248 &self,
249 name: impl IntoFqdn<'_>,
250 record_type: DnsRecordType,
251 origin: impl IntoFqdn<'_>,
252 ) -> Result<Vec<DnsRecord>> {
253 let name = name.into_name().to_ascii_lowercase();
254 let zone = origin.into_name().to_ascii_lowercase();
255 if zone.is_empty() {
256 return Err(Error::Api("edgedns: origin zone is required".to_string()));
257 }
258 let type_str = edgedns_record_type(record_type)?;
259 let path = self.record_path(&zone, &name, type_str);
260 let url = format!("{}{}", self.base_url(), path);
261 let Some(current) = self.fetch_rrset(&url).await? else {
262 return Ok(Vec::new());
263 };
264 let mut out = Vec::with_capacity(current.rdata.len());
265 for entry in current.rdata {
266 out.push(rdata_to_record(record_type, &entry)?);
267 }
268 Ok(out)
269 }
270
271 async fn fetch_rrset(&self, url: &str) -> Result<Option<RecordResponse>> {
272 match self.send("GET", url, None).await {
273 Ok(text) => {
274 let parsed: RecordResponse = serde_json::from_str(&text)
275 .map_err(|e| Error::Parse(format!("edgedns rrset parse: {e}")))?;
276 Ok(Some(parsed))
277 }
278 Err(Error::NotFound) => Ok(None),
279 Err(e) => Err(e),
280 }
281 }
282
283 async fn fetch_rdata(&self, url: &str) -> Result<Vec<String>> {
284 Ok(self
285 .fetch_rrset(url)
286 .await?
287 .map(|r| r.rdata)
288 .unwrap_or_default())
289 }
290
291 fn record_path(&self, zone: &str, name: &str, record_type: &str) -> String {
292 format!(
293 "/zones/{}/names/{}/types/{}",
294 url_encode(zone),
295 url_encode(name),
296 record_type
297 )
298 }
299
300 async fn send(&self, method: &str, url: &str, body: Option<&str>) -> Result<String> {
301 let parsed = url
302 .parse::<reqwest::Url>()
303 .map_err(|e| Error::Client(format!("edgedns url parse: {e}")))?;
304 let path_query = match parsed.query() {
305 Some(q) => format!("{}?{}", parsed.path(), q),
306 None => parsed.path().to_string(),
307 };
308 let host = match parsed.port() {
309 Some(p) => format!("{}:{}", parsed.host_str().unwrap_or(""), p),
310 None => parsed.host_str().unwrap_or("").to_string(),
311 };
312 let scheme = parsed.scheme().to_string();
313 let timestamp = Utc::now().format("%Y%m%dT%H:%M:%S+0000").to_string();
314 let nonce = generate_nonce();
315
316 let body_for_hash = if matches!(method, "POST" | "PUT") {
317 body.unwrap_or("").as_bytes()
318 } else {
319 &[][..]
320 };
321 let content_hash = if body_for_hash.is_empty() {
322 String::new()
323 } else {
324 let truncated = if body_for_hash.len() > self.max_body {
325 &body_for_hash[..self.max_body]
326 } else {
327 body_for_hash
328 };
329 BASE64_STANDARD.encode(sha256_digest(truncated))
330 };
331
332 let auth_without_signature = format!(
333 "EG1-HMAC-SHA256 client_token={};access_token={};timestamp={};nonce={};",
334 self.client_token, self.access_token, timestamp, nonce
335 );
336
337 let canonical_headers = String::new();
338 let data_to_sign = format!(
339 "{}\t{}\t{}\t{}\t{}\t{}\t{}",
340 method.to_ascii_uppercase(),
341 scheme,
342 host,
343 path_query,
344 canonical_headers,
345 content_hash,
346 auth_without_signature
347 );
348
349 let signing_key = BASE64_STANDARD.encode(hmac_sha256(
350 self.client_secret.as_bytes(),
351 timestamp.as_bytes(),
352 ));
353 let signature =
354 BASE64_STANDARD.encode(hmac_sha256(signing_key.as_bytes(), data_to_sign.as_bytes()));
355 let authorization = format!("{}signature={}", auth_without_signature, signature);
356
357 let mut request = match method {
358 "GET" => self.client.get(url),
359 "POST" => self.client.post(url),
360 "PUT" => self.client.put(url),
361 "DELETE" => self.client.delete(url),
362 other => {
363 return Err(Error::Unsupported(format!(
364 "edgedns unsupported method: {other}"
365 )));
366 }
367 };
368 request = request
369 .with_header("Authorization", authorization)
370 .with_header("Accept", "application/json");
371 if let Some(asw) = &self.account_switch_key {
372 request = request.with_header("X-AccountSwitchKey", asw);
373 }
374 if let Some(body) = body {
375 request = request
376 .with_header("Content-Type", "application/json")
377 .with_raw_body(body.to_string());
378 }
379
380 request.send_raw().await
381 }
382}
383
384fn parse_host(input: &str) -> (String, String) {
385 let trimmed = input.trim_end_matches('/');
386 if let Some(rest) = trimmed.strip_prefix("https://") {
387 return (rest.to_string(), "https".to_string());
388 }
389 if let Some(rest) = trimmed.strip_prefix("http://") {
390 return (rest.to_string(), "http".to_string());
391 }
392 (trimmed.to_string(), "https".to_string())
393}
394
395fn url_encode(value: &str) -> String {
396 let mut out = String::with_capacity(value.len());
397 for byte in value.as_bytes() {
398 let c = *byte as char;
399 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
400 out.push(c);
401 } else {
402 out.push_str(&format!("%{:02X}", byte));
403 }
404 }
405 out
406}
407
408fn generate_nonce() -> String {
409 let now = Utc::now();
410 let nanos = now.timestamp_nanos_opt().unwrap_or(now.timestamp());
411 let mut buf = [0u8; 16];
412 let bytes = (nanos as u128).to_le_bytes();
413 buf.copy_from_slice(&bytes);
414 format!(
415 "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
416 u32::from_le_bytes(buf[0..4].try_into().unwrap()),
417 u16::from_le_bytes(buf[4..6].try_into().unwrap()),
418 u16::from_le_bytes(buf[6..8].try_into().unwrap()),
419 u16::from_le_bytes(buf[8..10].try_into().unwrap()),
420 {
421 let mut full = [0u8; 8];
422 full[2..8].copy_from_slice(&buf[10..16]);
423 u64::from_be_bytes(full)
424 }
425 )
426}
427
428fn edgedns_record_type(record_type: DnsRecordType) -> Result<&'static str> {
429 match record_type {
430 DnsRecordType::A => Ok("A"),
431 DnsRecordType::AAAA => Ok("AAAA"),
432 DnsRecordType::CNAME => Ok("CNAME"),
433 DnsRecordType::NS => Ok("NS"),
434 DnsRecordType::MX => Ok("MX"),
435 DnsRecordType::TXT => Ok("TXT"),
436 DnsRecordType::SRV => Ok("SRV"),
437 DnsRecordType::CAA => Ok("CAA"),
438 DnsRecordType::TLSA => Err(Error::Unsupported(
439 "TLSA records are not supported by EdgeDNS".to_string(),
440 )),
441 }
442}
443
444struct EdgeDnsRecord {
445 record_type: String,
446 rdata: Vec<String>,
447}
448
449impl TryFrom<&DnsRecord> for EdgeDnsRecord {
450 type Error = Error;
451
452 fn try_from(record: &DnsRecord) -> Result<Self> {
453 Ok(match record {
454 DnsRecord::A(addr) => Self {
455 record_type: "A".to_string(),
456 rdata: vec![addr.to_string()],
457 },
458 DnsRecord::AAAA(addr) => Self {
459 record_type: "AAAA".to_string(),
460 rdata: vec![addr.to_string()],
461 },
462 DnsRecord::CNAME(value) => Self {
463 record_type: "CNAME".to_string(),
464 rdata: vec![ensure_dot(value)],
465 },
466 DnsRecord::NS(value) => Self {
467 record_type: "NS".to_string(),
468 rdata: vec![ensure_dot(value)],
469 },
470 DnsRecord::MX(MXRecord { priority, exchange }) => Self {
471 record_type: "MX".to_string(),
472 rdata: vec![format!("{} {}", priority, ensure_dot(exchange))],
473 },
474 DnsRecord::TXT(value) => Self {
475 record_type: "TXT".to_string(),
476 rdata: vec![{
477 let mut out = String::new();
478 txt_chunks_to_text(&mut out, value, " ");
479 out
480 }],
481 },
482 DnsRecord::SRV(SRVRecord {
483 target,
484 priority,
485 weight,
486 port,
487 }) => Self {
488 record_type: "SRV".to_string(),
489 rdata: vec![format!(
490 "{} {} {} {}",
491 priority,
492 weight,
493 port,
494 ensure_dot(target)
495 )],
496 },
497 DnsRecord::CAA(caa) => Self {
498 record_type: "CAA".to_string(),
499 rdata: vec![caa_to_rdata(caa)],
500 },
501 DnsRecord::TLSA(_) => {
502 return Err(Error::Unsupported(
503 "TLSA records are not supported by EdgeDNS".to_string(),
504 ));
505 }
506 })
507 }
508}
509
510fn caa_to_rdata(caa: &CAARecord) -> String {
511 match caa {
512 CAARecord::Issue {
513 issuer_critical,
514 name,
515 options,
516 } => {
517 let flags = if *issuer_critical { 128 } else { 0 };
518 let mut value = name.clone().unwrap_or_default();
519 for opt in options {
520 value.push_str(&format!(";{}", opt));
521 }
522 format!("{} issue \"{}\"", flags, value)
523 }
524 CAARecord::IssueWild {
525 issuer_critical,
526 name,
527 options,
528 } => {
529 let flags = if *issuer_critical { 128 } else { 0 };
530 let mut value = name.clone().unwrap_or_default();
531 for opt in options {
532 value.push_str(&format!(";{}", opt));
533 }
534 format!("{} issuewild \"{}\"", flags, value)
535 }
536 CAARecord::Iodef {
537 issuer_critical,
538 url,
539 } => {
540 let flags = if *issuer_critical { 128 } else { 0 };
541 format!("{} iodef \"{}\"", flags, url)
542 }
543 }
544}
545
546fn ensure_dot(value: &str) -> String {
547 if value.ends_with('.') {
548 value.to_string()
549 } else {
550 format!("{}.", value)
551 }
552}
553
554#[derive(Deserialize)]
555struct RecordResponse {
556 #[serde(default)]
557 ttl: u32,
558 #[serde(default)]
559 rdata: Vec<String>,
560}
561
562fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> Result<()> {
563 for r in records {
564 if r.as_type() != expected {
565 return Err(Error::Api(format!(
566 "RRSet record type mismatch: expected {}, got {}",
567 expected.as_str(),
568 r.as_type().as_str(),
569 )));
570 }
571 }
572 Ok(())
573}
574
575fn build_rdata(record_type: DnsRecordType, records: &[DnsRecord]) -> Result<Vec<String>> {
576 let mut out = Vec::with_capacity(records.len());
577 for record in records {
578 let representation = EdgeDnsRecord::try_from(record)?;
579 let expected_type = edgedns_record_type(record_type)?;
580 if representation.record_type != expected_type {
581 return Err(Error::Api(format!(
582 "RRSet record type mismatch: expected {}, got {}",
583 expected_type, representation.record_type,
584 )));
585 }
586 for entry in representation.rdata {
587 out.push(entry);
588 }
589 }
590 Ok(out)
591}
592
593fn rdata_to_record(record_type: DnsRecordType, entry: &str) -> Result<DnsRecord> {
594 match record_type {
595 DnsRecordType::A => entry
596 .parse()
597 .map(DnsRecord::A)
598 .map_err(|e| Error::Parse(format!("edgedns A rdata: {e}"))),
599 DnsRecordType::AAAA => entry
600 .parse()
601 .map(DnsRecord::AAAA)
602 .map_err(|e| Error::Parse(format!("edgedns AAAA rdata: {e}"))),
603 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(strip_trailing_dot(entry))),
604 DnsRecordType::NS => Ok(DnsRecord::NS(strip_trailing_dot(entry))),
605 DnsRecordType::MX => {
606 let (priority_str, exchange) = entry
607 .split_once(' ')
608 .ok_or_else(|| Error::Parse(format!("edgedns MX rdata: {entry}")))?;
609 let priority: u16 = priority_str
610 .parse()
611 .map_err(|e| Error::Parse(format!("edgedns MX priority: {e}")))?;
612 Ok(DnsRecord::MX(MXRecord {
613 priority,
614 exchange: strip_trailing_dot(exchange.trim()),
615 }))
616 }
617 DnsRecordType::TXT => Ok(DnsRecord::TXT(parse_txt_rdata(entry))),
618 DnsRecordType::SRV => {
619 let mut parts = entry.split_whitespace();
620 let priority: u16 = parts
621 .next()
622 .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
623 .parse()
624 .map_err(|e| Error::Parse(format!("edgedns SRV priority: {e}")))?;
625 let weight: u16 = parts
626 .next()
627 .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
628 .parse()
629 .map_err(|e| Error::Parse(format!("edgedns SRV weight: {e}")))?;
630 let port: u16 = parts
631 .next()
632 .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?
633 .parse()
634 .map_err(|e| Error::Parse(format!("edgedns SRV port: {e}")))?;
635 let target = parts
636 .next()
637 .ok_or_else(|| Error::Parse(format!("edgedns SRV rdata: {entry}")))?;
638 Ok(DnsRecord::SRV(SRVRecord {
639 priority,
640 weight,
641 port,
642 target: strip_trailing_dot(target),
643 }))
644 }
645 DnsRecordType::CAA => parse_caa_rdata(entry),
646 DnsRecordType::TLSA => Err(Error::Unsupported(
647 "TLSA records are not supported by EdgeDNS".to_string(),
648 )),
649 }
650}
651
652fn strip_trailing_dot(value: &str) -> String {
653 value.trim_end_matches('.').to_string()
654}
655
656fn parse_txt_rdata(entry: &str) -> String {
657 let trimmed = entry.trim();
658 let mut out = String::new();
659 let chars = trimmed.chars().peekable();
660 let mut in_quotes = false;
661 let mut escape = false;
662 for ch in chars {
663 if escape {
664 out.push(ch);
665 escape = false;
666 continue;
667 }
668 match ch {
669 '\\' if in_quotes => escape = true,
670 '"' => in_quotes = !in_quotes,
671 _ if in_quotes => out.push(ch),
672 _ => {}
673 }
674 }
675 if out.is_empty() {
676 trimmed.to_string()
677 } else {
678 out
679 }
680}
681
682fn parse_caa_rdata(entry: &str) -> Result<DnsRecord> {
683 let mut parts = entry.splitn(3, ' ');
684 let flags_str = parts
685 .next()
686 .ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
687 let tag = parts
688 .next()
689 .ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
690 let value_quoted = parts
691 .next()
692 .ok_or_else(|| Error::Parse(format!("edgedns CAA rdata: {entry}")))?;
693 let flags: u8 = flags_str
694 .parse()
695 .map_err(|e| Error::Parse(format!("edgedns CAA flags: {e}")))?;
696 let issuer_critical = flags & 128 != 0;
697 let value = value_quoted
698 .trim()
699 .trim_start_matches('"')
700 .trim_end_matches('"')
701 .to_string();
702 match tag {
703 "issue" => {
704 let (name, options) = parse_caa_value(&value);
705 Ok(DnsRecord::CAA(CAARecord::Issue {
706 issuer_critical,
707 name,
708 options,
709 }))
710 }
711 "issuewild" => {
712 let (name, options) = parse_caa_value(&value);
713 Ok(DnsRecord::CAA(CAARecord::IssueWild {
714 issuer_critical,
715 name,
716 options,
717 }))
718 }
719 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
720 issuer_critical,
721 url: value,
722 })),
723 other => Err(Error::Unsupported(format!(
724 "edgedns CAA tag unsupported: {other}"
725 ))),
726 }
727}
728
729fn parse_caa_value(value: &str) -> (Option<String>, Vec<DnsKeyValue>) {
730 let mut parts = value.split(';').map(str::trim);
731 let head = parts.next().unwrap_or("").to_string();
732 let name = if head.is_empty() { None } else { Some(head) };
733 let options = parts
734 .filter(|p| !p.is_empty())
735 .map(|p| match p.split_once('=') {
736 Some((k, v)) => DnsKeyValue {
737 key: k.trim().to_string(),
738 value: v.trim().to_string(),
739 },
740 None => DnsKeyValue {
741 key: p.trim().to_string(),
742 value: String::new(),
743 },
744 })
745 .collect();
746 (name, options)
747}