use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const API_BASE: &str = "https://api.cloudflare.com/client/v4";
const R2_BUCKET_ITEM_READ: &str = "6a018a9f2fc74eb6b293b0c548f38b39";
const R2_BUCKET_ITEM_WRITE: &str = "2efd5506f9c8494dacb1fa10a3e7d5b6";
const CACHE_PURGE: &str = "e17beae8b8cb423a99b1730f21238bed";
const CERTIFICATE_VALIDITY_DAYS: u32 = 5475;
const SECONDS_PER_DAY: i64 = 86_400;
const CERTIFICATE_REQUEST_TYPE: &str = "origin-ecc";
const PROVISIONING_TOKEN_MINUTES: i64 = 10;
const R2_STORAGE_WRITE: &str = "bf7481a1826f439697cb59a20b22293e";
const ZONE_READ: &str = "c8fed203ed3043cba015a93ad1616f1f";
const CACHE_SETTINGS_WRITE: &str = "9ff81cbbe65c400b97d92c3c1033cab6";
const SSL_AND_CERTIFICATES_WRITE: &str = "c03055bc037c4ea9afb9a9f104b7b721";
pub fn private_object_storage_bucket_name(project_id: &str) -> String {
format!("fn0-{project_id}-private-object-storage")
}
pub fn public_object_storage_bucket_name(project_id: &str) -> String {
format!("fn0-{project_id}-public-object-storage")
}
pub fn frontend_asset_bucket_name(project_id: &str) -> String {
format!("fn0-{project_id}-frontend-asset")
}
pub fn rendered_html_cache_bucket_name(project_id: &str) -> String {
format!("fn0-{project_id}-rendered-html-cache")
}
pub struct Provisioner {
client: reqwest::Client,
setup_token: String,
account_id: String,
zone_id: String,
}
struct TemporaryToken {
id: String,
value: String,
}
pub struct ProvisionedResources {
pub zone_name: String,
pub frontend_asset_hostname: String,
pub public_object_storage_hostname: String,
pub private_object_storage_bucket: String,
pub public_object_storage_bucket: String,
pub frontend_asset_bucket: String,
pub rendered_html_cache_bucket: String,
}
pub struct ConnectCredentials {
pub worker_access_key_id: String,
pub worker_secret: String,
pub frontend_asset_access_key_id: String,
pub frontend_asset_secret: String,
pub purge_token: String,
}
pub struct MintedCredentialIds {
pub worker: String,
pub frontend_asset: String,
pub purge: String,
}
pub struct IssuedCertificate {
pub certificate_pem: String,
pub private_key_pem: String,
pub not_after_epoch_seconds: i64,
}
#[derive(Deserialize)]
struct Envelope<T> {
success: bool,
#[serde(default)]
errors: Vec<ApiError>,
result: Option<T>,
}
#[derive(Deserialize)]
struct ApiError {
#[serde(default)]
code: i64,
#[serde(default)]
message: String,
}
fn describe(errors: &[ApiError]) -> String {
if errors.is_empty() {
return "no detail".to_string();
}
errors
.iter()
.map(|error| format!("{} ({})", error.message, error.code))
.collect::<Vec<_>>()
.join("; ")
}
impl Provisioner {
pub fn new(setup_token: String, account_id: String, zone_id: String) -> Self {
Self {
client: reqwest::Client::new(),
setup_token,
account_id,
zone_id,
}
}
async fn call<T: serde::de::DeserializeOwned>(
&self,
token: &str,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<(reqwest::StatusCode, Envelope<T>)> {
let mut request = self
.client
.request(method, format!("{API_BASE}{path}"))
.bearer_auth(token);
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await?;
let status = response.status();
let text = response.text().await?;
let envelope: Envelope<T> = serde_json::from_str(&text)
.with_context(|| format!("{path} returned {status}: {text}"))?;
Ok((status, envelope))
}
pub async fn verify(&self) -> Result<String> {
#[derive(Deserialize)]
struct Token {
id: String,
status: String,
}
for path in [
"/user/tokens/verify".to_string(),
format!("/accounts/{}/tokens/verify", self.account_id),
] {
if let Ok((_, envelope)) = self
.call::<Token>(&self.setup_token, reqwest::Method::GET, &path, None)
.await
&& let Some(token) = envelope.result.filter(|_| envelope.success)
{
if token.status != "active" {
return Err(anyhow!("the API token is {}, not active", token.status));
}
return Ok(token.id);
}
}
Err(anyhow!(
"Cloudflare rejected the API token. Check that it was copied whole and has not expired."
))
}
async fn zone_name(&self, token: &str) -> Result<String> {
#[derive(Deserialize)]
struct Zone {
name: String,
}
let (_, envelope) = self
.call::<Zone>(
token,
reqwest::Method::GET,
&format!("/zones/{}", self.zone_id),
None,
)
.await?;
envelope
.result
.filter(|_| envelope.success)
.map(|zone| zone.name)
.ok_or_else(|| {
anyhow!(
"could not read the zone. The token needs Zone -> Zone -> Read on it. {}",
describe(&envelope.errors)
)
})
}
async fn create_bucket(&self, token: &str, name: &str) -> Result<()> {
let (status, envelope) = self
.call::<serde_json::Value>(
token,
reqwest::Method::POST,
&format!("/accounts/{}/r2/buckets", self.account_id),
Some(serde_json::json!({ "name": name })),
)
.await?;
if envelope.success || already_exists(&envelope.errors) {
return Ok(());
}
Err(anyhow!(
"could not create bucket {name} ({status}). The token needs Account -> Workers R2 Storage -> Edit. {}",
describe(&envelope.errors)
))
}
async fn put_cors(&self, token: &str, bucket: &str) -> Result<()> {
let (status, envelope) = self
.call::<serde_json::Value>(
token,
reqwest::Method::PUT,
&format!("/accounts/{}/r2/buckets/{bucket}/cors", self.account_id),
Some(serde_json::json!({
"rules": [{
"allowed": {
"methods": ["GET", "PUT", "HEAD"],
"origins": ["*"],
"headers": ["*"],
},
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 86400,
}],
})),
)
.await?;
if envelope.success {
return Ok(());
}
Err(anyhow!(
"could not set CORS on {bucket} ({status}): {}",
describe(&envelope.errors)
))
}
async fn attach_custom_domain(&self, token: &str, bucket: &str, hostname: &str) -> Result<()> {
let (status, envelope) = self
.call::<serde_json::Value>(
token,
reqwest::Method::POST,
&format!(
"/accounts/{}/r2/buckets/{bucket}/domains/custom",
self.account_id
),
Some(serde_json::json!({
"domain": hostname,
"zoneId": self.zone_id,
"enabled": true,
})),
)
.await?;
if envelope.success || already_exists(&envelope.errors) {
return Ok(());
}
Err(anyhow!(
"could not point {hostname} at {bucket} ({status}): {}",
describe(&envelope.errors)
))
}
async fn ensure_cache_rule(&self, token: &str, zone_name: &str) -> Result<()> {
const RULE_DESCRIPTION: &str = "fn0 frontend assets and public objects";
#[derive(Deserialize)]
struct Ruleset {
#[serde(default)]
rules: Vec<serde_json::Value>,
}
let path = format!(
"/zones/{}/rulesets/phases/http_request_cache_settings/entrypoint",
self.zone_id
);
let (status, envelope) = self
.call::<Ruleset>(token, reqwest::Method::GET, &path, None)
.await?;
let mut rules = if envelope.success {
envelope.result.map(|set| set.rules).unwrap_or_default()
} else if status == reqwest::StatusCode::NOT_FOUND {
Vec::new()
} else {
return Err(anyhow!(
"could not read the zone's cache rules ({status}). The token needs Zone -> Cache Rules -> Edit. {}",
describe(&envelope.errors)
));
};
rules.retain(|rule| {
rule.get("description").and_then(|value| value.as_str()) != Some(RULE_DESCRIPTION)
});
rules.insert(
0,
serde_json::json!({
"action": "set_cache_settings",
"expression": format!(
r#"((http.host wildcard "fn0-*-frontend-asset.{zone_name}" or http.host wildcard "fn0-*-public-object-storage.{zone_name}") and http.request.method in {{"GET" "HEAD" "PURGE"}})"#
),
"description": RULE_DESCRIPTION,
"action_parameters": {
"cache": true,
"browser_ttl": { "mode": "respect_origin" },
},
}),
);
let (status, envelope) = self
.call::<serde_json::Value>(
token,
reqwest::Method::PUT,
&path,
Some(serde_json::json!({ "rules": rules })),
)
.await?;
if envelope.success {
return Ok(());
}
Err(anyhow!(
"could not write the zone's cache rules ({status}). The token needs Zone -> Cache Rules -> Edit. {}",
describe(&envelope.errors)
))
}
async fn mint_with_expiry(
&self,
name: &str,
policies: Vec<serde_json::Value>,
expires_on: Option<String>,
) -> Result<(String, String)> {
#[derive(Deserialize)]
struct Minted {
id: String,
value: String,
}
let (status, envelope) = self
.call::<Minted>(
&self.setup_token,
reqwest::Method::POST,
"/user/tokens",
Some(match expires_on {
Some(expires_on) => serde_json::json!({
"name": name, "policies": policies, "expires_on": expires_on,
}),
None => serde_json::json!({ "name": name, "policies": policies }),
}),
)
.await?;
let minted = envelope.result.filter(|_| envelope.success).ok_or_else(|| {
anyhow!(
"could not mint the {name} token ({status}). The token needs User -> API Tokens -> Edit. {}",
describe(&envelope.errors)
)
})?;
Ok((minted.id, minted.value))
}
async fn mint_provisioning_token(&self, purpose: &str) -> Result<TemporaryToken> {
let expires_on = (chrono::Utc::now()
+ chrono::Duration::minutes(PROVISIONING_TOKEN_MINUTES))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();
let (id, value) = self
.mint_with_expiry(
&format!("fn0 setup ({purpose})"),
vec![
serde_json::json!({
"effect": "allow",
"resources": { format!("com.cloudflare.api.account.{}", self.account_id): "*" },
"permission_groups": [{ "id": R2_STORAGE_WRITE }],
}),
serde_json::json!({
"effect": "allow",
"resources": { format!("com.cloudflare.api.account.zone.{}", self.zone_id): "*" },
"permission_groups": [
{ "id": ZONE_READ },
{ "id": CACHE_SETTINGS_WRITE },
{ "id": SSL_AND_CERTIFICATES_WRITE },
],
}),
],
Some(expires_on),
)
.await?;
Ok(TemporaryToken { id, value })
}
async fn revoke_token(&self, purpose: &str, id: &str) -> Result<()> {
let (status, envelope) = self
.call::<serde_json::Value>(
&self.setup_token,
reqwest::Method::DELETE,
&format!("/user/tokens/{id}"),
None,
)
.await?;
if envelope.success {
return Ok(());
}
Err(anyhow!(
"could not revoke the {purpose} token {id} ({status}): {}",
describe(&envelope.errors)
))
}
pub async fn run_managed(
&self,
project_id: &str,
) -> Result<(
ProvisionedResources,
ConnectCredentials,
MintedCredentialIds,
)> {
self.verify().await?;
let provisioning = self.mint_provisioning_token(project_id).await?;
let result = async {
let resources = self.provision(&provisioning.value, project_id).await?;
let (credentials, minted) = self.mint_credentials(project_id, &resources).await?;
Ok((resources, credentials, minted))
}
.await;
if let Err(error) = self.revoke_token("provisioning", &provisioning.id).await {
eprintln!(
"warning: {error}. It expires by itself within \
{PROVISIONING_TOKEN_MINUTES} minutes."
);
}
result
}
pub async fn run_manual(&self, project_id: &str) -> Result<ProvisionedResources> {
self.verify().await?;
self.provision(&self.setup_token, project_id).await
}
async fn provision(&self, token: &str, project_id: &str) -> Result<ProvisionedResources> {
let zone_name = self.zone_name(token).await?;
let private_object_storage_bucket = private_object_storage_bucket_name(project_id);
let public_object_storage_bucket = public_object_storage_bucket_name(project_id);
let frontend_asset_bucket = frontend_asset_bucket_name(project_id);
let rendered_html_cache_bucket = rendered_html_cache_bucket_name(project_id);
let frontend_asset_hostname = format!("{frontend_asset_bucket}.{zone_name}");
let public_object_storage_hostname = format!("{public_object_storage_bucket}.{zone_name}");
for bucket in [
&private_object_storage_bucket,
&public_object_storage_bucket,
&frontend_asset_bucket,
&rendered_html_cache_bucket,
] {
self.create_bucket(token, bucket).await?;
}
for bucket in [
&private_object_storage_bucket,
&public_object_storage_bucket,
&frontend_asset_bucket,
] {
self.put_cors(token, bucket).await?;
}
self.attach_custom_domain(token, &frontend_asset_bucket, &frontend_asset_hostname)
.await?;
self.attach_custom_domain(
token,
&public_object_storage_bucket,
&public_object_storage_hostname,
)
.await?;
self.ensure_cache_rule(token, &zone_name).await?;
Ok(ProvisionedResources {
zone_name,
frontend_asset_hostname,
public_object_storage_hostname,
private_object_storage_bucket,
public_object_storage_bucket,
frontend_asset_bucket,
rendered_html_cache_bucket,
})
}
fn bucket_scope(&self, buckets: &[&String]) -> serde_json::Value {
let resources: serde_json::Map<String, serde_json::Value> = buckets
.iter()
.map(|bucket| {
(
format!(
"com.cloudflare.edge.r2.bucket.{}_default_{bucket}",
self.account_id
),
serde_json::Value::String("*".to_string()),
)
})
.collect();
serde_json::json!({
"effect": "allow",
"resources": resources,
"permission_groups": [
{ "id": R2_BUCKET_ITEM_READ },
{ "id": R2_BUCKET_ITEM_WRITE },
],
})
}
async fn mint_credentials(
&self,
project_id: &str,
resources: &ProvisionedResources,
) -> Result<(ConnectCredentials, MintedCredentialIds)> {
let (worker_access_key_id, worker_token) = self
.mint_with_expiry(
&format!("fn0 worker ({project_id})"),
vec![self.bucket_scope(&[
&resources.private_object_storage_bucket,
&resources.public_object_storage_bucket,
&resources.rendered_html_cache_bucket,
])],
None,
)
.await?;
let (frontend_asset_access_key_id, frontend_asset_token) = self
.mint_with_expiry(
&format!("fn0 frontend assets ({project_id})"),
vec![self.bucket_scope(&[&resources.frontend_asset_bucket])],
None,
)
.await?;
let (purge_token_id, purge_token) = self
.mint_with_expiry(
&format!("fn0 cache purge ({project_id})"),
vec![serde_json::json!({
"effect": "allow",
"resources": {
format!("com.cloudflare.api.account.zone.{}", self.zone_id): "*",
},
"permission_groups": [{ "id": CACHE_PURGE }],
})],
None,
)
.await?;
let minted = MintedCredentialIds {
worker: worker_access_key_id.clone(),
frontend_asset: frontend_asset_access_key_id.clone(),
purge: purge_token_id,
};
Ok((
ConnectCredentials {
worker_access_key_id,
worker_secret: hex_sha256(&worker_token),
frontend_asset_access_key_id,
frontend_asset_secret: hex_sha256(&frontend_asset_token),
purge_token,
},
minted,
))
}
pub async fn revoke_minted_credentials(&self, ids: &MintedCredentialIds) {
for (purpose, id) in [
("worker", &ids.worker),
("frontend assets", &ids.frontend_asset),
("cache purge", &ids.purge),
] {
if let Err(error) = self.revoke_token(purpose, id).await {
eprintln!("warning: {error}. Delete it in the Cloudflare dashboard.");
}
}
}
pub async fn issue_origin_certificate(
&self,
hostname: &str,
mint_signing_token: bool,
) -> Result<IssuedCertificate> {
self.verify().await?;
if !mint_signing_token {
return self
.sign_origin_certificate(&self.setup_token, hostname)
.await;
}
let signing = self.mint_provisioning_token(hostname).await?;
let result = self.sign_origin_certificate(&signing.value, hostname).await;
if let Err(error) = self.revoke_token("signing", &signing.id).await {
eprintln!(
"warning: {error}. It expires by itself within \
{PROVISIONING_TOKEN_MINUTES} minutes."
);
}
result
}
async fn sign_origin_certificate(
&self,
token: &str,
hostname: &str,
) -> Result<IssuedCertificate> {
#[derive(Serialize)]
struct Body<'a> {
csr: &'a str,
hostnames: [&'a str; 1],
request_type: &'a str,
requested_validity: u32,
}
#[derive(Deserialize)]
struct Certificate {
certificate: String,
}
let key_pair = rcgen::KeyPair::generate()
.map_err(|error| anyhow!("could not generate a key pair: {error}"))?;
let mut params = rcgen::CertificateParams::new(vec![hostname.to_string()])
.map_err(|error| anyhow!("could not build the certificate request: {error}"))?;
params.distinguished_name = rcgen::DistinguishedName::new();
params
.distinguished_name
.push(rcgen::DnType::CommonName, hostname);
let csr_pem = params
.serialize_request(&key_pair)
.map_err(|error| anyhow!("could not sign the certificate request: {error}"))?
.pem()
.map_err(|error| anyhow!("could not encode the certificate request: {error}"))?;
let (status, envelope) = self
.call::<Certificate>(
token,
reqwest::Method::POST,
"/certificates",
Some(serde_json::to_value(Body {
csr: &csr_pem,
hostnames: [hostname],
request_type: CERTIFICATE_REQUEST_TYPE,
requested_validity: CERTIFICATE_VALIDITY_DAYS,
})?),
)
.await?;
let certificate = envelope
.result
.filter(|_| envelope.success)
.ok_or_else(|| {
anyhow!(
"Cloudflare would not sign the origin certificate ({status}): {}",
describe(&envelope.errors)
)
})?;
Ok(IssuedCertificate {
certificate_pem: certificate.certificate,
private_key_pem: key_pair.serialize_pem(),
not_after_epoch_seconds: chrono::Utc::now().timestamp()
+ i64::from(CERTIFICATE_VALIDITY_DAYS) * SECONDS_PER_DAY,
})
}
}
fn hex_sha256(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn already_exists(errors: &[ApiError]) -> bool {
errors.iter().any(|error| {
let message = error.message.to_lowercase();
message.contains("already exists")
|| message.contains("already configured")
|| message.contains("duplicate")
})
}