Skip to main content

dns_update_lite/providers/
porkbun.rs

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