Skip to main content

xbp_cli/provider_support/
cloudflare.rs

1use super::http::{AuthStrategy, HttpError, RequestFactory, ResponseBytes};
2use super::models::{
3    CloudflareDnsRecord, CloudflareDnsRecordBatch, CloudflareDnsRecordWrite, CloudflareDnsSettings,
4    CloudflareDnssec, CloudflareDnssecEdit, CloudflareEnvelope, CloudflareWorkerSecret,
5    CloudflareWorkerSettings, CloudflareZone, CloudflareZoneFilters, DomainAvailability,
6    PaginationInfo, RegisteredDomain, SecretRecord, SecretsQuota, SecretsStore,
7};
8use reqwest::header::{HeaderName, HeaderValue};
9use reqwest::StatusCode;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12
13const CLOUDFLARE_API_BASE: &str = "https://api.cloudflare.com/client/v4";
14
15#[derive(Debug, Clone)]
16pub struct CloudflareClient {
17    account_id: String,
18    http: RequestFactory,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CloudflareStoreCreateRequest {
23    pub name: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CloudflareSecretCreateRequest {
28    pub name: String,
29    pub value: String,
30    #[serde(default)]
31    pub scopes: Vec<String>,
32    #[serde(default)]
33    pub comment: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct CloudflareSecretEditRequest {
38    #[serde(default)]
39    pub name: Option<String>,
40    #[serde(default)]
41    pub value: Option<String>,
42    #[serde(default)]
43    pub scopes: Option<Vec<String>>,
44    #[serde(default)]
45    pub comment: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CloudflareSecretDuplicateRequest {
50    pub name: String,
51    #[serde(default)]
52    pub scopes: Vec<String>,
53    #[serde(default)]
54    pub comment: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CloudflareBulkDeleteRequest {
59    pub ids: Vec<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Default)]
63pub struct CloudflareZoneCreateRequest {
64    pub name: String,
65    #[serde(default)]
66    pub account: Option<CloudflareZoneAccountWrite>,
67    #[serde(default)]
68    pub jump_start: Option<bool>,
69    #[serde(rename = "type", default)]
70    pub zone_type: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Default)]
74pub struct CloudflareZoneEditRequest {
75    #[serde(default)]
76    pub paused: Option<bool>,
77    #[serde(rename = "type", default)]
78    pub zone_type: Option<String>,
79    #[serde(default)]
80    pub vanity_name_servers: Option<Vec<String>>,
81}
82
83#[derive(Debug, Clone, Serialize, Default)]
84pub struct CloudflareZoneAccountWrite {
85    #[serde(default)]
86    pub id: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Default)]
90pub struct CloudflareDnsRecordListFilters {
91    #[serde(default)]
92    pub name: Option<String>,
93    #[serde(rename = "type", default)]
94    pub record_type: Option<String>,
95    #[serde(default)]
96    pub page: Option<u64>,
97    #[serde(default)]
98    pub per_page: Option<u64>,
99}
100
101impl CloudflareClient {
102    pub fn new(
103        api_token: impl Into<String>,
104        account_id: impl Into<String>,
105    ) -> Result<Self, String> {
106        let token = api_token.into();
107        let http = RequestFactory::new(CLOUDFLARE_API_BASE)
108            .map_err(|error| error.to_string())?
109            .with_auth(AuthStrategy::Bearer(token))
110            .with_default_header(
111                HeaderName::from_static("content-type"),
112                HeaderValue::from_static("application/json"),
113            );
114        Ok(Self {
115            account_id: account_id.into(),
116            http,
117        })
118    }
119
120    pub async fn search_domains(
121        &self,
122        query: &str,
123        extension: &[String],
124    ) -> Result<Vec<DomainAvailability>, String> {
125        #[derive(Serialize)]
126        struct SearchQuery<'a> {
127            query: &'a str,
128            #[serde(skip_serializing_if = "<[String]>::is_empty")]
129            tlds: &'a [String],
130        }
131
132        let envelope: CloudflareEnvelope<Vec<DomainAvailability>> = self
133            .http
134            .get_json(
135                &format!("/accounts/{}/registrar/domain-search", self.account_id),
136                Some(&SearchQuery {
137                    query,
138                    tlds: extension,
139                }),
140            )
141            .await
142            .map_err(render_http_error)?;
143        Ok(envelope.result)
144    }
145
146    pub async fn check_domains(
147        &self,
148        domains: &[String],
149    ) -> Result<Vec<DomainAvailability>, String> {
150        let envelope: CloudflareEnvelope<Vec<DomainAvailability>> = self
151            .http
152            .post_json(
153                &format!("/accounts/{}/registrar/domain-check", self.account_id),
154                &json!({ "domains": domains }),
155            )
156            .await
157            .map_err(render_http_error)?;
158        Ok(envelope.result)
159    }
160
161    pub async fn list_registered_domains(
162        &self,
163    ) -> Result<(Vec<RegisteredDomain>, Option<PaginationInfo>), String> {
164        let envelope: CloudflareEnvelope<Vec<RegisteredDomain>> = self
165            .http
166            .get_json(
167                &format!("/accounts/{}/registrar/domains", self.account_id),
168                Option::<&Value>::None,
169            )
170            .await
171            .map_err(render_http_error)?;
172        Ok((envelope.result, envelope.result_info))
173    }
174
175    pub async fn list_stores(&self) -> Result<(Vec<SecretsStore>, Option<PaginationInfo>), String> {
176        let envelope: CloudflareEnvelope<Vec<SecretsStore>> = self
177            .http
178            .get_json(
179                &format!("/accounts/{}/secrets_store/stores", self.account_id),
180                Option::<&Value>::None,
181            )
182            .await
183            .map_err(render_http_error)?;
184        Ok((envelope.result, envelope.result_info))
185    }
186
187    pub async fn get_store(&self, store_id: &str) -> Result<SecretsStore, String> {
188        let envelope: CloudflareEnvelope<SecretsStore> = self
189            .http
190            .get_json(
191                &format!(
192                    "/accounts/{}/secrets_store/stores/{}",
193                    self.account_id, store_id
194                ),
195                Option::<&Value>::None,
196            )
197            .await
198            .map_err(render_http_error)?;
199        Ok(envelope.result)
200    }
201
202    pub async fn create_store(
203        &self,
204        request: &CloudflareStoreCreateRequest,
205    ) -> Result<SecretsStore, String> {
206        let envelope: CloudflareEnvelope<SecretsStore> = self
207            .http
208            .post_json(
209                &format!("/accounts/{}/secrets_store/stores", self.account_id),
210                request,
211            )
212            .await
213            .map_err(render_http_error)?;
214        Ok(envelope.result)
215    }
216
217    pub async fn delete_store(&self, store_id: &str) -> Result<(), String> {
218        let _: CloudflareEnvelope<Option<Value>> = self
219            .http
220            .delete_json(
221                &format!(
222                    "/accounts/{}/secrets_store/stores/{}",
223                    self.account_id, store_id
224                ),
225                Option::<&Value>::None,
226            )
227            .await
228            .map_err(render_http_error)?;
229        Ok(())
230    }
231
232    pub async fn list_secrets(
233        &self,
234        store_id: &str,
235    ) -> Result<(Vec<SecretRecord>, Option<PaginationInfo>), String> {
236        let envelope: CloudflareEnvelope<Vec<SecretRecord>> = self
237            .http
238            .get_json(
239                &format!(
240                    "/accounts/{}/secrets_store/stores/{}/secrets",
241                    self.account_id, store_id
242                ),
243                Option::<&Value>::None,
244            )
245            .await
246            .map_err(render_http_error)?;
247        Ok((envelope.result, envelope.result_info))
248    }
249
250    pub async fn get_secret(
251        &self,
252        store_id: &str,
253        secret_id: &str,
254    ) -> Result<SecretRecord, String> {
255        let envelope: CloudflareEnvelope<SecretRecord> = self
256            .http
257            .get_json(
258                &format!(
259                    "/accounts/{}/secrets_store/stores/{}/secrets/{}",
260                    self.account_id, store_id, secret_id
261                ),
262                Option::<&Value>::None,
263            )
264            .await
265            .map_err(render_http_error)?;
266        Ok(envelope.result)
267    }
268
269    pub async fn create_secret(
270        &self,
271        store_id: &str,
272        request: &CloudflareSecretCreateRequest,
273    ) -> Result<SecretRecord, String> {
274        let envelope: CloudflareEnvelope<Vec<SecretRecord>> = self
275            .http
276            .post_json(
277                &format!(
278                    "/accounts/{}/secrets_store/stores/{}/secrets",
279                    self.account_id, store_id
280                ),
281                &vec![request],
282            )
283            .await
284            .map_err(render_http_error)?;
285        envelope
286            .result
287            .into_iter()
288            .next()
289            .ok_or_else(|| "Cloudflare did not return a created secret.".to_string())
290    }
291
292    pub async fn edit_secret(
293        &self,
294        store_id: &str,
295        secret_id: &str,
296        request: &CloudflareSecretEditRequest,
297    ) -> Result<SecretRecord, String> {
298        let envelope: CloudflareEnvelope<SecretRecord> = self
299            .http
300            .patch_json(
301                &format!(
302                    "/accounts/{}/secrets_store/stores/{}/secrets/{}",
303                    self.account_id, store_id, secret_id
304                ),
305                request,
306            )
307            .await
308            .map_err(render_http_error)?;
309        Ok(envelope.result)
310    }
311
312    pub async fn delete_secret(&self, store_id: &str, secret_id: &str) -> Result<(), String> {
313        let _: CloudflareEnvelope<Option<Value>> = self
314            .http
315            .delete_json(
316                &format!(
317                    "/accounts/{}/secrets_store/stores/{}/secrets/{}",
318                    self.account_id, store_id, secret_id
319                ),
320                Option::<&Value>::None,
321            )
322            .await
323            .map_err(render_http_error)?;
324        Ok(())
325    }
326
327    pub async fn bulk_delete_secrets(
328        &self,
329        store_id: &str,
330        request: &CloudflareBulkDeleteRequest,
331    ) -> Result<(), String> {
332        let _: CloudflareEnvelope<Option<Value>> = self
333            .http
334            .delete_json_with_body(
335                &format!(
336                    "/accounts/{}/secrets_store/stores/{}/secrets",
337                    self.account_id, store_id
338                ),
339                Option::<&Value>::None,
340                request,
341            )
342            .await
343            .map_err(render_http_error)?;
344        Ok(())
345    }
346
347    pub async fn duplicate_secret(
348        &self,
349        store_id: &str,
350        secret_id: &str,
351        request: &CloudflareSecretDuplicateRequest,
352    ) -> Result<SecretRecord, String> {
353        let envelope: CloudflareEnvelope<SecretRecord> = self
354            .http
355            .post_json(
356                &format!(
357                    "/accounts/{}/secrets_store/stores/{}/secrets/{}/duplicate",
358                    self.account_id, store_id, secret_id
359                ),
360                request,
361            )
362            .await
363            .map_err(render_http_error)?;
364        Ok(envelope.result)
365    }
366
367    pub async fn get_quota(&self) -> Result<SecretsQuota, String> {
368        let envelope: CloudflareEnvelope<SecretsQuota> = self
369            .http
370            .get_json(
371                &format!("/accounts/{}/secrets_store/quota", self.account_id),
372                Option::<&Value>::None,
373            )
374            .await
375            .map_err(render_http_error)?;
376        Ok(envelope.result)
377    }
378
379    pub async fn list_worker_secrets(
380        &self,
381        script_name: &str,
382    ) -> Result<Vec<CloudflareWorkerSecret>, String> {
383        let envelope: CloudflareEnvelope<Vec<CloudflareWorkerSecret>> = self
384            .http
385            .get_json(
386                &format!(
387                    "/accounts/{}/workers/scripts/{}/secrets",
388                    self.account_id, script_name
389                ),
390                Option::<&Value>::None,
391            )
392            .await
393            .map_err(render_http_error)?;
394        Ok(envelope.result)
395    }
396
397    pub async fn get_worker_secret(
398        &self,
399        script_name: &str,
400        secret_name: &str,
401    ) -> Result<CloudflareWorkerSecret, String> {
402        let envelope: CloudflareEnvelope<CloudflareWorkerSecret> = self
403            .http
404            .get_json(
405                &format!(
406                    "/accounts/{}/workers/scripts/{}/secrets/{}",
407                    self.account_id, script_name, secret_name
408                ),
409                Option::<&Value>::None,
410            )
411            .await
412            .map_err(render_http_error)?;
413        Ok(envelope.result)
414    }
415
416    pub async fn put_worker_secret(
417        &self,
418        script_name: &str,
419        secret_name: &str,
420        secret_value: &str,
421    ) -> Result<CloudflareWorkerSecret, String> {
422        let envelope: CloudflareEnvelope<CloudflareWorkerSecret> = self
423            .http
424            .put_json(
425                &format!(
426                    "/accounts/{}/workers/scripts/{}/secrets",
427                    self.account_id, script_name
428                ),
429                &json!({
430                    "name": secret_name,
431                    "text": secret_value,
432                    "type": "secret_text"
433                }),
434            )
435            .await
436            .map_err(render_http_error)?;
437        Ok(envelope.result)
438    }
439
440    pub async fn delete_worker_secret(
441        &self,
442        script_name: &str,
443        secret_name: &str,
444    ) -> Result<(), String> {
445        let _: CloudflareEnvelope<Option<Value>> = self
446            .http
447            .delete_json(
448                &format!(
449                    "/accounts/{}/workers/scripts/{}/secrets/{}",
450                    self.account_id, script_name, secret_name
451                ),
452                Option::<&Value>::None,
453            )
454            .await
455            .map_err(render_http_error)?;
456        Ok(())
457    }
458
459    pub async fn patch_worker_secrets_bulk(
460        &self,
461        script_name: &str,
462        patch: &Value,
463    ) -> Result<Value, String> {
464        let envelope: CloudflareEnvelope<Value> = self
465            .http
466            .patch_json(
467                &format!(
468                    "/accounts/{}/workers/scripts/{}/secrets-bulk",
469                    self.account_id, script_name
470                ),
471                patch,
472            )
473            .await
474            .map_err(render_http_error)?;
475        Ok(envelope.result)
476    }
477
478    pub async fn get_worker_settings(
479        &self,
480        script_name: &str,
481    ) -> Result<Option<CloudflareWorkerSettings>, String> {
482        let result: Result<CloudflareEnvelope<CloudflareWorkerSettings>, HttpError> = self
483            .http
484            .get_json(
485                &format!(
486                    "/accounts/{}/workers/scripts/{}/settings",
487                    self.account_id, script_name
488                ),
489                Option::<&Value>::None,
490            )
491            .await;
492
493        match result {
494            Ok(envelope) => Ok(Some(envelope.result)),
495            Err(HttpError::Request {
496                status: Some(StatusCode::NOT_FOUND),
497                ..
498            }) => Ok(None),
499            Err(error) => Err(render_http_error(error)),
500        }
501    }
502
503    pub async fn list_zones(
504        &self,
505        filters: &CloudflareZoneFilters,
506    ) -> Result<(Vec<CloudflareZone>, Option<PaginationInfo>), String> {
507        let envelope: CloudflareEnvelope<Vec<CloudflareZone>> = self
508            .http
509            .get_json("/zones", Some(filters))
510            .await
511            .map_err(render_http_error)?;
512        Ok((envelope.result, envelope.result_info))
513    }
514
515    pub async fn get_zone(&self, zone_id: &str) -> Result<CloudflareZone, String> {
516        let envelope: CloudflareEnvelope<CloudflareZone> = self
517            .http
518            .get_json(&format!("/zones/{}", zone_id), Option::<&Value>::None)
519            .await
520            .map_err(render_http_error)?;
521        Ok(envelope.result)
522    }
523
524    pub async fn create_zone(
525        &self,
526        request: &CloudflareZoneCreateRequest,
527    ) -> Result<CloudflareZone, String> {
528        let envelope: CloudflareEnvelope<CloudflareZone> = self
529            .http
530            .post_json("/zones", request)
531            .await
532            .map_err(render_http_error)?;
533        Ok(envelope.result)
534    }
535
536    pub async fn edit_zone(
537        &self,
538        zone_id: &str,
539        request: &CloudflareZoneEditRequest,
540    ) -> Result<CloudflareZone, String> {
541        let envelope: CloudflareEnvelope<CloudflareZone> = self
542            .http
543            .patch_json(&format!("/zones/{}", zone_id), request)
544            .await
545            .map_err(render_http_error)?;
546        Ok(envelope.result)
547    }
548
549    pub async fn delete_zone(&self, zone_id: &str) -> Result<(), String> {
550        let _: CloudflareEnvelope<Option<Value>> = self
551            .http
552            .delete_json(&format!("/zones/{}", zone_id), Option::<&Value>::None)
553            .await
554            .map_err(render_http_error)?;
555        Ok(())
556    }
557
558    pub async fn list_records(
559        &self,
560        zone_id: &str,
561        filters: &CloudflareDnsRecordListFilters,
562    ) -> Result<(Vec<CloudflareDnsRecord>, Option<PaginationInfo>), String> {
563        let envelope: CloudflareEnvelope<Vec<CloudflareDnsRecord>> = self
564            .http
565            .get_json(&format!("/zones/{}/dns_records", zone_id), Some(filters))
566            .await
567            .map_err(render_http_error)?;
568        Ok((envelope.result, envelope.result_info))
569    }
570
571    pub async fn get_record(
572        &self,
573        zone_id: &str,
574        record_id: &str,
575    ) -> Result<CloudflareDnsRecord, String> {
576        let envelope: CloudflareEnvelope<CloudflareDnsRecord> = self
577            .http
578            .get_json(
579                &format!("/zones/{}/dns_records/{}", zone_id, record_id),
580                Option::<&Value>::None,
581            )
582            .await
583            .map_err(render_http_error)?;
584        Ok(envelope.result)
585    }
586
587    pub async fn create_record(
588        &self,
589        zone_id: &str,
590        request: &CloudflareDnsRecordWrite,
591    ) -> Result<CloudflareDnsRecord, String> {
592        let envelope: CloudflareEnvelope<CloudflareDnsRecord> = self
593            .http
594            .post_json(&format!("/zones/{}/dns_records", zone_id), request)
595            .await
596            .map_err(render_http_error)?;
597        Ok(envelope.result)
598    }
599
600    pub async fn replace_record(
601        &self,
602        zone_id: &str,
603        record_id: &str,
604        request: &CloudflareDnsRecordWrite,
605    ) -> Result<CloudflareDnsRecord, String> {
606        let envelope: CloudflareEnvelope<CloudflareDnsRecord> = self
607            .http
608            .put_json(
609                &format!("/zones/{}/dns_records/{}", zone_id, record_id),
610                request,
611            )
612            .await
613            .map_err(render_http_error)?;
614        Ok(envelope.result)
615    }
616
617    pub async fn edit_record(
618        &self,
619        zone_id: &str,
620        record_id: &str,
621        request: &CloudflareDnsRecordWrite,
622    ) -> Result<CloudflareDnsRecord, String> {
623        let envelope: CloudflareEnvelope<CloudflareDnsRecord> = self
624            .http
625            .patch_json(
626                &format!("/zones/{}/dns_records/{}", zone_id, record_id),
627                request,
628            )
629            .await
630            .map_err(render_http_error)?;
631        Ok(envelope.result)
632    }
633
634    pub async fn delete_record(&self, zone_id: &str, record_id: &str) -> Result<(), String> {
635        let _: CloudflareEnvelope<Option<Value>> = self
636            .http
637            .delete_json(
638                &format!("/zones/{}/dns_records/{}", zone_id, record_id),
639                Option::<&Value>::None,
640            )
641            .await
642            .map_err(render_http_error)?;
643        Ok(())
644    }
645
646    pub async fn batch_records(
647        &self,
648        zone_id: &str,
649        request: &CloudflareDnsRecordBatch,
650    ) -> Result<Value, String> {
651        let envelope: CloudflareEnvelope<Value> = self
652            .http
653            .post_json(&format!("/zones/{}/dns_records/batch", zone_id), request)
654            .await
655            .map_err(render_http_error)?;
656        Ok(envelope.result)
657    }
658
659    pub async fn export_records(&self, zone_id: &str) -> Result<ResponseBytes, String> {
660        self.http
661            .get_bytes(
662                &format!("/zones/{}/dns_records/export", zone_id),
663                Option::<&Value>::None,
664            )
665            .await
666            .map_err(render_http_error)
667    }
668
669    pub async fn import_records(&self, zone_id: &str, bytes: Vec<u8>) -> Result<Value, String> {
670        let response = self
671            .http
672            .post_bytes(
673                &format!("/zones/{}/dns_records/import", zone_id),
674                bytes,
675                "text/plain",
676            )
677            .await
678            .map_err(render_http_error)?;
679        let envelope: CloudflareEnvelope<Value> =
680            serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
681        Ok(envelope.result)
682    }
683
684    pub async fn get_dnssec(&self, zone_id: &str) -> Result<CloudflareDnssec, String> {
685        let envelope: CloudflareEnvelope<CloudflareDnssec> = self
686            .http
687            .get_json(
688                &format!("/zones/{}/dnssec", zone_id),
689                Option::<&Value>::None,
690            )
691            .await
692            .map_err(render_http_error)?;
693        Ok(envelope.result)
694    }
695
696    pub async fn edit_dnssec(
697        &self,
698        zone_id: &str,
699        request: &CloudflareDnssecEdit,
700    ) -> Result<CloudflareDnssec, String> {
701        let envelope: CloudflareEnvelope<CloudflareDnssec> = self
702            .http
703            .patch_json(&format!("/zones/{}/dnssec", zone_id), request)
704            .await
705            .map_err(render_http_error)?;
706        Ok(envelope.result)
707    }
708
709    pub async fn get_dns_settings(&self, zone_id: &str) -> Result<CloudflareDnsSettings, String> {
710        let envelope: CloudflareEnvelope<CloudflareDnsSettings> = self
711            .http
712            .get_json(
713                &format!("/zones/{}/dns_settings", zone_id),
714                Option::<&Value>::None,
715            )
716            .await
717            .map_err(render_http_error)?;
718        Ok(envelope.result)
719    }
720
721    pub async fn edit_dns_settings(
722        &self,
723        zone_id: &str,
724        request: &CloudflareDnsSettings,
725    ) -> Result<CloudflareDnsSettings, String> {
726        let envelope: CloudflareEnvelope<CloudflareDnsSettings> = self
727            .http
728            .patch_json(&format!("/zones/{}/dns_settings", zone_id), request)
729            .await
730            .map_err(render_http_error)?;
731        Ok(envelope.result)
732    }
733}
734
735fn render_http_error(error: HttpError) -> String {
736    error.to_string()
737}