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#[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        // FIXME: Similar to other impls, can dedup?
53        let mut recs: Records<T> = match response {
54            Some(rec) => rec,
55            None => return Ok(None)
56        };
57
58        // FIXME: Assumes no or single address (which probably makes
59        // sense for DDNS and DNS-01, but may cause issues with
60        // malformed zones).
61        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                // Assume we want to create it
132                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}