1use 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#[derive(Serialize, Debug)]
29pub struct AuthParams<'a> {
30 pub secretapikey: &'a str,
31 pub apikey: &'a str,
32}
33
34#[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#[derive(Deserialize, Debug)]
54pub struct ApiResponse {
55 pub status: String,
56 pub message: Option<String>,
57}
58
59#[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
80const 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}