Skip to main content

ddns_provider_cloudflare/
lib.rs

1//! Cloudflare DNS provider – production-ready
2//!
3//! * Supports `A` / `AAAA` record *upsert* (create or update).  
4//! * Auth via **API Token** (recommended) – needs `Zone:Read` and `DNS:Edit`.  
5//! * `zone_id`  and `record_id` are cached locally to reduce API calls.  
6//! * All business errors are mapped to [`ddns_provider::ProviderError`].
7
8use async_trait::async_trait;
9use ddns_provider::{DnsProvider, ProviderError, RecordType};
10use once_cell::sync::OnceCell;
11use reqwest::{
12    Client, Response, StatusCode,
13    header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT},
14};
15use serde_json::{Value, json};
16use tracing::{debug, info};
17
18const API_ROOT: &str = "https://api.cloudflare.com/client/v4";
19
20/*──────── provider struct ────────*/
21
22pub struct CfProvider {
23    zone_name: String,
24    record_name: String,
25    rtype: RecordType,
26    ttl: u32,
27    client: Client,
28
29    zone_id: OnceCell<String>,
30    record_id: OnceCell<String>,
31}
32
33impl CfProvider {
34    pub fn new(
35        zone: &str,
36        record: &str,
37        rtype: &str,
38        ttl: u32,
39        token: &str,
40    ) -> anyhow::Result<Self> {
41        let mut hdr = HeaderMap::new();
42        hdr.insert(
43            AUTHORIZATION,
44            HeaderValue::from_str(&format!("Bearer {token}"))?,
45        );
46        hdr.insert(USER_AGENT, HeaderValue::from_static("ddns-rs (+github)"));
47        hdr.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
48
49        Ok(Self {
50            zone_name: zone.to_owned(),
51            record_name: record.to_owned(),
52            rtype: if rtype.eq_ignore_ascii_case("AAAA") {
53                RecordType::AAAA
54            } else {
55                RecordType::A
56            },
57            ttl,
58            client: Client::builder().default_headers(hdr).build()?,
59            zone_id: OnceCell::new(),
60            record_id: OnceCell::new(),
61        })
62    }
63
64    /*──────── tiny HTTP wrapper ────────*/
65
66    async fn get(&self, path: &str) -> Result<Value, ProviderError> {
67        self.check(self.client.get(format!("{API_ROOT}{path}")).send().await?)
68            .await
69    }
70    async fn post(&self, path: &str, body: Value) -> Result<Value, ProviderError> {
71        self.check(
72            self.client
73                .post(format!("{API_ROOT}{path}"))
74                .json(&body)
75                .send()
76                .await?,
77        )
78        .await
79    }
80    async fn put(&self, path: &str, body: Value) -> Result<Value, ProviderError> {
81        self.check(
82            self.client
83                .put(format!("{API_ROOT}{path}"))
84                .json(&body)
85                .send()
86                .await?,
87        )
88        .await
89    }
90
91    async fn check(&self, resp: Response) -> Result<Value, ProviderError> {
92        let status = resp.status();
93        let v: Value = resp.json().await?;
94        if status == StatusCode::OK && v["success"].as_bool().unwrap_or(false) {
95            Ok(v)
96        } else {
97            let msg = v["errors"]
98                .get(0)
99                .and_then(|e| e["message"].as_str())
100                .unwrap_or("unknown error");
101            Err(ProviderError::Api(msg.to_owned()))
102        }
103    }
104
105    /*──────── zone / record helpers ────────*/
106
107    async fn ensure_zone_id(&self) -> Result<&str, ProviderError> {
108        if let Some(id) = self.zone_id.get() {
109            return Ok(id);
110        }
111        let v = self.get(&format!("/zones?name={}", self.zone_name)).await?;
112        let id = v["result"]
113            .get(0)
114            .and_then(|r| r["id"].as_str())
115            .ok_or_else(|| ProviderError::Api("zone not found".into()))?;
116        let _ = self.zone_id.set(id.to_owned());
117        Ok(self.zone_id.get().expect("zone_id set"))
118    }
119
120    async fn ensure_record_id(&self) -> Result<Option<&str>, ProviderError> {
121        if let Some(id) = self.record_id.get() {
122            return Ok(Some(id));
123        }
124        let zid = self.ensure_zone_id().await?;
125        let full = format!("{}.{}", self.record_name, self.zone_name);
126        let v = self
127            .get(&format!(
128                "/zones/{zid}/dns_records?type={}&name={full}",
129                self.rtype_str()
130            ))
131            .await?;
132        if let Some(id) = v["result"].get(0).and_then(|r| r["id"].as_str()) {
133            let _ = self.record_id.set(id.to_owned());
134            Ok(Some(self.record_id.get().unwrap()))
135        } else {
136            Ok(None)
137        }
138    }
139
140    fn rtype_str(&self) -> &'static str {
141        match self.rtype {
142            RecordType::A => "A",
143            RecordType::AAAA => "AAAA",
144        }
145    }
146
147    /*──────── create / update helpers ────────*/
148
149    async fn create_record(&self, zid: &str, content: &str) -> Result<(), ProviderError> {
150        let body = json!({
151            "type":    self.rtype_str(),
152            "name":    self.record_name,
153            "content": content,
154            "ttl":     self.ttl,
155            "proxied": false
156        });
157        let v = self
158            .post(&format!("/zones/{zid}/dns_records"), body)
159            .await?;
160        let id = v["result"]["id"]
161            .as_str()
162            .ok_or_else(|| ProviderError::Api("create: missing id".into()))?;
163        let _ = self.record_id.set(id.to_owned());
164        info!("Cloudflare created record id={id}");
165        Ok(())
166    }
167
168    async fn update_record(
169        &self,
170        zid: &str,
171        rid: &str,
172        content: &str,
173    ) -> Result<(), ProviderError> {
174        let body = json!({
175            "type":    self.rtype_str(),
176            "name":    self.record_name,
177            "content": content,
178            "ttl":     self.ttl,
179            "proxied": false
180        });
181        self.put(&format!("/zones/{zid}/dns_records/{rid}"), body)
182            .await?;
183        info!("Cloudflare updated record id={rid}");
184        Ok(())
185    }
186}
187
188/*──────── DnsProvider impl ────────*/
189
190#[async_trait]
191impl DnsProvider for CfProvider {
192    fn name(&self) -> &'static str {
193        "Cloudflare"
194    }
195    fn zone(&self) -> &str {
196        &self.zone_name
197    }
198    fn record(&self) -> &str {
199        &self.record_name
200    }
201    fn record_type(&self) -> RecordType {
202        self.rtype
203    }
204
205    async fn upsert_record(
206        &self,
207        _zone: &str,
208        _name: &str,
209        _typ: RecordType,
210        ip: &str,
211        _ttl: u32,
212    ) -> Result<(), ProviderError> {
213        let zid = self.ensure_zone_id().await?;
214        match self.ensure_record_id().await? {
215            Some(rid) => self.update_record(zid, rid, ip).await,
216            None => self.create_record(zid, ip).await,
217        }?;
218        debug!(
219            "Cloudflare upsert {}.{} -> {}",
220            self.record_name, self.zone_name, ip
221        );
222        Ok(())
223    }
224}
225
226/*──────── optional integration test (ignored) ────────*/
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::env;
231
232    #[tokio::test(flavor = "multi_thread")]
233    #[ignore]
234    async fn live_upsert() {
235        let token = env::var("CF_TOKEN").expect("CF_TOKEN not set");
236        let cf = CfProvider::new("example.com", "test-ddns", "A", 60, &token).unwrap();
237        cf.upsert_record("example.com", "test-ddns", RecordType::A, "1.1.1.1", 60)
238            .await
239            .unwrap();
240    }
241}