Skip to main content

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