zone_edit/porkbun/
mod.rs

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