char/dns-ez
cloudflare dns management via plaintext zonefiles
git clone https://git.t4t.associates/char/dns-ez
736d1a4
main
1use std:: fmt; 2use std:: net::{ Ipv4Addr , Ipv6Addr }; 3 4use anyhow::{ anyhow, bail, Result }; 5 6/// An absolute DNS name in canonical form: lowercase, with trailing dot. 7# [ derive ( Debug , Clone , PartialEq , Eq , PartialOrd , Ord , Hash )] 8pub struct Name ( String ); 9 10impl Name { 11pub fn new ( name : & str ) ->Self { 12// In rdata a free-standing "@" survives parsing as a literal first 13// label; per RFC 1035 it denotes the origin. 14let name = name. strip_prefix ( "@." ). unwrap_or ( name); 15Name ( format! ( "{}." , name. trim_end_matches ( '.' ). to_lowercase ())) 16} 17 18pub fn as_str ( & self ) ->& str { 19& self . 0 20} 21 22/// The name without its trailing dot, as provider APIs expect. 23pub fn bare ( & self ) ->& str { 24self . 0 . trim_end_matches ( '.' ) 25} 26} 27 28impl fmt:: Display for Name { 29fn fmt ( & self , f : & mut fmt:: Formatter < ' _ >) -> fmt:: Result { 30 f. write_str ( & self . 0 ) 31} 32} 33 34/// Record types this tool understands; anything else at the provider is 35/// left alone. 36pub const SUPPORTED_TYPES : [ & str ; 9 ] = 37[ "A" , "AAAA" , "CAA" , "CNAME" , "MX" , "NS" , "PTR" , "SRV" , "TXT" ]; 38 39/// Canonical rdata. Parsing from presentation format normalizes, so that a 40/// record read from a zone file and the same record fetched from a provider 41/// compare equal. 42# [ derive ( Debug , Clone , PartialEq , Eq , PartialOrd , Ord , Hash )] 43pub enum RData { 44A ( Ipv4Addr ), 45Aaaa ( Ipv6Addr ), 46Caa { 47flags : u8 , 48tag : String , 49value : String , 50}, 51Cname ( Name ), 52Mx { 53preference : u16 , 54exchange : Name , 55}, 56Ns ( Name ), 57Ptr ( Name ), 58Srv { 59priority : u16 , 60weight : u16 , 61port : u16 , 62target : Name , 63}, 64Txt ( String ), 65} 66 67impl RData { 68pub fn rtype ( & self ) ->&' static str { 69match self { 70RData :: A ( _) =>"A" , 71RData :: Aaaa ( _) =>"AAAA" , 72RData :: Caa { ..} =>"CAA" , 73RData :: Cname ( _) =>"CNAME" , 74RData :: Mx { ..} =>"MX" , 75RData :: Ns ( _) =>"NS" , 76RData :: Ptr ( _) =>"PTR" , 77RData :: Srv { ..} =>"SRV" , 78RData :: Txt ( _) =>"TXT" , 79} 80} 81 82pub fn parse ( rtype : & str , rdata : & str ) ->Result < RData > { 83let s: String = rdata. split_whitespace (). collect ::< Vec < _ >>(). join ( " " ); 84match rtype{ 85"A" => s 86. parse () 87. map ( RData :: A ) 88. map_err ( |_|anyhow! ( "bad A rdata: {:?}" , rdata)), 89"AAAA" => s 90. parse () 91. map ( RData :: Aaaa ) 92. map_err ( |_|anyhow! ( "bad AAAA rdata: {:?}" , rdata)), 93"CNAME" =>Ok ( RData :: Cname ( Name :: new ( & s))), 94"NS" =>Ok ( RData :: Ns ( Name :: new ( & s))), 95"PTR" =>Ok ( RData :: Ptr ( Name :: new ( & s))), 96"MX" =>Self :: parse_mx ( & s), 97"SRV" =>Self :: parse_srv ( & s), 98"CAA" =>Self :: parse_caa ( & s), 99"TXT" =>Ok ( RData :: Txt ( normalize_txt ( & s))), 100 _ =>Err ( anyhow! ( "{} records are not supported" , rtype)), 101} 102} 103 104fn parse_mx ( s : & str ) ->Result < RData > { 105let bad = ||anyhow! ( "bad MX rdata: {:?}" , s); 106let ( pref, host) = s. split_once ( ' ' ). ok_or_else ( bad) ?; 107Ok ( RData :: Mx { 108preference : pref. parse (). map_err ( |_|bad ()) ?, 109exchange : Name :: new ( host), 110}) 111} 112 113fn parse_srv ( s : & str ) ->Result < RData > { 114let bad = ||anyhow! ( "bad SRV rdata: {:?}" , s); 115let fields: Vec < & str > = s. split_whitespace (). collect (); 116let [ prio, weight, port, target] = fields. as_slice () else { 117return Err ( bad ()); 118}; 119Ok ( RData :: Srv { 120priority : prio. parse (). map_err ( |_|bad ()) ?, 121weight : weight. parse (). map_err ( |_|bad ()) ?, 122port : port. parse (). map_err ( |_|bad ()) ?, 123target : Name :: new ( target), 124}) 125} 126 127fn parse_caa ( s : & str ) ->Result < RData > { 128let bad = ||anyhow! ( "bad CAA rdata: {:?}" , s); 129let fields: Vec < & str > = s. splitn ( 3 , ' ' ). collect (); 130let [ flags, tag, value] = fields. as_slice () else { 131return Err ( bad ()); 132}; 133Ok ( RData :: Caa { 134flags : flags. parse (). map_err ( |_|bad ()) ?, 135tag : tag. to_string (), 136value : value. replace ( '"' , "" ), 137}) 138} 139} 140 141impl fmt:: Display for RData { 142fn fmt ( & self , f : & mut fmt:: Formatter < ' _ >) -> fmt:: Result { 143match self { 144RData :: A ( a) =>write! ( f, "{a}" ), 145RData :: Aaaa ( a) =>write! ( f, "{a}" ), 146RData :: Caa { flags, tag, value} =>write! ( f, "{flags} {tag} {value}" ), 147RData :: Cname ( n) |RData :: Ns ( n) |RData :: Ptr ( n) =>write! ( f, "{n}" ), 148RData :: Mx { 149 preference, 150 exchange, 151} =>write! ( f, "{preference} {exchange}" ), 152RData :: Srv { 153 priority, 154 weight, 155 port, 156 target, 157} =>{ 158write! ( f, "{priority} {weight} {port} {target}" ) 159} 160RData :: Txt ( s) => f. write_str ( s), 161} 162} 163} 164 165/// Zone files and APIs chunk TXT values over 255 bytes into adjacent quoted 166/// strings; compare the concatenated payload. 167fn normalize_txt ( s : & str ) ->String { 168if !s. contains ( '"' ) { 169return s. to_string (); 170} 171let mut out =String :: new (); 172let mut in_quote =false ; 173let mut chars = s. chars (); 174while let Some ( c) = chars. next () { 175match c{ 176'"' => in_quote = !in_quote, 177// Keep escapes verbatim so both sides stay comparable. 178'\\' =>{ 179 out. push ( c); 180 out. extend ( chars. next ()); 181} 182 cif in_quote => out. push ( c), 183 _ =>{} 184} 185} 186 out 187} 188 189/// A DNS record in canonical form. 190# [ derive ( Debug , Clone , PartialEq , Eq )] 191pub struct Record { 192pub name : Name , 193pub ttl : u32 , 194pub data : RData , 195} 196 197impl Record { 198pub fn parse ( name : & str , rtype : & str , ttl : u32 , rdata : & str ) ->Result < Record > { 199Ok ( Record { 200name : Name :: new ( name), 201 ttl, 202data : RData :: parse ( rtype, rdata) ?, 203}) 204} 205 206pub fn rtype ( & self ) ->&' static str { 207self . data . rtype () 208} 209 210/// Two records with equal keys are the same record for sync purposes; 211/// only the TTL may legitimately differ. 212pub fn key ( & self ) ->( & Name , &' static str , & RData ) { 213( & self . name , self . data . rtype (), & self . data ) 214} 215 216/// Apex NS records belong to the provider's nameservers and are 217/// untouchable. 218pub fn is_apex_ns ( & self , apex : & Name ) ->bool { 219self . rtype () =="NS" &&self . name ==* apex 220} 221} 222 223impl fmt:: Display for Record { 224fn fmt ( & self , f : & mut fmt:: Formatter < ' _ >) -> fmt:: Result { 225write! ( 226 f, 227"{} {} {} {}" , 228self . name , 229self . ttl , 230self . rtype (), 231self . data 232) 233} 234} 235 236/// A record at the provider of a type we don't manage; kept only for 237/// display. 238# [ derive ( Debug , Clone )] 239pub struct ForeignRecord { 240pub name : String , 241pub rtype : String , 242pub ttl : u32 , 243pub content : String , 244} 245 246/// A zone: its name (the apex) and the records it contains. 247# [ derive ( Debug , Clone )] 248pub struct Zone { 249pub name : Name , 250pub records : Vec < Record >, 251} 252 253impl Zone { 254/// Strip apex NS records, sort into canonical order, and remove exact 255/// duplicates. An RRset (same name and type) must share one TTL. 256pub fn normalize ( & mut self ) ->Result <()> { 257self . records . retain ( |r| !r. is_apex_ns ( & self . name )); 258self . records . sort_by ( |a, b| a. key (). cmp ( & b. key ())); 259self . records . dedup (); 260for pairin self . records . windows ( 2 ) { 261let [ a, b] = pairelse { continue }; 262if a. name == b. name && a. rtype () == b. rtype () && a. ttl != b. ttl { 263bail! ( 264"{} {} records must share one TTL (found {} and {})" , 265 a. name , 266 a. rtype (), 267 a. ttl , 268 b. ttl 269); 270} 271} 272Ok (()) 273} 274} 275 276# [ cfg ( test )] 277mod tests{ 278use super :: * ; 279 280# [ test ] 281fn apex_ns_is_detected () { 282let apex =Name :: new ( "example.com" ); 283let apex_ns =Record :: parse ( "example.com" , "NS" , 3600 , "ns1.example.com" ). unwrap (); 284let non_apex_ns =Record :: parse ( "sub.example.com" , "NS" , 3600 , "ns1.example.com" ). unwrap (); 285assert! ( apex_ns. is_apex_ns ( & apex)); 286assert! ( !non_apex_ns. is_apex_ns ( & apex)); 287} 288 289# [ test ] 290fn normalize_strips_apex_ns_and_validates_ttls () { 291let mut zone =Zone { 292name : Name :: new ( "example.com" ), 293records : vec! [ 294Record :: parse ( "example.com" , "NS" , 3600 , "ns1.example.com" ). unwrap (), 295Record :: parse ( "www.example.com" , "A" , 300 , "1.2.3.4" ). unwrap (), 296Record :: parse ( "www.example.com" , "A" , 300 , "1.2.3.4" ). unwrap (), // dup 297], 298}; 299 zone. normalize (). unwrap (); 300assert_eq! ( zone. records . len (), 1 ); 301assert! ( 302 !zone 303. records 304. iter () 305. any ( |r| r. rtype () =="NS" && r. name == zone. name ) 306); 307} 308 309# [ test ] 310fn normalize_rejects_mismatched_ttls_in_rrset () { 311let mut zone =Zone { 312name : Name :: new ( "example.com" ), 313records : vec! [ 314Record :: parse ( "www.example.com" , "A" , 300 , "1.2.3.4" ). unwrap (), 315Record :: parse ( "www.example.com" , "A" , 600 , "1.2.3.4" ). unwrap (), 316], 317}; 318assert! ( zone. normalize (). is_err ()); 319} 320 321# [ test ] 322fn names_are_absolute_and_lowercase () { 323assert_eq! ( Name :: new ( "WWW.Example.COM" ). as_str (), "www.example.com." ); 324assert_eq! ( Name :: new ( "www.example.com." ). as_str (), "www.example.com." ); 325} 326 327# [ test ] 328fn at_label_denotes_origin () { 329assert_eq! ( Name :: new ( "@.example.com." ). as_str (), "example.com." ); 330} 331 332# [ test ] 333fn txt_quotes_are_stripped () { 334let a =Record :: parse ( "x" , "TXT" , 300 , "\"v=spf1 -all\"" ). unwrap (); 335let b =Record :: parse ( "x" , "TXT" , 300 , "v=spf1 -all" ). unwrap (); 336assert_eq! ( a, b); 337} 338 339# [ test ] 340fn long_txt_chunks_compare_equal_to_single_string () { 341let chunked =Record :: parse ( "x" , "TXT" , 300 , "\"aaa\" \"bbb\"" ). unwrap (); 342let single =Record :: parse ( "x" , "TXT" , 300 , "aaabbb" ). unwrap (); 343assert_eq! ( chunked, single); 344} 345 346# [ test ] 347fn ipv6_is_compressed () { 348let a =Record :: parse ( "x" , "AAAA" , 300 , "2001:0DB8:0000:0000:0000:0000:0000:0001" ). unwrap (); 349assert_eq! ( a. data . to_string (), "2001:db8::1" ); 350} 351 352# [ test ] 353fn mx_target_is_normalized () { 354let a =Record :: parse ( "x" , "MX" , 300 , "10 Mail.Example.com" ). unwrap (); 355assert_eq! ( a. data . to_string (), "10 mail.example.com." ); 356} 357 358# [ test ] 359fn malformed_rdata_is_an_error () { 360assert! ( Record :: parse ( "x" , "MX" , 300 , "not-a-preference mail" ). is_err ()); 361assert! ( Record :: parse ( "x" , "A" , 300 , "999.1.1.1" ). is_err ()); 362assert! ( Record :: parse ( "x" , "SRV" , 300 , "0 5 5060" ). is_err ()); 363} 364}