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