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: &'static 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_record<T>(&self, _rtype: &RecordType, host: &str) -> Result<Option<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 mut recs = check_response(response)?;
65
66 let nr = recs.len();
70 if nr > 1 {
71 error!("Returned number of IPs is {nr}, should be 1");
72 return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
73 } else if nr == 0 {
74 warn!("No IP returned for {host}, continuing");
75 return Ok(None);
76 }
77
78 Ok(Some(recs.remove(0)))
79 }
80
81 fn get_zone_id(&self) -> Result<String> {
82 let mut id_p = self.zone_id.lock()
83 .map_err(|e| Error::LockingError(e.to_string()))?;
84
85 if let Some(id) = id_p.as_ref() {
86 return Ok(id.clone());
87 }
88
89 let zone = self.get_zone_info()?;
90 let id = zone.id;
91 *id_p = Some(id.clone());
92
93 Ok(id)
94 }
95
96 fn get_zone_info(&self) -> Result<ZoneInfo> {
97 let uri = format!("{API_BASE}/zones?name={}", self.config.domain);
98 let resp = http::client()
99 .get(uri)
100 .with_json_headers()
101 .with_auth(self.auth.get_header())
102 .call()?
103 .to_option::<Response<Vec<ZoneInfo>>>()?;
104 let mut zones = check_response(resp)?;
105
106 Ok(zones.remove(0))
107 }
108
109}
110
111fn check_response<T>(response: Option<Response<T>>) -> Result<T> {
112 let response = match response {
113 Some(r) => r,
114 None => return Err(Error::RecordNotFound("Record not found".to_string())),
115 };
116 if !response.success {
117 return Err(Error::ApiError("Failed to find record".to_string()))
118 }
119 Ok(response.result)
120}
121
122
123impl DnsProvider for Cloudflare {
124
125 fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
126 where
127 T: DeserializeOwned
128 {
129 let resp = self.get_upstream_record(&rtype, host)?;
130 let rec: GetRecord<T> = match resp {
131 Some(recs) => recs,
132 None => return Ok(None)
133 };
134 Ok(Some(rec.content))
135 }
136
137 fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
138 where
139 T: Display,
140 {
141 let zone_id = self.get_zone_id()?;
142 let url = format!("{API_BASE}/zones/{zone_id}/dns_records");
143
144 let rec = CreateRecord {
145 name: format!("{host}.{}", self.config.domain),
146 rtype,
147 content: record.to_string(),
148 ttl: 300,
149 };
150
151 if self.config.dry_run {
152 info!("DRY-RUN: Would have sent {rec:?} to {url}");
153 return Ok(())
154 }
155
156 let body = serde_json::to_string(&rec)?;
157 let _response = http::client().post(url)
158 .with_json_headers()
159 .with_auth(self.auth.get_header())
160 .send(body)?;
161
162 Ok(())
163 }
164
165 fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
166 where
167 T: DeserializeOwned + Display,
168 {
169 let rec: GetRecord<T> = match self.get_upstream_record(&rtype, host)? {
170 Some(rec) => rec,
171 None => {
172 warn!("UPDATE: Record {host} doesn't exist");
173 return Ok(())
174 }
175 };
176
177 let rec_id = rec.id;
178 let zone_id = self.get_zone_id()?;
179 let url = format!("{API_BASE}/zones/{zone_id}/dns_records/{rec_id}");
180
181 let record = CreateRecord {
182 name: host.to_string(),
183 rtype,
184 content: urec.to_string(),
185 ttl: 300,
186 };
187
188 if self.config.dry_run {
189 info!("DRY-RUN: Would have sent PUT to {url}");
190 return Ok(())
191 }
192
193 let body = serde_json::to_string(&record)?;
194 http::client().put(url)
195 .with_json_headers()
196 .with_auth(self.auth.get_header())
197 .send(body)?;
198
199 Ok(())
200 }
201
202 fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
203 {
204 let rec: GetRecord<String> = match self.get_upstream_record(&rtype, host)? {
205 Some(rec) => rec,
206 None => {
207 warn!("DELETE: Record {host} doesn't exist");
208 return Ok(())
209 }
210 };
211
212 let rec_id = rec.id;
213 let zone_id = self.get_zone_id()?;
214 let url = format!("{API_BASE}/zones/{zone_id}/dns_records/{rec_id}");
215
216 if self.config.dry_run {
217 info!("DRY-RUN: Would have sent DELETE to {url}");
218 return Ok(())
219 }
220
221 http::client().delete(url)
222 .with_json_headers()
223 .with_auth(self.auth.get_header())
224 .call()?;
225
226 Ok(())
227
228 }
229
230 generate_helpers!();
231
232}
233
234#[cfg(test)]
235pub(crate) mod tests {
236 use super::*;
237 use crate::{generate_tests, tests::*};
238 use std::env;
239
240 fn get_client() -> Cloudflare {
241 let auth = Auth {
242 key: env::var("CLOUDFLARE_API_KEY").unwrap(),
243 };
244 let config = Config {
245 domain: env::var("CLOUDFLARE_TEST_DOMAIN").unwrap(),
246 dry_run: false,
247 };
248 Cloudflare::new(config, auth)
249 }
250
251 generate_tests!("test_cloudflare");
252}