zone_update/porkbun/
mod.rs1mod 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
18pub(crate) const 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_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<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 mut recs: Records<T> = match response {
61 Some(rec) => rec,
62 None => return Ok(None)
63 };
64
65 let nr = recs.records.len();
69 if nr > 1 {
70 error!("Returned number of records is {}, should be 1", nr);
71 return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
72 } else if nr == 0 {
73 warn!("No IP returned for {host}, continuing");
74 return Ok(None);
75 }
76
77 Ok(Some(recs.records.remove(0)))
78 }
79
80 fn get_record_id(&self, rtype: &RecordType, host: &str) -> Result<Option<u64>> {
81 let id_p = self.get_upstream_record::<String>(rtype, host)?
82 .map(|r| r.id);
83 Ok(id_p)
84 }
85
86}
87
88
89impl DnsProvider for Porkbun {
90
91 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T> >
92 where
93 T: DeserializeOwned
94 {
95 let rec: Record<T> = match self.get_upstream_record(&rtype, host)? {
96 Some(rec) => rec,
97 None => return Ok(None)
98 };
99
100 Ok(Some(rec.content))
101 }
102
103 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
104 where
105 T: Serialize + DeserializeOwned + Display + Clone
106 {
107 let url = format!("{API_BASE}/create/{}", self.config.domain);
108
109 let record = CreateUpdate {
110 secretapikey: self.auth.secret.clone(),
111 apikey: self.auth.key.clone(),
112 name: host.to_string(),
113 rtype,
114 content: record.to_string(),
115 ttl: 300,
116 };
117 if self.config.dry_run {
118 info!("DRY-RUN: Would have sent {record:?} to {url}");
119 return Ok(())
120 }
121
122 let body = serde_json::to_string(&record)?;
123 let _response = http::client().post(url)
124 .with_json_headers()
125 .send(body)?
126 .check_error()?;
127
128 Ok(())
129 }
130
131 fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
132 where
133 T: Serialize + DeserializeOwned + Display + Clone
134 {
135 let existing = match self.get_upstream_record::<T>(&rtype, host)? {
136 Some(record) => record,
137 None => {
138 return self.create_record(rtype, host, urec);
140 }
141 };
142
143 let url = format!("{API_BASE}/edit/{}/{}", self.config.domain, existing.id);
144
145 let record = CreateUpdate {
146 secretapikey: self.auth.secret.clone(),
147 apikey: self.auth.key.clone(),
148 name: host.to_string(),
149 rtype,
150 content: urec.to_string(),
151 ttl: 300,
152 };
153
154 if self.config.dry_run {
155 info!("DRY-RUN: Would have sent {record:?} to {url}");
156 return Ok(())
157 }
158
159 let body = serde_json::to_string(&record)?;
160 let _response = http::client().post(url)
161 .with_json_headers()
162 .send(body)?
163 .check_error()?;
164
165 Ok(())
166 }
167
168 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
169 {
170 let id = match self.get_record_id(&rtype, host)? {
171 Some(id) => id,
172 None => {
173 warn!("No {rtype} record to delete for {host}");
174 return Ok(());
175 }
176 };
177
178 let url = format!("{API_BASE}/delete/{}/{id}", self.config.domain);
179 if self.config.dry_run {
180 info!("DRY-RUN: Would have sent DELETE to {url}");
181 return Ok(())
182 }
183
184 let auth = AuthOnly::from(self.auth.clone());
185 let body = serde_json::to_string(&auth)?;
186 http::client().post(url)
187 .with_json_headers()
188 .send(body)?;
189
190 Ok(())
191 }
192
193 generate_helpers!();
194
195}
196
197
198#[cfg(test)]
199pub(crate) mod tests {
200 use super::*;
201 use crate::{generate_tests, tests::*};
202 use std::env;
203
204 fn get_client() -> Porkbun {
205 let auth = Auth {
206 key: env::var("PORKBUN_KEY").unwrap(),
207 secret: env::var("PORKBUN_SECRET").unwrap(),
208 };
209 let config = Config {
210 domain: env::var("PORKBUN_TEST_DOMAIN").unwrap(),
211 dry_run: false,
212 };
213 Porkbun::new(config, auth)
214 }
215
216 generate_tests!("test_porkbun");
217}