use crate::error::{FnoxError, Result};
use crate::lease_backends::{Lease, LeaseBackend};
use async_trait::async_trait;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const URL: &str = "https://fnox.jdx.dev/leases/cloudflare";
const API_BASE: &str = "https://api.cloudflare.com/client/v4";
const MAX_TOKEN_NAME_LEN: usize = 100;
const TOKEN_NAME_PREFIX: &str = "fnox-lease-";
pub const CONSUMED_ENV_VARS: &[&str] = &["CLOUDFLARE_API_TOKEN", "CF_API_TOKEN"];
pub fn check_prerequisites() -> Option<String> {
let has_token =
std::env::var("CLOUDFLARE_API_TOKEN").is_ok() || std::env::var("CF_API_TOKEN").is_ok();
if has_token {
None
} else {
Some("Cloudflare API token not found. Set CLOUDFLARE_API_TOKEN with a token that has 'API Tokens: Edit' permission.".to_string())
}
}
pub fn required_env_vars() -> Vec<(&'static str, &'static str)> {
vec![(
"CLOUDFLARE_API_TOKEN",
"Cloudflare API token with 'API Tokens: Edit' permission (or set CF_API_TOKEN)",
)]
}
pub struct CloudflareBackend {
token_type: CloudflareTokenType,
account_id: Option<String>,
policies: Option<Vec<CloudflarePolicy>>,
env_var: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum CloudflareTokenType {
#[default]
User,
Account,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum CloudflarePolicyEffect {
#[default]
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CloudflarePolicy {
#[serde(default)]
pub effect: CloudflarePolicyEffect,
pub permission_groups: Vec<CloudflarePermissionGroup>,
pub resources: IndexMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CloudflarePermissionGroup {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl CloudflareBackend {
pub fn new(
token_type: CloudflareTokenType,
account_id: Option<String>,
policies: Option<Vec<CloudflarePolicy>>,
env_var: String,
) -> Result<Self> {
if matches!(token_type, CloudflareTokenType::Account) && account_id.is_none() {
return Err(FnoxError::Config(
"Cloudflare backend: 'account_id' is required when token_type is 'account'."
.to_string(),
));
}
Ok(Self {
token_type,
account_id,
policies,
env_var,
})
}
fn get_api_token() -> Result<String> {
std::env::var("CLOUDFLARE_API_TOKEN")
.or_else(|_| std::env::var("CF_API_TOKEN"))
.map_err(|_| FnoxError::ProviderAuthFailed {
provider: "Cloudflare".to_string(),
details: "No parent API token found".to_string(),
hint: "Set CLOUDFLARE_API_TOKEN or CF_API_TOKEN with a token that has 'API Tokens: Edit' permission".to_string(),
url: URL.to_string(),
})
}
fn tokens_path(&self) -> String {
match self.token_type {
CloudflareTokenType::Account => {
let id = self.account_id.as_ref().expect("validated in new()");
format!("{API_BASE}/accounts/{id}/tokens")
}
CloudflareTokenType::User => format!("{API_BASE}/user/tokens"),
}
}
fn build_api_policies(
policies: &[CloudflarePolicy],
account_id: &Option<String>,
) -> Result<Vec<serde_json::Value>> {
if policies.is_empty() {
return Err(FnoxError::Config(
"Cloudflare backend: 'policies' must contain at least one policy.".to_string(),
));
}
let mut result = Vec::with_capacity(policies.len());
for p in policies {
if p.permission_groups.is_empty() {
return Err(FnoxError::Config(
"Cloudflare backend: each policy must have at least one permission group."
.to_string(),
));
}
let mut resources = serde_json::Map::new();
for (key, value) in &p.resources {
if key.contains("{account_id}") && account_id.is_none() {
return Err(FnoxError::Config(
"Resource key contains '{account_id}' placeholder but 'account_id' \
is not set in the Cloudflare backend config."
.to_string(),
));
}
let resolved_key = if let Some(account_id) = account_id {
key.replace("{account_id}", account_id)
} else {
key.clone()
};
resources.insert(resolved_key, serde_json::Value::String(value.clone()));
}
result.push(serde_json::json!({
"effect": p.effect,
"resources": resources,
"permission_groups": p.permission_groups,
}));
}
Ok(result)
}
async fn cf_api_call(
client: &reqwest::Client,
token: &str,
url: &str,
action: &str,
) -> Result<serde_json::Value> {
let response = client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|e| FnoxError::ProviderApiError {
provider: "Cloudflare".to_string(),
details: e.to_string(),
hint: format!("Failed to {action}"),
url: URL.to_string(),
})?;
let status = response.status();
let body: serde_json::Value =
response
.json()
.await
.map_err(|e| FnoxError::ProviderInvalidResponse {
provider: "Cloudflare".to_string(),
details: e.to_string(),
hint: format!("Unexpected response while trying to {action}"),
url: URL.to_string(),
})?;
if !status.is_success() {
let errors = body["errors"]
.as_array()
.and_then(|arr| {
let msgs: Vec<_> = arr.iter().filter_map(|e| e["message"].as_str()).collect();
if msgs.is_empty() {
None
} else {
Some(msgs.join("; "))
}
})
.unwrap_or_else(|| format!("HTTP {status}"));
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(FnoxError::ProviderAuthFailed {
provider: "Cloudflare".to_string(),
details: errors,
hint:
"Check that your parent API token is valid and has sufficient permissions"
.to_string(),
url: URL.to_string(),
});
}
return Err(FnoxError::ProviderApiError {
provider: "Cloudflare".to_string(),
details: errors,
hint: format!("Failed to {action}"),
url: URL.to_string(),
});
}
Ok(body)
}
async fn fetch_parent_policies(
tokens_path: &str,
parent_token: &str,
) -> Result<Vec<serde_json::Value>> {
let client = crate::http::http_client();
let verify_resp = Self::cf_api_call(
&client,
parent_token,
&format!("{tokens_path}/verify"),
"verify parent token",
)
.await?;
let token_id = verify_resp["result"]["id"].as_str().ok_or_else(|| {
FnoxError::ProviderInvalidResponse {
provider: "Cloudflare".to_string(),
details: "Verify response missing 'result.id'".to_string(),
hint: "Check that the parent token is valid".to_string(),
url: URL.to_string(),
}
})?;
let details_resp = Self::cf_api_call(
&client,
parent_token,
&format!("{tokens_path}/{token_id}"),
"fetch parent token details",
)
.await?;
let policies = details_resp["result"]["policies"]
.as_array()
.ok_or_else(|| FnoxError::ProviderInvalidResponse {
provider: "Cloudflare".to_string(),
details: "Token details response missing 'result.policies'".to_string(),
hint: "Check that the parent token is valid and accessible".to_string(),
url: URL.to_string(),
})?;
let cleaned: Vec<serde_json::Value> = policies
.iter()
.filter_map(|p| {
let obj = p.as_object()?;
let groups: Vec<serde_json::Value> = obj
.get("permission_groups")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter(|g| {
let name = g["name"].as_str().unwrap_or("");
!name.contains("API Tokens")
})
.filter_map(|g| {
let id = g["id"].as_str()?;
Some(serde_json::json!({ "id": id }))
})
.collect();
if groups.is_empty() {
return None;
}
Some(serde_json::json!({
"effect": obj.get("effect").cloned().unwrap_or(serde_json::json!("allow")),
"resources": obj.get("resources").cloned().unwrap_or(serde_json::json!({})),
"permission_groups": groups,
}))
})
.collect();
if cleaned.is_empty() {
return Err(FnoxError::Config(
"Parent token only has 'API Tokens' permissions which cannot be inherited. \
Configure explicit policies or use a parent token with additional permissions."
.to_string(),
));
}
Ok(cleaned)
}
}
#[async_trait]
impl LeaseBackend for CloudflareBackend {
async fn create_lease(&self, duration: Duration, label: &str) -> Result<Lease> {
let parent_token = Self::get_api_token()?;
let tokens_path = self.tokens_path();
let now = chrono::Utc::now();
let expires_on =
now + chrono::Duration::seconds(duration.as_secs().min(i64::MAX as u64) as i64);
let raw_name = format!("{TOKEN_NAME_PREFIX}{label}");
let name = if raw_name.chars().count() > MAX_TOKEN_NAME_LEN {
raw_name.chars().take(MAX_TOKEN_NAME_LEN).collect()
} else {
raw_name
};
let policies = if let Some(ref configured) = self.policies {
Self::build_api_policies(configured, &self.account_id)?
} else {
tracing::debug!("No policies configured; inheriting from parent token");
Self::fetch_parent_policies(&tokens_path, &parent_token).await?
};
let body = serde_json::json!({
"name": name,
"policies": policies,
"expires_on": expires_on.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
});
let client = crate::http::http_client();
let response = client
.post(&tokens_path)
.bearer_auth(&parent_token)
.json(&body)
.send()
.await
.map_err(|e| FnoxError::ProviderApiError {
provider: "Cloudflare".to_string(),
details: e.to_string(),
hint: "Failed to connect to Cloudflare API".to_string(),
url: URL.to_string(),
})?;
let status = response.status();
let resp: serde_json::Value =
response
.json()
.await
.map_err(|e| FnoxError::ProviderInvalidResponse {
provider: "Cloudflare".to_string(),
details: e.to_string(),
hint: "Unexpected response from Cloudflare API".to_string(),
url: URL.to_string(),
})?;
if !status.is_success() || !resp["success"].as_bool().unwrap_or(false) {
let errors = resp["errors"]
.as_array()
.and_then(|arr| {
let msgs: Vec<_> = arr.iter().filter_map(|e| e["message"].as_str()).collect();
if msgs.is_empty() {
None
} else {
Some(msgs.join("; "))
}
})
.unwrap_or_else(|| format!("HTTP {status}"));
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(FnoxError::ProviderAuthFailed {
provider: "Cloudflare".to_string(),
details: errors,
hint: "Check that your parent API token has 'API Tokens: Edit' permission"
.to_string(),
url: URL.to_string(),
});
}
return Err(FnoxError::ProviderApiError {
provider: "Cloudflare".to_string(),
details: errors,
hint: "Check policies and account_id configuration".to_string(),
url: URL.to_string(),
});
}
let result = &resp["result"];
let token_value =
result["value"]
.as_str()
.ok_or_else(|| FnoxError::ProviderInvalidResponse {
provider: "Cloudflare".to_string(),
details: "Response missing 'result.value' field".to_string(),
hint: "Unexpected response from Cloudflare API".to_string(),
url: URL.to_string(),
})?;
let token_id = result["id"]
.as_str()
.ok_or_else(|| FnoxError::ProviderInvalidResponse {
provider: "Cloudflare".to_string(),
details: "Response missing 'result.id' field".to_string(),
hint: "Unexpected response from Cloudflare API".to_string(),
url: URL.to_string(),
})?;
let mut credentials = IndexMap::new();
credentials.insert(self.env_var.clone(), token_value.to_string());
let lease_id = token_id.to_string();
Ok(Lease {
credentials,
expires_at: Some(expires_on),
lease_id,
})
}
async fn revoke_lease(
&self,
lease_id: &str,
_credentials: Option<&IndexMap<String, String>>,
) -> Result<()> {
let parent_token = Self::get_api_token()?;
let tokens_path = self.tokens_path();
let client = crate::http::http_client();
let response = client
.delete(format!("{tokens_path}/{lease_id}"))
.bearer_auth(&parent_token)
.send()
.await
.map_err(|e| FnoxError::ProviderApiError {
provider: "Cloudflare".to_string(),
details: e.to_string(),
hint: "Failed to revoke Cloudflare API token".to_string(),
url: URL.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(FnoxError::ProviderAuthFailed {
provider: "Cloudflare".to_string(),
details: body_text,
hint: "Check that your parent API token has 'API Tokens: Edit' permission"
.to_string(),
url: URL.to_string(),
});
}
if status.as_u16() != 404 {
return Err(FnoxError::ProviderApiError {
provider: "Cloudflare".to_string(),
details: format!("HTTP {}: {}", status, body_text),
hint: "Failed to revoke Cloudflare API token".to_string(),
url: URL.to_string(),
});
}
}
Ok(())
}
fn max_lease_duration(&self) -> Duration {
Duration::from_secs(24 * 3600)
}
}