1use anyhow::{Context, Result, anyhow};
41use serde::{Deserialize, Serialize};
42use sha2::{Digest, Sha256};
43use std::collections::BTreeSet;
44
45const API_BASE: &str = "https://api.cloudflare.com/client/v4";
46
47const R2_BUCKET_ITEM_READ: &str = "6a018a9f2fc74eb6b293b0c548f38b39";
50const R2_BUCKET_ITEM_WRITE: &str = "2efd5506f9c8494dacb1fa10a3e7d5b6";
51const CACHE_PURGE: &str = "e17beae8b8cb423a99b1730f21238bed";
52
53const CERTIFICATE_VALIDITY_DAYS: u32 = 5475;
56const SECONDS_PER_DAY: i64 = 86_400;
57
58const CERTIFICATE_REQUEST_TYPE: &str = "origin-ecc";
61
62const PROVISIONING_TOKEN_MINUTES: i64 = 10;
66const R2_STORAGE_WRITE: &str = "bf7481a1826f439697cb59a20b22293e";
67const ZONE_READ: &str = "c8fed203ed3043cba015a93ad1616f1f";
68const CACHE_SETTINGS_WRITE: &str = "9ff81cbbe65c400b97d92c3c1033cab6";
69const ZONE_SETTINGS_WRITE: &str = "3030687196b94b638145a3953da2b699";
70const SSL_AND_CERTIFICATES_WRITE: &str = "c03055bc037c4ea9afb9a9f104b7b721";
71
72pub fn private_object_storage_bucket_name(project_id: &str) -> String {
76 format!("fn0-{project_id}-private-object-storage")
77}
78
79pub fn public_object_storage_bucket_name(project_id: &str) -> String {
80 format!("fn0-{project_id}-public-object-storage")
81}
82
83pub fn frontend_asset_bucket_name(project_id: &str) -> String {
84 format!("fn0-{project_id}-frontend-asset")
85}
86
87pub struct ReachableZone {
89 pub zone_id: String,
90 pub zone_name: String,
91 pub account_id: String,
92 pub account_name: String,
93}
94
95pub struct ZoneDiscovery {
99 client: reqwest::Client,
100 setup_token: String,
101}
102
103impl ZoneDiscovery {
104 pub fn new(setup_token: String) -> Self {
105 Self {
106 client: reqwest::Client::new(),
107 setup_token,
108 }
109 }
110
111 pub async fn list(&self, mint_reader: bool) -> Result<Vec<ReachableZone>> {
120 if !mint_reader {
121 return self.zones(&self.setup_token).await;
122 }
123 let expires_on = (chrono::Utc::now()
124 + chrono::Duration::minutes(PROVISIONING_TOKEN_MINUTES))
125 .format("%Y-%m-%dT%H:%M:%SZ")
126 .to_string();
127 let reader = mint_token(
128 &self.client,
129 &self.setup_token,
130 "fn0 setup (zone discovery)",
131 vec![serde_json::json!({
132 "effect": "allow",
133 "resources": { "com.cloudflare.api.account.*": "*" },
134 "permission_groups": [{ "id": ZONE_READ }],
135 })],
136 Some(expires_on),
137 )
138 .await?;
139 let result = self.zones(&reader.value).await;
140 if let Err(error) = revoke_token(&self.client, &self.setup_token, &reader.id).await {
141 eprintln!(
142 "warning: could not revoke the zone discovery token: {error}. It expires by \
143 itself within {PROVISIONING_TOKEN_MINUTES} minutes."
144 );
145 }
146 result
147 }
148
149 async fn zones(&self, token: &str) -> Result<Vec<ReachableZone>> {
150 #[derive(Deserialize)]
151 struct Zone {
152 id: String,
153 name: String,
154 account: Account,
155 }
156 #[derive(Deserialize)]
157 struct Account {
158 id: String,
159 #[serde(default)]
160 name: String,
161 }
162 let (status, envelope) = call::<Vec<Zone>>(
163 &self.client,
164 token,
165 reqwest::Method::GET,
166 "/zones?per_page=200",
167 None,
168 )
169 .await?;
170 let zones = envelope.result.filter(|_| envelope.success).ok_or_else(|| {
171 anyhow!(
172 "could not list your zones ({status}). The token needs Zone -> Zone -> Read. {}",
173 describe(&envelope.errors)
174 )
175 })?;
176 Ok(zones
177 .into_iter()
178 .map(|zone| ReachableZone {
179 zone_id: zone.id,
180 zone_name: zone.name,
181 account_id: zone.account.id,
182 account_name: zone.account.name,
183 })
184 .collect())
185 }
186}
187
188pub struct Provisioner {
189 client: reqwest::Client,
190 setup_token: String,
193 account_id: String,
194 zone_id: String,
195}
196
197struct TemporaryToken {
199 id: String,
200 value: String,
201}
202
203pub struct ProvisionedResources {
205 pub zone_name: String,
206 pub frontend_asset_hostname: String,
207 pub public_object_storage_hostname: String,
208 pub private_object_storage_bucket: String,
209 pub public_object_storage_bucket: String,
210 pub frontend_asset_bucket: String,
211}
212
213pub struct ConnectCredentials {
215 pub worker_access_key_id: String,
218 pub worker_secret: String,
223 pub frontend_asset_access_key_id: String,
227 pub frontend_asset_secret: String,
228 pub purge_token: String,
229}
230
231pub struct MintedCredentialIds {
234 pub worker: String,
235 pub frontend_asset: String,
236 pub purge: String,
237}
238
239pub struct IssuedCertificate {
240 pub certificate_pem: String,
241 pub private_key_pem: String,
242 pub not_after_epoch_seconds: i64,
243}
244
245#[derive(Deserialize)]
246struct Envelope<T> {
247 success: bool,
248 #[serde(default)]
249 errors: Vec<ApiError>,
250 result: Option<T>,
251}
252
253#[derive(Deserialize)]
254struct ApiError {
255 #[serde(default)]
256 code: i64,
257 #[serde(default)]
258 message: String,
259}
260
261async fn mint_token(
262 client: &reqwest::Client,
263 setup_token: &str,
264 name: &str,
265 policies: Vec<serde_json::Value>,
266 expires_on: Option<String>,
267) -> Result<TemporaryToken> {
268 #[derive(Deserialize)]
269 struct Minted {
270 id: String,
271 value: String,
272 }
273 let (status, envelope) = call::<Minted>(
274 client,
275 setup_token,
276 reqwest::Method::POST,
277 "/user/tokens",
278 Some(match expires_on {
279 Some(expires_on) => serde_json::json!({
280 "name": name, "policies": policies, "expires_on": expires_on,
281 }),
282 None => serde_json::json!({ "name": name, "policies": policies }),
283 }),
284 )
285 .await?;
286 let minted = envelope.result.filter(|_| envelope.success).ok_or_else(|| {
287 anyhow!(
288 "could not mint the {name} token ({status}). The token needs User -> API Tokens -> Edit. {}",
289 describe(&envelope.errors)
290 )
291 })?;
292 Ok(TemporaryToken {
293 id: minted.id,
294 value: minted.value,
295 })
296}
297
298async fn revoke_token(client: &reqwest::Client, setup_token: &str, id: &str) -> Result<()> {
299 let (status, envelope) = call::<serde_json::Value>(
300 client,
301 setup_token,
302 reqwest::Method::DELETE,
303 &format!("/user/tokens/{id}"),
304 None,
305 )
306 .await?;
307 if envelope.success {
308 return Ok(());
309 }
310 Err(anyhow!(
311 "could not revoke token {id} ({status}): {}",
312 describe(&envelope.errors)
313 ))
314}
315
316async fn call<T: serde::de::DeserializeOwned>(
317 client: &reqwest::Client,
318 token: &str,
319 method: reqwest::Method,
320 path: &str,
321 body: Option<serde_json::Value>,
322) -> Result<(reqwest::StatusCode, Envelope<T>)> {
323 let mut request = client
324 .request(method, format!("{API_BASE}{path}"))
325 .bearer_auth(token);
326 if let Some(body) = body {
327 request = request.json(&body);
328 }
329 let response = request.send().await?;
330 let status = response.status();
331 let text = response.text().await?;
332 let envelope: Envelope<T> =
333 serde_json::from_str(&text).with_context(|| format!("{path} returned {status}: {text}"))?;
334 Ok((status, envelope))
335}
336
337fn describe(errors: &[ApiError]) -> String {
338 if errors.is_empty() {
339 return "no detail".to_string();
340 }
341 errors
342 .iter()
343 .map(|error| format!("{} ({})", error.message, error.code))
344 .collect::<Vec<_>>()
345 .join("; ")
346}
347
348impl Provisioner {
349 pub fn new(setup_token: String, account_id: String, zone_id: String) -> Self {
350 Self {
351 client: reqwest::Client::new(),
352 setup_token,
353 account_id,
354 zone_id,
355 }
356 }
357
358 async fn call<T: serde::de::DeserializeOwned>(
359 &self,
360 token: &str,
361 method: reqwest::Method,
362 path: &str,
363 body: Option<serde_json::Value>,
364 ) -> Result<(reqwest::StatusCode, Envelope<T>)> {
365 call(&self.client, token, method, path, body).await
366 }
367
368 pub async fn verify(&self) -> Result<String> {
371 #[derive(Deserialize)]
372 struct Token {
373 id: String,
374 status: String,
375 }
376 for path in [
380 "/user/tokens/verify".to_string(),
381 format!("/accounts/{}/tokens/verify", self.account_id),
382 ] {
383 if let Ok((_, envelope)) = self
384 .call::<Token>(&self.setup_token, reqwest::Method::GET, &path, None)
385 .await
386 && let Some(token) = envelope.result.filter(|_| envelope.success)
387 {
388 if token.status != "active" {
389 return Err(anyhow!("the API token is {}, not active", token.status));
390 }
391 return Ok(token.id);
392 }
393 }
394 Err(anyhow!(
395 "Cloudflare rejected the API token. Check that it was copied whole and has not expired."
396 ))
397 }
398
399 async fn zone_name(&self, token: &str) -> Result<String> {
400 #[derive(Deserialize)]
401 struct Zone {
402 name: String,
403 }
404 let (_, envelope) = self
405 .call::<Zone>(
406 token,
407 reqwest::Method::GET,
408 &format!("/zones/{}", self.zone_id),
409 None,
410 )
411 .await?;
412 envelope
413 .result
414 .filter(|_| envelope.success)
415 .map(|zone| zone.name)
416 .ok_or_else(|| {
417 anyhow!(
418 "could not read the zone. The token needs Zone -> Zone -> Read on it. {}",
419 describe(&envelope.errors)
420 )
421 })
422 }
423
424 async fn create_bucket(&self, token: &str, name: &str) -> Result<()> {
425 let (status, envelope) = self
426 .call::<serde_json::Value>(
427 token,
428 reqwest::Method::POST,
429 &format!("/accounts/{}/r2/buckets", self.account_id),
430 Some(serde_json::json!({ "name": name })),
431 )
432 .await?;
433 if envelope.success || already_exists(&envelope.errors) {
434 return Ok(());
435 }
436 Err(anyhow!(
437 "could not create bucket {name} ({status}). The token needs Account -> Workers R2 Storage -> Edit. {}",
438 describe(&envelope.errors)
439 ))
440 }
441
442 async fn put_cors(&self, token: &str, bucket: &str, app_origin: Option<&str>) -> Result<()> {
448 let path = format!("/accounts/{}/r2/buckets/{bucket}/cors", self.account_id);
449 let (method, body) = match app_origin {
450 Some(origin) => (
451 reqwest::Method::PUT,
452 Some(serde_json::json!({
453 "rules": [{
454 "allowed": {
455 "methods": ["GET", "PUT", "HEAD"],
456 "origins": [origin],
457 "headers": ["*"],
458 },
459 "exposeHeaders": ["ETag"],
460 "maxAgeSeconds": 86400,
461 }],
462 })),
463 ),
464 None => (reqwest::Method::DELETE, None),
465 };
466 let (status, envelope) = self
467 .call::<serde_json::Value>(token, method, &path, body)
468 .await?;
469 if envelope.success || (app_origin.is_none() && cors_absent(&envelope.errors)) {
472 return Ok(());
473 }
474 Err(anyhow!(
475 "could not set CORS on {bucket} ({status}): {}",
476 describe(&envelope.errors)
477 ))
478 }
479
480 async fn attach_custom_domain(&self, token: &str, bucket: &str, hostname: &str) -> Result<()> {
481 if self.custom_domain_present(token, bucket, hostname).await {
482 return Ok(());
483 }
484 let (status, envelope) = self
485 .call::<serde_json::Value>(
486 token,
487 reqwest::Method::POST,
488 &format!(
489 "/accounts/{}/r2/buckets/{bucket}/domains/custom",
490 self.account_id
491 ),
492 Some(serde_json::json!({
493 "domain": hostname,
494 "zoneId": self.zone_id,
495 "enabled": true,
496 })),
497 )
498 .await?;
499 if envelope.success || already_exists(&envelope.errors) {
500 return Ok(());
501 }
502 Err(anyhow!(
503 "could not point {hostname} at {bucket} ({status}): {}",
504 describe(&envelope.errors)
505 ))
506 }
507
508 async fn custom_domain_present(&self, token: &str, bucket: &str, hostname: &str) -> bool {
516 #[derive(Deserialize)]
517 struct Domain {
518 domain: String,
519 }
520 #[derive(Deserialize)]
521 struct Domains {
522 #[serde(default)]
523 domains: Vec<Domain>,
524 }
525
526 let Ok((_, envelope)) = self
527 .call::<Domains>(
528 token,
529 reqwest::Method::GET,
530 &format!(
531 "/accounts/{}/r2/buckets/{bucket}/domains/custom",
532 self.account_id
533 ),
534 None,
535 )
536 .await
537 else {
538 return false;
539 };
540 envelope
541 .result
542 .filter(|_| envelope.success)
543 .is_some_and(|listed| {
544 listed
545 .domains
546 .iter()
547 .any(|attached| attached.domain == hostname)
548 })
549 }
550
551 async fn ensure_cache_rule(
569 &self,
570 token: &str,
571 zone_name: &str,
572 app_hostname: Option<&str>,
573 replaced_app_hostname: Option<&str>,
574 ) -> Result<()> {
575 const RULE_DESCRIPTION: &str = "fn0 frontend assets and public objects";
576
577 #[derive(Deserialize)]
578 struct Ruleset {
579 #[serde(default)]
580 rules: Vec<serde_json::Value>,
581 }
582
583 let path = format!(
584 "/zones/{}/rulesets/phases/http_request_cache_settings/entrypoint",
585 self.zone_id
586 );
587 let (status, envelope) = self
588 .call::<Ruleset>(token, reqwest::Method::GET, &path, None)
589 .await?;
590 let mut rules = if envelope.success {
593 envelope.result.map(|set| set.rules).unwrap_or_default()
594 } else if status == reqwest::StatusCode::NOT_FOUND {
595 Vec::new()
596 } else {
597 return Err(anyhow!(
598 "could not read the zone's cache rules ({status}). The token needs Zone -> Cache Rules -> Edit. {}",
599 describe(&envelope.errors)
600 ));
601 };
602
603 let managed_rule = rules.iter().find(|rule| {
604 rule.get("description").and_then(|value| value.as_str()) == Some(RULE_DESCRIPTION)
605 });
606 let mut app_hostnames = managed_rule
607 .map(cache_rule_app_hostnames)
608 .unwrap_or_default();
609 if let Some(replaced_app_hostname) = replaced_app_hostname {
610 app_hostnames.remove(replaced_app_hostname);
611 }
612 if let Some(app_hostname) = app_hostname {
613 app_hostnames.insert(app_hostname.to_string());
614 }
615
616 rules.retain(|rule| {
617 rule.get("description").and_then(|value| value.as_str()) != Some(RULE_DESCRIPTION)
618 });
619 rules.insert(
620 0,
621 serde_json::json!({
622 "action": "set_cache_settings",
623 "expression": format!(
624 "(({}) and http.request.method in {{\"GET\" \"HEAD\" \"PURGE\"}})",
625 cache_rule_host_expression(zone_name, &app_hostnames),
626 ),
627 "description": RULE_DESCRIPTION,
628 "action_parameters": {
629 "cache": true,
630 "browser_ttl": { "mode": "respect_origin" },
631 },
632 }),
633 );
634
635 let (status, envelope) = self
636 .call::<serde_json::Value>(
637 token,
638 reqwest::Method::PUT,
639 &path,
640 Some(serde_json::json!({ "rules": rules })),
641 )
642 .await?;
643 if envelope.success {
644 return Ok(());
645 }
646 Err(anyhow!(
647 "could not write the zone's cache rules ({status}). The token needs Zone -> Cache Rules -> Edit. {}",
648 describe(&envelope.errors)
649 ))
650 }
651
652 async fn ensure_tiered_cache(&self, token: &str) -> Result<()> {
653 let path = format!(
654 "/zones/{}/cache/tiered_cache_smart_topology_enable",
655 self.zone_id
656 );
657 let (status, envelope) = self
658 .call::<serde_json::Value>(
659 token,
660 reqwest::Method::PATCH,
661 &path,
662 Some(serde_json::json!({ "value": "on" })),
663 )
664 .await?;
665 if envelope.success {
666 return Ok(());
667 }
668 Err(anyhow!(
669 "could not enable Smart Tiered Cache ({status}). The token needs Zone -> Zone Settings -> Edit. {}",
670 describe(&envelope.errors)
671 ))
672 }
673
674 pub async fn ensure_app_cache(
675 &self,
676 app_hostname: &str,
677 replaced_app_hostname: Option<&str>,
678 mint_writing_token: bool,
679 ) -> Result<()> {
680 if !mint_writing_token {
681 let zone_name = self.zone_name(&self.setup_token).await?;
682 self.ensure_tiered_cache(&self.setup_token).await?;
683 return self
684 .ensure_cache_rule(
685 &self.setup_token,
686 &zone_name,
687 Some(app_hostname),
688 replaced_app_hostname,
689 )
690 .await;
691 }
692
693 let writing = self.mint_provisioning_token(app_hostname).await?;
694 let result = async {
695 let zone_name = self.zone_name(&writing.value).await?;
696 self.ensure_tiered_cache(&writing.value).await?;
697 self.ensure_cache_rule(
698 &writing.value,
699 &zone_name,
700 Some(app_hostname),
701 replaced_app_hostname,
702 )
703 .await
704 }
705 .await;
706 if let Err(error) = self.revoke_token("cache settings", &writing.id).await {
707 eprintln!(
708 "warning: {error}. It expires by itself within \
709 {PROVISIONING_TOKEN_MINUTES} minutes."
710 );
711 }
712 result
713 }
714
715 async fn mint_with_expiry(
716 &self,
717 name: &str,
718 policies: Vec<serde_json::Value>,
719 expires_on: Option<String>,
720 ) -> Result<(String, String)> {
721 let minted =
722 mint_token(&self.client, &self.setup_token, name, policies, expires_on).await?;
723 Ok((minted.id, minted.value))
724 }
725
726 async fn mint_provisioning_token(&self, purpose: &str) -> Result<TemporaryToken> {
729 let expires_on = (chrono::Utc::now()
730 + chrono::Duration::minutes(PROVISIONING_TOKEN_MINUTES))
731 .format("%Y-%m-%dT%H:%M:%SZ")
732 .to_string();
733 let (id, value) = self
734 .mint_with_expiry(
735 &format!("fn0 setup ({purpose})"),
736 vec![
737 serde_json::json!({
738 "effect": "allow",
739 "resources": { format!("com.cloudflare.api.account.{}", self.account_id): "*" },
740 "permission_groups": [{ "id": R2_STORAGE_WRITE }],
741 }),
742 serde_json::json!({
743 "effect": "allow",
744 "resources": { format!("com.cloudflare.api.account.zone.{}", self.zone_id): "*" },
745 "permission_groups": [
746 { "id": ZONE_READ },
747 { "id": CACHE_SETTINGS_WRITE },
748 { "id": ZONE_SETTINGS_WRITE },
749 { "id": SSL_AND_CERTIFICATES_WRITE },
750 ],
751 }),
752 ],
753 Some(expires_on),
754 )
755 .await?;
756 Ok(TemporaryToken { id, value })
757 }
758
759 async fn revoke_token(&self, purpose: &str, id: &str) -> Result<()> {
760 revoke_token(&self.client, &self.setup_token, id)
761 .await
762 .with_context(|| format!("the {purpose} token"))
763 }
764
765 pub async fn run_managed(
768 &self,
769 project_id: &str,
770 app_origin: &str,
771 app_hostname: &str,
772 ) -> Result<(
773 ProvisionedResources,
774 ConnectCredentials,
775 MintedCredentialIds,
776 )> {
777 self.verify().await?;
778 let provisioning = self.mint_provisioning_token(project_id).await?;
779 let result = async {
780 let resources = self
781 .provision(&provisioning.value, project_id, app_origin, app_hostname)
782 .await?;
783 let (credentials, minted) = self.mint_credentials(project_id, &resources).await?;
784 Ok((resources, credentials, minted))
785 }
786 .await;
787 if let Err(error) = self.revoke_token("provisioning", &provisioning.id).await {
790 eprintln!(
791 "warning: {error}. It expires by itself within \
792 {PROVISIONING_TOKEN_MINUTES} minutes."
793 );
794 }
795 result
796 }
797
798 pub async fn run_manual(
802 &self,
803 project_id: &str,
804 app_origin: &str,
805 app_hostname: &str,
806 ) -> Result<ProvisionedResources> {
807 self.verify().await?;
808 self.provision(&self.setup_token, project_id, app_origin, app_hostname)
809 .await
810 }
811
812 async fn provision(
813 &self,
814 token: &str,
815 project_id: &str,
816 app_origin: &str,
817 app_hostname: &str,
818 ) -> Result<ProvisionedResources> {
819 let zone_name = self.zone_name(token).await?;
820
821 let private_object_storage_bucket = private_object_storage_bucket_name(project_id);
822 let public_object_storage_bucket = public_object_storage_bucket_name(project_id);
823 let frontend_asset_bucket = frontend_asset_bucket_name(project_id);
824 let frontend_asset_hostname = format!("{frontend_asset_bucket}.{zone_name}");
825 let public_object_storage_hostname = format!("{public_object_storage_bucket}.{zone_name}");
826
827 for bucket in [
828 &private_object_storage_bucket,
829 &public_object_storage_bucket,
830 &frontend_asset_bucket,
831 ] {
832 self.create_bucket(token, bucket).await?;
833 }
834 for bucket in [
835 &private_object_storage_bucket,
836 &public_object_storage_bucket,
837 &frontend_asset_bucket,
838 ] {
839 self.put_cors(token, bucket, Some(app_origin)).await?;
840 }
841 self.attach_custom_domain(token, &frontend_asset_bucket, &frontend_asset_hostname)
842 .await?;
843 self.attach_custom_domain(
844 token,
845 &public_object_storage_bucket,
846 &public_object_storage_hostname,
847 )
848 .await?;
849 self.ensure_cache_rule(token, &zone_name, Some(app_hostname), None)
850 .await?;
851 self.ensure_tiered_cache(token).await?;
852
853 Ok(ProvisionedResources {
854 zone_name,
855 frontend_asset_hostname,
856 public_object_storage_hostname,
857 private_object_storage_bucket,
858 public_object_storage_bucket,
859 frontend_asset_bucket,
860 })
861 }
862
863 fn bucket_scope(&self, buckets: &[&String]) -> serde_json::Value {
864 let resources: serde_json::Map<String, serde_json::Value> = buckets
865 .iter()
866 .map(|bucket| {
867 (
868 format!(
869 "com.cloudflare.edge.r2.bucket.{}_default_{bucket}",
870 self.account_id
871 ),
872 serde_json::Value::String("*".to_string()),
873 )
874 })
875 .collect();
876 serde_json::json!({
877 "effect": "allow",
878 "resources": resources,
879 "permission_groups": [
880 { "id": R2_BUCKET_ITEM_READ },
881 { "id": R2_BUCKET_ITEM_WRITE },
882 ],
883 })
884 }
885
886 async fn mint_credentials(
887 &self,
888 project_id: &str,
889 resources: &ProvisionedResources,
890 ) -> Result<(ConnectCredentials, MintedCredentialIds)> {
891 let (worker_access_key_id, worker_token) = self
892 .mint_with_expiry(
893 &format!("fn0 worker ({project_id})"),
894 vec![self.bucket_scope(&[
895 &resources.private_object_storage_bucket,
896 &resources.public_object_storage_bucket,
897 ])],
898 None,
899 )
900 .await?;
901
902 let (frontend_asset_access_key_id, frontend_asset_token) = self
903 .mint_with_expiry(
904 &format!("fn0 frontend assets ({project_id})"),
905 vec![self.bucket_scope(&[&resources.frontend_asset_bucket])],
906 None,
907 )
908 .await?;
909
910 let (purge_token_id, purge_token) = self
911 .mint_with_expiry(
912 &format!("fn0 cache purge ({project_id})"),
913 vec![serde_json::json!({
914 "effect": "allow",
915 "resources": {
916 format!("com.cloudflare.api.account.zone.{}", self.zone_id): "*",
917 },
918 "permission_groups": [{ "id": CACHE_PURGE }],
919 })],
920 None,
921 )
922 .await?;
923
924 let minted = MintedCredentialIds {
925 worker: worker_access_key_id.clone(),
926 frontend_asset: frontend_asset_access_key_id.clone(),
927 purge: purge_token_id,
928 };
929 Ok((
930 ConnectCredentials {
931 worker_access_key_id,
932 worker_secret: hex_sha256(&worker_token),
933 frontend_asset_access_key_id,
934 frontend_asset_secret: hex_sha256(&frontend_asset_token),
935 purge_token,
936 },
937 minted,
938 ))
939 }
940
941 pub async fn revoke_minted_credentials(&self, ids: &MintedCredentialIds) {
945 for (purpose, id) in [
946 ("worker", &ids.worker),
947 ("frontend assets", &ids.frontend_asset),
948 ("cache purge", &ids.purge),
949 ] {
950 if let Err(error) = self.revoke_token(purpose, id).await {
951 eprintln!("warning: {error}. Delete it in the Cloudflare dashboard.");
952 }
953 }
954 }
955
956 pub async fn issue_origin_certificate(
964 &self,
965 hostname: &str,
966 mint_signing_token: bool,
967 ) -> Result<IssuedCertificate> {
968 self.verify().await?;
969 if !mint_signing_token {
970 return self
971 .sign_origin_certificate(&self.setup_token, hostname)
972 .await;
973 }
974 let signing = self.mint_provisioning_token(hostname).await?;
975 let result = self.sign_origin_certificate(&signing.value, hostname).await;
976 if let Err(error) = self.revoke_token("signing", &signing.id).await {
977 eprintln!(
978 "warning: {error}. It expires by itself within \
979 {PROVISIONING_TOKEN_MINUTES} minutes."
980 );
981 }
982 result
983 }
984
985 pub async fn put_app_cors(
989 &self,
990 project_id: &str,
991 app_origin: &str,
992 mint_writing_token: bool,
993 ) -> Result<()> {
994 let buckets = [
995 private_object_storage_bucket_name(project_id),
996 public_object_storage_bucket_name(project_id),
997 frontend_asset_bucket_name(project_id),
998 ];
999 if !mint_writing_token {
1000 for bucket in &buckets {
1001 self.put_cors(&self.setup_token, bucket, Some(app_origin))
1002 .await?;
1003 }
1004 return Ok(());
1005 }
1006 let writing = self.mint_provisioning_token(app_origin).await?;
1007 let result = async {
1008 for bucket in &buckets {
1009 self.put_cors(&writing.value, bucket, Some(app_origin))
1010 .await?;
1011 }
1012 Ok(())
1013 }
1014 .await;
1015 if let Err(error) = self.revoke_token("CORS", &writing.id).await {
1016 eprintln!(
1017 "warning: {error}. It expires by itself within \
1018 {PROVISIONING_TOKEN_MINUTES} minutes."
1019 );
1020 }
1021 result
1022 }
1023
1024 async fn sign_origin_certificate(
1025 &self,
1026 token: &str,
1027 hostname: &str,
1028 ) -> Result<IssuedCertificate> {
1029 #[derive(Serialize)]
1030 struct Body<'a> {
1031 csr: &'a str,
1032 hostnames: [&'a str; 1],
1033 request_type: &'a str,
1034 requested_validity: u32,
1035 }
1036 #[derive(Deserialize)]
1037 struct Certificate {
1038 certificate: String,
1039 }
1040
1041 let key_pair = rcgen::KeyPair::generate()
1042 .map_err(|error| anyhow!("could not generate a key pair: {error}"))?;
1043 let mut params = rcgen::CertificateParams::new(vec![hostname.to_string()])
1044 .map_err(|error| anyhow!("could not build the certificate request: {error}"))?;
1045 params.distinguished_name = rcgen::DistinguishedName::new();
1046 params
1047 .distinguished_name
1048 .push(rcgen::DnType::CommonName, hostname);
1049 let csr_pem = params
1050 .serialize_request(&key_pair)
1051 .map_err(|error| anyhow!("could not sign the certificate request: {error}"))?
1052 .pem()
1053 .map_err(|error| anyhow!("could not encode the certificate request: {error}"))?;
1054
1055 let (status, envelope) = self
1056 .call::<Certificate>(
1057 token,
1058 reqwest::Method::POST,
1059 "/certificates",
1060 Some(serde_json::to_value(Body {
1061 csr: &csr_pem,
1062 hostnames: [hostname],
1063 request_type: CERTIFICATE_REQUEST_TYPE,
1064 requested_validity: CERTIFICATE_VALIDITY_DAYS,
1065 })?),
1066 )
1067 .await?;
1068 let certificate = envelope
1069 .result
1070 .filter(|_| envelope.success)
1071 .ok_or_else(|| {
1072 anyhow!(
1073 "Cloudflare would not sign the origin certificate ({status}): {}",
1074 describe(&envelope.errors)
1075 )
1076 })?;
1077
1078 Ok(IssuedCertificate {
1079 certificate_pem: certificate.certificate,
1080 private_key_pem: key_pair.serialize_pem(),
1081 not_after_epoch_seconds: chrono::Utc::now().timestamp()
1085 + i64::from(CERTIFICATE_VALIDITY_DAYS) * SECONDS_PER_DAY,
1086 })
1087 }
1088}
1089
1090fn cache_rule_app_hostnames(rule: &serde_json::Value) -> BTreeSet<String> {
1093 let Some(expression) = rule.get("expression").and_then(|value| value.as_str()) else {
1094 return BTreeSet::new();
1095 };
1096 if let Some(hostname) = expression
1097 .split_once("http.host eq \"")
1098 .and_then(|(_, remainder)| remainder.split_once('"'))
1099 .map(|(hostname, _)| hostname)
1100 {
1101 return BTreeSet::from([hostname.to_string()]);
1102 }
1103 let Some(host_list) = expression
1104 .split_once("http.host in {")
1105 .and_then(|(_, remainder)| remainder.split_once('}'))
1106 .map(|(host_list, _)| host_list)
1107 else {
1108 return BTreeSet::new();
1109 };
1110 host_list
1111 .split('"')
1112 .skip(1)
1113 .step_by(2)
1114 .filter(|hostname| !hostname.is_empty())
1115 .map(str::to_string)
1116 .collect()
1117}
1118
1119fn cache_rule_host_expression(zone_name: &str, app_hostnames: &BTreeSet<String>) -> String {
1120 let mut host_expressions = vec![
1121 format!(r#"http.host wildcard "fn0-*-frontend-asset.{zone_name}""#),
1122 format!(r#"http.host wildcard "fn0-*-public-object-storage.{zone_name}""#),
1123 ];
1124 if !app_hostnames.is_empty() {
1125 let exact_hostnames = app_hostnames
1126 .iter()
1127 .map(|hostname| format!(r#""{hostname}""#))
1128 .collect::<Vec<_>>()
1129 .join(" ");
1130 host_expressions.push(format!("http.host in {{{exact_hostnames}}}"));
1131 }
1132 host_expressions.join(" or ")
1133}
1134
1135fn hex_sha256(value: &str) -> String {
1136 let digest = Sha256::digest(value.as_bytes());
1137 let mut out = String::with_capacity(digest.len() * 2);
1138 for byte in digest {
1139 out.push_str(&format!("{byte:02x}"));
1140 }
1141 out
1142}
1143
1144fn cors_absent(errors: &[ApiError]) -> bool {
1150 errors.iter().any(|error| error.code == 10059)
1151}
1152
1153fn already_exists(errors: &[ApiError]) -> bool {
1154 errors.iter().any(|error| {
1155 let message = error.message.to_lowercase();
1156 message.contains("already exists")
1157 || message.contains("already configured")
1158 || message.contains("duplicate")
1159 })
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::{cache_rule_app_hostnames, cache_rule_host_expression};
1165 use std::collections::BTreeSet;
1166
1167 #[test]
1168 fn cache_rule_expression_contains_bucket_and_app_hosts() {
1169 let app_hostnames =
1170 BTreeSet::from(["app.example.com".to_string(), "www.example.com".to_string()]);
1171
1172 assert_eq!(
1173 cache_rule_host_expression("example.com", &app_hostnames),
1174 r#"http.host wildcard "fn0-*-frontend-asset.example.com" or http.host wildcard "fn0-*-public-object-storage.example.com" or http.host in {"app.example.com" "www.example.com"}"#
1175 );
1176 }
1177
1178 #[test]
1179 fn cache_rule_app_hostnames_reads_managed_rule_expression() {
1180 let rule = serde_json::json!({
1181 "expression": r#"((http.host wildcard "fn0-*-frontend-asset.example.com" or http.host in {"app.example.com" "www.example.com"}) and http.request.method in {"GET" "HEAD" "PURGE"})"#
1182 });
1183
1184 assert_eq!(
1185 cache_rule_app_hostnames(&rule),
1186 BTreeSet::from(["app.example.com".to_string(), "www.example.com".to_string(),])
1187 );
1188 }
1189
1190 #[test]
1191 fn cache_rule_app_hostnames_reads_single_host_expression() {
1192 let rule = serde_json::json!({
1193 "expression": r#"http.host eq "control.example.com""#
1194 });
1195
1196 assert_eq!(
1197 cache_rule_app_hostnames(&rule),
1198 BTreeSet::from(["control.example.com".to_string()])
1199 );
1200 }
1201}