zone_edit/porkbun/
mod.rs

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