1use anyhow::{Context, Result, anyhow};
42use serde::{Deserialize, Serialize};
43use sha2::{Digest, Sha256};
44use std::collections::BTreeSet;
45
46const API_BASE: &str = "https://api.cloudflare.com/client/v4";
47
48const R2_BUCKET_ITEM_READ: &str = "6a018a9f2fc74eb6b293b0c548f38b39";
51const R2_BUCKET_ITEM_WRITE: &str = "2efd5506f9c8494dacb1fa10a3e7d5b6";
52const CACHE_PURGE: &str = "e17beae8b8cb423a99b1730f21238bed";
53
54const CERTIFICATE_VALIDITY_DAYS: u32 = 5475;
57const SECONDS_PER_DAY: i64 = 86_400;
58
59const CERTIFICATE_REQUEST_TYPE: &str = "origin-ecc";
62
63const PROVISIONING_TOKEN_MINUTES: i64 = 10;
67const R2_STORAGE_WRITE: &str = "bf7481a1826f439697cb59a20b22293e";
68const ZONE_READ: &str = "c8fed203ed3043cba015a93ad1616f1f";
69const DNS_WRITE: &str = "4755a26eedb94da69e1066d98aa820be";
72const CACHE_SETTINGS_WRITE: &str = "9ff81cbbe65c400b97d92c3c1033cab6";
73const ZONE_SETTINGS_WRITE: &str = "3030687196b94b638145a3953da2b699";
74const SSL_AND_CERTIFICATES_WRITE: &str = "c03055bc037c4ea9afb9a9f104b7b721";
75
76pub fn private_object_storage_bucket_name(project_id: &str) -> String {
80 format!("fn0-{project_id}-private-object-storage")
81}
82
83pub fn public_object_storage_bucket_name(project_id: &str) -> String {
84 format!("fn0-{project_id}-public-object-storage")
85}
86
87pub fn frontend_asset_bucket_name(project_id: &str) -> String {
88 format!("fn0-{project_id}-frontend-asset")
89}
90
91pub struct ReachableZone {
93 pub zone_id: String,
94 pub zone_name: String,
95 pub account_id: String,
96 pub account_name: String,
97}
98
99pub struct ZoneDiscovery {
103 client: reqwest::Client,
104 setup_token: String,
105}
106
107impl ZoneDiscovery {
108 pub fn new(setup_token: String) -> Self {
109 Self {
110 client: reqwest::Client::new(),
111 setup_token,
112 }
113 }
114
115 pub async fn list(&self, mint_reader: bool) -> Result<Vec<ReachableZone>> {
124 if !mint_reader {
125 return self.zones(&self.setup_token).await;
126 }
127 let expires_on = (chrono::Utc::now()
128 + chrono::Duration::minutes(PROVISIONING_TOKEN_MINUTES))
129 .format("%Y-%m-%dT%H:%M:%SZ")
130 .to_string();
131 let reader = mint_token(
132 &self.client,
133 &self.setup_token,
134 "fn0 setup (zone discovery)",
135 vec![serde_json::json!({
136 "effect": "allow",
137 "resources": { "com.cloudflare.api.account.*": "*" },
138 "permission_groups": [{ "id": ZONE_READ }],
139 })],
140 Some(expires_on),
141 )
142 .await?;
143 let result = self.zones(&reader.value).await;
144 if let Err(error) = revoke_token(&self.client, &self.setup_token, &reader.id).await {
145 eprintln!(
146 "warning: could not revoke the zone discovery token: {error}. It expires by \
147 itself within {PROVISIONING_TOKEN_MINUTES} minutes."
148 );
149 }
150 result
151 }
152
153 async fn zones(&self, token: &str) -> Result<Vec<ReachableZone>> {
154 #[derive(Deserialize)]
155 struct Zone {
156 id: String,
157 name: String,
158 account: Account,
159 }
160 #[derive(Deserialize)]
161 struct Account {
162 id: String,
163 #[serde(default)]
164 name: String,
165 }
166 let (status, envelope) = call::<Vec<Zone>>(
167 &self.client,
168 token,
169 reqwest::Method::GET,
170 "/zones?per_page=200",
171 None,
172 )
173 .await?;
174 let zones = envelope.result.filter(|_| envelope.success).ok_or_else(|| {
175 anyhow!(
176 "could not list your zones ({status}). The token needs Zone -> Zone -> Read. {}",
177 describe(&envelope.errors)
178 )
179 })?;
180 Ok(zones
181 .into_iter()
182 .map(|zone| ReachableZone {
183 zone_id: zone.id,
184 zone_name: zone.name,
185 account_id: zone.account.id,
186 account_name: zone.account.name,
187 })
188 .collect())
189 }
190}
191
192pub struct Provisioner {
193 client: reqwest::Client,
194 setup_token: String,
197 account_id: String,
198 zone_id: String,
199}
200
201struct TemporaryToken {
203 id: String,
204 value: String,
205}
206
207pub struct ProvisionedResources {
209 pub zone_name: String,
210 pub frontend_asset_hostname: String,
211 pub public_object_storage_hostname: String,
212 pub private_object_storage_bucket: String,
213 pub public_object_storage_bucket: String,
214 pub frontend_asset_bucket: String,
215}
216
217pub struct ConnectCredentials {
219 pub worker_access_key_id: String,
222 pub worker_secret: String,
227 pub frontend_asset_access_key_id: String,
231 pub frontend_asset_secret: String,
232 pub purge_token: String,
233}
234
235pub struct MintedCredentialIds {
238 pub worker: String,
239 pub frontend_asset: String,
240 pub purge: String,
241}
242
243pub struct IssuedCertificate {
244 pub certificate_pem: String,
245 pub private_key_pem: String,
246 pub not_after_epoch_seconds: i64,
247}
248
249#[derive(Deserialize)]
250struct Envelope<T> {
251 success: bool,
252 #[serde(default)]
253 errors: Vec<ApiError>,
254 result: Option<T>,
255}
256
257#[derive(Deserialize)]
258struct ApiError {
259 #[serde(default)]
260 code: i64,
261 #[serde(default)]
262 message: String,
263}
264
265async fn mint_token(
266 client: &reqwest::Client,
267 setup_token: &str,
268 name: &str,
269 policies: Vec<serde_json::Value>,
270 expires_on: Option<String>,
271) -> Result<TemporaryToken> {
272 #[derive(Deserialize)]
273 struct Minted {
274 id: String,
275 value: String,
276 }
277 let (status, envelope) = call::<Minted>(
278 client,
279 setup_token,
280 reqwest::Method::POST,
281 "/user/tokens",
282 Some(match expires_on {
283 Some(expires_on) => serde_json::json!({
284 "name": name, "policies": policies, "expires_on": expires_on,
285 }),
286 None => serde_json::json!({ "name": name, "policies": policies }),
287 }),
288 )
289 .await?;
290 let minted = envelope.result.filter(|_| envelope.success).ok_or_else(|| {
291 anyhow!(
292 "could not mint the {name} token ({status}). The token needs User -> API Tokens -> Edit. {}",
293 describe(&envelope.errors)
294 )
295 })?;
296 Ok(TemporaryToken {
297 id: minted.id,
298 value: minted.value,
299 })
300}
301
302async fn revoke_token(client: &reqwest::Client, setup_token: &str, id: &str) -> Result<()> {
303 let (status, envelope) = call::<serde_json::Value>(
304 client,
305 setup_token,
306 reqwest::Method::DELETE,
307 &format!("/user/tokens/{id}"),
308 None,
309 )
310 .await?;
311 if envelope.success {
312 return Ok(());
313 }
314 Err(anyhow!(
315 "could not revoke token {id} ({status}): {}",
316 describe(&envelope.errors)
317 ))
318}
319
320async fn call<T: serde::de::DeserializeOwned>(
321 client: &reqwest::Client,
322 token: &str,
323 method: reqwest::Method,
324 path: &str,
325 body: Option<serde_json::Value>,
326) -> Result<(reqwest::StatusCode, Envelope<T>)> {
327 let mut request = client
328 .request(method, format!("{API_BASE}{path}"))
329 .bearer_auth(token);
330 if let Some(body) = body {
331 request = request.json(&body);
332 }
333 let response = request.send().await?;
334 let status = response.status();
335 let text = response.text().await?;
336 let envelope: Envelope<T> =
337 serde_json::from_str(&text).with_context(|| format!("{path} returned {status}: {text}"))?;
338 Ok((status, envelope))
339}
340
341fn describe(errors: &[ApiError]) -> String {
342 if errors.is_empty() {
343 return "no detail".to_string();
344 }
345 errors
346 .iter()
347 .map(|error| format!("{} ({})", error.message, error.code))
348 .collect::<Vec<_>>()
349 .join("; ")
350}
351
352impl Provisioner {
353 pub fn new(setup_token: String, account_id: String, zone_id: String) -> Self {
354 Self {
355 client: reqwest::Client::new(),
356 setup_token,
357 account_id,
358 zone_id,
359 }
360 }
361
362 async fn call<T: serde::de::DeserializeOwned>(
363 &self,
364 token: &str,
365 method: reqwest::Method,
366 path: &str,
367 body: Option<serde_json::Value>,
368 ) -> Result<(reqwest::StatusCode, Envelope<T>)> {
369 call(&self.client, token, method, path, body).await
370 }
371
372 pub async fn verify(&self) -> Result<String> {
375 #[derive(Deserialize)]
376 struct Token {
377 id: String,
378 status: String,
379 }
380 for path in [
384 "/user/tokens/verify".to_string(),
385 format!("/accounts/{}/tokens/verify", self.account_id),
386 ] {
387 if let Ok((_, envelope)) = self
388 .call::<Token>(&self.setup_token, reqwest::Method::GET, &path, None)
389 .await
390 && let Some(token) = envelope.result.filter(|_| envelope.success)
391 {
392 if token.status != "active" {
393 return Err(anyhow!("the API token is {}, not active", token.status));
394 }
395 return Ok(token.id);
396 }
397 }
398 Err(anyhow!(
399 "Cloudflare rejected the API token. Check that it was copied whole and has not expired."
400 ))
401 }
402
403 async fn zone_name(&self, token: &str) -> Result<String> {
404 #[derive(Deserialize)]
405 struct Zone {
406 name: String,
407 }
408 let (_, envelope) = self
409 .call::<Zone>(
410 token,
411 reqwest::Method::GET,
412 &format!("/zones/{}", self.zone_id),
413 None,
414 )
415 .await?;
416 envelope
417 .result
418 .filter(|_| envelope.success)
419 .map(|zone| zone.name)
420 .ok_or_else(|| {
421 anyhow!(
422 "could not read the zone. The token needs Zone -> Zone -> Read on it. {}",
423 describe(&envelope.errors)
424 )
425 })
426 }
427
428 async fn create_bucket(&self, token: &str, name: &str) -> Result<()> {
429 let (status, envelope) = self
430 .call::<serde_json::Value>(
431 token,
432 reqwest::Method::POST,
433 &format!("/accounts/{}/r2/buckets", self.account_id),
434 Some(serde_json::json!({ "name": name })),
435 )
436 .await?;
437 if envelope.success || already_exists(&envelope.errors) {
438 return Ok(());
439 }
440 Err(anyhow!(
441 "could not create bucket {name} ({status}). The token needs Account -> Workers R2 Storage -> Edit. {}",
442 describe(&envelope.errors)
443 ))
444 }
445
446 async fn put_cors(&self, token: &str, bucket: &str, app_origin: Option<&str>) -> Result<()> {
452 let path = format!("/accounts/{}/r2/buckets/{bucket}/cors", self.account_id);
453 let (method, body) = match app_origin {
454 Some(origin) => (
455 reqwest::Method::PUT,
456 Some(serde_json::json!({
457 "rules": [{
458 "allowed": {
459 "methods": ["GET", "PUT", "HEAD"],
460 "origins": [origin],
461 "headers": ["*"],
462 },
463 "exposeHeaders": ["ETag"],
464 "maxAgeSeconds": 86400,
465 }],
466 })),
467 ),
468 None => (reqwest::Method::DELETE, None),
469 };
470 let (status, envelope) = self
471 .call::<serde_json::Value>(token, method, &path, body)
472 .await?;
473 if envelope.success || (app_origin.is_none() && cors_absent(&envelope.errors)) {
476 return Ok(());
477 }
478 Err(anyhow!(
479 "could not set CORS on {bucket} ({status}): {}",
480 describe(&envelope.errors)
481 ))
482 }
483
484 async fn attach_custom_domain(&self, token: &str, bucket: &str, hostname: &str) -> Result<()> {
485 if self.custom_domain_present(token, bucket, hostname).await {
486 return Ok(());
487 }
488 let (status, envelope) = self
489 .call::<serde_json::Value>(
490 token,
491 reqwest::Method::POST,
492 &format!(
493 "/accounts/{}/r2/buckets/{bucket}/domains/custom",
494 self.account_id
495 ),
496 Some(serde_json::json!({
497 "domain": hostname,
498 "zoneId": self.zone_id,
499 "enabled": true,
500 })),
501 )
502 .await?;
503 if envelope.success || already_exists(&envelope.errors) {
504 return Ok(());
505 }
506 Err(anyhow!(
507 "could not point {hostname} at {bucket} ({status}): {}",
508 describe(&envelope.errors)
509 ))
510 }
511
512 async fn custom_domain_present(&self, token: &str, bucket: &str, hostname: &str) -> bool {
520 #[derive(Deserialize)]
521 struct Domain {
522 domain: String,
523 }
524 #[derive(Deserialize)]
525 struct Domains {
526 #[serde(default)]
527 domains: Vec<Domain>,
528 }
529
530 let Ok((_, envelope)) = self
531 .call::<Domains>(
532 token,
533 reqwest::Method::GET,
534 &format!(
535 "/accounts/{}/r2/buckets/{bucket}/domains/custom",
536 self.account_id
537 ),
538 None,
539 )
540 .await
541 else {
542 return false;
543 };
544 envelope
545 .result
546 .filter(|_| envelope.success)
547 .is_some_and(|listed| {
548 listed
549 .domains
550 .iter()
551 .any(|attached| attached.domain == hostname)
552 })
553 }
554
555 async fn ensure_cache_rule(
573 &self,
574 token: &str,
575 zone_name: &str,
576 app_hostname: Option<&str>,
577 replaced_app_hostname: Option<&str>,
578 ) -> Result<()> {
579 const RULE_DESCRIPTION: &str = "fn0 frontend assets and public objects";
580
581 #[derive(Deserialize)]
582 struct Ruleset {
583 #[serde(default)]
584 rules: Vec<serde_json::Value>,
585 }
586
587 let path = format!(
588 "/zones/{}/rulesets/phases/http_request_cache_settings/entrypoint",
589 self.zone_id
590 );
591 let (status, envelope) = self
592 .call::<Ruleset>(token, reqwest::Method::GET, &path, None)
593 .await?;
594 let mut rules = if envelope.success {
597 envelope.result.map(|set| set.rules).unwrap_or_default()
598 } else if status == reqwest::StatusCode::NOT_FOUND {
599 Vec::new()
600 } else {
601 return Err(anyhow!(
602 "could not read the zone's cache rules ({status}). The token needs Zone -> Cache Rules -> Edit. {}",
603 describe(&envelope.errors)
604 ));
605 };
606
607 let managed_rule = rules.iter().find(|rule| {
608 rule.get("description").and_then(|value| value.as_str()) == Some(RULE_DESCRIPTION)
609 });
610 let mut app_hostnames = managed_rule
611 .map(cache_rule_app_hostnames)
612 .unwrap_or_default();
613 if let Some(replaced_app_hostname) = replaced_app_hostname {
614 app_hostnames.remove(replaced_app_hostname);
615 }
616 if let Some(app_hostname) = app_hostname {
617 app_hostnames.insert(app_hostname.to_string());
618 }
619
620 rules.retain(|rule| {
621 rule.get("description").and_then(|value| value.as_str()) != Some(RULE_DESCRIPTION)
622 });
623 rules.insert(
624 0,
625 serde_json::json!({
626 "action": "set_cache_settings",
627 "expression": format!(
628 "(({}) and http.request.method in {{\"GET\" \"HEAD\" \"PURGE\"}})",
629 cache_rule_host_expression(zone_name, &app_hostnames),
630 ),
631 "description": RULE_DESCRIPTION,
632 "action_parameters": {
633 "cache": true,
634 "browser_ttl": { "mode": "respect_origin" },
635 },
636 }),
637 );
638
639 let (status, envelope) = self
640 .call::<serde_json::Value>(
641 token,
642 reqwest::Method::PUT,
643 &path,
644 Some(serde_json::json!({ "rules": rules })),
645 )
646 .await?;
647 if envelope.success {
648 return Ok(());
649 }
650 Err(anyhow!(
651 "could not write the zone's cache rules ({status}). The token needs Zone -> Cache Rules -> Edit. {}",
652 describe(&envelope.errors)
653 ))
654 }
655
656 async fn ensure_tiered_cache(&self, token: &str) -> Result<()> {
657 let path = format!(
658 "/zones/{}/cache/tiered_cache_smart_topology_enable",
659 self.zone_id
660 );
661 let (status, envelope) = self
662 .call::<serde_json::Value>(
663 token,
664 reqwest::Method::PATCH,
665 &path,
666 Some(serde_json::json!({ "value": "on" })),
667 )
668 .await?;
669 if envelope.success {
670 return Ok(());
671 }
672 Err(anyhow!(
673 "could not enable Smart Tiered Cache ({status}). The token needs Zone -> Zone Settings -> Edit. {}",
674 describe(&envelope.errors)
675 ))
676 }
677
678 pub async fn ensure_app_cache(
679 &self,
680 app_hostname: &str,
681 replaced_app_hostname: Option<&str>,
682 mint_writing_token: bool,
683 ) -> Result<()> {
684 if !mint_writing_token {
685 let zone_name = self.zone_name(&self.setup_token).await?;
686 self.ensure_tiered_cache(&self.setup_token).await?;
687 return self
688 .ensure_cache_rule(
689 &self.setup_token,
690 &zone_name,
691 Some(app_hostname),
692 replaced_app_hostname,
693 )
694 .await;
695 }
696
697 let writing = self.mint_provisioning_token(app_hostname).await?;
698 let result = async {
699 let zone_name = self.zone_name(&writing.value).await?;
700 self.ensure_tiered_cache(&writing.value).await?;
701 self.ensure_cache_rule(
702 &writing.value,
703 &zone_name,
704 Some(app_hostname),
705 replaced_app_hostname,
706 )
707 .await
708 }
709 .await;
710 if let Err(error) = self.revoke_token("cache settings", &writing.id).await {
711 eprintln!(
712 "warning: {error}. It expires by itself within \
713 {PROVISIONING_TOKEN_MINUTES} minutes."
714 );
715 }
716 result
717 }
718
719 pub async fn ensure_app_dns_record(
729 &self,
730 app_hostname: &str,
731 origin_hostname: &str,
732 replaced_app_hostname: Option<&str>,
733 mint_writing_token: bool,
734 ) -> Result<()> {
735 if !mint_writing_token {
736 return self
737 .write_app_dns_record(
738 &self.setup_token,
739 app_hostname,
740 origin_hostname,
741 replaced_app_hostname,
742 )
743 .await;
744 }
745
746 let writing = self.mint_provisioning_token(app_hostname).await?;
747 let result = self
748 .write_app_dns_record(
749 &writing.value,
750 app_hostname,
751 origin_hostname,
752 replaced_app_hostname,
753 )
754 .await;
755 if let Err(error) = self.revoke_token("DNS record", &writing.id).await {
756 eprintln!(
757 "warning: {error}. It expires by itself within \
758 {PROVISIONING_TOKEN_MINUTES} minutes."
759 );
760 }
761 result
762 }
763
764 async fn write_app_dns_record(
765 &self,
766 token: &str,
767 app_hostname: &str,
768 origin_hostname: &str,
769 replaced_app_hostname: Option<&str>,
770 ) -> Result<()> {
771 let existing = self.dns_records(token, app_hostname).await?;
772 match decide_app_dns_record(&existing, origin_hostname) {
773 AppDnsRecordWrite::AlreadyPointed => {}
774 AppDnsRecordWrite::Create => {
775 self.create_app_dns_record(token, app_hostname, origin_hostname)
776 .await?
777 }
778 AppDnsRecordWrite::Repoint { record_id } => {
779 self.repoint_app_dns_record(token, record_id, app_hostname, origin_hostname)
780 .await?
781 }
782 AppDnsRecordWrite::Occupied { record_types } => {
783 return Err(anyhow!(
784 "{app_hostname} already resolves through {record_types} record(s), and only a \
785 CNAME can be repointed at {origin_hostname}. Delete them in the Cloudflare \
786 dashboard, or set the project up under a name that is free."
787 ));
788 }
789 }
790
791 if let Some(replaced_app_hostname) =
795 replaced_app_hostname.filter(|replaced| *replaced != app_hostname)
796 {
797 self.remove_replaced_app_dns_record(token, replaced_app_hostname, origin_hostname)
798 .await?;
799 }
800 Ok(())
801 }
802
803 async fn remove_replaced_app_dns_record(
807 &self,
808 token: &str,
809 replaced_app_hostname: &str,
810 origin_hostname: &str,
811 ) -> Result<()> {
812 let zone_name = self.zone_name(token).await?;
813 if !replaced_app_hostname.ends_with(&format!(".{zone_name}")) {
814 eprintln!(
815 "warning: {replaced_app_hostname} is not in {zone_name}, so its record still \
816 points at fn0. Delete it in the zone that holds it."
817 );
818 return Ok(());
819 }
820
821 let records = self.dns_records(token, replaced_app_hostname).await?;
822 if records.is_empty() {
823 return Ok(());
824 }
825 let Some(written_here) = replaced_app_dns_record(&records, origin_hostname) else {
826 eprintln!(
827 "warning: left the DNS record for {replaced_app_hostname} in place: it is not the \
828 proxied CNAME fn0 wrote. Read it, and remove it yourself if that hostname should \
829 stop resolving."
830 );
831 return Ok(());
832 };
833 self.delete_dns_record(token, &written_here.id, replaced_app_hostname)
834 .await
835 }
836
837 async fn dns_records(&self, token: &str, hostname: &str) -> Result<Vec<DnsRecord>> {
841 let (status, envelope) = self
842 .call::<Vec<DnsRecord>>(
843 token,
844 reqwest::Method::GET,
845 &format!("/zones/{}/dns_records?name={hostname}", self.zone_id),
846 None,
847 )
848 .await?;
849 if envelope.success {
850 return Ok(envelope.result.unwrap_or_default());
851 }
852 Err(anyhow!(
853 "could not read the DNS records for {hostname} ({status}). The token needs Zone -> DNS -> Edit. {}",
854 describe(&envelope.errors)
855 ))
856 }
857
858 async fn create_app_dns_record(
859 &self,
860 token: &str,
861 app_hostname: &str,
862 origin_hostname: &str,
863 ) -> Result<()> {
864 let (status, envelope) = self
865 .call::<serde_json::Value>(
866 token,
867 reqwest::Method::POST,
868 &format!("/zones/{}/dns_records", self.zone_id),
869 Some(serde_json::json!({
870 "type": "CNAME",
871 "name": app_hostname,
872 "content": origin_hostname,
873 "proxied": true,
874 })),
875 )
876 .await?;
877 if envelope.success {
878 return Ok(());
879 }
880 Err(anyhow!(
881 "could not point {app_hostname} at {origin_hostname} ({status}). The token needs Zone -> DNS -> Edit. {}",
882 describe(&envelope.errors)
883 ))
884 }
885
886 async fn repoint_app_dns_record(
887 &self,
888 token: &str,
889 record_id: &str,
890 app_hostname: &str,
891 origin_hostname: &str,
892 ) -> Result<()> {
893 let (status, envelope) = self
894 .call::<serde_json::Value>(
895 token,
896 reqwest::Method::PATCH,
897 &format!("/zones/{}/dns_records/{record_id}", self.zone_id),
898 Some(serde_json::json!({
899 "content": origin_hostname,
900 "proxied": true,
901 })),
902 )
903 .await?;
904 if envelope.success {
905 return Ok(());
906 }
907 Err(anyhow!(
908 "could not repoint {app_hostname} at {origin_hostname} ({status}). The token needs Zone -> DNS -> Edit. {}",
909 describe(&envelope.errors)
910 ))
911 }
912
913 async fn delete_dns_record(&self, token: &str, record_id: &str, hostname: &str) -> Result<()> {
914 let (status, envelope) = self
915 .call::<serde_json::Value>(
916 token,
917 reqwest::Method::DELETE,
918 &format!("/zones/{}/dns_records/{record_id}", self.zone_id),
919 None,
920 )
921 .await?;
922 if envelope.success {
923 return Ok(());
924 }
925 Err(anyhow!(
926 "could not delete the DNS record for {hostname} ({status}). The token needs Zone -> DNS -> Edit. {}",
927 describe(&envelope.errors)
928 ))
929 }
930
931 async fn mint_with_expiry(
932 &self,
933 name: &str,
934 policies: Vec<serde_json::Value>,
935 expires_on: Option<String>,
936 ) -> Result<(String, String)> {
937 let minted =
938 mint_token(&self.client, &self.setup_token, name, policies, expires_on).await?;
939 Ok((minted.id, minted.value))
940 }
941
942 async fn mint_provisioning_token(&self, purpose: &str) -> Result<TemporaryToken> {
945 let expires_on = (chrono::Utc::now()
946 + chrono::Duration::minutes(PROVISIONING_TOKEN_MINUTES))
947 .format("%Y-%m-%dT%H:%M:%SZ")
948 .to_string();
949 let (id, value) = self
950 .mint_with_expiry(
951 &format!("fn0 setup ({purpose})"),
952 vec![
953 serde_json::json!({
954 "effect": "allow",
955 "resources": { format!("com.cloudflare.api.account.{}", self.account_id): "*" },
956 "permission_groups": [{ "id": R2_STORAGE_WRITE }],
957 }),
958 serde_json::json!({
959 "effect": "allow",
960 "resources": { format!("com.cloudflare.api.account.zone.{}", self.zone_id): "*" },
961 "permission_groups": [
962 { "id": ZONE_READ },
963 { "id": CACHE_SETTINGS_WRITE },
964 { "id": ZONE_SETTINGS_WRITE },
965 { "id": SSL_AND_CERTIFICATES_WRITE },
966 { "id": DNS_WRITE },
967 ],
968 }),
969 ],
970 Some(expires_on),
971 )
972 .await?;
973 Ok(TemporaryToken { id, value })
974 }
975
976 async fn revoke_token(&self, purpose: &str, id: &str) -> Result<()> {
977 revoke_token(&self.client, &self.setup_token, id)
978 .await
979 .with_context(|| format!("the {purpose} token"))
980 }
981
982 pub async fn run_managed(
985 &self,
986 project_id: &str,
987 app_origin: &str,
988 app_hostname: &str,
989 ) -> Result<(
990 ProvisionedResources,
991 ConnectCredentials,
992 MintedCredentialIds,
993 )> {
994 self.verify().await?;
995 let provisioning = self.mint_provisioning_token(project_id).await?;
996 let result = async {
997 let resources = self
998 .provision(&provisioning.value, project_id, app_origin, app_hostname)
999 .await?;
1000 let (credentials, minted) = self.mint_credentials(project_id, &resources).await?;
1001 Ok((resources, credentials, minted))
1002 }
1003 .await;
1004 if let Err(error) = self.revoke_token("provisioning", &provisioning.id).await {
1007 eprintln!(
1008 "warning: {error}. It expires by itself within \
1009 {PROVISIONING_TOKEN_MINUTES} minutes."
1010 );
1011 }
1012 result
1013 }
1014
1015 pub async fn run_manual(
1019 &self,
1020 project_id: &str,
1021 app_origin: &str,
1022 app_hostname: &str,
1023 ) -> Result<ProvisionedResources> {
1024 self.verify().await?;
1025 self.provision(&self.setup_token, project_id, app_origin, app_hostname)
1026 .await
1027 }
1028
1029 async fn provision(
1030 &self,
1031 token: &str,
1032 project_id: &str,
1033 app_origin: &str,
1034 app_hostname: &str,
1035 ) -> Result<ProvisionedResources> {
1036 let zone_name = self.zone_name(token).await?;
1037
1038 let private_object_storage_bucket = private_object_storage_bucket_name(project_id);
1039 let public_object_storage_bucket = public_object_storage_bucket_name(project_id);
1040 let frontend_asset_bucket = frontend_asset_bucket_name(project_id);
1041 let frontend_asset_hostname = format!("{frontend_asset_bucket}.{zone_name}");
1042 let public_object_storage_hostname = format!("{public_object_storage_bucket}.{zone_name}");
1043
1044 for bucket in [
1045 &private_object_storage_bucket,
1046 &public_object_storage_bucket,
1047 &frontend_asset_bucket,
1048 ] {
1049 self.create_bucket(token, bucket).await?;
1050 }
1051 for bucket in [
1052 &private_object_storage_bucket,
1053 &public_object_storage_bucket,
1054 &frontend_asset_bucket,
1055 ] {
1056 self.put_cors(token, bucket, Some(app_origin)).await?;
1057 }
1058 self.attach_custom_domain(token, &frontend_asset_bucket, &frontend_asset_hostname)
1059 .await?;
1060 self.attach_custom_domain(
1061 token,
1062 &public_object_storage_bucket,
1063 &public_object_storage_hostname,
1064 )
1065 .await?;
1066 self.ensure_cache_rule(token, &zone_name, Some(app_hostname), None)
1067 .await?;
1068 self.ensure_tiered_cache(token).await?;
1069
1070 Ok(ProvisionedResources {
1071 zone_name,
1072 frontend_asset_hostname,
1073 public_object_storage_hostname,
1074 private_object_storage_bucket,
1075 public_object_storage_bucket,
1076 frontend_asset_bucket,
1077 })
1078 }
1079
1080 fn bucket_scope(&self, buckets: &[&String]) -> serde_json::Value {
1081 let resources: serde_json::Map<String, serde_json::Value> = buckets
1082 .iter()
1083 .map(|bucket| {
1084 (
1085 format!(
1086 "com.cloudflare.edge.r2.bucket.{}_default_{bucket}",
1087 self.account_id
1088 ),
1089 serde_json::Value::String("*".to_string()),
1090 )
1091 })
1092 .collect();
1093 serde_json::json!({
1094 "effect": "allow",
1095 "resources": resources,
1096 "permission_groups": [
1097 { "id": R2_BUCKET_ITEM_READ },
1098 { "id": R2_BUCKET_ITEM_WRITE },
1099 ],
1100 })
1101 }
1102
1103 async fn mint_credentials(
1104 &self,
1105 project_id: &str,
1106 resources: &ProvisionedResources,
1107 ) -> Result<(ConnectCredentials, MintedCredentialIds)> {
1108 let (worker_access_key_id, worker_token) = self
1109 .mint_with_expiry(
1110 &format!("fn0 worker ({project_id})"),
1111 vec![self.bucket_scope(&[
1112 &resources.private_object_storage_bucket,
1113 &resources.public_object_storage_bucket,
1114 ])],
1115 None,
1116 )
1117 .await?;
1118
1119 let (frontend_asset_access_key_id, frontend_asset_token) = self
1120 .mint_with_expiry(
1121 &format!("fn0 frontend assets ({project_id})"),
1122 vec![self.bucket_scope(&[&resources.frontend_asset_bucket])],
1123 None,
1124 )
1125 .await?;
1126
1127 let (purge_token_id, purge_token) = self
1128 .mint_with_expiry(
1129 &format!("fn0 cache purge ({project_id})"),
1130 vec![serde_json::json!({
1131 "effect": "allow",
1132 "resources": {
1133 format!("com.cloudflare.api.account.zone.{}", self.zone_id): "*",
1134 },
1135 "permission_groups": [{ "id": CACHE_PURGE }],
1136 })],
1137 None,
1138 )
1139 .await?;
1140
1141 let minted = MintedCredentialIds {
1142 worker: worker_access_key_id.clone(),
1143 frontend_asset: frontend_asset_access_key_id.clone(),
1144 purge: purge_token_id,
1145 };
1146 Ok((
1147 ConnectCredentials {
1148 worker_access_key_id,
1149 worker_secret: hex_sha256(&worker_token),
1150 frontend_asset_access_key_id,
1151 frontend_asset_secret: hex_sha256(&frontend_asset_token),
1152 purge_token,
1153 },
1154 minted,
1155 ))
1156 }
1157
1158 pub async fn revoke_minted_credentials(&self, ids: &MintedCredentialIds) {
1162 for (purpose, id) in [
1163 ("worker", &ids.worker),
1164 ("frontend assets", &ids.frontend_asset),
1165 ("cache purge", &ids.purge),
1166 ] {
1167 if let Err(error) = self.revoke_token(purpose, id).await {
1168 eprintln!("warning: {error}. Delete it in the Cloudflare dashboard.");
1169 }
1170 }
1171 }
1172
1173 pub async fn issue_origin_certificate(
1181 &self,
1182 hostname: &str,
1183 mint_signing_token: bool,
1184 ) -> Result<IssuedCertificate> {
1185 self.verify().await?;
1186 if !mint_signing_token {
1187 return self
1188 .sign_origin_certificate(&self.setup_token, hostname)
1189 .await;
1190 }
1191 let signing = self.mint_provisioning_token(hostname).await?;
1192 let result = self.sign_origin_certificate(&signing.value, hostname).await;
1193 if let Err(error) = self.revoke_token("signing", &signing.id).await {
1194 eprintln!(
1195 "warning: {error}. It expires by itself within \
1196 {PROVISIONING_TOKEN_MINUTES} minutes."
1197 );
1198 }
1199 result
1200 }
1201
1202 pub async fn put_app_cors(
1206 &self,
1207 project_id: &str,
1208 app_origin: &str,
1209 mint_writing_token: bool,
1210 ) -> Result<()> {
1211 let buckets = [
1212 private_object_storage_bucket_name(project_id),
1213 public_object_storage_bucket_name(project_id),
1214 frontend_asset_bucket_name(project_id),
1215 ];
1216 if !mint_writing_token {
1217 for bucket in &buckets {
1218 self.put_cors(&self.setup_token, bucket, Some(app_origin))
1219 .await?;
1220 }
1221 return Ok(());
1222 }
1223 let writing = self.mint_provisioning_token(app_origin).await?;
1224 let result = async {
1225 for bucket in &buckets {
1226 self.put_cors(&writing.value, bucket, Some(app_origin))
1227 .await?;
1228 }
1229 Ok(())
1230 }
1231 .await;
1232 if let Err(error) = self.revoke_token("CORS", &writing.id).await {
1233 eprintln!(
1234 "warning: {error}. It expires by itself within \
1235 {PROVISIONING_TOKEN_MINUTES} minutes."
1236 );
1237 }
1238 result
1239 }
1240
1241 async fn sign_origin_certificate(
1242 &self,
1243 token: &str,
1244 hostname: &str,
1245 ) -> Result<IssuedCertificate> {
1246 #[derive(Serialize)]
1247 struct Body<'a> {
1248 csr: &'a str,
1249 hostnames: [&'a str; 1],
1250 request_type: &'a str,
1251 requested_validity: u32,
1252 }
1253 #[derive(Deserialize)]
1254 struct Certificate {
1255 certificate: String,
1256 }
1257
1258 let key_pair = rcgen::KeyPair::generate()
1259 .map_err(|error| anyhow!("could not generate a key pair: {error}"))?;
1260 let mut params = rcgen::CertificateParams::new(vec![hostname.to_string()])
1261 .map_err(|error| anyhow!("could not build the certificate request: {error}"))?;
1262 params.distinguished_name = rcgen::DistinguishedName::new();
1263 params
1264 .distinguished_name
1265 .push(rcgen::DnType::CommonName, hostname);
1266 let csr_pem = params
1267 .serialize_request(&key_pair)
1268 .map_err(|error| anyhow!("could not sign the certificate request: {error}"))?
1269 .pem()
1270 .map_err(|error| anyhow!("could not encode the certificate request: {error}"))?;
1271
1272 let (status, envelope) = self
1273 .call::<Certificate>(
1274 token,
1275 reqwest::Method::POST,
1276 "/certificates",
1277 Some(serde_json::to_value(Body {
1278 csr: &csr_pem,
1279 hostnames: [hostname],
1280 request_type: CERTIFICATE_REQUEST_TYPE,
1281 requested_validity: CERTIFICATE_VALIDITY_DAYS,
1282 })?),
1283 )
1284 .await?;
1285 let certificate = envelope
1286 .result
1287 .filter(|_| envelope.success)
1288 .ok_or_else(|| {
1289 anyhow!(
1290 "Cloudflare would not sign the origin certificate ({status}): {}",
1291 describe(&envelope.errors)
1292 )
1293 })?;
1294
1295 Ok(IssuedCertificate {
1296 certificate_pem: certificate.certificate,
1297 private_key_pem: key_pair.serialize_pem(),
1298 not_after_epoch_seconds: chrono::Utc::now().timestamp()
1302 + i64::from(CERTIFICATE_VALIDITY_DAYS) * SECONDS_PER_DAY,
1303 })
1304 }
1305}
1306
1307fn cache_rule_app_hostnames(rule: &serde_json::Value) -> BTreeSet<String> {
1310 let Some(expression) = rule.get("expression").and_then(|value| value.as_str()) else {
1311 return BTreeSet::new();
1312 };
1313 if let Some(hostname) = expression
1314 .split_once("http.host eq \"")
1315 .and_then(|(_, remainder)| remainder.split_once('"'))
1316 .map(|(hostname, _)| hostname)
1317 {
1318 return BTreeSet::from([hostname.to_string()]);
1319 }
1320 let Some(host_list) = expression
1321 .split_once("http.host in {")
1322 .and_then(|(_, remainder)| remainder.split_once('}'))
1323 .map(|(host_list, _)| host_list)
1324 else {
1325 return BTreeSet::new();
1326 };
1327 host_list
1328 .split('"')
1329 .skip(1)
1330 .step_by(2)
1331 .filter(|hostname| !hostname.is_empty())
1332 .map(str::to_string)
1333 .collect()
1334}
1335
1336fn cache_rule_host_expression(zone_name: &str, app_hostnames: &BTreeSet<String>) -> String {
1337 let mut host_expressions = vec![
1338 format!(r#"http.host wildcard "fn0-*-frontend-asset.{zone_name}""#),
1339 format!(r#"http.host wildcard "fn0-*-public-object-storage.{zone_name}""#),
1340 ];
1341 if !app_hostnames.is_empty() {
1342 let exact_hostnames = app_hostnames
1343 .iter()
1344 .map(|hostname| format!(r#""{hostname}""#))
1345 .collect::<Vec<_>>()
1346 .join(" ");
1347 host_expressions.push(format!("http.host in {{{exact_hostnames}}}"));
1348 }
1349 host_expressions.join(" or ")
1350}
1351
1352#[derive(Deserialize)]
1353struct DnsRecord {
1354 id: String,
1355 #[serde(rename = "type")]
1356 record_type: String,
1357 content: String,
1358 #[serde(default)]
1360 proxied: bool,
1361}
1362
1363enum AppDnsRecordWrite<'a> {
1364 AlreadyPointed,
1365 Create,
1366 Repoint {
1367 record_id: &'a str,
1368 },
1369 Occupied {
1372 record_types: String,
1373 },
1374}
1375
1376fn decide_app_dns_record<'a>(
1381 records: &'a [DnsRecord],
1382 origin_hostname: &str,
1383) -> AppDnsRecordWrite<'a> {
1384 let resolving: Vec<&DnsRecord> = records
1385 .iter()
1386 .filter(|record| matches!(record.record_type.as_str(), "A" | "AAAA" | "CNAME"))
1387 .collect();
1388 match resolving.as_slice() {
1389 [] => AppDnsRecordWrite::Create,
1390 [record] if record.record_type == "CNAME" => {
1391 if record.content == origin_hostname && record.proxied {
1392 AppDnsRecordWrite::AlreadyPointed
1393 } else {
1394 AppDnsRecordWrite::Repoint {
1395 record_id: &record.id,
1396 }
1397 }
1398 }
1399 _ => AppDnsRecordWrite::Occupied {
1400 record_types: resolving
1401 .iter()
1402 .map(|record| record.record_type.as_str())
1403 .collect::<Vec<_>>()
1404 .join(", "),
1405 },
1406 }
1407}
1408
1409fn replaced_app_dns_record<'a>(
1410 records: &'a [DnsRecord],
1411 origin_hostname: &str,
1412) -> Option<&'a DnsRecord> {
1413 records.iter().find(|record| {
1414 record.record_type == "CNAME" && record.proxied && record.content == origin_hostname
1415 })
1416}
1417
1418fn hex_sha256(value: &str) -> String {
1419 let digest = Sha256::digest(value.as_bytes());
1420 let mut out = String::with_capacity(digest.len() * 2);
1421 for byte in digest {
1422 out.push_str(&format!("{byte:02x}"));
1423 }
1424 out
1425}
1426
1427fn cors_absent(errors: &[ApiError]) -> bool {
1433 errors.iter().any(|error| error.code == 10059)
1434}
1435
1436fn already_exists(errors: &[ApiError]) -> bool {
1437 errors.iter().any(|error| {
1438 let message = error.message.to_lowercase();
1439 message.contains("already exists")
1440 || message.contains("already configured")
1441 || message.contains("duplicate")
1442 })
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447 use super::{
1448 AppDnsRecordWrite, DnsRecord, cache_rule_app_hostnames, cache_rule_host_expression,
1449 decide_app_dns_record, replaced_app_dns_record,
1450 };
1451 use std::collections::BTreeSet;
1452
1453 const ORIGIN: &str = "worker.fn0.dev";
1454
1455 fn record(record_type: &str, content: &str, proxied: bool) -> DnsRecord {
1456 DnsRecord {
1457 id: format!("id-{record_type}-{content}"),
1458 record_type: record_type.to_string(),
1459 content: content.to_string(),
1460 proxied,
1461 }
1462 }
1463
1464 #[test]
1465 fn app_dns_record_is_created_when_the_hostname_is_free() {
1466 assert!(matches!(
1467 decide_app_dns_record(&[], ORIGIN),
1468 AppDnsRecordWrite::Create
1469 ));
1470 }
1471
1472 #[test]
1473 fn app_dns_record_already_pointed_is_left_alone() {
1474 let records = [
1475 record("CNAME", ORIGIN, true),
1476 record("TXT", "some verification string", false),
1477 ];
1478
1479 assert!(matches!(
1480 decide_app_dns_record(&records, ORIGIN),
1481 AppDnsRecordWrite::AlreadyPointed
1482 ));
1483 }
1484
1485 #[test]
1486 fn app_dns_record_is_repointed_when_it_is_grey_or_aimed_elsewhere() {
1487 for records in [
1488 [record("CNAME", ORIGIN, false)],
1489 [record("CNAME", "somewhere.example.com", true)],
1490 ] {
1491 let AppDnsRecordWrite::Repoint { record_id } = decide_app_dns_record(&records, ORIGIN)
1492 else {
1493 panic!("expected a repoint");
1494 };
1495 assert_eq!(record_id, records[0].id);
1496 }
1497 }
1498
1499 #[test]
1500 fn app_dns_record_refuses_an_address_record_it_cannot_repoint() {
1501 let records = [record("A", "203.0.113.7", true)];
1502
1503 let AppDnsRecordWrite::Occupied { record_types } = decide_app_dns_record(&records, ORIGIN)
1504 else {
1505 panic!("expected the hostname to read as occupied");
1506 };
1507 assert_eq!(record_types, "A");
1508 }
1509
1510 #[test]
1511 fn replaced_app_dns_record_matches_only_the_record_fn0_wrote() {
1512 assert!(
1513 replaced_app_dns_record(&[record("CNAME", ORIGIN, true)], ORIGIN)
1514 .is_some_and(|found| found.content == ORIGIN)
1515 );
1516 for records in [
1517 [record("CNAME", ORIGIN, false)],
1518 [record("CNAME", "somewhere.example.com", true)],
1519 [record("A", "203.0.113.7", true)],
1520 ] {
1521 assert!(replaced_app_dns_record(&records, ORIGIN).is_none());
1522 }
1523 }
1524
1525 #[test]
1526 fn cache_rule_expression_contains_bucket_and_app_hosts() {
1527 let app_hostnames =
1528 BTreeSet::from(["app.example.com".to_string(), "www.example.com".to_string()]);
1529
1530 assert_eq!(
1531 cache_rule_host_expression("example.com", &app_hostnames),
1532 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"}"#
1533 );
1534 }
1535
1536 #[test]
1537 fn cache_rule_app_hostnames_reads_managed_rule_expression() {
1538 let rule = serde_json::json!({
1539 "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"})"#
1540 });
1541
1542 assert_eq!(
1543 cache_rule_app_hostnames(&rule),
1544 BTreeSet::from(["app.example.com".to_string(), "www.example.com".to_string(),])
1545 );
1546 }
1547
1548 #[test]
1549 fn cache_rule_app_hostnames_reads_single_host_expression() {
1550 let rule = serde_json::json!({
1551 "expression": r#"http.host eq "control.example.com""#
1552 });
1553
1554 assert_eq!(
1555 cache_rule_app_hostnames(&rule),
1556 BTreeSet::from(["control.example.com".to_string()])
1557 );
1558 }
1559}