ddns_provider_cloudflare/
lib.rs1use 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
20pub 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 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 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 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#[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#[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}