use crate::cli::commands::{
DnsCmd, DnsProviderKind, DnsRecordBatchCmd, DnsRecordCreateCmd, DnsRecordDeleteCmd,
DnsRecordEditCmd, DnsRecordExportCmd, DnsRecordGetCmd, DnsRecordImportCmd, DnsRecordListCmd,
DnsRecordReplaceCmd, DnsRecordsSubCommand, DnsSettingsCmd, DnsSettingsEditCmd,
DnsSettingsSubCommand, DnsSubCommand, DnsZoneCreateCmd, DnsZoneDeleteCmd, DnsZoneEditCmd,
DnsZoneGetCmd, DnsZoneListCmd, DnsZonesSubCommand, DnssecCmd, DnssecEditCmd, DnssecGetCmd,
DnssecSubCommand,
};
use crate::config::{
resolve_cloudflare_account_id, resolve_cloudflare_api_token, update_dns_inventory_cache,
};
use crate::provider_support::cloudflare::{
CloudflareClient, CloudflareDnsRecordListFilters, CloudflareZoneAccountWrite,
CloudflareZoneCreateRequest, CloudflareZoneEditRequest,
};
use crate::provider_support::{
dns_providers, CloudflareDnsRecordBatch, CloudflareDnsRecordWrite, CloudflareDnsSettings,
CloudflareDnsSettingsInternalDns, CloudflareDnsSettingsNameservers, CloudflareDnsSettingsSoa,
CloudflareDnssecEdit, CloudflareZoneFilters, ProviderDescriptor, ProviderStatus,
};
use colored::Colorize;
use serde_json::Value;
use std::fs;
pub async fn run_dns(cmd: DnsCmd, _debug: bool) -> Result<(), String> {
match cmd.command {
DnsSubCommand::Providers => {
print_dns_providers(&dns_providers());
Ok(())
}
DnsSubCommand::Zones(zones) => match zones.command {
DnsZonesSubCommand::List(args) => list_zones(args).await,
DnsZonesSubCommand::Get(args) => get_zone(args).await,
DnsZonesSubCommand::Create(args) => create_zone(args).await,
DnsZonesSubCommand::Edit(args) => edit_zone(args).await,
DnsZonesSubCommand::Delete(args) => delete_zone(args).await,
},
DnsSubCommand::Records(records) => match records.command {
DnsRecordsSubCommand::List(args) => list_records(args).await,
DnsRecordsSubCommand::Get(args) => get_record(args).await,
DnsRecordsSubCommand::Create(args) => create_record(args).await,
DnsRecordsSubCommand::Replace(args) => replace_record(args).await,
DnsRecordsSubCommand::Edit(args) => edit_record(args).await,
DnsRecordsSubCommand::Delete(args) => delete_record(args).await,
DnsRecordsSubCommand::Batch(args) => batch_records(args).await,
DnsRecordsSubCommand::Import(args) => import_records(args).await,
DnsRecordsSubCommand::Export(args) => export_records(args).await,
},
DnsSubCommand::Dnssec(DnssecCmd { command }) => match command {
DnssecSubCommand::Get(args) => get_dnssec(args).await,
DnssecSubCommand::Edit(args) => edit_dnssec(args).await,
},
DnsSubCommand::Settings(DnsSettingsCmd { command }) => match command {
DnsSettingsSubCommand::Get(args) => get_settings(args).await,
DnsSettingsSubCommand::Edit(args) => edit_settings(args).await,
},
}
}
async fn list_zones(args: DnsZoneListCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(args.account_id.clone());
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let (zones, page) = client
.list_zones(&CloudflareZoneFilters {
account_id: args.account_id.clone(),
account_name: args.account_name,
account_name_op: args.account_name_op,
name: args.name,
name_op: args.name_op,
status: args.status,
r#type: args.zone_types,
r#match: args.r#match,
order: args.order,
direction: args.direction,
page: args.page,
per_page: args.per_page,
})
.await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_zones(account_id, &zones))?;
print_json(&serde_json::json!({
"provider": "cloudflare",
"zones": zones,
"result_info": page,
}))
}
async fn get_zone(args: DnsZoneGetCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let zone = client.get_zone(&args.zone_id).await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_zone(account_id, &zone))?;
print_json(&zone)
}
async fn create_zone(args: DnsZoneCreateCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(args.account_id.clone());
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let zone = client
.create_zone(&CloudflareZoneCreateRequest {
name: args.name,
account: args
.account_id
.map(|id| CloudflareZoneAccountWrite { id: Some(id) }),
jump_start: Some(args.jump_start),
zone_type: args.zone_type,
})
.await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_zone(account_id, &zone))?;
print_json(&zone)
}
async fn edit_zone(args: DnsZoneEditCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let zone = client
.edit_zone(
&args.zone_id,
&CloudflareZoneEditRequest {
paused: args.paused,
zone_type: args.zone_type,
vanity_name_servers: (!args.vanity_name_servers.is_empty())
.then_some(args.vanity_name_servers),
},
)
.await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_zone(account_id, &zone))?;
print_json(&zone)
}
async fn delete_zone(args: DnsZoneDeleteCmd) -> Result<(), String> {
let client = cloudflare_client(
args.provider,
args.token,
resolved_cloudflare_account_id(None),
)?;
client.delete_zone(&args.zone_id).await?;
update_dns_inventory_cache(|cache| cache.remove_cloudflare_zone(&args.zone_id))?;
println!("Deleted zone {}", args.zone_id);
Ok(())
}
async fn list_records(args: DnsRecordListCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let (records, page) = client
.list_records(
&args.zone_id,
&CloudflareDnsRecordListFilters {
name: args.name,
record_type: args.record_type,
page: args.page,
per_page: args.per_page,
},
)
.await?;
update_dns_inventory_cache(|cache| {
cache.record_cloudflare_records(account_id, &args.zone_id, &records)
})?;
print_json(&serde_json::json!({
"provider": "cloudflare",
"zone_id": args.zone_id,
"records": records,
"result_info": page
}))
}
async fn get_record(args: DnsRecordGetCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let record = client.get_record(&args.zone_id, &args.record_id).await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_record(account_id, &record))?;
print_json(&record)
}
async fn create_record(args: DnsRecordCreateCmd) -> Result<(), String> {
let zone_id = args.zone_id.clone();
let token = args.token.clone();
let provider = args.provider;
let account_id = resolved_cloudflare_account_id(None);
let request = record_write_from_create(args)?;
let client = cloudflare_client(provider, token, account_id.clone())?;
let record = client.create_record(&zone_id, &request).await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_record(account_id, &record))?;
print_json(&record)
}
async fn replace_record(args: DnsRecordReplaceCmd) -> Result<(), String> {
let zone_id = args.common.zone_id.clone();
let provider = args.common.provider;
let token = args.common.token.clone();
let account_id = resolved_cloudflare_account_id(None);
let request = record_write_from_create(args.common)?;
let client = cloudflare_client(provider, token, account_id.clone())?;
let record = client
.replace_record(&zone_id, &args.record_id, &request)
.await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_record(account_id, &record))?;
print_json(&record)
}
async fn edit_record(args: DnsRecordEditCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let record = client
.edit_record(
&args.zone_id,
&args.record_id,
&CloudflareDnsRecordWrite {
record_type: args.record_type,
name: args.name,
content: args.content,
ttl: args.ttl,
proxied: args.proxied,
priority: args.priority,
comment: args.comment,
tags: (!args.tags.is_empty()).then_some(args.tags),
data: parse_optional_json(args.data_json.as_deref())?,
settings: parse_optional_json(args.settings_json.as_deref())?,
},
)
.await?;
update_dns_inventory_cache(|cache| cache.record_cloudflare_record(account_id, &record))?;
print_json(&record)
}
async fn delete_record(args: DnsRecordDeleteCmd) -> Result<(), String> {
let client = cloudflare_client(
args.provider,
args.token,
resolved_cloudflare_account_id(None),
)?;
client.delete_record(&args.zone_id, &args.record_id).await?;
update_dns_inventory_cache(|cache| {
cache.remove_cloudflare_record(&args.zone_id, &args.record_id)
})?;
println!("Deleted record {}", args.record_id);
Ok(())
}
async fn batch_records(args: DnsRecordBatchCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let payload = fs::read_to_string(&args.input)
.map_err(|error| format!("Failed to read {}: {}", args.input.display(), error))?;
let request: CloudflareDnsRecordBatch =
serde_json::from_str(&payload).map_err(|error| format!("Invalid batch JSON: {}", error))?;
let result = client.batch_records(&args.zone_id, &request).await?;
refresh_cloudflare_records_cache(&client, account_id, &args.zone_id).await?;
print_json(&result)
}
async fn import_records(args: DnsRecordImportCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let bytes = fs::read(&args.file)
.map_err(|error| format!("Failed to read {}: {}", args.file.display(), error))?;
let result = client.import_records(&args.zone_id, bytes).await?;
refresh_cloudflare_records_cache(&client, account_id, &args.zone_id).await?;
print_json(&result)
}
async fn export_records(args: DnsRecordExportCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let response = client.export_records(&args.zone_id).await?;
refresh_cloudflare_records_cache(&client, account_id, &args.zone_id).await?;
if let Some(output) = args.output {
fs::write(&output, &response.body)
.map_err(|error| format!("Failed to write {}: {}", output.display(), error))?;
println!("Wrote exported zone file to {}", output.display());
return Ok(());
}
println!("{}", String::from_utf8_lossy(&response.body));
Ok(())
}
async fn get_dnssec(args: DnssecGetCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let dnssec = client.get_dnssec(&args.zone_id).await?;
update_dns_inventory_cache(|cache| {
cache.record_cloudflare_dnssec(account_id, &args.zone_id, &dnssec)
})?;
print_json(&dnssec)
}
async fn edit_dnssec(args: DnssecEditCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let dnssec = client
.edit_dnssec(
&args.zone_id,
&CloudflareDnssecEdit {
status: args.status,
dnssec_multi_signer: args.dnssec_multi_signer,
dnssec_presigned: args.dnssec_presigned,
dnssec_use_nsec3: args.dnssec_use_nsec3,
},
)
.await?;
update_dns_inventory_cache(|cache| {
cache.record_cloudflare_dnssec(account_id, &args.zone_id, &dnssec)
})?;
print_json(&dnssec)
}
async fn get_settings(args: crate::cli::commands::DnsSettingsGetCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let settings = client.get_dns_settings(&args.zone_id).await?;
update_dns_inventory_cache(|cache| {
cache.record_cloudflare_settings(account_id, &args.zone_id, &settings)
})?;
print_json(&settings)
}
async fn edit_settings(args: DnsSettingsEditCmd) -> Result<(), String> {
let account_id = resolved_cloudflare_account_id(None);
let client = cloudflare_client(args.provider, args.token, account_id.clone())?;
let soa = match args.soa_json {
Some(raw) => Some(
serde_json::from_str::<CloudflareDnsSettingsSoa>(&raw)
.map_err(|error| format!("Invalid SOA JSON: {}", error))?,
),
None => None,
};
let settings = client
.edit_dns_settings(
&args.zone_id,
&CloudflareDnsSettings {
flatten_all_cnames: args.flatten_all_cnames,
foundation_dns: args.foundation_dns,
multi_provider: args.multi_provider,
nameservers: args.nameservers_type.map(|nameserver_type| {
CloudflareDnsSettingsNameservers {
r#type: Some(nameserver_type),
ns_set: args.nameservers_ns_set,
}
}),
ns_ttl: args.ns_ttl,
secondary_overrides: args.secondary_overrides,
soa,
zone_mode: args.zone_mode,
internal_dns: args.reference_zone_id.map(|reference_zone_id| {
CloudflareDnsSettingsInternalDns {
reference_zone_id: Some(reference_zone_id),
}
}),
},
)
.await?;
update_dns_inventory_cache(|cache| {
cache.record_cloudflare_settings(account_id, &args.zone_id, &settings)
})?;
print_json(&settings)
}
fn record_write_from_create(args: DnsRecordCreateCmd) -> Result<CloudflareDnsRecordWrite, String> {
Ok(CloudflareDnsRecordWrite {
record_type: Some(args.record_type),
name: Some(args.name),
content: Some(args.content),
ttl: args.ttl,
proxied: args.proxied,
priority: args.priority,
comment: args.comment,
tags: (!args.tags.is_empty()).then_some(args.tags),
data: parse_optional_json(args.data_json.as_deref())?,
settings: parse_optional_json(args.settings_json.as_deref())?,
})
}
fn parse_optional_json(raw: Option<&str>) -> Result<Option<Value>, String> {
raw.map(|value| {
serde_json::from_str::<Value>(value)
.map_err(|error| format!("Invalid JSON payload `{}`: {}", value, error))
})
.transpose()
}
fn cloudflare_client(
provider: DnsProviderKind,
token_override: Option<String>,
account_id_override: Option<String>,
) -> Result<CloudflareClient, String> {
if provider != DnsProviderKind::Cloudflare {
return Err(format!(
"Provider `{:?}` is planned but not implemented for `xbp dns` yet.",
provider
));
}
let token = token_override
.or_else(resolve_cloudflare_api_token)
.ok_or_else(|| {
"No Cloudflare API token found. Use `--token`, `CLOUDFLARE_API_TOKEN`, or `xbp config cloudflare set-key`.".to_string()
})?;
let account_id = account_id_override
.or_else(resolve_cloudflare_account_id)
.unwrap_or_default();
CloudflareClient::new(token, account_id)
}
fn resolved_cloudflare_account_id(account_id_override: Option<String>) -> Option<String> {
account_id_override.or_else(resolve_cloudflare_account_id)
}
async fn refresh_cloudflare_records_cache(
client: &CloudflareClient,
account_id: Option<String>,
zone_id: &str,
) -> Result<(), String> {
let (records, _) = client
.list_records(zone_id, &CloudflareDnsRecordListFilters::default())
.await?;
update_dns_inventory_cache(|cache| {
cache.record_cloudflare_records(account_id, zone_id, &records)
})
}
fn print_json(value: &impl serde::Serialize) -> Result<(), String> {
println!(
"{}",
serde_json::to_string_pretty(value)
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
Ok(())
}
fn print_dns_providers(providers: &[ProviderDescriptor]) {
let implemented_count = providers
.iter()
.filter(|provider| provider.status == ProviderStatus::Implemented)
.count();
let planned_count = providers.len().saturating_sub(implemented_count);
println!("\n{}", "DNS providers".bright_cyan().bold());
println!("{}", "─".repeat(88).bright_black());
println!(
" {} {} {} {}",
"Implemented".bright_green().bold(),
implemented_count.to_string().bright_white().bold(),
"Planned".bright_yellow().bold(),
planned_count.to_string().bright_white().bold()
);
println!("{}", "─".repeat(88).bright_black());
for provider in providers {
let (icon, key, status, capabilities, notes) = render_dns_provider_row(provider);
println!(" {} {} {} {} {}", icon, key, status, capabilities, notes);
}
println!("{}", "─".repeat(88).bright_black());
println!(
" {} {}",
"Use with:".bright_blue().bold(),
"`xbp dns --provider <key> ...`".bright_white()
);
}
fn render_dns_provider_row(
provider: &ProviderDescriptor,
) -> (
colored::ColoredString,
colored::ColoredString,
colored::ColoredString,
colored::ColoredString,
colored::ColoredString,
) {
let key = format!("{:<12}", provider.key);
let capability_count = format!("{:>2} ops", provider.capabilities.len());
let notes = provider
.notes
.as_deref()
.unwrap_or("No notes.")
.to_string()
.bright_black();
match provider.status {
ProviderStatus::Implemented => (
"✓".bright_green().bold(),
key.bright_cyan().bold(),
format!("{:<12}", "IMPLEMENTED")
.black()
.on_bright_green()
.bold(),
capability_count.bright_magenta().bold(),
notes,
),
ProviderStatus::Planned => (
"◌".bright_yellow().bold(),
key.bright_white(),
format!("{:<12}", "PLANNED")
.black()
.on_bright_yellow()
.bold(),
capability_count.bright_black(),
notes,
),
}
}
#[cfg(test)]
mod tests {
use super::{parse_optional_json, render_dns_provider_row};
use crate::provider_support::{ProviderDescriptor, ProviderDomain, ProviderStatus};
#[test]
fn parses_optional_json_strings() {
let value = parse_optional_json(Some(r#"{"ipv4_only":true}"#)).expect("json");
assert!(value.expect("value")["ipv4_only"].as_bool().unwrap());
}
#[test]
fn implemented_provider_row_contains_operation_count() {
let provider = ProviderDescriptor {
key: "cloudflare".to_string(),
label: "Cloudflare".to_string(),
domain: ProviderDomain::Dns,
status: ProviderStatus::Implemented,
capabilities: vec!["zones.list".to_string(), "records.list".to_string()],
notes: Some("Cloudflare core DNS APIs.".to_string()),
};
let (_, _, _, capabilities, _) = render_dns_provider_row(&provider);
assert!(capabilities.to_string().contains("2 ops"));
}
}