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 let mut recs: Records<T> = match response {
53 Some(rec) => rec,
54 None => return Ok(None)
55 };
56
57 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 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}