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
18pub(crate) const 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_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        // FIXME: Similar to other impls, can dedup?
60        let mut recs: Records<T> = match response {
61            Some(rec) => rec,
62            None => return Ok(None)
63        };
64
65        // FIXME: Assumes no or single address (which probably makes
66        // sense for DDNS and DNS-01, but may cause issues with
67        // malformed zones).
68        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                // Assume we want to create it
139                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}