use crate::cli::ui::{divider, section, status_line, tip, Loader};
use crate::commands::cli_session::{
fetch_cloudflare_credentials_from_dashboard, resolve_cli_access_token,
};
use crate::commands::login::run_login;
use crate::commands::ssh_helpers::prompt_for_password;
use crate::config::{
get_cloudflare_account_id, resolve_cloudflare_account_id, resolve_cloudflare_api_token,
ApiConfig, SecretProvider, SshConfig,
};
use crate::utils::open_with_default_handler;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use reqwest::Client;
use serde::Deserialize;
use std::env;
use std::io::IsTerminal;
use std::time::Duration;
use tokio::time::sleep;
const SETUP_WIDTH: usize = 72;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CloudflareCredentialSource {
Missing,
Environment,
Config,
Dashboard,
}
#[derive(Debug, Clone)]
struct CloudflareCredentialStatus {
cli_session: bool,
token_source: CloudflareCredentialSource,
account_source: CloudflareCredentialSource,
token_preview: Option<String>,
account_preview: Option<String>,
}
pub fn is_interactive_terminal() -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
pub async fn credentials_available() -> bool {
if resolve_cloudflare_api_token().is_some() && resolve_cloudflare_account_id().is_some() {
return true;
}
matches!(
fetch_cloudflare_credentials_from_dashboard().await,
Ok(Some(_))
)
}
pub async fn ensure_cloudflare_credentials_interactive() -> Result<(), String> {
if credentials_available().await {
return Ok(());
}
if !is_interactive_terminal() {
return Err(non_interactive_credentials_message());
}
run_config_cloudflare_setup().await
}
pub async fn run_config_cloudflare_setup() -> Result<(), String> {
print_setup_header();
let status = survey_cloudflare_credential_status().await?;
print_cloudflare_credential_status(&status);
if status.token_source != CloudflareCredentialSource::Missing
&& status.account_source != CloudflareCredentialSource::Missing
{
println!();
let reconfigure = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Cloudflare credentials are already configured. Reconfigure now?")
.default(false)
.interact()
.map_err(|error| format!("Failed to read setup choice: {error}"))?;
if !reconfigure {
tip("Run `xbp config cloudflare status` any time to review credential sources.");
return Ok(());
}
}
println!();
let method = Select::with_theme(&ColorfulTheme::default())
.with_prompt("How do you want to authenticate with Cloudflare?")
.items(&[
"Link via XBP dashboard (recommended)",
"Enter API token manually",
"Cancel",
])
.default(0)
.interact()
.map_err(|error| format!("Failed to read setup choice: {error}"))?;
match method {
0 => run_dashboard_link_flow().await,
1 => run_manual_token_flow().await,
_ => {
println!("{}", "Setup cancelled.".bright_black());
Ok(())
}
}
}
pub async fn run_config_cloudflare_login() -> Result<(), String> {
print_setup_header();
tip("This flow links Cloudflare through the XBP dashboard OAuth app.");
run_dashboard_link_flow().await
}
pub async fn run_config_cloudflare_status() -> Result<(), String> {
print_setup_header();
let status = survey_cloudflare_credential_status().await?;
print_cloudflare_credential_status(&status);
print_setup_next_steps(&status);
Ok(())
}
pub async fn run_config_cloudflare_account_set(account_id: Option<String>) -> Result<(), String> {
let account_id = match account_id {
Some(value) => value.trim().to_string(),
None => prompt_for_account_id()?,
};
if account_id.is_empty() {
return Err("Refusing to store an empty Cloudflare account ID.".to_string());
}
let mut config = SshConfig::load()?;
config.cloudflare_account_id = Some(account_id.clone());
config.save()?;
println!(
"{} Saved Cloudflare account ID: {}",
"OK".bright_green().bold(),
mask_credential(&account_id)
);
Ok(())
}
pub async fn run_config_cloudflare_account_delete() -> Result<(), String> {
if get_cloudflare_account_id()?.is_none() {
println!("{}", "No Cloudflare account ID is currently set.".bright_black());
return Ok(());
}
let mut config = SshConfig::load()?;
config.cloudflare_account_id = None;
config.save()?;
println!(
"{} {}",
"OK".bright_green().bold(),
"Deleted Cloudflare account ID from global config."
);
Ok(())
}
pub async fn run_config_cloudflare_account_show(raw: bool) -> Result<(), String> {
run_config_cloudflare_status().await?;
if raw {
if let Some(account_id) = get_cloudflare_account_id()? {
println!("account_id={account_id}");
}
}
Ok(())
}
async fn run_dashboard_link_flow() -> Result<(), String> {
section("Dashboard OAuth");
println!(
" {}",
"Sign in to XBP, link Cloudflare in settings, and let the CLI reuse that OAuth session."
.bright_white()
);
println!();
if resolve_cli_access_token().is_err() {
let start_login = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Start `xbp login` now?")
.default(true)
.interact()
.map_err(|error| format!("Failed to read login confirmation: {error}"))?;
if !start_login {
return Err("Dashboard linking requires an active CLI session. Run `xbp login` first.".to_string());
}
println!();
run_login().await?;
println!();
} else {
status_line("CLI session", "active", true);
}
let api = ApiConfig::load();
let settings_url = api.cloudflare_settings_url();
println!();
println!(
" {} {}",
"Open".bright_blue().bold(),
settings_url.bright_cyan().underline()
);
println!(
" {}",
"Link Cloudflare there, then keep that page open briefly so XBP can sync credentials."
.bright_black()
);
println!();
if let Err(error) = open_with_default_handler(&settings_url) {
tip(&format!("Could not open the browser automatically: {error}"));
}
poll_dashboard_cloudflare_credentials().await?;
let status = survey_cloudflare_credential_status().await?;
print_cloudflare_credential_status(&status);
print_setup_next_steps(&status);
Ok(())
}
async fn run_manual_token_flow() -> Result<(), String> {
section("API token");
println!(
" {}",
"Create a Cloudflare API token with DNS, Workers, Secrets Store, and Registrar permissions."
.bright_white()
);
println!(
" {}",
"https://dash.cloudflare.com/profile/api-tokens".bright_cyan()
);
println!();
let token = prompt_for_password("Paste Cloudflare API token: ")?;
if token.trim().is_empty() {
return Err("Refusing to store an empty Cloudflare API token.".to_string());
}
let loader = Loader::start("Verifying Cloudflare API token");
let accounts = list_cloudflare_accounts(&token).await.map_err(|error| {
loader.fail("invalid token");
error
})?;
loader.success_with(&format!(
"Verified token ({} account{})",
accounts.len(),
if accounts.len() == 1 { "" } else { "s" }
));
let account_id = select_cloudflare_account_id(&accounts)?;
let token_preview = mask_credential(&token);
let mut config = SshConfig::load()?;
config.set_secret(SecretProvider::Cloudflare, Some(token));
config.cloudflare_account_id = Some(account_id.clone());
config.save()?;
println!();
status_line("API token", &token_preview, true);
status_line("Account ID", &mask_credential(&account_id), true);
tip("Credentials saved to global XBP config.");
Ok(())
}
async fn poll_dashboard_cloudflare_credentials() -> Result<(), String> {
let loader = Loader::start("Waiting for linked Cloudflare credentials");
let deadline = Duration::from_secs(180);
let poll_every = Duration::from_secs(3);
let started = std::time::Instant::now();
let mut attempt = 0_u32;
loop {
attempt += 1;
if started.elapsed() >= deadline {
loader.fail("timed out");
return Err(
"Timed out waiting for dashboard Cloudflare credentials. Open `/settings/cloudflare`, link Cloudflare, then run `xbp config cloudflare login` again.".to_string(),
);
}
loader.update(&format!(
"Waiting for dashboard sync (attempt {attempt})"
));
match fetch_cloudflare_credentials_from_dashboard().await? {
Some(credentials) => {
loader.success_with(&format!(
"Linked account {}",
mask_credential(&credentials.account_id)
));
return Ok(());
}
None => sleep(poll_every).await,
}
}
}
async fn survey_cloudflare_credential_status() -> Result<CloudflareCredentialStatus, String> {
let cli_session = resolve_cli_access_token().is_ok();
let config_token = SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Cloudflare).map(str::to_string));
let env_token = env::var("CLOUDFLARE_API_TOKEN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let config_account = get_cloudflare_account_id()?;
let env_account = env::var("CLOUDFLARE_ACCOUNT_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let dashboard = fetch_cloudflare_credentials_from_dashboard().await?;
let (token_source, token_preview) = if env_token.is_some() {
(CloudflareCredentialSource::Environment, env_token)
} else if config_token.is_some() {
(CloudflareCredentialSource::Config, config_token)
} else if dashboard.is_some() {
(
CloudflareCredentialSource::Dashboard,
dashboard.as_ref().map(|value| value.access_token.clone()),
)
} else {
(CloudflareCredentialSource::Missing, None)
};
let (account_source, account_preview) = if env_account.is_some() {
(CloudflareCredentialSource::Environment, env_account)
} else if config_account.is_some() {
(CloudflareCredentialSource::Config, config_account)
} else if dashboard.is_some() {
(
CloudflareCredentialSource::Dashboard,
dashboard.as_ref().map(|value| value.account_id.clone()),
)
} else {
(CloudflareCredentialSource::Missing, None)
};
Ok(CloudflareCredentialStatus {
cli_session,
token_source,
account_source,
token_preview,
account_preview,
})
}
fn print_setup_header() {
println!();
println!(
"{} {}",
"Cloudflare".bright_cyan().bold(),
"credential setup".bright_white().bold()
);
divider(SETUP_WIDTH);
}
fn print_cloudflare_credential_status(status: &CloudflareCredentialStatus) {
section("Current sources");
status_line(
"CLI session",
if status.cli_session {
"active"
} else {
"not signed in"
},
status.cli_session,
);
print_source_line(
"API token",
status.token_source,
status.token_preview.as_deref(),
);
print_source_line(
"Account ID",
status.account_source,
status.account_preview.as_deref(),
);
divider(SETUP_WIDTH);
status_line(
"Ready for CLI commands",
if cloudflare_credentials_ready_from_status(status) {
"yes"
} else {
"not yet"
},
cloudflare_credentials_ready_from_status(status),
);
}
fn print_setup_next_steps(status: &CloudflareCredentialStatus) {
println!();
if cloudflare_credentials_ready_from_status(status) {
tip("You can run `xbp domains`, `xbp dns`, and `xbp secrets --provider cloudflare` without passing `--token` or `--account-id`.");
return;
}
if !status.cli_session {
tip("Run `xbp login` to enable dashboard OAuth credentials.");
}
if status.token_source == CloudflareCredentialSource::Missing {
tip("Run `xbp config cloudflare` to start the interactive setup wizard.");
}
if status.account_source == CloudflareCredentialSource::Missing {
tip("Set an account ID with `xbp config cloudflare set-account-id` or link Cloudflare in the dashboard.");
}
}
fn print_source_line(label: &str, source: CloudflareCredentialSource, preview: Option<&str>) {
let (status, ok) = match source {
CloudflareCredentialSource::Missing => ("missing".to_string(), false),
CloudflareCredentialSource::Environment => ("environment variable".to_string(), true),
CloudflareCredentialSource::Config => (
preview
.map(mask_credential)
.unwrap_or_else(|| "saved in config".to_string()),
true,
),
CloudflareCredentialSource::Dashboard => (
preview
.map(mask_credential)
.unwrap_or_else(|| "linked in dashboard".to_string()),
true,
),
};
status_line(label, &status, ok);
}
fn cloudflare_credentials_ready_from_status(status: &CloudflareCredentialStatus) -> bool {
status.token_source != CloudflareCredentialSource::Missing
&& status.account_source != CloudflareCredentialSource::Missing
}
fn non_interactive_credentials_message() -> String {
"Cloudflare credentials are not configured. Run `xbp config cloudflare` in an interactive terminal, or set `xbp config cloudflare set-key` and `set-account-id`.".to_string()
}
fn prompt_for_account_id() -> Result<String, String> {
Input::with_theme(&ColorfulTheme::default())
.with_prompt("Cloudflare account ID")
.interact_text()
.map_err(|error| format!("Failed to read Cloudflare account ID: {error}"))
}
fn mask_credential(secret: &str) -> String {
let chars: Vec<char> = secret.chars().collect();
if chars.is_empty() {
return "(empty)".to_string();
}
if chars.len() <= 8 {
return "*".repeat(chars.len());
}
let prefix: String = chars.iter().take(4).collect();
let suffix: String = chars.iter().skip(chars.len() - 4).collect();
format!("{prefix}...{suffix}")
}
#[derive(Debug, Deserialize)]
struct CloudflareAccountsEnvelope {
success: bool,
result: Vec<CloudflareAccountSummary>,
}
#[derive(Debug, Deserialize)]
struct CloudflareAccountSummary {
id: String,
name: String,
}
async fn list_cloudflare_accounts(token: &str) -> Result<Vec<CloudflareAccountSummary>, String> {
let client = Client::new();
let response = client
.get("https://api.cloudflare.com/client/v4/accounts")
.bearer_auth(token)
.send()
.await
.map_err(|error| format!("Failed to verify Cloudflare token: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"Cloudflare rejected the API token (HTTP {}).",
response.status().as_u16()
));
}
let payload = response
.json::<CloudflareAccountsEnvelope>()
.await
.map_err(|error| format!("Failed to parse Cloudflare accounts response: {error}"))?;
if !payload.success {
return Err("Cloudflare token verification failed.".to_string());
}
if payload.result.is_empty() {
return Err("No Cloudflare accounts were visible for this API token.".to_string());
}
Ok(payload.result)
}
fn select_cloudflare_account_id(accounts: &[CloudflareAccountSummary]) -> Result<String, String> {
if accounts.len() == 1 {
println!(
" {} {}",
"Using account".bright_blue().bold(),
format!("{} ({})", accounts[0].name, accounts[0].id).bright_white()
);
return Ok(accounts[0].id.clone());
}
let labels = accounts
.iter()
.map(|account| format!("{} ({})", account.name, account.id))
.collect::<Vec<_>>();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select the Cloudflare account for this CLI")
.items(&labels)
.default(0)
.interact()
.map_err(|error| format!("Failed to read account selection: {error}"))?;
Ok(accounts[selection].id.clone())
}
#[cfg(test)]
mod tests {
use super::{
cloudflare_credentials_ready_from_status, mask_credential, CloudflareCredentialSource,
CloudflareCredentialStatus,
};
#[test]
fn masks_long_credentials() {
assert_eq!(mask_credential("abcdefgh12345678"), "abcd...5678");
}
#[test]
fn status_requires_both_token_and_account() {
let ready = CloudflareCredentialStatus {
cli_session: true,
token_source: CloudflareCredentialSource::Config,
account_source: CloudflareCredentialSource::Config,
token_preview: Some("token".to_string()),
account_preview: Some("account".to_string()),
};
assert!(cloudflare_credentials_ready_from_status(&ready));
let missing_account = CloudflareCredentialStatus {
account_source: CloudflareCredentialSource::Missing,
account_preview: None,
..ready
};
assert!(!cloudflare_credentials_ready_from_status(&missing_account));
}
}