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: &'static 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_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        // FIXME: Assumes no or single address (which probably makes
67        // sense for DDNS and DNS-01, but may cause issues with
68        // malformed zones).
69        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}