1use async_trait::async_trait;
2use rcgen::{CertificateParams, KeyPair};
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::config::{DnsTarget, ResolvedCloudflareConfig};
8use crate::dns_provider::{DnsError, DnsProvider, OriginCertificate};
9
10pub struct CloudflareClient {
12 client: Client,
13 api_token: String,
14 zone_id: String,
15 dns_target: DnsTarget,
16 base_domain: String,
17}
18
19#[derive(Debug, Serialize)]
20struct CreateDnsRecord {
21 #[serde(rename = "type")]
22 record_type: String,
23 name: String,
24 content: String,
25 ttl: u32,
26 proxied: bool,
27}
28
29#[derive(Debug, Deserialize)]
30struct DnsRecordResponse {
31 success: bool,
32 result: Option<DnsRecord>,
33 errors: Vec<CloudflareApiError>,
34}
35
36#[derive(Debug, Deserialize)]
37struct DnsRecord {
38 id: String,
39}
40
41#[derive(Debug, Deserialize)]
42struct CloudflareApiError {
43 message: String,
44}
45
46#[derive(Debug, Deserialize)]
47struct DeleteResponse {
48 success: bool,
49}
50
51#[derive(Debug, Serialize)]
53struct CreateOriginCertRequest {
54 csr: String,
56 hostnames: Vec<String>,
58 request_type: String,
60 requested_validity: u32,
62}
63
64#[derive(Debug, Deserialize)]
66struct OriginCertResponse {
67 success: bool,
68 result: Option<OriginCertResult>,
69 errors: Vec<CloudflareApiError>,
70}
71
72#[derive(Debug, Deserialize)]
73struct OriginCertResult {
74 certificate: String,
75 expires_on: String,
76}
77
78#[derive(Debug, Deserialize)]
80struct ListOriginCertsResponse {
81 success: bool,
82 result: Option<Vec<OriginCertListItem>>,
83 errors: Vec<CloudflareApiError>,
84}
85
86#[derive(Debug, Deserialize)]
88struct OriginCertListItem {
89 id: String,
90 hostnames: Vec<String>,
91 expires_on: String,
92}
93
94#[derive(Debug, Deserialize)]
96struct RevokeOriginCertResponse {
97 success: bool,
98 errors: Vec<CloudflareApiError>,
99}
100
101#[derive(Debug, Error)]
102pub enum CloudflareError {
103 #[error("HTTP request failed: {0}")]
104 Request(#[from] reqwest::Error),
105
106 #[error("API error: {0}")]
107 Api(String),
108}
109
110impl CloudflareClient {
111 pub fn new(config: &ResolvedCloudflareConfig, base_domain: &str) -> Self {
112 Self {
113 client: Client::new(),
114 api_token: config.api_token.clone(),
115 zone_id: config.zone_id.clone(),
116 dns_target: config.dns_target.clone(),
117 base_domain: base_domain.to_string(),
118 }
119 }
120
121 pub async fn create_record(
130 &self,
131 subdomain: &str,
132 proxied: bool,
133 ) -> Result<String, CloudflareError> {
134 let full_name = format!("{}.{}", subdomain, self.base_domain);
135
136 let (record_type, content) = match &self.dns_target {
137 DnsTarget::Ip(ip) => ("A", ip.clone()),
138 DnsTarget::Cname(hostname) => ("CNAME", hostname.clone()),
139 };
140
141 tracing::info!(
142 "Creating DNS {} record: {} -> {} (proxied: {})",
143 record_type,
144 full_name,
145 content,
146 proxied
147 );
148
149 let response = self
150 .client
151 .post(format!(
152 "https://api.cloudflare.com/client/v4/zones/{}/dns_records",
153 self.zone_id
154 ))
155 .bearer_auth(&self.api_token)
156 .json(&CreateDnsRecord {
157 record_type: record_type.to_string(),
158 name: full_name.clone(),
159 content,
160 ttl: 60, proxied,
162 })
163 .send()
164 .await?;
165
166 let result: DnsRecordResponse = response.json().await?;
167
168 if result.success {
169 let record = result
170 .result
171 .ok_or_else(|| CloudflareError::Api("No record in response".to_string()))?;
172 tracing::info!("Created DNS record {} with ID {}", full_name, record.id);
173 Ok(record.id)
174 } else {
175 let error_msg = result
176 .errors
177 .into_iter()
178 .map(|e| e.message)
179 .collect::<Vec<_>>()
180 .join(", ");
181 Err(CloudflareError::Api(error_msg))
182 }
183 }
184
185 pub async fn delete_record(&self, record_id: &str) -> Result<(), CloudflareError> {
187 tracing::info!("Deleting DNS record {}", record_id);
188
189 let response = self
190 .client
191 .delete(format!(
192 "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}",
193 self.zone_id, record_id
194 ))
195 .bearer_auth(&self.api_token)
196 .send()
197 .await?;
198
199 let result: DeleteResponse = response.json().await?;
200
201 if result.success {
202 tracing::info!("Deleted DNS record {}", record_id);
203 Ok(())
204 } else {
205 Err(CloudflareError::Api(format!(
206 "Failed to delete record {}",
207 record_id
208 )))
209 }
210 }
211
212 pub async fn create_origin_certificate(
224 &self,
225 validity_days: u32,
226 ) -> Result<OriginCertificate, CloudflareError> {
227 tracing::info!(
228 "Creating Origin CA certificate for *.{} (valid for {} days)",
229 self.base_domain,
230 validity_days
231 );
232
233 let key_pair = KeyPair::generate()
235 .map_err(|e| CloudflareError::Api(format!("Failed to generate key pair: {}", e)))?;
236
237 let mut params = CertificateParams::default();
239 params.distinguished_name = rcgen::DistinguishedName::new();
240
241 let csr = params
243 .serialize_request(&key_pair)
244 .map_err(|e| CloudflareError::Api(format!("Failed to generate CSR: {}", e)))?;
245
246 let csr_pem = csr
247 .pem()
248 .map_err(|e| CloudflareError::Api(format!("Failed to encode CSR as PEM: {}", e)))?;
249
250 let hostnames = vec![format!("*.{}", self.base_domain), self.base_domain.clone()];
252
253 tracing::debug!(
254 "Requesting Origin CA certificate for hostnames: {:?}",
255 hostnames
256 );
257
258 let response = self
261 .client
262 .post("https://api.cloudflare.com/client/v4/certificates")
263 .bearer_auth(&self.api_token)
264 .json(&CreateOriginCertRequest {
265 csr: csr_pem,
266 hostnames,
267 request_type: "origin-ecc".to_string(),
268 requested_validity: validity_days,
269 })
270 .send()
271 .await?;
272
273 let result: OriginCertResponse = response.json().await?;
274
275 if result.success {
276 let cert_result = result
277 .result
278 .ok_or_else(|| CloudflareError::Api("No certificate in response".to_string()))?;
279
280 let private_key_pem = key_pair.serialize_pem();
281
282 tracing::info!(
283 "Created Origin CA certificate for *.{}, expires: {}",
284 self.base_domain,
285 cert_result.expires_on
286 );
287 tracing::debug!(
288 "Certificate length: {} bytes, Key length: {} bytes",
289 cert_result.certificate.len(),
290 private_key_pem.len()
291 );
292
293 Ok(OriginCertificate {
294 certificate: cert_result.certificate,
295 private_key: private_key_pem,
296 expires_on: cert_result.expires_on,
297 })
298 } else {
299 let error_msg = result
300 .errors
301 .into_iter()
302 .map(|e| e.message)
303 .collect::<Vec<_>>()
304 .join(", ");
305 Err(CloudflareError::Api(format!(
306 "Failed to create Origin CA certificate: {}",
307 error_msg
308 )))
309 }
310 }
311
312 async fn list_origin_certificates(&self) -> Result<Vec<OriginCertListItem>, CloudflareError> {
314 let response = self
315 .client
316 .get(format!(
317 "https://api.cloudflare.com/client/v4/certificates?zone_id={}",
318 self.zone_id
319 ))
320 .bearer_auth(&self.api_token)
321 .send()
322 .await?;
323
324 let result: ListOriginCertsResponse = response.json().await?;
325
326 if result.success {
327 Ok(result.result.unwrap_or_default())
328 } else {
329 let error_msg = result
330 .errors
331 .into_iter()
332 .map(|e| e.message)
333 .collect::<Vec<_>>()
334 .join(", ");
335 Err(CloudflareError::Api(format!(
336 "Failed to list Origin CA certificates: {}",
337 error_msg
338 )))
339 }
340 }
341
342 async fn revoke_origin_certificate(&self, cert_id: &str) -> Result<(), CloudflareError> {
344 tracing::info!("Revoking Origin CA certificate {}", cert_id);
345
346 let response = self
347 .client
348 .delete(format!(
349 "https://api.cloudflare.com/client/v4/certificates/{}",
350 cert_id
351 ))
352 .bearer_auth(&self.api_token)
353 .send()
354 .await?;
355
356 let result: RevokeOriginCertResponse = response.json().await?;
357
358 if result.success {
359 tracing::info!("Revoked Origin CA certificate {}", cert_id);
360 Ok(())
361 } else {
362 let error_msg = result
363 .errors
364 .into_iter()
365 .map(|e| e.message)
366 .collect::<Vec<_>>()
367 .join(", ");
368 Err(CloudflareError::Api(format!(
369 "Failed to revoke certificate {}: {}",
370 cert_id, error_msg
371 )))
372 }
373 }
374
375 pub async fn cleanup_old_origin_certificates(&self) -> Result<u32, CloudflareError> {
381 let wildcard = format!("*.{}", self.base_domain);
382 let certs = self.list_origin_certificates().await?;
383
384 let mut revoked = 0;
385 for cert in certs {
386 let matches = cert
388 .hostnames
389 .iter()
390 .any(|h| h == &self.base_domain || h == &wildcard);
391
392 if matches {
393 tracing::info!(
394 "Found old Origin CA certificate {} for {:?}, expires {}",
395 cert.id,
396 cert.hostnames,
397 cert.expires_on
398 );
399
400 if let Err(e) = self.revoke_origin_certificate(&cert.id).await {
401 tracing::warn!("Failed to revoke certificate {}: {}", cert.id, e);
402 } else {
403 revoked += 1;
404 }
405 }
406 }
407
408 if revoked > 0 {
409 tracing::info!("Revoked {} old Origin CA certificate(s)", revoked);
410 }
411
412 Ok(revoked)
413 }
414}
415
416impl From<CloudflareError> for DnsError {
417 fn from(err: CloudflareError) -> Self {
418 match err {
419 CloudflareError::Request(e) => DnsError::Request(e.to_string()),
420 CloudflareError::Api(msg) => DnsError::Api(msg),
421 }
422 }
423}
424
425#[async_trait]
426impl DnsProvider for CloudflareClient {
427 async fn create_record(&self, subdomain: &str, proxied: bool) -> Result<String, DnsError> {
428 CloudflareClient::create_record(self, subdomain, proxied)
429 .await
430 .map_err(Into::into)
431 }
432
433 async fn delete_record(&self, record_id: &str) -> Result<(), DnsError> {
434 CloudflareClient::delete_record(self, record_id)
435 .await
436 .map_err(Into::into)
437 }
438
439 async fn create_origin_certificate(
440 &self,
441 validity_days: u32,
442 ) -> Result<Option<OriginCertificate>, DnsError> {
443 CloudflareClient::create_origin_certificate(self, validity_days)
444 .await
445 .map(Some)
446 .map_err(Into::into)
447 }
448
449 async fn cleanup_old_origin_certificates(&self) -> Result<u32, DnsError> {
450 CloudflareClient::cleanup_old_origin_certificates(self)
451 .await
452 .map_err(Into::into)
453 }
454}