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