Skip to main content

dns_update/providers/
nifcloud.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use crate::{
13    DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord,
14    crypto::hmac_sha256,
15    http::{HttpClient, HttpClientBuilder},
16    utils::txt_chunks_to_text,
17};
18use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
19use chrono::Utc;
20use quick_xml::se::to_string as xml_to_string;
21use serde::{Deserialize, Serialize};
22use std::time::Duration;
23
24const DEFAULT_ENDPOINT: &str = "https://dns.api.nifcloud.com";
25const API_VERSION: &str = "2012-12-12N2013-12-16";
26const XMLNS: &str = "https://route53.amazonaws.com/doc/2012-12-12/";
27
28#[derive(Clone)]
29pub struct NifcloudProvider {
30    client: HttpClient,
31    access_key: String,
32    secret_key: String,
33    endpoint: String,
34}
35
36#[derive(Serialize, Debug)]
37#[serde(rename = "ChangeResourceRecordSetsRequest")]
38struct ChangeRequest {
39    #[serde(rename = "@xmlns")]
40    xmlns: &'static str,
41    #[serde(rename = "ChangeBatch")]
42    change_batch: ChangeBatch,
43}
44
45#[derive(Serialize, Debug)]
46struct ChangeBatch {
47    #[serde(rename = "Comment")]
48    comment: String,
49    #[serde(rename = "Changes")]
50    changes: Changes,
51}
52
53#[derive(Serialize, Debug)]
54struct Changes {
55    #[serde(rename = "Change")]
56    change: Vec<Change>,
57}
58
59#[derive(Serialize, Debug)]
60struct Change {
61    #[serde(rename = "Action")]
62    action: &'static str,
63    #[serde(rename = "ResourceRecordSet")]
64    resource_record_set: ResourceRecordSet,
65}
66
67#[derive(Serialize, Debug)]
68struct ResourceRecordSet {
69    #[serde(rename = "Name")]
70    name: String,
71    #[serde(rename = "Type")]
72    record_type: &'static str,
73    #[serde(rename = "TTL")]
74    ttl: u32,
75    #[serde(rename = "ResourceRecords")]
76    resource_records: ResourceRecords,
77}
78
79#[derive(Serialize, Debug)]
80struct ResourceRecords {
81    #[serde(rename = "ResourceRecord")]
82    resource_record: Vec<ResourceRecord>,
83}
84
85#[derive(Serialize, Debug)]
86struct ResourceRecord {
87    #[serde(rename = "Value")]
88    value: String,
89}
90
91#[derive(Deserialize, Debug)]
92struct ChangeResponse {
93    #[serde(rename = "ChangeInfo")]
94    #[allow(dead_code)]
95    change_info: ChangeInfo,
96}
97
98#[derive(Deserialize, Debug)]
99#[allow(dead_code)]
100struct ChangeInfo {
101    #[serde(rename = "Id")]
102    id: String,
103}
104
105#[derive(Deserialize, Debug)]
106struct ErrorResponse {
107    #[serde(rename = "Error", default)]
108    error: NifcloudError,
109}
110
111#[derive(Deserialize, Debug, Default)]
112struct NifcloudError {
113    #[serde(rename = "Code", default)]
114    code: String,
115    #[serde(rename = "Message", default)]
116    message: String,
117}
118
119#[derive(Deserialize, Debug, Default)]
120struct ListResponse {
121    #[serde(rename = "ResourceRecordSets", default)]
122    resource_record_sets: ListedRecordSets,
123    #[serde(rename = "IsTruncated", default)]
124    is_truncated: String,
125    #[serde(rename = "NextRecordName", default)]
126    next_record_name: Option<String>,
127    #[serde(rename = "NextRecordType", default)]
128    next_record_type: Option<String>,
129    #[serde(rename = "NextRecordIdentifier", default)]
130    next_record_identifier: Option<String>,
131}
132
133#[derive(Deserialize, Debug, Default)]
134struct ListedRecordSets {
135    #[serde(rename = "ResourceRecordSet", default)]
136    resource_record_set: Vec<ListedRecordSet>,
137}
138
139#[derive(Deserialize, Debug, Clone)]
140struct ListedRecordSet {
141    #[serde(rename = "Name", default)]
142    name: String,
143    #[serde(rename = "Type", default)]
144    record_type: String,
145    #[serde(rename = "TTL", default)]
146    ttl: u32,
147    #[serde(rename = "SetIdentifier", default)]
148    set_identifier: Option<String>,
149    #[serde(rename = "ResourceRecords", default)]
150    resource_records: ListedResourceRecords,
151}
152
153#[derive(Deserialize, Debug, Default, Clone)]
154struct ListedResourceRecords {
155    #[serde(rename = "ResourceRecord", default)]
156    resource_record: Vec<ListedResourceRecord>,
157}
158
159#[derive(Deserialize, Debug, Clone)]
160struct ListedResourceRecord {
161    #[serde(rename = "Value", default)]
162    value: String,
163}
164
165impl NifcloudProvider {
166    pub(crate) fn new(
167        access_key: impl AsRef<str>,
168        secret_key: impl AsRef<str>,
169        timeout: Option<Duration>,
170    ) -> crate::Result<Self> {
171        let access_key = access_key.as_ref();
172        let secret_key = secret_key.as_ref();
173        if access_key.is_empty() || secret_key.is_empty() {
174            return Err(Error::Api("Nifcloud credentials missing".into()));
175        }
176        let client = HttpClientBuilder::default()
177            .with_header("Accept", "application/xml")
178            .with_timeout(timeout)
179            .build();
180        Ok(Self {
181            client,
182            access_key: access_key.to_string(),
183            secret_key: secret_key.to_string(),
184            endpoint: DEFAULT_ENDPOINT.to_string(),
185        })
186    }
187
188    #[cfg(test)]
189    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
190        Self {
191            endpoint: endpoint.as_ref().to_string(),
192            ..self
193        }
194    }
195
196    fn signed(&self, request: crate::http::HttpRequest) -> crate::http::HttpRequest {
197        let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
198        let mac = hmac_sha256(self.secret_key.as_bytes(), date.as_bytes());
199        let signature = BASE64_STANDARD.encode(&mac);
200        let auth = format!(
201            "NIFTY3-HTTPS NiftyAccessKeyId={},Algorithm=HmacSHA256,Signature={}",
202            self.access_key, signature
203        );
204        request
205            .set_header("Content-Type", "text/xml; charset=utf-8")
206            .with_header("Date", date)
207            .with_header("X-Nifty-Authorization", auth)
208    }
209
210    pub(crate) async fn set_rrset(
211        &self,
212        name: impl IntoFqdn<'_>,
213        record_type: DnsRecordType,
214        ttl: u32,
215        records: Vec<DnsRecord>,
216        origin: impl IntoFqdn<'_>,
217    ) -> crate::Result<()> {
218        let type_str = dns_type_str(record_type)?;
219        check_record_types(record_type, &records)?;
220        let name_str = name.into_name().to_string();
221        let domain = origin.into_name().to_string();
222        let subdomain_name = normalized_record_name(&name_str, &domain);
223
224        let desired = build_values(record_type, &records)?;
225        let existing = self
226            .list_existing(&domain, &subdomain_name, type_str)
227            .await?;
228
229        let mut changes: Vec<Change> = Vec::new();
230        if let Some(prev) = existing.first() {
231            let current_values: Vec<String> = prev
232                .resource_records
233                .resource_record
234                .iter()
235                .map(|r| r.value.clone())
236                .collect();
237            if prev.ttl == ttl && values_equal(&current_values, &desired) {
238                return Ok(());
239            }
240            changes.push(Change {
241                action: "DELETE",
242                resource_record_set: ResourceRecordSet {
243                    name: subdomain_name.clone(),
244                    record_type: type_str,
245                    ttl: prev.ttl,
246                    resource_records: ResourceRecords {
247                        resource_record: current_values
248                            .into_iter()
249                            .map(|value| ResourceRecord { value })
250                            .collect(),
251                    },
252                },
253            });
254        }
255
256        if !desired.is_empty() {
257            changes.push(Change {
258                action: "CREATE",
259                resource_record_set: ResourceRecordSet {
260                    name: subdomain_name,
261                    record_type: type_str,
262                    ttl,
263                    resource_records: ResourceRecords {
264                        resource_record: desired
265                            .into_iter()
266                            .map(|value| ResourceRecord { value })
267                            .collect(),
268                    },
269                },
270            });
271        }
272
273        if changes.is_empty() {
274            return Ok(());
275        }
276
277        self.send_change(
278            &domain,
279            ChangeRequest {
280                xmlns: XMLNS,
281                change_batch: ChangeBatch {
282                    comment: "Managed by dns-update".into(),
283                    changes: Changes { change: changes },
284                },
285            },
286        )
287        .await
288        .map(|_| ())
289    }
290
291    pub(crate) async fn add_to_rrset(
292        &self,
293        name: impl IntoFqdn<'_>,
294        record_type: DnsRecordType,
295        ttl: u32,
296        records: Vec<DnsRecord>,
297        origin: impl IntoFqdn<'_>,
298    ) -> crate::Result<()> {
299        if records.is_empty() {
300            return Ok(());
301        }
302        let type_str = dns_type_str(record_type)?;
303        check_record_types(record_type, &records)?;
304        let name_str = name.into_name().to_string();
305        let domain = origin.into_name().to_string();
306        let subdomain_name = normalized_record_name(&name_str, &domain);
307
308        let new_values = build_values(record_type, &records)?;
309        let existing = self
310            .list_existing(&domain, &subdomain_name, type_str)
311            .await?;
312
313        let mut changes: Vec<Change> = Vec::new();
314        let merged: Vec<String>;
315        let merged_ttl: u32;
316
317        if let Some(prev) = existing.first() {
318            let current_values: Vec<String> = prev
319                .resource_records
320                .resource_record
321                .iter()
322                .map(|r| r.value.clone())
323                .collect();
324            let mut combined = current_values.clone();
325            for value in &new_values {
326                if !combined.iter().any(|v| v == value) {
327                    combined.push(value.clone());
328                }
329            }
330            if values_equal(&current_values, &combined) {
331                return Ok(());
332            }
333            changes.push(Change {
334                action: "DELETE",
335                resource_record_set: ResourceRecordSet {
336                    name: subdomain_name.clone(),
337                    record_type: type_str,
338                    ttl: prev.ttl,
339                    resource_records: ResourceRecords {
340                        resource_record: current_values
341                            .into_iter()
342                            .map(|value| ResourceRecord { value })
343                            .collect(),
344                    },
345                },
346            });
347            merged = combined;
348            merged_ttl = prev.ttl;
349        } else {
350            merged = new_values;
351            merged_ttl = ttl;
352        }
353
354        changes.push(Change {
355            action: "CREATE",
356            resource_record_set: ResourceRecordSet {
357                name: subdomain_name,
358                record_type: type_str,
359                ttl: merged_ttl,
360                resource_records: ResourceRecords {
361                    resource_record: merged
362                        .into_iter()
363                        .map(|value| ResourceRecord { value })
364                        .collect(),
365                },
366            },
367        });
368
369        self.send_change(
370            &domain,
371            ChangeRequest {
372                xmlns: XMLNS,
373                change_batch: ChangeBatch {
374                    comment: "Managed by dns-update".into(),
375                    changes: Changes { change: changes },
376                },
377            },
378        )
379        .await
380        .map(|_| ())
381    }
382
383    pub(crate) async fn remove_from_rrset(
384        &self,
385        name: impl IntoFqdn<'_>,
386        record_type: DnsRecordType,
387        records: Vec<DnsRecord>,
388        origin: impl IntoFqdn<'_>,
389    ) -> crate::Result<()> {
390        if records.is_empty() {
391            return Ok(());
392        }
393        let type_str = dns_type_str(record_type)?;
394        check_record_types(record_type, &records)?;
395        let name_str = name.into_name().to_string();
396        let domain = origin.into_name().to_string();
397        let subdomain_name = normalized_record_name(&name_str, &domain);
398
399        let to_remove = build_values(record_type, &records)?;
400        let existing = self
401            .list_existing(&domain, &subdomain_name, type_str)
402            .await?;
403
404        let Some(prev) = existing.first() else {
405            return Ok(());
406        };
407        let current_values: Vec<String> = prev
408            .resource_records
409            .resource_record
410            .iter()
411            .map(|r| r.value.clone())
412            .collect();
413        let remaining: Vec<String> = current_values
414            .iter()
415            .filter(|v| !to_remove.iter().any(|r| r == *v))
416            .cloned()
417            .collect();
418        if remaining.len() == current_values.len() {
419            return Ok(());
420        }
421
422        let mut changes: Vec<Change> = Vec::new();
423        changes.push(Change {
424            action: "DELETE",
425            resource_record_set: ResourceRecordSet {
426                name: subdomain_name.clone(),
427                record_type: type_str,
428                ttl: prev.ttl,
429                resource_records: ResourceRecords {
430                    resource_record: current_values
431                        .into_iter()
432                        .map(|value| ResourceRecord { value })
433                        .collect(),
434                },
435            },
436        });
437        if !remaining.is_empty() {
438            changes.push(Change {
439                action: "CREATE",
440                resource_record_set: ResourceRecordSet {
441                    name: subdomain_name,
442                    record_type: type_str,
443                    ttl: prev.ttl,
444                    resource_records: ResourceRecords {
445                        resource_record: remaining
446                            .into_iter()
447                            .map(|value| ResourceRecord { value })
448                            .collect(),
449                    },
450                },
451            });
452        }
453
454        self.send_change(
455            &domain,
456            ChangeRequest {
457                xmlns: XMLNS,
458                change_batch: ChangeBatch {
459                    comment: "Managed by dns-update".into(),
460                    changes: Changes { change: changes },
461                },
462            },
463        )
464        .await
465        .map(|_| ())
466    }
467
468    pub(crate) async fn list_rrset(
469        &self,
470        name: impl IntoFqdn<'_>,
471        record_type: DnsRecordType,
472        origin: impl IntoFqdn<'_>,
473    ) -> crate::Result<Vec<DnsRecord>> {
474        let type_str = dns_type_str(record_type)?;
475        let name_str = name.into_name().to_string();
476        let domain = origin.into_name().to_string();
477        let subdomain_name = normalized_record_name(&name_str, &domain);
478
479        let existing = self
480            .list_existing(&domain, &subdomain_name, type_str)
481            .await?;
482        let mut out = Vec::new();
483        for rrset in existing {
484            for record in rrset.resource_records.resource_record {
485                out.push(parse_value(record_type, &record.value)?);
486            }
487        }
488        Ok(out)
489    }
490
491    async fn list_existing(
492        &self,
493        domain: &str,
494        subdomain_name: &str,
495        type_str: &str,
496    ) -> crate::Result<Vec<ListedRecordSet>> {
497        let target_name_a = subdomain_name.to_string();
498        let target_name_b = if subdomain_name == "@" {
499            domain.trim_end_matches('.').to_string()
500        } else {
501            format!("{}.{}", subdomain_name, domain.trim_end_matches('.'))
502        };
503
504        let mut out: Vec<ListedRecordSet> = Vec::new();
505        let mut next_name: Option<String> = Some(target_name_b.clone());
506        let mut next_type: Option<String> = Some(type_str.to_string());
507        let mut next_identifier: Option<String> = None;
508
509        loop {
510            let mut query = String::new();
511            if let Some(n) = next_name.as_ref() {
512                query.push_str(&format!("name={}", urlencode(n)));
513            }
514            if let Some(t) = next_type.as_ref() {
515                if !query.is_empty() {
516                    query.push('&');
517                }
518                query.push_str(&format!("type={t}"));
519            }
520            if let Some(i) = next_identifier.as_ref() {
521                if !query.is_empty() {
522                    query.push('&');
523                }
524                query.push_str(&format!("identifier={}", urlencode(i)));
525            }
526            let url = if query.is_empty() {
527                format!(
528                    "{}/{}/hostedzone/{}/rrset",
529                    self.endpoint, API_VERSION, domain
530                )
531            } else {
532                format!(
533                    "{}/{}/hostedzone/{}/rrset?{}",
534                    self.endpoint, API_VERSION, domain, query
535                )
536            };
537            let response = self.signed(self.client.get(url)).send_raw().await?;
538            if response.contains("<Error>") {
539                let parsed: Result<ErrorResponse, _> = quick_xml::de::from_str(&response);
540                if let Ok(err) = parsed {
541                    return Err(Error::Api(format!(
542                        "Nifcloud error {}: {}",
543                        err.error.code, err.error.message
544                    )));
545                }
546                return Err(Error::Api(format!("Nifcloud error response: {response}")));
547            }
548            let list: ListResponse = quick_xml::de::from_str(&response)
549                .map_err(|e| Error::Serialize(format!("XML deserialization failed: {e}")))?;
550            for rrset in list.resource_record_sets.resource_record_set {
551                if rrset.set_identifier.is_some() {
552                    continue;
553                }
554                if rrset.record_type != type_str {
555                    continue;
556                }
557                let candidate = rrset.name.trim_end_matches('.');
558                if candidate == target_name_a.trim_end_matches('.')
559                    || candidate == target_name_b.trim_end_matches('.')
560                {
561                    out.push(rrset);
562                }
563            }
564            if list.is_truncated.eq_ignore_ascii_case("true")
565                && (list.next_record_name.is_some() || list.next_record_identifier.is_some())
566            {
567                next_name = list.next_record_name;
568                next_type = list.next_record_type;
569                next_identifier = list.next_record_identifier;
570                continue;
571            }
572            break;
573        }
574        Ok(out)
575    }
576
577    async fn send_change(&self, domain: &str, body: ChangeRequest) -> crate::Result<String> {
578        let xml_body = xml_to_string(&body)
579            .map_err(|e| Error::Serialize(format!("XML serialization failed: {e}")))?;
580        let payload = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}", xml_body);
581        let url = format!(
582            "{}/{}/hostedzone/{}/rrset",
583            self.endpoint, API_VERSION, domain
584        );
585        let response = self
586            .signed(self.client.post(url).with_raw_body(payload))
587            .send_raw()
588            .await?;
589        if response.contains("<Error>") {
590            let parsed: Result<ErrorResponse, _> = quick_xml::de::from_str(&response);
591            if let Ok(err) = parsed {
592                return Err(Error::Api(format!(
593                    "Nifcloud error {}: {}",
594                    err.error.code, err.error.message
595                )));
596            }
597            return Err(Error::Api(format!("Nifcloud error response: {response}")));
598        }
599        let _info: ChangeResponse = quick_xml::de::from_str(&response)
600            .map_err(|e| Error::Serialize(format!("XML deserialization failed: {e}")))?;
601        Ok(response)
602    }
603}
604
605fn urlencode(input: &str) -> String {
606    let mut out = String::with_capacity(input.len());
607    for byte in input.bytes() {
608        match byte {
609            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
610                out.push(byte as char);
611            }
612            _ => {
613                out.push_str(&format!("%{byte:02X}"));
614            }
615        }
616    }
617    out
618}
619
620fn normalized_record_name(name: &str, domain: &str) -> String {
621    let unfqdn = name.trim_end_matches('.');
622    let domain = domain.trim_end_matches('.');
623    if unfqdn == domain {
624        "@".to_string()
625    } else if let Some(prefix) = unfqdn.strip_suffix(&format!(".{}", domain)) {
626        prefix.to_string()
627    } else {
628        unfqdn.to_string()
629    }
630}
631
632fn values_equal(a: &[String], b: &[String]) -> bool {
633    if a.len() != b.len() {
634        return false;
635    }
636    let mut a_sorted: Vec<&String> = a.iter().collect();
637    let mut b_sorted: Vec<&String> = b.iter().collect();
638    a_sorted.sort();
639    b_sorted.sort();
640    a_sorted == b_sorted
641}
642
643fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
644    for record in records {
645        if record.as_type() != expected {
646            return Err(Error::Api(format!(
647                "RRSet record type mismatch: expected {}, got {}",
648                expected.as_str(),
649                record.as_type().as_str(),
650            )));
651        }
652    }
653    Ok(())
654}
655
656fn dns_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
657    match record_type {
658        DnsRecordType::A => Ok("A"),
659        DnsRecordType::AAAA => Ok("AAAA"),
660        DnsRecordType::CNAME => Ok("CNAME"),
661        DnsRecordType::NS => Ok("NS"),
662        DnsRecordType::MX => Ok("MX"),
663        DnsRecordType::TXT => Ok("TXT"),
664        DnsRecordType::SRV => Err(Error::Unsupported(
665            "SRV records are not supported by Nifcloud".into(),
666        )),
667        DnsRecordType::CAA => Err(Error::Unsupported(
668            "CAA records are not supported by Nifcloud".into(),
669        )),
670        DnsRecordType::TLSA => Err(Error::Unsupported(
671            "TLSA records are not supported by Nifcloud".into(),
672        )),
673    }
674}
675
676fn build_values(record_type: DnsRecordType, records: &[DnsRecord]) -> crate::Result<Vec<String>> {
677    dns_type_str(record_type)?;
678    let mut out = Vec::with_capacity(records.len());
679    for record in records {
680        out.push(build_value(record)?);
681    }
682    Ok(out)
683}
684
685fn build_value(record: &DnsRecord) -> crate::Result<String> {
686    Ok(match record {
687        DnsRecord::A(addr) => addr.to_string(),
688        DnsRecord::AAAA(addr) => addr.to_string(),
689        DnsRecord::CNAME(target) => target.clone(),
690        DnsRecord::NS(target) => target.clone(),
691        DnsRecord::MX(mx) => format!("{} {}", mx.priority, mx.exchange),
692        DnsRecord::TXT(text) => {
693            let mut out = String::new();
694            txt_chunks_to_text(&mut out, text, " ");
695            out
696        }
697        DnsRecord::SRV(_) => {
698            return Err(Error::Unsupported(
699                "SRV records are not supported by Nifcloud".into(),
700            ));
701        }
702        DnsRecord::CAA(_) => {
703            return Err(Error::Unsupported(
704                "CAA records are not supported by Nifcloud".into(),
705            ));
706        }
707        DnsRecord::TLSA(_) => {
708            return Err(Error::Unsupported(
709                "TLSA records are not supported by Nifcloud".into(),
710            ));
711        }
712    })
713}
714
715fn parse_value(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
716    Ok(match record_type {
717        DnsRecordType::A => DnsRecord::A(
718            value
719                .parse()
720                .map_err(|e| Error::Parse(format!("invalid A value: {e}")))?,
721        ),
722        DnsRecordType::AAAA => DnsRecord::AAAA(
723            value
724                .parse()
725                .map_err(|e| Error::Parse(format!("invalid AAAA value: {e}")))?,
726        ),
727        DnsRecordType::CNAME => DnsRecord::CNAME(value.trim_end_matches('.').to_string()),
728        DnsRecordType::NS => DnsRecord::NS(value.trim_end_matches('.').to_string()),
729        DnsRecordType::MX => {
730            let (prio, exchange) = value
731                .split_once(' ')
732                .ok_or_else(|| Error::Parse(format!("invalid MX value: {value}")))?;
733            DnsRecord::MX(MXRecord {
734                priority: prio
735                    .trim()
736                    .parse()
737                    .map_err(|e| Error::Parse(format!("invalid MX priority: {e}")))?,
738                exchange: exchange.trim().trim_end_matches('.').to_string(),
739            })
740        }
741        DnsRecordType::TXT => DnsRecord::TXT(unquote_txt(value)),
742        DnsRecordType::SRV => {
743            return Err(Error::Unsupported(
744                "SRV records are not supported by Nifcloud".into(),
745            ));
746        }
747        DnsRecordType::CAA => {
748            return Err(Error::Unsupported(
749                "CAA records are not supported by Nifcloud".into(),
750            ));
751        }
752        DnsRecordType::TLSA => {
753            return Err(Error::Unsupported(
754                "TLSA records are not supported by Nifcloud".into(),
755            ));
756        }
757    })
758}
759
760fn unquote_txt(content: &str) -> String {
761    let trimmed = content.trim();
762    let mut out = String::new();
763    let mut chars = trimmed.chars().peekable();
764    let mut in_quotes = false;
765    while let Some(ch) = chars.next() {
766        match ch {
767            '"' => {
768                in_quotes = !in_quotes;
769            }
770            '\\' => {
771                if let Some(next) = chars.next() {
772                    out.push(next);
773                }
774            }
775            c if !in_quotes && c.is_whitespace() => {}
776            c => out.push(c),
777        }
778    }
779    out
780}