siphon_server/
cloudflare.rs

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
10/// Cloudflare API client for DNS and Origin CA management
11pub 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/// Request body for creating an Origin CA certificate
52#[derive(Debug, Serialize)]
53struct CreateOriginCertRequest {
54    /// PEM-encoded CSR
55    csr: String,
56    /// Hostnames to include in the certificate
57    hostnames: Vec<String>,
58    /// Certificate type: "origin-rsa" or "origin-ecc"
59    request_type: String,
60    /// Validity period in days (7, 30, 90, 365, 730, 1095, or 5475)
61    requested_validity: u32,
62}
63
64/// Response from Origin CA certificate creation
65#[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/// Response from listing Origin CA certificates
79#[derive(Debug, Deserialize)]
80struct ListOriginCertsResponse {
81    success: bool,
82    result: Option<Vec<OriginCertListItem>>,
83    errors: Vec<CloudflareApiError>,
84}
85
86/// An Origin CA certificate in the list response
87#[derive(Debug, Deserialize)]
88struct OriginCertListItem {
89    id: String,
90    hostnames: Vec<String>,
91    expires_on: String,
92}
93
94/// Response from revoking an Origin CA certificate
95#[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    /// Create a DNS record for a subdomain (A record for IP, CNAME for hostname)
122    ///
123    /// # Arguments
124    /// * `subdomain` - The subdomain to create (e.g., "myapp")
125    /// * `proxied` - Whether to proxy through Cloudflare (true for HTTP, false for TCP)
126    ///
127    /// # Returns
128    /// The DNS record ID for later deletion
129    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, // Short TTL for dynamic records
161                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    /// Delete a DNS record
186    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    /// Create an Origin CA certificate for the base domain
213    ///
214    /// This generates a private key and CSR locally, then requests a certificate
215    /// from Cloudflare's Origin CA. The certificate is valid for HTTPS connections
216    /// from Cloudflare to this origin server (Full Strict mode).
217    ///
218    /// # Arguments
219    /// * `validity_days` - Certificate validity in days (default: 365)
220    ///
221    /// # Returns
222    /// An OriginCertificate containing the certificate and private key in PEM format
223    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        // Generate a new key pair
234        let key_pair = KeyPair::generate()
235            .map_err(|e| CloudflareError::Api(format!("Failed to generate key pair: {}", e)))?;
236
237        // Create certificate parameters for CSR
238        let mut params = CertificateParams::default();
239        params.distinguished_name = rcgen::DistinguishedName::new();
240
241        // Generate CSR
242        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        // Hostnames: wildcard + base domain
251        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        // Request certificate from Cloudflare Origin CA
259        // Use origin-ecc since rcgen generates ECDSA keys by default
260        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    /// List all Origin CA certificates for the zone
313    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    /// Revoke an Origin CA certificate by its ID
343    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    /// Clean up old Origin CA certificates for this domain
376    ///
377    /// This revokes any existing Origin CA certificates that match our base domain
378    /// (either *.base_domain or base_domain). Should be called before creating
379    /// a new certificate to avoid accumulating old ones.
380    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            // Check if this certificate is for our domain
387            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}