1mod types;
2
3use std::fmt::Display;
4
5use serde::{de::DeserializeOwned, Serialize};
6use tracing::{error, info, warn};
7
8use crate::{
9 errors::{Error, Result},
10 http::{self, ResponseToOption, WithHeaders},
11 porkbun::types::{
12 AuthOnly,
13 CreateUpdate,
14 Record,
15 Records
16 },
17 Config,
18 DnsProvider,
19 RecordType
20};
21
22
23pub(crate) const API_BASE: &str = "https://api.porkbun.com/api/json/v3/dns";
24
25#[derive(Clone)]
26pub struct Auth {
27 pub key: String,
28 pub secret: String,
29}
30
31pub struct Porkbun {
32 config: Config,
33 auth: Auth,
34}
35
36impl Porkbun {
37 pub fn new(config: Config, auth: Auth) -> Self {
38 Self {
39 config,
40 auth,
41 }
42 }
43
44 fn get_upstream_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<Record<T>>>
45 where
46 T: DeserializeOwned
47 {
48 let url = format!("{API_BASE}/retrieveByNameType/{}/{rtype}/{host}", self.config.domain);
49 let auth = AuthOnly::from(self.auth.clone());
50
51 let body = serde_json::to_string(&auth)?;
52 let response = http::client().post(url)
53 .with_json_headers()
54 .send(body)?
55 .to_option()?;
56
57 let mut recs: Records<T> = match response {
59 Some(rec) => rec,
60 None => return Ok(None)
61 };
62
63 let nr = recs.records.len();
67 if nr > 1 {
68 error!("Returned number of IPs is {}, should be 1", nr);
69 return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
70 } else if nr == 0 {
71 warn!("No IP returned for {host}, continuing");
72 return Ok(None);
73 }
74
75 Ok(Some(recs.records.remove(0)))
76 }
77
78 fn get_record_id(&self, rtype: &RecordType, host: &str) -> Result<Option<u64>> {
79 let id_p = self.get_upstream_record::<String>(rtype, host)?
80 .map(|r| r.id);
81 Ok(id_p)
82 }
83
84}
85
86
87impl DnsProvider for Porkbun {
88
89 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T> >
90 where
91 T: DeserializeOwned
92 {
93 let rec: Record<T> = match self.get_upstream_record(&rtype, host)? {
94 Some(rec) => rec,
95 None => return Ok(None)
96 };
97
98 Ok(Some(rec.content))
99 }
100
101 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
102 where
103 T: Serialize + DeserializeOwned + Display + Clone
104 {
105 let url = format!("{API_BASE}/create/{}", self.config.domain);
106
107 let record = CreateUpdate {
108 secretapikey: self.auth.secret.clone(),
109 apikey: self.auth.key.clone(),
110 name: host.to_string(),
111 rtype,
112 content: record.to_string(),
113 ttl: 300,
114 };
115 if self.config.dry_run {
116 info!("DRY-RUN: Would have sent {record:?} to {url}");
117 return Ok(())
118 }
119
120 let body = serde_json::to_string(&record)?;
121 let _response = http::client().post(url)
122 .with_json_headers()
123 .send(body)?
124 .check_error()?;
125
126 Ok(())
127 }
128
129 fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
130 where
131 T: Serialize + DeserializeOwned + Display + Clone
132 {
133 let existing = match self.get_upstream_record::<T>(&rtype, host)? {
134 Some(record) => record,
135 None => {
136 return self.create_record(rtype, host, urec);
138 }
139 };
140
141 let url = format!("{API_BASE}/edit/{}/{}", self.config.domain, existing.id);
142
143 let record = CreateUpdate {
144 secretapikey: self.auth.secret.clone(),
145 apikey: self.auth.key.clone(),
146 name: host.to_string(),
147 rtype,
148 content: urec.to_string(),
149 ttl: 300,
150 };
151
152 if self.config.dry_run {
153 info!("DRY-RUN: Would have sent {record:?} to {url}");
154 return Ok(())
155 }
156
157 let body = serde_json::to_string(&record)?;
158 let _response = http::client().post(url)
159 .with_json_headers()
160 .send(body)?
161 .check_error()?;
162
163 Ok(())
164 }
165
166 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
167 {
168 let id = match self.get_record_id(&rtype, host)? {
169 Some(id) => id,
170 None => {
171 warn!("No {rtype} record to delete for {host}");
172 return Ok(());
173 }
174 };
175
176 let url = format!("{API_BASE}/delete/{}/{id}", self.config.domain);
177 if self.config.dry_run {
178 info!("DRY-RUN: Would have sent DELETE to {url}");
179 return Ok(())
180 }
181
182 let auth = AuthOnly::from(self.auth.clone());
183 let body = serde_json::to_string(&auth)?;
184 http::client().post(url)
185 .with_json_headers()
186 .send(body)?;
187
188 Ok(())
189 }
190}
191
192
193#[cfg(test)]
194pub(crate) mod tests {
195 use super::*;
196 use crate::{generate_tests, tests::*};
197 use std::env;
198
199 fn get_client() -> Porkbun {
200 let auth = Auth {
201 key: env::var("PORKBUN_KEY").unwrap(),
202 secret: env::var("PORKBUN_SECRET").unwrap(),
203 };
204 let config = Config {
205 domain: env::var("PORKBUN_TEST_DOMAIN").unwrap(),
206 dry_run: false,
207 };
208 Porkbun::new(config, auth)
209 }
210
211 generate_tests!("test_porkbun");
212}