Skip to main content

zone_update/cloudflare/
mod.rs

1mod 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/// Authentication credentials for the Cloudflare API.
18///
19/// Contains the API key and secret required for requests.
20#[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
32/// Synchronous Cloudflare DNS provider implementation.
33///
34/// Holds configuration and authentication state for performing API calls.
35pub struct Cloudflare {
36    config: Config,
37    auth: Auth,
38    zone_id: Mutex<Option<String>>,
39}
40
41impl Cloudflare {
42
43    /// Create a new `Cloudflare` provider instance.
44    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        // FIXME: Assumes no or single address (which probably makes
76        // sense for DDNS and DNS-01, but may cause issues with
77        // malformed zones).
78        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}