1use 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}