Skip to main content

fn0_deploy/
cloudflare_provision.rs

1//! Provisioning a user's Cloudflare account, on the user's own machine.
2//!
3//! This is the half of bring-your-own-Cloudflare that deliberately does not run
4//! on fn0's servers. Creating buckets, attaching a CDN hostname, writing a cache
5//! rule and signing origin certificates all need an account-wide token, and a
6//! token that can do those things can also delete every bucket in the account.
7//! Measured, not assumed: an account-scoped `Workers R2 Storage Edit` token
8//! reaches every bucket in the account over the S3 API, `DeleteBucket`
9//! included.
10//!
11//! So the account-wide token stays here and is discarded when the command
12//! exits. What fn0 receives is what this module mints at the end: two R2 tokens
13//! that cannot delete a bucket or call the REST API, and a token that can purge
14//! one zone's cache and nothing else.
15//!
16//! The two R2 tokens are split by who holds them. The worker token reaches the
17//! project's objects and public objects, and is the only one
18//! published to the fleet; the asset token reaches the deployed frontend and
19//! stays in control. So a compromised worker cannot rewrite a deployed
20//! frontend, and asset GC — which is the only thing that deletes on a schedule
21//! — holds a credential that cannot open a bucket holding user data.
22//!
23//! There are two ways in, because the convenient one asks for a permission not
24//! everyone should grant.
25//!
26//! [`Provisioner::run_managed`] takes a setup token carrying only
27//! `User -> API Tokens -> Edit` and mints everything else from it, including
28//! the short-lived token that does the provisioning. Cloudflare lets a token
29//! grant permissions it does not hold, so one checkbox is enough; it also
30//! refuses to let a minted token mint further tokens, so the provisioning token
31//! cannot widen itself. Both measured against the live API. The catch is that a
32//! token which can create tokens can create *any* token, so it is account-wide
33//! however short its list looks.
34//!
35//! [`Provisioner::run_manual`] takes a token that can provision but cannot
36//! create tokens, and mints nothing. The user makes the two long-lived
37//! credentials themselves. It is more work and it never puts a token capable of
38//! escalation on disk.
39
40use 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
47// Permission group ids, read from `/user/tokens/permission_groups`. Cloudflare
48// identifies these by uuid rather than by name in the tokens API.
49const R2_BUCKET_ITEM_READ: &str = "6a018a9f2fc74eb6b293b0c548f38b39";
50const R2_BUCKET_ITEM_WRITE: &str = "2efd5506f9c8494dacb1fa10a3e7d5b6";
51const CACHE_PURGE: &str = "e17beae8b8cb423a99b1730f21238bed";
52
53/// Origin CA's longest offered validity. Renewal would be a live-traffic event
54/// on a hostname fn0 does not control the DNS for, so the fewer the better.
55const CERTIFICATE_VALIDITY_DAYS: u32 = 5475;
56const SECONDS_PER_DAY: i64 = 86_400;
57
58/// rcgen generates an ECDSA P-256 key by default, which Origin CA signs under
59/// `origin-ecc`. Asking for `origin-rsa` with an ECDSA CSR is rejected.
60const CERTIFICATE_REQUEST_TYPE: &str = "origin-ecc";
61
62// Permission groups the provisioning token is minted with. Setup is seconds of
63// API calls; the expiry is generous by comparison and exists so that a crash
64// between minting and revoking leaves nothing usable for long.
65const 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
72/// One set of buckets per project. The two that are served publicly take their
73/// hostname from their own name, so a bucket and the address it answers on are
74/// the same string and cannot drift apart.
75pub 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
87/// A zone the setup token can reach, and the account that owns it.
88pub struct ReachableZone {
89    pub zone_id: String,
90    pub zone_name: String,
91    pub account_id: String,
92    pub account_name: String,
93}
94
95/// Reads which zones a token can reach, so nobody has to copy a hex id out of
96/// the dashboard. Separate from [`Provisioner`], which cannot be built until an
97/// account and a zone have been picked — which is what this is for.
98pub 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    /// `GET /zones` carries the owning account on every zone, so one call
112    /// settles both ids.
113    ///
114    /// A setup token carries only `API Tokens -> Edit` and cannot read zones,
115    /// so `mint_reader` has it mint one that can and revokes it afterwards. The
116    /// minted policy scopes `com.cloudflare.api.account.*` — a wildcard the
117    /// tokens API accepts, and the only form available here, since the account
118    /// id is the thing being discovered. Measured against the live API.
119    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    /// The user's setup token. Used only to mint and revoke; never to
191    /// provision, because it is not granted the permissions to.
192    setup_token: String,
193    account_id: String,
194    zone_id: String,
195}
196
197/// A token minted for the length of one command, and revoked at the end of it.
198struct TemporaryToken {
199    id: String,
200    value: String,
201}
202
203/// What provisioning creates. Names only — no credentials.
204pub 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
213/// The three long-lived credentials fn0 is given.
214pub struct ConnectCredentials {
215    /// Reaches the two object-storage buckets. The only R2 credential published
216    /// to the worker fleet.
217    pub worker_access_key_id: String,
218    /// SHA-256 of the token, which is what R2 takes as an S3 secret access key.
219    /// The token value never leaves the user's machine: the hash is the only
220    /// form fn0 needs, and unlike the token it cannot be replayed against the
221    /// REST API.
222    pub worker_secret: String,
223    /// Reaches the frontend-asset bucket only, and stays in control. Asset GC
224    /// runs on a schedule and deletes; holding a credential that cannot open a
225    /// bucket of user data is what keeps a bug in it from being unbounded.
226    pub frontend_asset_access_key_id: String,
227    pub frontend_asset_secret: String,
228    pub purge_token: String,
229}
230
231/// The ids of the credentials the managed path minted, so a connect that fn0
232/// refused does not leave them live on the user's account.
233pub 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    /// Confirms the token works and returns its id, which is also the S3 access
369    /// key id if this token is ever used against R2 directly.
370    pub async fn verify(&self) -> Result<String> {
371        #[derive(Deserialize)]
372        struct Token {
373            id: String,
374            status: String,
375        }
376        // A token made under My Profile is user-owned; one made on the
377        // account's own API Tokens page is account-owned, and each is only
378        // accepted by its own endpoint. Onboarding documents the former.
379        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    /// Bounds who may read these objects from a browser to the one origin the
443    /// project answers on. `None` removes the configuration entirely, which is
444    /// how "no origin may read this" has to be spelled: R2 rejects both an
445    /// empty `origins` and an empty `rules` with `10001`, measured against the
446    /// live API.
447    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        // Deleting a configuration that was never written is the state asked
470        // for, not a failure.
471        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    /// Whether `hostname` is already attached to `bucket` specifically.
509    ///
510    /// Cloudflare answers "already in use" for a hostname attached to any
511    /// bucket, and [`already_exists`] deliberately refuses to read that as
512    /// success — pointed at someone else's bucket it would serve 404s. Asking
513    /// this bucket first is what makes a retry after a partial provision
514    /// distinguishable from that.
515    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    /// Adds the cache rule fn0's public hostnames need, keeping every other rule
552    /// in the zone.
553    ///
554    /// The match is a wildcard over the whole zone rather than one hostname, so
555    /// this rule is written once and never grows: a free zone allows ten cache
556    /// rules, which a rule per project would exhaust at ten projects. Both
557    /// halves of the pattern are required — a bare `*-frontend-asset` would also
558    /// match a hostname of the user's own and quietly pull it into fn0's caching
559    /// policy.
560    ///
561    /// A `PUT` to a phase entrypoint replaces the whole rule list, so the
562    /// existing rules are read back and carried through. `PURGE` has to be in
563    /// the method match or the purge API answers `success: true` while the edge
564    /// keeps serving the old object. `browser_ttl: respect_origin` is what stops
565    /// a fresh zone's four-hour default Browser Cache TTL from overriding the
566    /// `max-age=0` fn0 stores on public objects — four hours of browser copies
567    /// is the one staleness no purge can reach.
568    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        // A zone with no cache rules has no entrypoint ruleset at all, which
591        // reads as 404 rather than an empty list.
592        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    /// Mints a token that can do the provisioning, since the setup token
727    /// itself is only allowed to create tokens.
728    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    /// The convenient path: one `API Tokens -> Edit` token, everything else
766    /// minted here and the provisioning token revoked on the way out.
767    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        // Best effort, and reported rather than swallowed: the token expires on
788        // its own, but a user who has to wait for that should know why.
789        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    /// The careful path: provision with the token as given, mint nothing. The
799    /// caller's token is expected to be unable to create tokens, which is the
800    /// whole reason to choose this.
801    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    /// Best effort, and reported rather than swallowed: unlike the provisioning
942    /// token these carry no expiry, so one left behind stays until the user
943    /// finds it.
944    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    /// Signs an origin certificate for `hostname` through the zone owner's own
957    /// Origin CA.
958    ///
959    /// The key pair is generated here and the private key is sent to fn0
960    /// alongside the certificate, because the worker has to present it during
961    /// the TLS handshake. Nothing that can sign another one is: the token that
962    /// did the signing is revoked before this returns.
963    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    /// Repoints the buckets' CORS at a domain the project has moved to.
986    /// Provisioning writes the same rules for the domain the project starts
987    /// with; this is how they follow it afterwards.
988    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            // Derived rather than parsed out of the response: Cloudflare answers
1082            // with a Go-formatted timestamp, and the validity we asked for is
1083            // the same fact without a format to get wrong.
1084            not_after_epoch_seconds: chrono::Utc::now().timestamp()
1085                + i64::from(CERTIFICATE_VALIDITY_DAYS) * SECONDS_PER_DAY,
1086        })
1087    }
1088}
1089
1090/// R2's S3 API takes the SHA-256 of the token value as the secret access key,
1091/// lowercase hex — the same string `printf '%s' <token> | sha256sum` produces.
1092fn 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
1144/// Deliberately does not accept "already in use": Cloudflare answers that when a
1145/// hostname is attached to a *different* bucket, which is a failure to report
1146/// rather than a step to skip. Treating it as success ends in a hostname that
1147/// resolves and serves 404 for every object.
1148/// `10059 The CORS configuration does not exist.`, measured.
1149fn 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}