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