mod cloudflare; mod dns; mod reconcile; mod ui; mod zonefile; use std::io::{self, IsTerminal}; use std::path::Path; use std::process::ExitCode; use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use cloudflare::{Client, split_listed}; use dns::{Name, Zone}; use reconcile::Action; /// Sync DNS records from RFC 1035 zone files to Cloudflare. /// /// Set CF_API_TOKEN to a Cloudflare API token with Zone.DNS edit permission. #[derive(Parser)] #[command(version)] struct Cli { #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { /// Make Cloudflare match the zone files Apply { /// Show the changes without applying them #[arg(long)] dry_run: bool, /// Do not delete records not present on disk #[arg(long)] no_prune: bool, /// Apply without prompting #[arg(long)] yes: bool, /// Zone files to sync #[arg(required = true)] files: Vec, }, /// Write .zone from the records currently at Cloudflare Import { /// Replace the zone file if it already exists #[arg(long)] overwrite: bool, /// Import all zones on the account #[arg(long)] all: bool, /// Zone names to import (omit with --all to import every zone) zones: Vec, }, /// List all zones on the Cloudflare account Zones, } #[derive(Clone, Copy)] enum Mode { DryRun, Apply { yes: bool }, } fn main() -> ExitCode { let cli = Cli::parse(); let token = std::env::var("CF_API_TOKEN") .or_else(|_| std::env::var("CLOUDFLARE_API_TOKEN")) .unwrap_or_else(|_| { eprintln!("error: set CF_API_TOKEN to a Cloudflare API token"); std::process::exit(2); }); let client = Client::new(token); let mut failed = false; match &cli.command { Command::Zones => match client.list_zones() { Ok(zones) => { for zone in zones { println!("{}", zone.name); } } Err(e) => { eprintln!("error: {e:#}"); failed = true; } }, Command::Import { overwrite, all, zones, } => { let zones: Vec = if *all { match client.list_zones() { Ok(list) => list.into_iter().map(|z| z.name).collect(), Err(e) => { eprintln!("error: {e:#}"); failed = true; Vec::new() } } } else if zones.is_empty() { eprintln!("error: provide zone names or use --all"); failed = true; Vec::new() } else { zones.clone() }; for zone in &zones { if let Err(e) = import(&client, zone, *overwrite) { eprintln!("error: {e:#}"); failed = true; } } } Command::Apply { dry_run, no_prune, yes, files, } => { let mode = if *dry_run { Mode::DryRun } else { Mode::Apply { yes: *yes } }; if !sync_all(&client, files, mode, !no_prune) { failed = true; } } } if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS } } fn sync_all(client: &Client, files: &[String], mode: Mode, prune: bool) -> bool { let mut ok = true; for file in files { if let Err(e) = sync(client, Path::new(file), mode, prune) { eprintln!("error: {e:#}"); ok = false; } } ok } fn import(client: &Client, name: &str, overwrite: bool) -> Result<()> { let path = std::path::PathBuf::from(format!("{name}.zone")); if path.exists() && !overwrite { bail!( "{}: already exists (use --overwrite to replace it)", path.display() ); } let zone_id = client.zone_id(name)?; let apex = Name::new(name); let (managed, foreign) = split_listed(client.list_records(&zone_id)?, &apex); let zone = Zone { name: apex, records: managed.into_iter().map(|l| l.record).collect(), }; std::fs::write(&path, zonefile::render(&zone, &foreign)) .with_context(|| path.display().to_string())?; println!( "{name}: wrote {} ({} records, {} unmanaged commented out)", path.display(), zone.records.len(), foreign.len() ); Ok(()) } fn sync(client: &Client, path: &Path, mode: Mode, prune: bool) -> Result<()> { let mut zone = zonefile::load(path)?; zone.normalize()?; let name = zone.name.bare(); let zone_id = client.zone_id(name)?; let (managed, foreign) = split_listed(client.list_records(&zone_id)?, &zone.name); println!( "{name}: {} records on disk, {} live ({} unmanaged)", zone.records.len(), managed.len(), foreign.len() ); let actions = reconcile::plan(&zone.records, &managed); if actions.is_empty() { println!("{name}: up to date"); return Ok(()); } for action in &actions { ui::print_action(action, prune); } let Mode::Apply { yes } = mode else { return Ok(()); }; if !yes { if !io::stdin().is_terminal() { bail!("not a TTY, use --yes to apply without prompting"); } if !ui::prompt(&format!("Apply {name}? [y/N] ")) { return Ok(()); } } for action in actions { match action { Action::Create(r) => client.create(&zone_id, &r)?, Action::Update { id, to, .. } => client.update(&zone_id, &id, &to)?, Action::Delete(live) if prune => client.delete(&zone_id, &live.id)?, Action::Delete(_) => {} } } println!("{name}: applied"); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn apply_prunes_unless_no_prune() { let cli = Cli::try_parse_from(["dns-ez", "apply", "x.zone"]).unwrap(); assert!(matches!( cli.command, Command::Apply { no_prune: false, dry_run: false, .. } )); let cli = Cli::try_parse_from(["dns-ez", "apply", "--no-prune", "--dry-run", "x.zone"]).unwrap(); assert!(matches!( cli.command, Command::Apply { no_prune: true, dry_run: true, .. } )); } }