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