zone_update/cloudflare/
mod.rs1mod types;
2
3use std::{fmt::{Debug, Display}, sync::Mutex};
4
5use serde::{de::DeserializeOwned, Deserialize};
6use tracing::{error, info, warn};
7
8use crate::{
9 cloudflare::types::{CreateRecord, GetRecord, GetRecords, Response, ZoneInfo}, errors::{Error, Result}, generate_helpers,
10 http::{self, ResponseToOption, WithHeaders}, Config, DnsProvider, RecordType
11};
12
13
14const API_BASE: &str = "https://api.cloudflare.com/client/v4";
15
16
17#[derive(Clone, Debug, Deserialize)]
21pub struct Auth {
22 pub key: String,
23}
24
25impl Auth {
26 fn get_header(&self) -> String {
27 format!("Bearer {}", self.key)
28 }
29}
30
31
32pub struct Cloudflare {
36 config: Config,
37 auth: Auth,
38 zone_id: Mutex<Option<String>>,
39}
40
41impl Cloudflare {
42
43 pub fn new(config: Config, auth: Auth) -> Self {
45 Self {
46 config,
47 auth,
48 zone_id: Mutex::new(None),
49 }
50 }
51
52 fn get_upstream_records<T>(&self, _rtype: &RecordType, host: &str) -> Result<Vec<GetRecord<T>>>
53 where
54 T: DeserializeOwned
55 {
56 let zone_id = self.get_zone_id()?;
57 let url = format!("{API_BASE}/zones/{zone_id}/dns_records?name={host}.{}", self.config.domain);
58
59 let response = http::client().get(url)
60 .with_json_headers()
61 .with_auth(self.auth.get_header())
62 .call()?
63 .to_option::<Response<GetRecords<T>>>()?;
64 let recs = check_response(response)?;
65
66 Ok(recs)
67 }
68
69 fn get_upstream_record<T>(&self, _rtype: &RecordType, host: &str) -> Result<Option<GetRecord<T>>>
70 where
71 T: DeserializeOwned
72 {
73 let mut recs = self.get_upstream_records(_rtype, host)?;
74
75 let nr = recs.len();
79 if nr > 1 {
80 error!("Returned number of IPs is {nr}, should be 1");
81 return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
82 } else if nr == 0 {
83 warn!("No IP returned for {host}, continuing");
84 return Ok(None);
85 }
86
87 Ok(Some(recs.remove(0)))
88 }
89
90 fn get_zone_id(&self) -> Result<String> {
91 let mut id_p = self.zone_id.lock()
92 .map_err(|e| Error::LockingError(e.to_string()))?;
93
94 if let Some(id) = id_p.as_ref() {
95 return Ok(id.clone());
96 }
97
98 let zone = self.get_zone_info()?;
99 let id = zone.id;
100 *id_p = Some(id.clone());
101
102 Ok(id)
103 }
104
105 fn get_zone_info(&self) -> Result<ZoneInfo> {
106 let uri = format!("{API_BASE}/zones?name={}", self.config.domain);
107 let resp = http::client()
108 .get(uri)
109 .with_json_headers()
110 .with_auth(self.auth.get_header())
111 .call()?
112 .to_option::<Response<Vec<ZoneInfo>>>()?;
113 let mut zones = check_response(resp)?;
114
115 Ok(zones.remove(0))
116 }
117
118 fn do_delete(&self, rec: GetRecord<String>) -> Result<()> {
119 let url = format!("{API_BASE}/zones/{}/dns_records/{}", self.get_zone_id()?, rec.id);
120
121 if self.config.dry_run {
122 info!("DRY-RUN: Would have sent DELETE to {url}");
123 return Ok(())
124 }
125
126 info!("Deleting DNS {} record {}", rec.rtype, rec.name);
127 http::client().delete(url)
128 .with_json_headers()
129 .with_auth(self.auth.get_header())
130 .call()?;
131 Ok(())
132 }
133
134}
135
136fn check_response<T>(response: Option<Response<T>>) -> Result<T> {
137 let response = match response {
138 Some(r) => r,
139 None => return Err(Error::RecordNotFound("Record not found".to_string())),
140 };
141 if !response.success {
142 return Err(Error::ApiError("Failed to find record".to_string()))
143 }
144 Ok(response.result)
145}
146
147
148impl DnsProvider for Cloudflare {
149
150 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
151 where
152 T: DeserializeOwned
153 {
154 let resp = self.get_upstream_record(&rtype, host)?;
155 let rec: GetRecord<T> = match resp {
156 Some(recs) => recs,
157 None => return Ok(None)
158 };
159 Ok(Some(rec.content))
160 }
161
162 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
163 where
164 T: Display,
165 {
166 let zone_id = self.get_zone_id()?;
167 let url = format!("{API_BASE}/zones/{zone_id}/dns_records");
168
169 let rec = CreateRecord {
170 name: format!("{host}.{}", self.config.domain),
171 rtype,
172 content: record.to_string(),
173 ttl: 300,
174 };
175
176 if self.config.dry_run {
177 info!("DRY-RUN: Would have sent {rec:?} to {url}");
178 return Ok(())
179 }
180
181 let body = serde_json::to_string(&rec)?;
182 let _response = http::client().post(url)
183 .with_json_headers()
184 .with_auth(self.auth.get_header())
185 .send(body)?;
186
187 Ok(())
188 }
189
190 fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
191 where
192 T: DeserializeOwned + Display,
193 {
194 let rec: GetRecord<T> = match self.get_upstream_record(&rtype, host)? {
195 Some(rec) => rec,
196 None => {
197 warn!("UPDATE: Record {host} doesn't exist");
198 return Ok(())
199 }
200 };
201
202 let rec_id = rec.id;
203 let zone_id = self.get_zone_id()?;
204 let url = format!("{API_BASE}/zones/{zone_id}/dns_records/{rec_id}");
205
206 let record = CreateRecord {
207 name: host.to_string(),
208 rtype,
209 content: urec.to_string(),
210 ttl: 300,
211 };
212
213 if self.config.dry_run {
214 info!("DRY-RUN: Would have sent PUT to {url}");
215 return Ok(())
216 }
217
218 let body = serde_json::to_string(&record)?;
219 http::client().put(url)
220 .with_json_headers()
221 .with_auth(self.auth.get_header())
222 .send(body)?;
223
224 Ok(())
225 }
226
227 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
228 {
229 let rec: GetRecord<String> = match self.get_upstream_record(&rtype, host)? {
230 Some(rec) => rec,
231 None => {
232 warn!("DELETE: Record {host} doesn't exist");
233 return Ok(())
234 }
235 };
236
237 self.do_delete(rec)?;
238
239 Ok(())
240
241 }
242
243 fn delete_all_records(&self, rtype: RecordType, host: &str) -> Result<()>
244 {
245 let recs: Vec<GetRecord<String>> = self.get_upstream_records(&rtype, host)?;
246 for rec in recs {
247 self.do_delete(rec)?;
248 }
249
250 Ok(())
251 }
252
253 generate_helpers!();
254}
255
256#[cfg(test)]
257pub(crate) mod tests {
258 use super::*;
259 use crate::{generate_tests, tests::*};
260 use std::env;
261
262 fn get_client() -> Cloudflare {
263 let auth = Auth {
264 key: env::var("CLOUDFLARE_API_KEY").unwrap(),
265 };
266 let config = Config {
267 domain: env::var("CLOUDFLARE_TEST_DOMAIN").unwrap(),
268 dry_run: false,
269 };
270 Cloudflare::new(config, auth)
271 }
272
273 generate_tests!("test_cloudflare");
274}