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}