Skip to main content

dns_update/providers/
netcup.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    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, Serialize};
19use std::{
20    net::AddrParseError,
21    sync::Arc,
22    time::{Duration, Instant},
23};
24use tokio::sync::Mutex;
25
26const DEFAULT_ENDPOINT: &str = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON";
27const SESSION_TTL_SECS: u64 = 10 * 60;
28
29#[derive(Clone)]
30pub struct NetcupProvider {
31    client: HttpClient,
32    endpoint: String,
33    customer_number: String,
34    api_key: String,
35    api_password: String,
36    session: Arc<Mutex<Option<(String, Instant)>>>,
37}
38
39#[derive(Serialize, Debug)]
40struct Request<P: Serialize> {
41    action: &'static str,
42    param: P,
43}
44
45#[derive(Serialize, Debug)]
46struct LoginParam<'a> {
47    customernumber: &'a str,
48    apikey: &'a str,
49    apipassword: &'a str,
50}
51
52#[derive(Serialize, Debug)]
53struct LogoutParam<'a> {
54    customernumber: &'a str,
55    apikey: &'a str,
56    apisessionid: &'a str,
57}
58
59#[derive(Serialize, Debug)]
60struct InfoDnsRecordsParam<'a> {
61    domainname: &'a str,
62    customernumber: &'a str,
63    apikey: &'a str,
64    apisessionid: &'a str,
65}
66
67#[derive(Serialize, Debug)]
68struct UpdateDnsRecordsParam<'a> {
69    domainname: &'a str,
70    customernumber: &'a str,
71    apikey: &'a str,
72    apisessionid: &'a str,
73    dnsrecordset: DnsRecordSet,
74}
75
76#[derive(Serialize, Debug)]
77struct DnsRecordSet {
78    dnsrecords: Vec<NetcupRecord>,
79}
80
81#[derive(Serialize, Deserialize, Clone, Debug)]
82struct NetcupRecord {
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    id: Option<String>,
85    hostname: String,
86    #[serde(rename = "type")]
87    record_type: String,
88    #[serde(default, skip_serializing_if = "String::is_empty")]
89    priority: String,
90    destination: String,
91    #[serde(default, skip_serializing_if = "is_false")]
92    deleterecord: bool,
93    #[serde(default, skip_serializing_if = "String::is_empty")]
94    state: String,
95}
96
97fn is_false(v: &bool) -> bool {
98    !*v
99}
100
101fn ensure_trailing_dot(value: &str) -> String {
102    if value.ends_with('.') {
103        value.to_string()
104    } else {
105        format!("{value}.")
106    }
107}
108
109fn strip_trailing_dot(value: &str) -> String {
110    value.strip_suffix('.').unwrap_or(value).to_string()
111}
112
113#[derive(Deserialize, Debug)]
114struct ResponseMsg {
115    #[serde(default)]
116    status: String,
117    #[serde(default, rename = "statuscode")]
118    status_code: i64,
119    #[serde(default, rename = "shortmessage")]
120    short_message: String,
121    #[serde(default, rename = "longmessage")]
122    long_message: String,
123    #[serde(default, rename = "responsedata")]
124    response_data: serde_json::Value,
125}
126
127#[derive(Deserialize, Debug)]
128struct LoginResponse {
129    #[serde(default, rename = "apisessionid")]
130    api_session_id: String,
131}
132
133#[derive(Deserialize, Debug)]
134struct InfoDnsRecordsResponse {
135    #[serde(default)]
136    dnsrecords: Vec<NetcupRecord>,
137}
138
139impl NetcupProvider {
140    pub(crate) fn new(
141        customer_number: impl AsRef<str>,
142        api_key: impl AsRef<str>,
143        api_password: impl AsRef<str>,
144        timeout: Option<Duration>,
145    ) -> Self {
146        let client = HttpClientBuilder::default().with_timeout(timeout).build();
147        Self {
148            client,
149            endpoint: DEFAULT_ENDPOINT.to_string(),
150            customer_number: customer_number.as_ref().to_string(),
151            api_key: api_key.as_ref().to_string(),
152            api_password: api_password.as_ref().to_string(),
153            session: Arc::new(Mutex::new(None)),
154        }
155    }
156
157    #[cfg(test)]
158    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
159        Self {
160            endpoint: endpoint.as_ref().to_string(),
161            ..self
162        }
163    }
164
165    pub(crate) async fn set_rrset(
166        &self,
167        name: impl IntoFqdn<'_>,
168        record_type: DnsRecordType,
169        _ttl: u32,
170        records: Vec<DnsRecord>,
171        origin: impl IntoFqdn<'_>,
172    ) -> crate::Result<()> {
173        check_record_types(record_type, &records)?;
174        let name = name.into_name().into_owned();
175        let origin = origin.into_name().into_owned();
176        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
177        let session = self.ensure_session().await?;
178        let listed = self.list_all_records(&origin, &session).await?;
179
180        let type_str = record_type.as_str();
181        let existing: Vec<NetcupRecord> = listed
182            .into_iter()
183            .filter(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str))
184            .collect();
185
186        let desired: Vec<NetcupRecord> = records
187            .iter()
188            .map(|r| encode_record(r, &hostname))
189            .collect::<crate::Result<Vec<_>>>()?;
190
191        let mut batch: Vec<NetcupRecord> = Vec::new();
192        let mut remaining: Vec<NetcupRecord> = existing.clone();
193
194        for want in &desired {
195            if let Some(idx) = remaining.iter().position(|r| same_payload(r, want)) {
196                remaining.swap_remove(idx);
197            } else {
198                batch.push(want.clone());
199            }
200        }
201        for stale in remaining {
202            batch.push(NetcupRecord {
203                id: stale.id,
204                hostname: stale.hostname,
205                record_type: stale.record_type,
206                priority: stale.priority,
207                destination: stale.destination,
208                deleterecord: true,
209                state: String::new(),
210            });
211        }
212
213        if batch.is_empty() {
214            return Ok(());
215        }
216        self.update_dns_records(&origin, &session, batch).await
217    }
218
219    pub(crate) async fn add_to_rrset(
220        &self,
221        name: impl IntoFqdn<'_>,
222        record_type: DnsRecordType,
223        _ttl: u32,
224        records: Vec<DnsRecord>,
225        origin: impl IntoFqdn<'_>,
226    ) -> crate::Result<()> {
227        check_record_types(record_type, &records)?;
228        if records.is_empty() {
229            return Ok(());
230        }
231        let name = name.into_name().into_owned();
232        let origin = origin.into_name().into_owned();
233        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
234        let session = self.ensure_session().await?;
235        let listed = self.list_all_records(&origin, &session).await?;
236
237        let type_str = record_type.as_str();
238        let existing: Vec<NetcupRecord> = listed
239            .into_iter()
240            .filter(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str))
241            .collect();
242
243        let desired: Vec<NetcupRecord> = records
244            .iter()
245            .map(|r| encode_record(r, &hostname))
246            .collect::<crate::Result<Vec<_>>>()?;
247
248        let mut batch: Vec<NetcupRecord> = Vec::new();
249        for want in desired {
250            if !existing.iter().any(|r| same_payload(r, &want)) {
251                batch.push(want);
252            }
253        }
254
255        if batch.is_empty() {
256            return Ok(());
257        }
258        self.update_dns_records(&origin, &session, batch).await
259    }
260
261    pub(crate) async fn remove_from_rrset(
262        &self,
263        name: impl IntoFqdn<'_>,
264        record_type: DnsRecordType,
265        records: Vec<DnsRecord>,
266        origin: impl IntoFqdn<'_>,
267    ) -> crate::Result<()> {
268        check_record_types(record_type, &records)?;
269        if records.is_empty() {
270            return Ok(());
271        }
272        let name = name.into_name().into_owned();
273        let origin = origin.into_name().into_owned();
274        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
275        let session = self.ensure_session().await?;
276        let listed = self.list_all_records(&origin, &session).await?;
277
278        let type_str = record_type.as_str();
279        let existing: Vec<NetcupRecord> = listed
280            .into_iter()
281            .filter(|r| r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str))
282            .collect();
283
284        let targets: Vec<NetcupRecord> = records
285            .iter()
286            .map(|r| encode_record(r, &hostname))
287            .collect::<crate::Result<Vec<_>>>()?;
288
289        let mut batch: Vec<NetcupRecord> = Vec::new();
290        for target in &targets {
291            if let Some(found) = existing.iter().find(|r| same_payload(r, target)) {
292                batch.push(NetcupRecord {
293                    id: found.id.clone(),
294                    hostname: found.hostname.clone(),
295                    record_type: found.record_type.clone(),
296                    priority: found.priority.clone(),
297                    destination: found.destination.clone(),
298                    deleterecord: true,
299                    state: String::new(),
300                });
301            }
302        }
303
304        if batch.is_empty() {
305            return Ok(());
306        }
307        self.update_dns_records(&origin, &session, batch).await
308    }
309
310    pub(crate) async fn list_rrset(
311        &self,
312        name: impl IntoFqdn<'_>,
313        record_type: DnsRecordType,
314        origin: impl IntoFqdn<'_>,
315    ) -> crate::Result<Vec<DnsRecord>> {
316        let name = name.into_name().into_owned();
317        let origin = origin.into_name().into_owned();
318        let hostname = strip_origin_from_name(&name, &origin, Some("@"));
319        let session = self.ensure_session().await?;
320        let listed = self.list_all_records(&origin, &session).await?;
321
322        let type_str = record_type.as_str();
323        let mut out = Vec::new();
324        for r in listed {
325            if r.hostname == hostname && r.record_type.eq_ignore_ascii_case(type_str) {
326                out.push(decode_record(record_type, &r)?);
327            }
328        }
329        Ok(out)
330    }
331
332    async fn ensure_session(&self) -> crate::Result<String> {
333        let mut guard = self.session.lock().await;
334        if let Some((ref id, expiry)) = *guard
335            && Instant::now() < expiry
336        {
337            return Ok(id.clone());
338        }
339        let id = self.login().await?;
340        let expiry = Instant::now() + Duration::from_secs(SESSION_TTL_SECS);
341        *guard = Some((id.clone(), expiry));
342        Ok(id)
343    }
344
345    async fn login(&self) -> crate::Result<String> {
346        let payload = Request {
347            action: "login",
348            param: LoginParam {
349                customernumber: &self.customer_number,
350                apikey: &self.api_key,
351                apipassword: &self.api_password,
352            },
353        };
354        let response: ResponseMsg = self
355            .client
356            .post(&self.endpoint)
357            .with_body(payload)?
358            .send()
359            .await?;
360        check_status(&response)?;
361        let parsed: LoginResponse = serde_json::from_value(response.response_data)
362            .map_err(|e| Error::Serialize(format!("Failed to parse Netcup login: {e}")))?;
363        Ok(parsed.api_session_id)
364    }
365
366    async fn update_dns_records(
367        &self,
368        domain: &str,
369        session: &str,
370        records: Vec<NetcupRecord>,
371    ) -> crate::Result<()> {
372        let payload = Request {
373            action: "updateDnsRecords",
374            param: UpdateDnsRecordsParam {
375                domainname: domain,
376                customernumber: &self.customer_number,
377                apikey: &self.api_key,
378                apisessionid: session,
379                dnsrecordset: DnsRecordSet {
380                    dnsrecords: records,
381                },
382            },
383        };
384
385        let response: ResponseMsg = self
386            .client
387            .post(&self.endpoint)
388            .with_body(payload)?
389            .send_with_retry(3)
390            .await?;
391        check_status(&response)?;
392        Ok(())
393    }
394
395    async fn list_all_records(
396        &self,
397        domain: &str,
398        session: &str,
399    ) -> crate::Result<Vec<NetcupRecord>> {
400        let payload = Request {
401            action: "infoDnsRecords",
402            param: InfoDnsRecordsParam {
403                domainname: domain,
404                customernumber: &self.customer_number,
405                apikey: &self.api_key,
406                apisessionid: session,
407            },
408        };
409        let response: ResponseMsg = self
410            .client
411            .post(&self.endpoint)
412            .with_body(payload)?
413            .send()
414            .await?;
415        check_status(&response)?;
416        let parsed: InfoDnsRecordsResponse = serde_json::from_value(response.response_data)
417            .map_err(|e| Error::Serialize(format!("Failed to parse Netcup record list: {e}")))?;
418        Ok(parsed.dnsrecords)
419    }
420
421    #[allow(dead_code)]
422    async fn logout(&self, session: &str) -> crate::Result<()> {
423        let payload = Request {
424            action: "logout",
425            param: LogoutParam {
426                customernumber: &self.customer_number,
427                apikey: &self.api_key,
428                apisessionid: session,
429            },
430        };
431        let response: ResponseMsg = self
432            .client
433            .post(&self.endpoint)
434            .with_body(payload)?
435            .send()
436            .await?;
437        check_status(&response)
438    }
439}
440
441fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
442    for r in records {
443        if r.as_type() != expected {
444            return Err(Error::Api(format!(
445                "RRSet record type mismatch: expected {}, got {}",
446                expected.as_str(),
447                r.as_type().as_str(),
448            )));
449        }
450    }
451    Ok(())
452}
453
454fn check_status(response: &ResponseMsg) -> crate::Result<()> {
455    if response.status == "success" {
456        Ok(())
457    } else {
458        Err(Error::Api(format!(
459            "Netcup API error: status={} code={} short={} long={}",
460            response.status, response.status_code, response.short_message, response.long_message
461        )))
462    }
463}
464
465fn same_payload(a: &NetcupRecord, b: &NetcupRecord) -> bool {
466    a.hostname == b.hostname
467        && a.record_type.eq_ignore_ascii_case(&b.record_type)
468        && a.destination == b.destination
469        && a.priority == b.priority
470}
471
472fn encode_record(record: &DnsRecord, hostname: &str) -> crate::Result<NetcupRecord> {
473    let (record_type, destination, priority) = match record {
474        DnsRecord::A(addr) => ("A", addr.to_string(), String::new()),
475        DnsRecord::AAAA(addr) => ("AAAA", addr.to_string(), String::new()),
476        DnsRecord::CNAME(value) => ("CNAME", ensure_trailing_dot(value), String::new()),
477        DnsRecord::NS(value) => ("NS", ensure_trailing_dot(value), String::new()),
478        DnsRecord::MX(mx) => (
479            "MX",
480            ensure_trailing_dot(&mx.exchange),
481            mx.priority.to_string(),
482        ),
483        DnsRecord::TXT(value) => ("TXT", value.clone(), String::new()),
484        DnsRecord::SRV(srv) => (
485            "SRV",
486            format!(
487                "{} {} {}",
488                srv.weight,
489                srv.port,
490                ensure_trailing_dot(&srv.target),
491            ),
492            srv.priority.to_string(),
493        ),
494        DnsRecord::CAA(caa) => {
495            let (flags, tag, value) = caa.clone().decompose();
496            (
497                "CAA",
498                format!("{} {} \"{}\"", flags, tag, value.replace('"', "\\\"")),
499                String::new(),
500            )
501        }
502        DnsRecord::TLSA(tlsa) => (
503            "TLSA",
504            format!(
505                "{} {} {} {}",
506                u8::from(tlsa.cert_usage),
507                u8::from(tlsa.selector),
508                u8::from(tlsa.matching),
509                tlsa.cert_data
510                    .iter()
511                    .map(|b| format!("{:02x}", b))
512                    .collect::<String>()
513            ),
514            String::new(),
515        ),
516    };
517
518    Ok(NetcupRecord {
519        id: None,
520        hostname: hostname.to_string(),
521        record_type: record_type.to_string(),
522        priority,
523        destination,
524        deleterecord: false,
525        state: String::new(),
526    })
527}
528
529fn decode_record(record_type: DnsRecordType, record: &NetcupRecord) -> crate::Result<DnsRecord> {
530    Ok(match record_type {
531        DnsRecordType::A => {
532            DnsRecord::A(record.destination.parse().map_err(|e: AddrParseError| {
533                Error::Parse(format!(
534                    "invalid Netcup A value '{}': {e}",
535                    record.destination
536                ))
537            })?)
538        }
539        DnsRecordType::AAAA => {
540            DnsRecord::AAAA(record.destination.parse().map_err(|e: AddrParseError| {
541                Error::Parse(format!(
542                    "invalid Netcup AAAA value '{}': {e}",
543                    record.destination
544                ))
545            })?)
546        }
547        DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(&record.destination)),
548        DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(&record.destination)),
549        DnsRecordType::MX => {
550            let priority: u16 = record.priority.parse().map_err(|e| {
551                Error::Parse(format!(
552                    "invalid Netcup MX priority '{}': {e}",
553                    record.priority
554                ))
555            })?;
556            DnsRecord::MX(MXRecord {
557                priority,
558                exchange: strip_trailing_dot(&record.destination),
559            })
560        }
561        DnsRecordType::TXT => DnsRecord::TXT(record.destination.clone()),
562        DnsRecordType::SRV => parse_srv(record)?,
563        DnsRecordType::TLSA => parse_tlsa(&record.destination)?,
564        DnsRecordType::CAA => parse_caa(&record.destination)?,
565    })
566}
567
568fn parse_srv(record: &NetcupRecord) -> crate::Result<DnsRecord> {
569    let priority: u16 = record.priority.parse().map_err(|e| {
570        Error::Parse(format!(
571            "invalid Netcup SRV priority '{}': {e}",
572            record.priority
573        ))
574    })?;
575    let value = record.destination.as_str();
576    let mut parts = value.split_whitespace();
577    let weight = parts
578        .next()
579        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
580        .parse()
581        .map_err(|e| Error::Parse(format!("invalid SRV weight in '{value}': {e}")))?;
582    let port = parts
583        .next()
584        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?
585        .parse()
586        .map_err(|e| Error::Parse(format!("invalid SRV port in '{value}': {e}")))?;
587    let target = parts
588        .next()
589        .ok_or_else(|| Error::Parse(format!("invalid SRV value '{value}'")))?;
590    Ok(DnsRecord::SRV(SRVRecord {
591        priority,
592        weight,
593        port,
594        target: strip_trailing_dot(target),
595    }))
596}
597
598fn parse_tlsa(value: &str) -> crate::Result<DnsRecord> {
599    let mut parts = value.split_whitespace();
600    let usage_raw: u8 = parts
601        .next()
602        .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
603        .parse()
604        .map_err(|e| Error::Parse(format!("invalid TLSA usage in '{value}': {e}")))?;
605    let selector_raw: u8 = parts
606        .next()
607        .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
608        .parse()
609        .map_err(|e| Error::Parse(format!("invalid TLSA selector in '{value}': {e}")))?;
610    let matching_raw: u8 = parts
611        .next()
612        .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?
613        .parse()
614        .map_err(|e| Error::Parse(format!("invalid TLSA matching in '{value}': {e}")))?;
615    let hex = parts
616        .next()
617        .ok_or_else(|| Error::Parse(format!("invalid TLSA value '{value}'")))?;
618    Ok(DnsRecord::TLSA(TLSARecord {
619        cert_usage: tlsa_cert_usage_from_u8(usage_raw)?,
620        selector: tlsa_selector_from_u8(selector_raw)?,
621        matching: tlsa_matching_from_u8(matching_raw)?,
622        cert_data: decode_hex(hex)?,
623    }))
624}
625
626fn parse_caa(value: &str) -> crate::Result<DnsRecord> {
627    let mut parts = value.splitn(3, char::is_whitespace);
628    let flags: u8 = parts
629        .next()
630        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
631        .parse()
632        .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
633    let tag = parts
634        .next()
635        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
636        .to_ascii_lowercase();
637    let raw_value = parts
638        .next()
639        .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
640        .trim();
641    let unquoted = raw_value
642        .strip_prefix('"')
643        .and_then(|s| s.strip_suffix('"'))
644        .map(|s| s.replace("\\\"", "\""))
645        .unwrap_or_else(|| raw_value.to_string());
646
647    let issuer_critical = flags & 0x80 != 0;
648    match tag.as_str() {
649        "issue" => {
650            let (name, options) = parse_caa_kv(&unquoted);
651            Ok(DnsRecord::CAA(CAARecord::Issue {
652                issuer_critical,
653                name,
654                options,
655            }))
656        }
657        "issuewild" => {
658            let (name, options) = parse_caa_kv(&unquoted);
659            Ok(DnsRecord::CAA(CAARecord::IssueWild {
660                issuer_critical,
661                name,
662                options,
663            }))
664        }
665        "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
666            issuer_critical,
667            url: unquoted,
668        })),
669        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
670    }
671}
672
673fn parse_caa_kv(value: &str) -> (Option<String>, Vec<KeyValue>) {
674    let mut parts = value.split(';').map(str::trim);
675    let name_part = parts.next().unwrap_or("").trim().to_string();
676    let name = if name_part.is_empty() {
677        None
678    } else {
679        Some(name_part)
680    };
681    let options = parts
682        .filter(|p| !p.is_empty())
683        .map(|p| match p.split_once('=') {
684            Some((k, v)) => KeyValue {
685                key: k.trim().to_string(),
686                value: v.trim().to_string(),
687            },
688            None => KeyValue {
689                key: p.trim().to_string(),
690                value: String::new(),
691            },
692        })
693        .collect();
694    (name, options)
695}
696
697fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
698    if !hex.len().is_multiple_of(2) {
699        return Err(Error::Parse(format!("invalid hex string: {hex}")));
700    }
701    (0..hex.len())
702        .step_by(2)
703        .map(|i| {
704            u8::from_str_radix(&hex[i..i + 2], 16)
705                .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
706        })
707        .collect()
708}
709
710fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
711    Ok(match value {
712        0 => TlsaCertUsage::PkixTa,
713        1 => TlsaCertUsage::PkixEe,
714        2 => TlsaCertUsage::DaneTa,
715        3 => TlsaCertUsage::DaneEe,
716        255 => TlsaCertUsage::Private,
717        _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
718    })
719}
720
721fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
722    Ok(match value {
723        0 => TlsaSelector::Full,
724        1 => TlsaSelector::Spki,
725        255 => TlsaSelector::Private,
726        _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
727    })
728}
729
730fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
731    Ok(match value {
732        0 => TlsaMatching::Raw,
733        1 => TlsaMatching::Sha256,
734        2 => TlsaMatching::Sha512,
735        255 => TlsaMatching::Private,
736        _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
737    })
738}