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::{DnsRecord, Error, IntoFqdn, http::HttpClientBuilder, utils::strip_origin_from_name};
13use serde::{Deserialize, Serialize};
14use std::{
15    net::{Ipv4Addr, Ipv6Addr},
16    time::Duration,
17};
18
19#[derive(Clone)]
20pub struct PorkBunProvider {
21    client: HttpClientBuilder,
22    api_key: String,
23    secret_api_key: String,
24    endpoint: String,
25}
26
27/// The parameters for authenticating requests to the Porkbun API.
28#[derive(Serialize, Debug)]
29pub struct AuthParams<'a> {
30    pub secretapikey: &'a str,
31    pub apikey: &'a str,
32}
33
34/// The parameters for create and update requests to the Porkbun API.
35// Note: there are some fields in this struct that are only needed when
36// creating a new record, not when modifying an existing one, we use the same
37// struct for both operations because it simplifies the code and the extra
38// fields are simply ignored by the API during an update operation.
39#[derive(Serialize, Debug)]
40pub struct DnsRecordParams<'a> {
41    #[serde(flatten)]
42    pub auth: AuthParams<'a>,
43    pub name: &'a str,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub ttl: Option<u32>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub notes: Option<&'a str>,
48    #[serde(flatten)]
49    content: RecordData,
50}
51
52/// The response for create and update requests to the Porkbun API.
53#[derive(Deserialize, Debug)]
54pub struct ApiResponse {
55    pub status: String,
56    pub message: Option<String>,
57}
58
59// Note: some of these types are not supported at the `dns-update` library
60// level, but we include them here for completeness.
61#[derive(Serialize, Clone, Debug)]
62#[serde(tag = "type")]
63#[allow(clippy::upper_case_acronyms)]
64pub enum RecordData {
65    A { content: Ipv4Addr },
66    MX { content: String, prio: u16 },
67    CNAME { content: String },
68    ALIAS { content: String },
69    TXT { content: String },
70    NS { content: String },
71    AAAA { content: Ipv6Addr },
72    SRV { content: String, prio: u16 },
73    TLSA { content: String },
74    CAA { content: String },
75    HTTPS { content: String },
76    SVCB { content: String },
77    SSHFP { content: String },
78}
79
80/// The default endpoint for the Porkbun API.
81const DEFAULT_API_ENDPOINT: &str = "https://api.porkbun.com/api/json/v3";
82
83impl PorkBunProvider {
84    pub(crate) fn new(
85        api_key: impl AsRef<str>,
86        secret_api_key: impl AsRef<str>,
87        timeout: Option<Duration>,
88    ) -> Self {
89        let client = HttpClientBuilder::default().with_timeout(timeout);
90
91        Self {
92            client,
93            api_key: api_key.as_ref().to_string(),
94            secret_api_key: secret_api_key.as_ref().to_string(),
95            endpoint: DEFAULT_API_ENDPOINT.to_string(),
96        }
97    }
98
99    #[cfg(test)]
100    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
101        Self {
102            endpoint: endpoint.as_ref().to_string(),
103            ..self
104        }
105    }
106
107    pub(crate) async fn create(
108        &self,
109        name: impl IntoFqdn<'_>,
110        record: DnsRecord,
111        ttl: u32,
112        origin: impl IntoFqdn<'_>,
113    ) -> crate::Result<()> {
114        let name = name.into_name();
115        let domain = origin.into_name();
116        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
117
118        self.client
119            .post(format!(
120                "{endpoint}/dns/create/{domain}",
121                endpoint = self.endpoint,
122                domain = domain
123            ))
124            .with_body(DnsRecordParams {
125                auth: AuthParams {
126                    secretapikey: &self.secret_api_key,
127                    apikey: &self.api_key,
128                },
129                name: &subdomain,
130                ttl: Some(ttl),
131                notes: None,
132                content: record.into(),
133            })?
134            .send_with_retry::<ApiResponse>(3)
135            .await?
136            .into_result()
137    }
138
139    pub(crate) async fn update(
140        &self,
141        name: impl IntoFqdn<'_>,
142        record: DnsRecord,
143        ttl: u32,
144        origin: impl IntoFqdn<'_>,
145    ) -> crate::Result<()> {
146        let name = name.into_name();
147        let domain = origin.into_name();
148        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
149        let content: RecordData = record.into();
150
151        self.client
152            .post(format!(
153                "{endpoint}/dns/editByNameType/{domain}/{type}/{subdomain}",
154                endpoint = self.endpoint,
155                domain = domain,
156                type = content.variant_name(),
157                subdomain = subdomain,
158            ))
159            .with_body(DnsRecordParams {
160                auth: AuthParams {
161                    secretapikey: &self.secret_api_key,
162                    apikey: &self.api_key,
163                },
164                name: &subdomain,
165                ttl: Some(ttl),
166                notes: None,
167                content,
168            })?
169            .send_with_retry::<ApiResponse>(3)
170            .await?
171            .into_result()
172    }
173
174    pub(crate) async fn delete(
175        &self,
176        name: impl IntoFqdn<'_>,
177        origin: impl IntoFqdn<'_>,
178        record_type: crate::DnsRecordType,
179    ) -> crate::Result<()> {
180        let name = name.into_name();
181        let domain = origin.into_name();
182        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
183
184        self.client
185            .post(format!(
186                "{endpoint}/dns/deleteByNameType/{domain}/{type}/{subdomain}",
187                endpoint = self.endpoint,
188                domain = domain,
189                type = record_type,
190                subdomain = subdomain,
191            ))
192            .with_body(AuthParams {
193                secretapikey: &self.secret_api_key,
194                apikey: &self.api_key,
195            })?
196            .send_with_retry::<ApiResponse>(3)
197            .await?
198            .into_result()
199    }
200}
201
202impl ApiResponse {
203    fn into_result(self) -> crate::Result<()> {
204        if self.status == "SUCCESS" {
205            Ok(())
206        } else {
207            Err(Error::Api(self.message.unwrap_or(self.status)))
208        }
209    }
210}
211
212impl RecordData {
213    pub fn variant_name(&self) -> &'static str {
214        match self {
215            RecordData::A { .. } => "A",
216            RecordData::MX { .. } => "MX",
217            RecordData::CNAME { .. } => "CNAME",
218            RecordData::ALIAS { .. } => "ALIAS",
219            RecordData::TXT { .. } => "TXT",
220            RecordData::NS { .. } => "NS",
221            RecordData::AAAA { .. } => "AAAA",
222            RecordData::SRV { .. } => "SRV",
223            RecordData::TLSA { .. } => "TLSA",
224            RecordData::CAA { .. } => "CAA",
225            RecordData::HTTPS { .. } => "HTTPS",
226            RecordData::SVCB { .. } => "SVCB",
227            RecordData::SSHFP { .. } => "SSHFP",
228        }
229    }
230}
231
232impl From<DnsRecord> for RecordData {
233    fn from(record: DnsRecord) -> Self {
234        match record {
235            DnsRecord::A(content) => RecordData::A { content },
236            DnsRecord::AAAA(content) => RecordData::AAAA { content },
237            DnsRecord::CNAME(content) => RecordData::CNAME { content },
238            DnsRecord::NS(content) => RecordData::NS { content },
239            DnsRecord::MX(mx) => RecordData::MX {
240                content: mx.exchange,
241                prio: mx.priority,
242            },
243            DnsRecord::TXT(content) => RecordData::TXT { content },
244            DnsRecord::SRV(srv) => RecordData::SRV {
245                content: format!("{} {} {}", srv.weight, srv.port, srv.target),
246                prio: srv.priority,
247            },
248            DnsRecord::TLSA(tlsa) => RecordData::TLSA {
249                content: tlsa.to_string(),
250            },
251            DnsRecord::CAA(caa) => RecordData::CAA {
252                content: caa.to_string(),
253            },
254        }
255    }
256}