xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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};
use crate::provider_support::cloudflare::{
    CloudflareClient, CloudflareDnsRecordListFilters, CloudflareZoneAccountWrite,
    CloudflareZoneCreateRequest, CloudflareZoneEditRequest,
};
use crate::provider_support::{
    dns_providers, CloudflareDnsRecordBatch, CloudflareDnsRecordWrite, CloudflareDnsSettings,
    CloudflareDnsSettingsInternalDns, CloudflareDnsSettingsNameservers, CloudflareDnsSettingsSoa,
    CloudflareDnssecEdit, CloudflareZoneFilters,
};
use serde_json::Value;
use std::fs;

pub async fn run_dns(cmd: DnsCmd, _debug: bool) -> Result<(), String> {
    match cmd.command {
        DnsSubCommand::Providers => {
            for provider in dns_providers() {
                println!(
                    "{}\t{:?}\t{}",
                    provider.key,
                    provider.status,
                    provider.notes.unwrap_or_default()
                );
            }
            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 client = cloudflare_client(args.provider, args.token, args.account_id.clone())?;
    let (zones, page) = client
        .list_zones(&CloudflareZoneFilters {
            account_id: args.account_id,
            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?;
    print_json(&serde_json::json!({
        "provider": "cloudflare",
        "zones": zones,
        "result_info": page,
    }))
}

async fn get_zone(args: DnsZoneGetCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    print_json(&client.get_zone(&args.zone_id).await?)
}

async fn create_zone(args: DnsZoneCreateCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, args.account_id.clone())?;
    print_json(
        &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?,
    )
}

async fn edit_zone(args: DnsZoneEditCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    print_json(
        &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?,
    )
}

async fn delete_zone(args: DnsZoneDeleteCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    client.delete_zone(&args.zone_id).await?;
    println!("Deleted zone {}", args.zone_id);
    Ok(())
}

async fn list_records(args: DnsRecordListCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    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?;
    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 client = cloudflare_client(args.provider, args.token, None)?;
    print_json(&client.get_record(&args.zone_id, &args.record_id).await?)
}

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 request = record_write_from_create(args)?;
    let client = cloudflare_client(provider, token, None)?;
    print_json(&client.create_record(&zone_id, &request).await?)
}

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 request = record_write_from_create(args.common)?;
    let client = cloudflare_client(provider, token, None)?;
    print_json(
        &client
            .replace_record(&zone_id, &args.record_id, &request)
            .await?,
    )
}

async fn edit_record(args: DnsRecordEditCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    print_json(
        &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?,
    )
}

async fn delete_record(args: DnsRecordDeleteCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    client.delete_record(&args.zone_id, &args.record_id).await?;
    println!("Deleted record {}", args.record_id);
    Ok(())
}

async fn batch_records(args: DnsRecordBatchCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    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))?;
    print_json(&client.batch_records(&args.zone_id, &request).await?)
}

async fn import_records(args: DnsRecordImportCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    let bytes = fs::read(&args.file)
        .map_err(|error| format!("Failed to read {}: {}", args.file.display(), error))?;
    print_json(&client.import_records(&args.zone_id, bytes).await?)
}

async fn export_records(args: DnsRecordExportCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    let response = client.export_records(&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 client = cloudflare_client(args.provider, args.token, None)?;
    print_json(&client.get_dnssec(&args.zone_id).await?)
}

async fn edit_dnssec(args: DnssecEditCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    print_json(
        &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?,
    )
}

async fn get_settings(args: crate::cli::commands::DnsSettingsGetCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    print_json(&client.get_dns_settings(&args.zone_id).await?)
}

async fn edit_settings(args: DnsSettingsEditCmd) -> Result<(), String> {
    let client = cloudflare_client(args.provider, args.token, None)?;
    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,
    };
    print_json(
        &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?,
    )
}

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 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(())
}

#[cfg(test)]
mod tests {
    use super::parse_optional_json;

    #[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());
    }
}