Skip to main content

zone_update/porkbun/
mod.rs

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/// Authentication credentials for the Porkbun API.
21///
22/// Contains the API key and secret required for requests.
23#[derive(Clone, Debug, Deserialize)]
24pub struct Auth {
25    pub key: String,
26    pub secret: String,
27}
28
29/// Synchronous Porkbun DNS provider implementation.
30///
31/// Holds configuration and authentication state for performing API calls.
32pub struct Porkbun {
33    config: Config,
34    auth: Auth,
35}
36
37impl Porkbun {
38    /// Create a new `Porkbun` provider instance.
39    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        // FIXME: Similar to other impls, can dedup?
60        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        // FIXME (?): Assumes no or single address (which probably makes
75        // sense for DDNS and DNS-01, but may cause issues with
76        // malformed zones).
77        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                // Assume we want to create it
158                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}