char/dns-ez

cloudflare dns management via plaintext zonefiles

git clone https://git.t4t.associates/char/dns-ez

Charlotte Sommove to anyhow for error handling736d1a4

main
6.7 KiB246 linesraw
1mod cloudflare;
2mod dns;
3mod reconcile;
4mod ui;
5mod zonefile;
6
7use std::io::{self, IsTerminal};
8use std::path::Path;
9use std::process::ExitCode;
10
11use anyhow::{Context, Result, bail};
12use clap::{Parser, Subcommand};
13use cloudflare::{Client, split_listed};
14use dns::{Name, Zone};
15use reconcile::Action;
16
17/// Sync DNS records from RFC 1035 zone files to Cloudflare.
18///
19/// Set CF_API_TOKEN to a Cloudflare API token with Zone.DNS edit permission.
20#[derive(Parser)]
21#[command(version)]
22struct Cli {
23    #[command(subcommand)]
24    command: Command,
25}
26
27#[derive(Subcommand)]
28enum Command {
29    /// Make Cloudflare match the zone files
30    Apply {
31        /// Show the changes without applying them
32        #[arg(long)]
33        dry_run: bool,
34        /// Do not delete records not present on disk
35        #[arg(long)]
36        no_prune: bool,
37        /// Apply without prompting
38        #[arg(long)]
39        yes: bool,
40        /// Zone files to sync
41        #[arg(required = true)]
42        files: Vec<String>,
43    },
44    /// Write <zone>.zone from the records currently at Cloudflare
45    Import {
46        /// Replace the zone file if it already exists
47        #[arg(long)]
48        overwrite: bool,
49        /// Import all zones on the account
50        #[arg(long)]
51        all: bool,
52        /// Zone names to import (omit with --all to import every zone)
53        zones: Vec<String>,
54    },
55    /// List all zones on the Cloudflare account
56    Zones,
57}
58
59#[derive(Clone, Copy)]
60enum Mode {
61    DryRun,
62    Apply { yes: bool },
63}
64
65fn main() -> ExitCode {
66    let cli = Cli::parse();
67    let token = std::env::var("CF_API_TOKEN")
68        .or_else(|_| std::env::var("CLOUDFLARE_API_TOKEN"))
69        .unwrap_or_else(|_| {
70            eprintln!("error: set CF_API_TOKEN to a Cloudflare API token");
71            std::process::exit(2);
72        });
73    let client = Client::new(token);
74    let mut failed = false;
75    match &cli.command {
76        Command::Zones => match client.list_zones() {
77            Ok(zones) => {
78                for zone in zones {
79                    println!("{}", zone.name);
80                }
81            }
82            Err(e) => {
83                eprintln!("error: {e:#}");
84                failed = true;
85            }
86        },
87        Command::Import {
88            overwrite,
89            all,
90            zones,
91        } => {
92            let zones: Vec<String> = if *all {
93                match client.list_zones() {
94                    Ok(list) => list.into_iter().map(|z| z.name).collect(),
95                    Err(e) => {
96                        eprintln!("error: {e:#}");
97                        failed = true;
98                        Vec::new()
99                    }
100                }
101            } else if zones.is_empty() {
102                eprintln!("error: provide zone names or use --all");
103                failed = true;
104                Vec::new()
105            } else {
106                zones.clone()
107            };
108            for zone in &zones {
109                if let Err(e) = import(&client, zone, *overwrite) {
110                    eprintln!("error: {e:#}");
111                    failed = true;
112                }
113            }
114        }
115        Command::Apply {
116            dry_run,
117            no_prune,
118            yes,
119            files,
120        } => {
121            let mode = if *dry_run {
122                Mode::DryRun
123            } else {
124                Mode::Apply { yes: *yes }
125            };
126            if !sync_all(&client, files, mode, !no_prune) {
127                failed = true;
128            }
129        }
130    }
131    if failed {
132        ExitCode::FAILURE
133    } else {
134        ExitCode::SUCCESS
135    }
136}
137
138fn sync_all(client: &Client, files: &[String], mode: Mode, prune: bool) -> bool {
139    let mut ok = true;
140    for file in files {
141        if let Err(e) = sync(client, Path::new(file), mode, prune) {
142            eprintln!("error: {e:#}");
143            ok = false;
144        }
145    }
146    ok
147}
148
149fn import(client: &Client, name: &str, overwrite: bool) -> Result<()> {
150    let path = std::path::PathBuf::from(format!("{name}.zone"));
151    if path.exists() && !overwrite {
152        bail!(
153            "{}: already exists (use --overwrite to replace it)",
154            path.display()
155        );
156    }
157    let zone_id = client.zone_id(name)?;
158    let apex = Name::new(name);
159    let (managed, foreign) = split_listed(client.list_records(&zone_id)?, &apex);
160    let zone = Zone {
161        name: apex,
162        records: managed.into_iter().map(|l| l.record).collect(),
163    };
164    std::fs::write(&path, zonefile::render(&zone, &foreign))
165        .with_context(|| path.display().to_string())?;
166    println!(
167        "{name}: wrote {} ({} records, {} unmanaged commented out)",
168        path.display(),
169        zone.records.len(),
170        foreign.len()
171    );
172    Ok(())
173}
174
175fn sync(client: &Client, path: &Path, mode: Mode, prune: bool) -> Result<()> {
176    let mut zone = zonefile::load(path)?;
177    zone.normalize()?;
178    let name = zone.name.bare();
179
180    let zone_id = client.zone_id(name)?;
181    let (managed, foreign) = split_listed(client.list_records(&zone_id)?, &zone.name);
182    println!(
183        "{name}: {} records on disk, {} live ({} unmanaged)",
184        zone.records.len(),
185        managed.len(),
186        foreign.len()
187    );
188
189    let actions = reconcile::plan(&zone.records, &managed);
190    if actions.is_empty() {
191        println!("{name}: up to date");
192        return Ok(());
193    }
194    for action in &actions {
195        ui::print_action(action, prune);
196    }
197    let Mode::Apply { yes } = mode else {
198        return Ok(());
199    };
200    if !yes {
201        if !io::stdin().is_terminal() {
202            bail!("not a TTY, use --yes to apply without prompting");
203        }
204        if !ui::prompt(&format!("Apply {name}? [y/N] ")) {
205            return Ok(());
206        }
207    }
208    for action in actions {
209        match action {
210            Action::Create(r) => client.create(&zone_id, &r)?,
211            Action::Update { id, to, .. } => client.update(&zone_id, &id, &to)?,
212            Action::Delete(live) if prune => client.delete(&zone_id, &live.id)?,
213            Action::Delete(_) => {}
214        }
215    }
216    println!("{name}: applied");
217    Ok(())
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn apply_prunes_unless_no_prune() {
226        let cli = Cli::try_parse_from(["dns-ez", "apply", "x.zone"]).unwrap();
227        assert!(matches!(
228            cli.command,
229            Command::Apply {
230                no_prune: false,
231                dry_run: false,
232                ..
233            }
234        ));
235        let cli =
236            Cli::try_parse_from(["dns-ez", "apply", "--no-prune", "--dry-run", "x.zone"]).unwrap();
237        assert!(matches!(
238            cli.command,
239            Command::Apply {
240                no_prune: true,
241                dry_run: true,
242                ..
243            }
244        ));
245    }
246}