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, pointing the app hostname at the fleet and signing origin certificates
6//! all need an account-wide token, and a token that can do those things can also
7//! delete every bucket in the account.
8//! Measured, not assumed: an account-scoped `Workers R2 Storage Edit` token
9//! reaches every bucket in the account over the S3 API, `DeleteBucket`
10//! included.
11//!
12//! So the account-wide token stays here and is discarded when the command
13//! exits. What fn0 receives is what this module mints at the end: two R2 tokens
14//! that cannot delete a bucket or call the REST API, and a token that can purge
15//! one zone's cache and nothing else.
16//!
17//! The two R2 tokens are split by who holds them. The worker token reaches the
18//! project's objects and public objects, and is the only one
19//! published to the fleet; the asset token reaches the deployed frontend and
20//! stays in control. So a compromised worker cannot rewrite a deployed
21//! frontend, and asset GC — which is the only thing that deletes on a schedule
22//! — holds a credential that cannot open a bucket holding user data.
23//!
24//! There are two ways in, because the convenient one asks for a permission not
25//! everyone should grant.
26//!
27//! [`Provisioner::run_managed`] takes a setup token carrying only
28//! `User -> API Tokens -> Edit` and mints everything else from it, including
29//! the short-lived token that does the provisioning. Cloudflare lets a token
30//! grant permissions it does not hold, so one checkbox is enough; it also
31//! refuses to let a minted token mint further tokens, so the provisioning token
32//! cannot widen itself. Both measured against the live API. The catch is that a
33//! token which can create tokens can create *any* token, so it is account-wide
34//! however short its list looks.
35//!
36//! [`Provisioner::run_manual`] takes a token that can provision but cannot
37//! create tokens, and mints nothing. The user makes the two long-lived
38//! credentials themselves. It is more work and it never puts a token capable of
39//! escalation on disk.
40
41use 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
48// Permission group ids, read from `/user/tokens/permission_groups`. Cloudflare
49// identifies these by uuid rather than by name in the tokens API.
50const R2_BUCKET_ITEM_READ: &str = "6a018a9f2fc74eb6b293b0c548f38b39";
51const R2_BUCKET_ITEM_WRITE: &str = "2efd5506f9c8494dacb1fa10a3e7d5b6";
52const CACHE_PURGE: &str = "e17beae8b8cb423a99b1730f21238bed";
53
54/// Origin CA's longest offered validity. Renewal would be a live-traffic event
55/// on a hostname fn0 does not control the DNS for, so the fewer the better.
56const CERTIFICATE_VALIDITY_DAYS: u32 = 5475;
57const SECONDS_PER_DAY: i64 = 86_400;
58
59/// rcgen generates an ECDSA P-256 key by default, which Origin CA signs under
60/// `origin-ecc`. Asking for `origin-rsa` with an ECDSA CSR is rejected.
61const CERTIFICATE_REQUEST_TYPE: &str = "origin-ecc";
62
63// Permission groups the provisioning token is minted with. Setup is seconds of
64// API calls; the expiry is generous by comparison and exists so that a crash
65// between minting and revoking leaves nothing usable for long.
66const PROVISIONING_TOKEN_MINUTES: i64 = 10;
67const R2_STORAGE_WRITE: &str = "bf7481a1826f439697cb59a20b22293e";
68const ZONE_READ: &str = "c8fed203ed3043cba015a93ad1616f1f";
69/// `DNS Write`, not `Zone DNS Settings Write`: the latter carries the zone's DNS
70/// settings and cannot touch a record.
71const DNS_WRITE: &str = "4755a26eedb94da69e1066d98aa820be";
72const CACHE_SETTINGS_WRITE: &str = "9ff81cbbe65c400b97d92c3c1033cab6";
73const ZONE_SETTINGS_WRITE: &str = "3030687196b94b638145a3953da2b699";
74const SSL_AND_CERTIFICATES_WRITE: &str = "c03055bc037c4ea9afb9a9f104b7b721";
75
76/// One set of buckets per project. The two that are served publicly take their
77/// hostname from their own name, so a bucket and the address it answers on are
78/// the same string and cannot drift apart.
79pub 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
91/// A zone the setup token can reach, and the account that owns it.
92pub struct ReachableZone {
93    pub zone_id: String,
94    pub zone_name: String,
95    pub account_id: String,
96    pub account_name: String,
97}
98
99/// Reads which zones a token can reach, so nobody has to copy a hex id out of
100/// the dashboard. Separate from [`Provisioner`], which cannot be built until an
101/// account and a zone have been picked — which is what this is for.
102pub 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    /// `GET /zones` carries the owning account on every zone, so one call
116    /// settles both ids.
117    ///
118    /// A setup token carries only `API Tokens -> Edit` and cannot read zones,
119    /// so `mint_reader` has it mint one that can and revokes it afterwards. The
120    /// minted policy scopes `com.cloudflare.api.account.*` — a wildcard the
121    /// tokens API accepts, and the only form available here, since the account
122    /// id is the thing being discovered. Measured against the live API.
123    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    /// The user's setup token. Used only to mint and revoke; never to
195    /// provision, because it is not granted the permissions to.
196    setup_token: String,
197    account_id: String,
198    zone_id: String,
199}
200
201/// A token minted for the length of one command, and revoked at the end of it.
202struct TemporaryToken {
203    id: String,
204    value: String,
205}
206
207/// What provisioning creates. Names only — no credentials.
208pub 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
217/// The three long-lived credentials fn0 is given.
218pub struct ConnectCredentials {
219    /// Reaches the two object-storage buckets. The only R2 credential published
220    /// to the worker fleet.
221    pub worker_access_key_id: String,
222    /// SHA-256 of the token, which is what R2 takes as an S3 secret access key.
223    /// The token value never leaves the user's machine: the hash is the only
224    /// form fn0 needs, and unlike the token it cannot be replayed against the
225    /// REST API.
226    pub worker_secret: String,
227    /// Reaches the frontend-asset bucket only, and stays in control. Asset GC
228    /// runs on a schedule and deletes; holding a credential that cannot open a
229    /// bucket of user data is what keeps a bug in it from being unbounded.
230    pub frontend_asset_access_key_id: String,
231    pub frontend_asset_secret: String,
232    pub purge_token: String,
233}
234
235/// The ids of the credentials the managed path minted, so a connect that fn0
236/// refused does not leave them live on the user's account.
237pub 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    /// Confirms the token works and returns its id, which is also the S3 access
373    /// key id if this token is ever used against R2 directly.
374    pub async fn verify(&self) -> Result<String> {
375        #[derive(Deserialize)]
376        struct Token {
377            id: String,
378            status: String,
379        }
380        // A token made under My Profile is user-owned; one made on the
381        // account's own API Tokens page is account-owned, and each is only
382        // accepted by its own endpoint. Onboarding documents the former.
383        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    /// Bounds who may read these objects from a browser to the one origin the
447    /// project answers on. `None` removes the configuration entirely, which is
448    /// how "no origin may read this" has to be spelled: R2 rejects both an
449    /// empty `origins` and an empty `rules` with `10001`, measured against the
450    /// live API.
451    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        // Deleting a configuration that was never written is the state asked
474        // for, not a failure.
475        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    /// Whether `hostname` is already attached to `bucket` specifically.
513    ///
514    /// Cloudflare answers "already in use" for a hostname attached to any
515    /// bucket, and [`already_exists`] deliberately refuses to read that as
516    /// success — pointed at someone else's bucket it would serve 404s. Asking
517    /// this bucket first is what makes a retry after a partial provision
518    /// distinguishable from that.
519    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    /// Adds the cache rule fn0's public hostnames need, keeping every other rule
556    /// in the zone.
557    ///
558    /// The match is a wildcard over the whole zone rather than one hostname, so
559    /// this rule is written once and never grows: a free zone allows ten cache
560    /// rules, which a rule per project would exhaust at ten projects. Both
561    /// halves of the pattern are required — a bare `*-frontend-asset` would also
562    /// match a hostname of the user's own and quietly pull it into fn0's caching
563    /// policy.
564    ///
565    /// A `PUT` to a phase entrypoint replaces the whole rule list, so the
566    /// existing rules are read back and carried through. `PURGE` has to be in
567    /// the method match or the purge API answers `success: true` while the edge
568    /// keeps serving the old object. `browser_ttl: respect_origin` is what stops
569    /// a fresh zone's four-hour default Browser Cache TTL from overriding the
570    /// `max-age=0` fn0 stores on public objects — four hours of browser copies
571    /// is the one staleness no purge can reach.
572    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        // A zone with no cache rules has no entrypoint ruleset at all, which
595        // reads as 404 rather than an empty list.
596        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    /// Points the app hostname at the worker fleet and takes the record the
720    /// project used to answer on back out of the zone.
721    ///
722    /// The record is proxied. An Origin CA certificate is trusted by
723    /// Cloudflare's edge and by nothing else, so a grey-clouded record reaches
724    /// the fleet and fails the handshake.
725    ///
726    /// Removing the replaced record is not tidying: it points at fn0 by name,
727    /// and any project that registers that hostname next inherits the traffic.
728    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        // Guarded rather than assumed: control reports a replaced domain only
792        // when it differs, and a version of it that ever reported the same one
793        // would have this delete the record just written.
794        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    /// Only the record this command itself would have written is removed.
804    /// Anything else on that hostname is the user's, and a domain change is no
805    /// reason to delete it for them.
806    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    /// `?name=` matches the whole name, measured against the live API: listing
838    /// `example.com` answers with the apex records alone rather than everything
839    /// under it.
840    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    /// Mints a token that can do the provisioning, since the setup token
943    /// itself is only allowed to create tokens.
944    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    /// The convenient path: one `API Tokens -> Edit` token, everything else
983    /// minted here and the provisioning token revoked on the way out.
984    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        // Best effort, and reported rather than swallowed: the token expires on
1005        // its own, but a user who has to wait for that should know why.
1006        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    /// The careful path: provision with the token as given, mint nothing. The
1016    /// caller's token is expected to be unable to create tokens, which is the
1017    /// whole reason to choose this.
1018    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    /// Best effort, and reported rather than swallowed: unlike the provisioning
1159    /// token these carry no expiry, so one left behind stays until the user
1160    /// finds it.
1161    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    /// Signs an origin certificate for `hostname` through the zone owner's own
1174    /// Origin CA.
1175    ///
1176    /// The key pair is generated here and the private key is sent to fn0
1177    /// alongside the certificate, because the worker has to present it during
1178    /// the TLS handshake. Nothing that can sign another one is: the token that
1179    /// did the signing is revoked before this returns.
1180    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    /// Repoints the buckets' CORS at a domain the project has moved to.
1203    /// Provisioning writes the same rules for the domain the project starts
1204    /// with; this is how they follow it afterwards.
1205    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            // Derived rather than parsed out of the response: Cloudflare answers
1299            // with a Go-formatted timestamp, and the validity we asked for is
1300            // the same fact without a format to get wrong.
1301            not_after_epoch_seconds: chrono::Utc::now().timestamp()
1302                + i64::from(CERTIFICATE_VALIDITY_DAYS) * SECONDS_PER_DAY,
1303        })
1304    }
1305}
1306
1307/// R2's S3 API takes the SHA-256 of the token value as the secret access key,
1308/// lowercase hex — the same string `printf '%s' <token> | sha256sum` produces.
1309fn 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    /// Absent on the record types that cannot be proxied at all.
1359    #[serde(default)]
1360    proxied: bool,
1361}
1362
1363enum AppDnsRecordWrite<'a> {
1364    AlreadyPointed,
1365    Create,
1366    Repoint {
1367        record_id: &'a str,
1368    },
1369    /// Held by records a CNAME cannot join: Cloudflare refuses an A next to a
1370    /// CNAME on one name, and refuses a second record of either kind.
1371    Occupied {
1372        record_types: String,
1373    },
1374}
1375
1376/// A CNAME already on the hostname is repointed rather than refused. The user
1377/// named this hostname on the command line, so where it resolves is what they
1378/// are asking to change; an address record of another type is a different
1379/// enough thing to stop for.
1380fn 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
1427/// Deliberately does not accept "already in use": Cloudflare answers that when a
1428/// hostname is attached to a *different* bucket, which is a failure to report
1429/// rather than a step to skip. Treating it as success ends in a hostname that
1430/// resolves and serves 404 for every object.
1431/// `10059 The CORS configuration does not exist.`, measured.
1432fn 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}