char/dns-ez
cloudflare dns management via plaintext zonefiles
git clone https://git.t4t.associates/char/dns-ez
736d1a4
main
1use std:: path:: Path ; 2 3use anyhow::{ Context , Result , anyhow, bail}; 4use domain:: rdata:: ZoneRecordData ; 5use domain:: zonefile:: inplace::{ Entry , Zonefile }; 6 7use crate :: dns::{ ForeignRecord , Name , RData , Record , Zone }; 8 9/// Load a zone file, returning the zone name and its records in canonical form. 10/// 11/// The zone name comes from `$ORIGIN` if the file sets one, otherwise from 12/// the file name (`example.com.zone` -> `example.com`). 13pub fn load ( path : & Path ) ->Result < Zone > { 14let text = std:: fs:: read_to_string ( path). with_context ( || path. display (). to_string ()) ?; 15let origin =find_origin ( & text) 16. or_else ( ||{ 17 path. file_stem () 18. and_then ( |s| s. to_str ()) 19. map ( str:: to_string) 20}) 21. ok_or_else ( ||anyhow! ( "{}: cannot determine zone name" , path. display ())) ?; 22Ok ( Zone { 23name : Name :: new ( & origin), 24records : parse ( & text, & origin) ?, 25}) 26} 27 28fn find_origin ( text : & str ) ->Option < String > { 29for linein text. lines () { 30let line = line. split ( ';' ). next (). unwrap_or ( "" ). trim (); 31let mut words = line. split_whitespace (); 32if words 33. next () 34. is_some_and ( |w| w. eq_ignore_ascii_case ( "$ORIGIN" )) 35{ 36return words. next (). map ( |n| n. trim_end_matches ( '.' ). to_string ()); 37} 38} 39None 40} 41 42fn parse ( text : & str , origin : & str ) ->Result < Vec < Record >> { 43// Prepending an absolute origin lets files without $ORIGIN use relative names. 44let mut zone =Zonefile :: from ( format! ( "$ORIGIN {origin}.\n{text}" ). as_str ()); 45let mut records =Vec :: new (); 46loop { 47match zone. next_entry () { 48Ok ( Some ( Entry :: Record ( rec))) =>{ 49let rtype =match rec. data () { 50ZoneRecordData :: A ( _) =>"A" , 51ZoneRecordData :: Aaaa ( _) =>"AAAA" , 52ZoneRecordData :: Caa ( _) =>"CAA" , 53ZoneRecordData :: Cname ( _) =>"CNAME" , 54ZoneRecordData :: Mx ( _) =>"MX" , 55ZoneRecordData :: Ns ( _) =>"NS" , 56ZoneRecordData :: Ptr ( _) =>"PTR" , 57ZoneRecordData :: Srv ( _) =>"SRV" , 58ZoneRecordData :: Txt ( _) =>"TXT" , 59// The provider owns the SOA; nothing for us to sync. 60ZoneRecordData :: Soa ( _) =>continue , 61 _ =>{ 62bail! ( 63"{}: {} records are not supported" , 64 rec. owner (), 65 rec. rtype () 66); 67} 68}; 69 records. push ( 70Record :: parse ( 71& rec. owner (). to_string (), 72 rtype, 73 rec. ttl (). as_secs (), 74& rec. data (). to_string (), 75) 76. with_context ( || rec. owner (). to_string ()) ?, 77); 78} 79Ok ( Some ( Entry :: Include { ..})) =>{ 80bail! ( "$INCLUDE is not supported; keep each zone in a single file" ); 81} 82Ok ( None ) =>return Ok ( records), 83Err ( e) =>bail! ( "zone file error: {}" , e), 84} 85} 86} 87 88/// Render records as an RFC 1035 master file for `zone`. 89/// `foreign` records (types we don't manage) are included as comments so 90/// they're visible without breaking a later parse. 91pub fn render ( zone : & Zone , foreign : & [ ForeignRecord ]) ->String { 92let apex =& zone. name ; 93let suffix =format! ( ".{apex}" ); 94let relative = |name : & str | ->String { 95if name == apex. as_str () { 96"@" . into () 97} else { 98 name. strip_suffix ( & suffix). unwrap_or ( name). to_string () 99} 100}; 101let mut sorted: Vec < & Record > = zone. records . iter (). collect (); 102 sorted. sort_by ( |a, b|{ 103name_key ( a. name . as_str ()) 104. cmp ( & name_key ( b. name . as_str ())) 105. then_with ( || a. rtype (). cmp ( b. rtype ())) 106}); 107 108let width = sorted 109. iter () 110. map ( |r|relative ( r. name . as_str ()). len ()) 111. max () 112. unwrap_or ( 1 ); 113let mut out =format! ( "$ORIGIN {apex}\n$TTL 3600\n\n" ); 114for rin & sorted{ 115 out +=& format! ( 116"{:<width$} {:>5} IN {:<5} {}\n" , 117relative ( r. name . as_str ()), 118 r. ttl , 119 r. rtype (), 120render_rdata ( & r. data ) 121); 122} 123if !foreign. is_empty () { 124let mut foreign_sorted: Vec <( Name , & ForeignRecord )> = 125 foreign. iter (). map ( |f|( Name :: new ( & f. name ), f)). collect (); 126 foreign_sorted. sort_by ( |a, b|name_key ( a. 0 . as_str ()). cmp ( & name_key ( b. 0 . as_str ()))); 127 out +="\n; present at the provider, but of an unmanaged type:\n" ; 128for ( name, r) in foreign_sorted{ 129 out +=& format! ( 130"; {:<width$} {:>5} IN {:<5} {}\n" , 131relative ( name. as_str ()), 132 r. ttl , 133 r. rtype , 134 r. content 135); 136} 137} 138 out 139} 140 141fn render_rdata ( data : & RData ) ->String { 142match data{ 143RData :: Txt ( s) =>quote_txt ( s), 144RData :: Caa { flags, tag, value} =>format! ( "{flags} {tag} \"{value}\"" ), 145 _ => data. to_string (), 146} 147} 148 149/// Canonical DNS name order: compare labels from the rightmost, so records 150/// cluster by subdomain (`_mytxt.a` sorts before `_aaa.b`, and the apex — 151/// being a suffix of every other name — naturally comes first). 152fn name_key ( name : & str ) ->Vec < & str > { 153 name. trim_end_matches ( '.' ). split ( '.' ). rev (). collect () 154} 155 156/// A character-string is at most 255 bytes, so long values (2048-bit DKIM 157/// keys are the usual culprit) must be split into quoted chunks. 158fn quote_txt ( s : & str ) ->String { 159let mut chunks =Vec :: new (); 160let mut rest = s; 161while rest. len () >255 { 162let mut cut =255 ; 163while !rest. is_char_boundary ( cut) { 164 cut -=1 ; 165} 166// Don't orphan the backslash of an escape sequence. 167if rest. as_bytes ()[ cut -1 ] ==b'\\' { 168 cut -=1 ; 169} 170 chunks. push ( & rest[ ..cut]); 171 rest =& rest[ cut..]; 172} 173 chunks. push ( rest); 174 chunks 175. iter () 176. map ( |c|format! ( "\"{c}\"" )) 177. collect ::< Vec < _ >>() 178. join ( " " ) 179} 180 181# [ cfg ( test )] 182mod tests{ 183use super :: * ; 184 185const ZONE : & str ="\ 186$TTL 3600 187@ IN SOA ns1.cloudflare.com. dns.example.com. ( 1882026072201 ; serial 1897200 3600 86400 3600 ) 190IN NS ns1.cloudflare.com. 191IN A 203.0.113.10 192IN MX 10 mail 193www IN CNAME @ 194api 60 IN A 203.0.113.20 195@ IN TXT \"v=spf1 mx -all\" 196_sip._tcp IN SRV 0 5 5060 sip.example.com. 197@ IN CAA 0 issue \"letsencrypt.org\" 198" ; 199 200# [ test ] 201fn parses_a_realistic_zone () { 202let records =parse ( ZONE , "example.com" ). unwrap (); 203let has = |name : & str , rtype : & str , rdata : & str |{ 204 records. iter (). any ( |r|{ 205 r. name . as_str () == name && r. rtype () == rtype && r. data . to_string () == rdata 206}) 207}; 208assert! ( has ( "www.example.com." , "CNAME" , "example.com." )); 209assert! ( has ( "example.com." , "MX" , "10 mail.example.com." )); 210assert! ( has ( "example.com." , "TXT" , "v=spf1 mx -all" )); 211assert! ( has ( 212"_sip._tcp.example.com." , 213"SRV" , 214"0 5 5060 sip.example.com." 215)); 216assert! ( has ( "example.com." , "CAA" , "0 issue letsencrypt.org" )); 217assert! ( !records. iter (). any ( |r| r. rtype () =="SOA" )); 218assert! ( records. iter (). all ( |r| r. ttl ==3600 || r. ttl ==60 )); 219let api = records 220. iter () 221. find ( |r| r. name . as_str () =="api.example.com." ) 222. unwrap (); 223assert_eq! ( api. ttl , 60 ); 224} 225 226# [ test ] 227fn render_then_parse_is_the_identity () { 228let zone =Zone { 229name : Name :: new ( "example.com" ), 230records : parse ( ZONE , "example.com" ). unwrap (), 231}; 232let rendered =render ( & zone, & []); 233let mut reparsed =parse ( & rendered, "example.com" ). unwrap (); 234let by_key = |a : & Record , b : & Record | a. key (). cmp ( & b. key ()); 235let mut records = zone. records . clone (); 236 records. sort_by ( by_key); 237 reparsed. sort_by ( by_key); 238assert_eq! ( records, reparsed); 239} 240 241# [ test ] 242fn render_sorts_by_reversed_labels () { 243let records =vec! [ 244Record :: parse ( "www.example.com." , "A" , 3600 , "203.0.113.2" ). unwrap (), 245Record :: parse ( "_aaa.b.example.com." , "TXT" , 3600 , "1" ). unwrap (), 246Record :: parse ( "_mytxt.a.example.com." , "TXT" , 3600 , "2" ). unwrap (), 247Record :: parse ( "b.example.com." , "A" , 3600 , "203.0.113.3" ). unwrap (), 248Record :: parse ( "example.com." , "A" , 3600 , "203.0.113.1" ). unwrap (), 249]; 250let zone =Zone { 251name : Name :: new ( "example.com" ), 252 records, 253}; 254let rendered =render ( & zone, & []); 255let names: Vec < & str > = rendered 256. lines () 257. filter ( |l| !l. starts_with ( '$' ) && !l. is_empty ()) 258. map ( |l| l. split_whitespace (). next (). unwrap ()) 259. collect (); 260assert_eq! ( names, [ "@" , "_mytxt.a" , "b" , "_aaa.b" , "www" ]); 261} 262 263# [ test ] 264fn long_txt_is_chunked_and_round_trips () { 265let long ="x" . repeat ( 600 ); 266let records =vec! [ Record :: parse ( "example.com." , "TXT" , 300 , & long). unwrap ()]; 267let zone =Zone { 268name : Name :: new ( "example.com" ), 269records : records. clone (), 270}; 271let rendered =render ( & zone, & []); 272let reparsed =parse ( & rendered, "example.com" ). unwrap (); 273assert_eq! ( records, reparsed); 274} 275}