Skip to main content

fn0_deploy/
cloudflare.rs

1//! Connecting a project to the owner's own Cloudflare account.
2//!
3//! The account-wide token is used here and only here. It provisions the
4//! account, mints two narrow credentials, and goes out of scope; fn0 receives
5//! the narrow credentials and never the token that made them.
6
7use anyhow::{Result, anyhow};
8use serde::{Deserialize, Serialize};
9
10use crate::cloudflare_provision::{
11    ASSET_BUCKET, DataPlaneCredentials, PAGE_BUCKET, ProvisionedResources, Provisioner,
12    object_bucket_name,
13};
14
15#[derive(Serialize)]
16struct ConnectInput<'a> {
17    project_id: &'a str,
18    account_id: &'a str,
19    zone_id: &'a str,
20    zone_name: &'a str,
21    static_hostname: &'a str,
22    object_bucket: &'a str,
23    asset_bucket: &'a str,
24    page_bucket: &'a str,
25    dataplane_access_key_id: &'a str,
26    dataplane_secret: &'a str,
27    purge_token: &'a str,
28}
29
30#[derive(Deserialize)]
31#[serde(tag = "t", rename_all_fields = "camelCase")]
32enum Connect {
33    Ok,
34    CredentialRejected {
35        reason: String,
36    },
37    AlreadyConnected {
38        account_id: String,
39        zone_name: String,
40    },
41    NotLoggedIn,
42    NotFound,
43    InternalError {
44        reason: String,
45    },
46}
47
48/// One `API Tokens -> Edit` token; the CLI provisions and mints from it.
49pub async fn cloudflare_connect_managed(
50    project_id: &str,
51    account_id: &str,
52    zone_id: &str,
53    api_token: &str,
54) -> Result<()> {
55    println!("provisioning your Cloudflare account (this runs locally)...");
56    let provisioner = Provisioner::new(
57        api_token.to_string(),
58        account_id.to_string(),
59        zone_id.to_string(),
60    );
61    let (resources, credentials) = provisioner.run_managed(project_id).await?;
62    print_resources(&resources);
63    println!("  minted a bucket-scoped R2 token and a purge-only token");
64    send_connect(project_id, account_id, zone_id, &resources, &credentials).await
65}
66
67/// A token that can provision but cannot create tokens. Provisions and stops;
68/// the user makes the two long-lived credentials themselves.
69pub async fn cloudflare_provision(
70    project_id: &str,
71    account_id: &str,
72    zone_id: &str,
73    api_token: &str,
74) -> Result<()> {
75    println!("provisioning your Cloudflare account (this runs locally)...");
76    let provisioner = Provisioner::new(
77        api_token.to_string(),
78        account_id.to_string(),
79        zone_id.to_string(),
80    );
81    let resources = provisioner.run_manual(project_id).await?;
82    print_resources(&resources);
83
84    println!();
85    println!("Now create the two credentials fn0 will keep. Neither can be minted for");
86    println!("you here, because this token deliberately cannot create tokens.");
87    println!();
88    println!("1. R2 -> Manage API Tokens -> Create API token");
89    println!("     permission: Object Read & Write");
90    println!("     apply to specific buckets:");
91    println!("       {}", resources.object_bucket);
92    println!("       {}", resources.asset_bucket);
93    println!("       {}", resources.page_bucket);
94    println!("     the screen shows an Access Key ID and a Secret Access Key; keep both");
95    println!();
96    println!("2. My Profile -> API Tokens -> Create Custom Token");
97    println!("     permission: Zone -> Cache Purge -> Purge");
98    println!("     zone resources: {} only", resources.zone_name);
99    println!();
100    println!("Then finish with:");
101    println!();
102    println!("    forte cloudflare connect \\");
103    println!("      --account-id {account_id} \\");
104    println!("      --zone-id {zone_id} \\");
105    println!("      --zone-name {} \\", resources.zone_name);
106    println!("      --dataplane-access-key-id <Access Key ID> \\");
107    println!("      --dataplane-secret <Secret Access Key> \\");
108    println!("      --purge-token <purge token>");
109    println!();
110    println!("You can delete the token you just used once you have done that.");
111    Ok(())
112}
113
114/// The second half of the careful path: credentials the user made by hand.
115pub async fn cloudflare_connect_manual(
116    project_id: &str,
117    account_id: &str,
118    zone_id: &str,
119    zone_name: &str,
120    dataplane_access_key_id: &str,
121    dataplane_secret: &str,
122    purge_token: &str,
123) -> Result<()> {
124    let resources = ProvisionedResources {
125        zone_name: zone_name.to_string(),
126        static_hostname: format!("static.{zone_name}"),
127        object_bucket: object_bucket_name(project_id),
128        asset_bucket: ASSET_BUCKET.to_string(),
129        page_bucket: PAGE_BUCKET.to_string(),
130    };
131    let credentials = DataPlaneCredentials {
132        dataplane_access_key_id: dataplane_access_key_id.to_string(),
133        dataplane_secret: dataplane_secret.to_string(),
134        purge_token: purge_token.to_string(),
135    };
136    send_connect(project_id, account_id, zone_id, &resources, &credentials).await
137}
138
139fn print_resources(resources: &ProvisionedResources) {
140    println!("  zone:    {}", resources.zone_name);
141    println!(
142        "  buckets: {}, {}, {}",
143        resources.object_bucket, resources.asset_bucket, resources.page_bucket
144    );
145    println!("  assets:  https://{}", resources.static_hostname);
146}
147
148async fn send_connect(
149    project_id: &str,
150    account_id: &str,
151    zone_id: &str,
152    provisioned: &ProvisionedResources,
153    credentials: &DataPlaneCredentials,
154) -> Result<()> {
155    let creds = crate::credentials::require()?;
156    let url = format!(
157        "{}/__forte_action/cloudflare_connect",
158        creds.control_url.trim_end_matches('/')
159    );
160    let response = reqwest::Client::new()
161        .post(&url)
162        .bearer_auth(&creds.token)
163        .json(&ConnectInput {
164            project_id,
165            account_id,
166            zone_id,
167            zone_name: &provisioned.zone_name,
168            static_hostname: &provisioned.static_hostname,
169            object_bucket: &provisioned.object_bucket,
170            asset_bucket: &provisioned.asset_bucket,
171            page_bucket: &provisioned.page_bucket,
172            dataplane_access_key_id: &credentials.dataplane_access_key_id,
173            dataplane_secret: &credentials.dataplane_secret,
174            purge_token: &credentials.purge_token,
175        })
176        .send()
177        .await?
178        .error_for_status()?;
179
180    match response.json::<Connect>().await? {
181        Connect::Ok => {
182            println!();
183            println!("connected. the token you used was not sent to fn0 and is no longer");
184            println!("needed — you can delete it in the Cloudflare dashboard.");
185            println!("workers pick this up within a second; no redeploy needed.");
186            Ok(())
187        }
188        Connect::CredentialRejected { reason } => {
189            Err(anyhow!("fn0 rejected the credentials: {reason}"))
190        }
191        Connect::AlreadyConnected {
192            account_id,
193            zone_name,
194        } => Err(anyhow!(
195            "project '{project_id}' is already connected to account {account_id} ({zone_name}). \
196             Reconnecting is not supported yet — it would have to decide whether to rotate \
197             credentials and whether to move objects already written to that account."
198        )),
199        Connect::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
200        Connect::NotFound => Err(anyhow!(
201            "project '{project_id}' not found or not owned by you."
202        )),
203        Connect::InternalError { reason } => Err(anyhow!("cloudflare_connect: {reason}")),
204    }
205}
206
207#[derive(Serialize)]
208struct StatusInput<'a> {
209    project_id: &'a str,
210}
211
212#[derive(Deserialize)]
213#[serde(tag = "t", rename_all_fields = "camelCase")]
214enum Status {
215    Platform,
216    Connected {
217        account_id: String,
218        zone_name: String,
219        static_hostname: String,
220        asset_bucket: String,
221        page_bucket: String,
222        healthy: bool,
223        problem: Option<String>,
224    },
225    NotLoggedIn,
226    NotFound,
227    InternalError {
228        reason: String,
229    },
230}
231
232pub async fn cloudflare_status(project_id: &str) -> Result<()> {
233    let creds = crate::credentials::require()?;
234    let url = format!(
235        "{}/__forte_action/cloudflare_status",
236        creds.control_url.trim_end_matches('/')
237    );
238    let response = reqwest::Client::new()
239        .post(&url)
240        .bearer_auth(&creds.token)
241        .json(&StatusInput { project_id })
242        .send()
243        .await?
244        .error_for_status()?;
245
246    match response.json::<Status>().await? {
247        Status::Platform => {
248            println!("project '{project_id}' runs on the fn0 platform Cloudflare account.");
249            println!("run `forte cloudflare connect` to move it onto your own.");
250            Ok(())
251        }
252        Status::Connected {
253            account_id,
254            zone_name,
255            static_hostname,
256            asset_bucket,
257            page_bucket,
258            healthy,
259            problem,
260        } => {
261            println!("account: {account_id}");
262            println!("zone:    {zone_name}");
263            println!("assets:  {asset_bucket} -> https://{static_hostname}");
264            println!("pages:   {page_bucket}");
265            match (healthy, problem) {
266                (true, _) => println!("status:  ok"),
267                (false, Some(problem)) => println!("status:  degraded - {problem}"),
268                (false, None) => println!("status:  degraded"),
269            }
270            Ok(())
271        }
272        Status::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
273        Status::NotFound => Err(anyhow!(
274            "project '{project_id}' not found or not owned by you."
275        )),
276        Status::InternalError { reason } => Err(anyhow!("cloudflare_status: {reason}")),
277    }
278}