char/dns-ez
cloudflare dns management via plaintext zonefiles
git clone https://git.t4t.associates/char/dns-ez
c6d896d
main
1use anyhow::{ Context , Result , bail}; 2use serde:: de:: DeserializeOwned ; 3use serde::{ Deserialize , Serialize }; 4use ureq:: http:: Method ; 5 6use crate :: api:: Api ; 7use crate :: dns::{ ForeignRecord , Name , RData , Record , SUPPORTED_TYPES }; 8use crate :: provider::{ Listed , Provider }; 9use crate :: reconcile:: Action ; 10 11const BASE : & str ="https://www.wixapis.com/domains/v1" ; 12 13pub struct Client { 14api : Api , 15} 16 17/// Wix holds one record set per name and type, with every value in it. 18# [ derive ( Debug , Clone , PartialEq , Serialize , Deserialize )] 19# [ serde ( rename_all = "camelCase" )] 20struct RecordSet { 21# [ serde ( rename = "type" )] 22rtype : String , 23host_name : String , 24# [ serde ( default )] 25ttl : u32 , 26# [ serde ( default )] 27values : Vec < String >, 28} 29 30impl RecordSet { 31fn holds ( & self , r : & Record ) ->bool { 32self . rtype == r. rtype () &&Name :: new ( & self . host_name ) == r. name 33} 34} 35 36# [ derive ( Deserialize )] 37# [ serde ( rename_all = "camelCase" )] 38struct ZoneResponse { 39dns_zone : DnsZone , 40} 41 42# [ derive ( Deserialize )] 43struct DnsZone { 44# [ serde ( default )] 45records : Vec < RecordSet >, 46} 47 48# [ derive ( Debug , PartialEq , Serialize )] 49struct Update { 50deletions : Vec < RecordSet >, 51additions : Vec < RecordSet >, 52} 53 54# [ derive ( Deserialize )] 55struct ApiError { 56message : String , 57} 58 59impl Client { 60pub fn new ( key : String , account_id : String ) ->Self { 61Client { 62api : Api :: new ( 63BASE , 64vec! [( "Authorization" , key), ( "wix-account-id" , account_id)], 65), 66} 67} 68 69fn zone ( & self , zone : & Name ) ->Result < Vec < RecordSet >> { 70let path =format! ( "/dns-zones/{}" , zone. bare ()); 71let resp: ZoneResponse =self . call ( Method :: GET , & path, None ::< & ()>) ?; 72Ok ( resp. dns_zone . records ) 73} 74 75fn call < R : DeserializeOwned >( 76& self , 77method : Method , 78path : & str , 79body : Option < & impl Serialize >, 80) ->Result < R > { 81let ( status, text) =self . api . call ( method. clone (), path, body) ?; 82if !( 200 ..300 ). contains ( & status) { 83let detail = serde_json:: from_str ::< ApiError >( & text). map_or ( text, |e| e. message ); 84bail! ( "{method} {path}: HTTP {status}: {detail}" ); 85} 86 serde_json:: from_str ( & text). with_context ( ||format! ( "{method} {path}: unexpected response" )) 87} 88} 89 90impl Provider for Client { 91fn list_zones ( & self ) ->Result < Vec < String >> { 92bail! ( "Wix has no API for listing domains; name the zones explicitly" ) 93} 94 95fn records ( & self , zone : & Name ) ->Result < Vec < Listed >> { 96let mut listed =Vec :: new (); 97for setin self . zone ( zone) ?{ 98let managed =SUPPORTED_TYPES . contains ( & set. rtype . as_str ()); 99for valuein & set. values { 100 listed. push ( if managed{ 101Listed :: Managed ( 102Record :: parse ( & set. host_name , & set. rtype , set. ttl , value) 103. with_context ( ||format! ( "{} {}" , set. host_name , set. rtype )) ?, 104) 105} else { 106Listed :: Foreign ( ForeignRecord { 107name : set. host_name . clone (), 108rtype : set. rtype . clone (), 109ttl : set. ttl , 110content : value. clone (), 111}) 112}); 113} 114} 115Ok ( listed) 116} 117 118fn apply ( & self , zone : & Name , actions : & [ Action ]) ->Result <()> { 119let path =format! ( "/dns-zones/{}" , zone. bare ()); 120let update =update ( & self . zone ( zone) ?, actions); 121self . call ::< serde_json:: Value >( Method :: PATCH , & path, Some ( & update)) ?; 122Ok (()) 123} 124} 125 126/// Fold record-level actions into one update that replaces every record set 127/// they touch: Wix can't edit a set in place, only delete it and re-add it 128/// whole in the same call. Values Wix already holds are kept verbatim, so 129/// each deletion matches the stored set exactly. 130fn update ( live : & [ RecordSet ], actions : & [ Action ]) ->Update { 131let ( mut deletions, mut additions) =( Vec :: new (), Vec ::< RecordSet >:: new ()); 132for actionin actions{ 133let ( Action :: Create ( r) |Action :: Update { to : r, ..} |Action :: Delete ( r)) = action; 134let i =match additions. iter (). position ( |s| s. holds ( r)) { 135Some ( i) => i, 136None =>{ 137 additions. push ( match live. iter (). find ( |s| s. holds ( r)) { 138Some ( s) =>{ 139 deletions. push ( s. clone ()); 140 s. clone () 141} 142None =>RecordSet { 143rtype : r. rtype (). to_string (), 144host_name : r. name . bare (). to_string (), 145ttl : r. ttl , 146values : Vec :: new (), 147}, 148}); 149 additions. len () -1 150} 151}; 152let set =& mut additions[ i]; 153match action{ 154Action :: Create ( r) =>{ 155 set. values . push ( value ( & r. data )); 156 set. ttl = r. ttl ; 157} 158Action :: Update { to, ..} => set. ttl = to. ttl , 159Action :: Delete ( r) => set 160. values 161. retain ( |v|RData :: parse ( r. rtype (), v). ok (). as_ref () !=Some ( & r. data )), 162} 163} 164 additions. retain ( |s| !s. values . is_empty ()); 165Update { 166 deletions, 167 additions, 168} 169} 170 171/// Wix takes presentation-format values without trailing dots or TXT quoting. 172fn value ( data : & RData ) ->String { 173match data{ 174RData :: Cname ( n) |RData :: Ns ( n) |RData :: Ptr ( n) => n. bare (). to_string (), 175RData :: Mx { 176 preference, 177 exchange, 178} =>format! ( "{preference} {}" , exchange. bare ()), 179RData :: Srv { 180 priority, 181 weight, 182 port, 183 target, 184} =>format! ( "{priority} {weight} {port} {}" , target. bare ()), 185 _ => data. to_string (), 186} 187} 188 189# [ cfg ( test )] 190mod tests{ 191use super :: * ; 192 193fn set ( rtype : & str , host_name : & str , ttl : u32 , values : & [ & str ]) ->RecordSet { 194RecordSet { 195rtype : rtype. to_string (), 196host_name : host_name. to_string (), 197 ttl, 198values : values. iter (). map ( |v| v. to_string ()). collect (), 199} 200} 201 202fn rec ( name : & str , rtype : & str , ttl : u32 , rdata : & str ) ->Record { 203Record :: parse ( name, rtype, ttl, rdata). unwrap () 204} 205 206# [ test ] 207fn adding_a_value_replaces_only_its_set () { 208let live =[ 209set ( "A" , "example.com" , 3600 , & [ "192.0.2.1" ]), 210set ( "TXT" , "example.com" , 3600 , & [ "hello" ]), 211]; 212let actions =[ Action :: Create ( rec ( "example.com" , "A" , 3600 , "192.0.2.2" ))]; 213assert_eq! ( 214update ( & live, & actions), 215Update { 216deletions : vec! [ live[ 0 ]. clone ()], 217additions : vec! [ set ( "A" , "example.com" , 3600 , & [ "192.0.2.1" , "192.0.2.2" ])], 218} 219); 220} 221 222# [ test ] 223fn removing_one_value_keeps_the_others_verbatim () { 224let live =[ set ( 225"MX" , 226"example.com" , 2273600 , 228& [ "10 mx1.example.net" , "20 mx2.example.net" ], 229)]; 230let actions =[ Action :: Delete ( rec ( 231"example.com" , 232"MX" , 2333600 , 234"20 mx2.example.net." , 235))]; 236assert_eq! ( 237update ( & live, & actions). additions , 238[ set ( "MX" , "example.com" , 3600 , & [ "10 mx1.example.net" ])] 239); 240} 241 242# [ test ] 243fn removing_the_last_value_deletes_the_set () { 244let live =[ set ( "TXT" , "example.com" , 300 , & [ "v=spf1 -all" ])]; 245let actions =[ Action :: Delete ( rec ( 246"example.com" , 247"TXT" , 248300 , 249"\"v=spf1 -all\"" , 250))]; 251assert_eq! ( 252update ( & live, & actions), 253Update { 254deletions : vec! [ live[ 0 ]. clone ()], 255additions : vec! [], 256} 257); 258} 259 260# [ test ] 261fn ttl_updates_apply_to_the_set () { 262let live =[ set ( 263"A" , 264"www.example.com" , 2653600 , 266& [ "192.0.2.1" , "192.0.2.2" ], 267)]; 268let actions =[ 269Action :: Update { 270from_ttl : 3600 , 271to : rec ( "www.example.com" , "A" , 300 , "192.0.2.1" ), 272}, 273Action :: Update { 274from_ttl : 3600 , 275to : rec ( "www.example.com" , "A" , 300 , "192.0.2.2" ), 276}, 277]; 278assert_eq! ( 279update ( & live, & actions). additions , 280[ set ( 281"A" , 282"www.example.com" , 283300 , 284& [ "192.0.2.1" , "192.0.2.2" ] 285)] 286); 287} 288 289# [ test ] 290fn a_set_can_be_replaced_by_one_of_another_type () { 291let live =[ set ( "A" , "x.example.com" , 3600 , & [ "192.0.2.1" ])]; 292let actions =[ 293Action :: Create ( rec ( "x.example.com" , "CNAME" , 3600 , "target.example.net." )), 294Action :: Delete ( rec ( "x.example.com" , "A" , 3600 , "192.0.2.1" )), 295]; 296assert_eq! ( 297update ( & live, & actions), 298Update { 299deletions : vec! [ live[ 0 ]. clone ()], 300additions : vec! [ set ( "CNAME" , "x.example.com" , 3600 , & [ "target.example.net" ])], 301} 302); 303} 304}