char/dns-ez
cloudflare dns management via plaintext zonefiles
git clone https://git.t4t.associates/char/dns-ez
736d1a4
main
1use std:: time:: Duration ; 2 3use anyhow::{ Context , Result , anyhow, bail}; 4use serde::{ Deserialize , Serialize }; 5 6use crate :: dns::{ ForeignRecord , Name , RData , Record , SUPPORTED_TYPES }; 7 8const BASE : & str ="https://api.cloudflare.com/client/v4" ; 9const ATTEMPTS : u32 =4 ; 10 11pub struct Client { 12agent : ureq:: Agent , 13token : String , 14} 15 16# [ derive ( Deserialize )] 17struct Envelope { 18success : bool , 19# [ serde ( default )] 20errors : Vec < ApiError >, 21result : Option < serde_json:: Value >, 22result_info : Option < ResultInfo >, 23} 24 25# [ derive ( Deserialize )] 26struct ApiError { 27code : i64 , 28message : String , 29} 30 31# [ derive ( Deserialize )] 32struct ResultInfo { 33total_pages : u32 , 34} 35 36/// The result of listing a zone's records: managed records carry their 37/// provider ID, everything else is kept only for display. 38pub enum Listed { 39Managed ( LiveRecord ), 40Foreign ( ForeignRecord ), 41} 42 43/// A managed record as it exists at the provider, with its provider ID. 44# [ derive ( Debug , Clone )] 45pub struct LiveRecord { 46pub id : String , 47pub record : Record , 48} 49 50/// Split the provider's records into the ones we manage and the ones we 51/// leave alone. Apex NS records belong to the provider's nameservers and 52/// are untouchable, so they count as foreign too. 53pub fn split_listed ( listed : Vec < Listed >, apex : & Name ) ->( Vec < LiveRecord >, Vec < ForeignRecord >) { 54let ( mut managed, mut foreign) =( Vec :: new (), Vec :: new ()); 55for lin listed{ 56match l{ 57Listed :: Managed ( live) if live. record . is_apex_ns ( apex) =>{ 58 foreign. push ( ForeignRecord { 59name : live. record . name . to_string (), 60rtype : live. record . rtype (). to_string (), 61ttl : live. record . ttl , 62content : live. record . data . to_string (), 63}); 64} 65Listed :: Managed ( live) => managed. push ( live), 66Listed :: Foreign ( f) => foreign. push ( f), 67} 68} 69( managed, foreign) 70} 71 72/// A DNS record as returned by the Cloudflare API. 73# [ derive ( Deserialize )] 74struct ApiRecord { 75id : String , 76# [ serde ( rename = "type" )] 77rtype : String , 78name : String , 79# [ serde ( default )] 80content : String , 81# [ serde ( default )] 82ttl : u32 , 83# [ serde ( default )] 84priority : Option < u32 >, 85# [ serde ( default )] 86data : Option < ApiRecordData >, 87} 88 89# [ derive ( Deserialize )] 90struct ApiRecordData { 91priority : u64 , 92weight : u64 , 93port : u64 , 94target : String , 95flags : u64 , 96tag : String , 97value : String , 98} 99 100impl ApiRecord { 101fn listed ( self ) ->Result < Listed > { 102if !SUPPORTED_TYPES . contains ( & self . rtype . as_str ()) { 103return Ok ( Listed :: Foreign ( ForeignRecord { 104name : self . name , 105rtype : self . rtype , 106ttl : self . ttl , 107content : self . content , 108})); 109} 110let rdata =self . presentation_rdata () ?; 111let record =Record :: parse ( & self . name , & self . rtype , self . ttl , & rdata) 112. with_context ( ||format! ( "{} {}" , self . name , self . rtype )) ?; 113Ok ( Listed :: Managed ( LiveRecord { 114id : self . id , 115 record, 116})) 117} 118 119/// Rebuild presentation-format rdata from the API response. Most types 120/// use content verbatim; MX prepends the priority field, and SRV/CAA 121/// arrive as structured data with an empty content. 122fn presentation_rdata ( & self ) ->Result < String > { 123Ok ( match self . rtype . as_str () { 124"MX" =>format! ( "{} {}" , self . priority . unwrap_or ( 0 ), self . content ), 125"SRV" if self . content . is_empty () =>{ 126let d =self 127. data 128. as_ref () 129. ok_or_else ( ||anyhow! ( "{}: SRV record has no data" , self . name )) ?; 130format! ( "{} {} {} {}" , d. priority , d. weight , d. port , d. target ) 131} 132"CAA" if self . content . is_empty () =>{ 133let d =self 134. data 135. as_ref () 136. ok_or_else ( ||anyhow! ( "{}: CAA record has no data" , self . name )) ?; 137format! ( "{} {} \"{}\"" , d. flags , d. tag , d. value ) 138} 139 _ =>self . content . clone (), 140}) 141} 142} 143 144impl Client { 145pub fn new ( token : String ) ->Self { 146let config = ureq:: Agent :: config_builder () 147. timeout_global ( Some ( Duration :: from_secs ( 30 ))) 148. http_status_as_error ( false ) 149. build (); 150Client { 151agent : ureq:: Agent :: new_with_config ( config), 152 token, 153} 154} 155 156pub fn zone_id ( & self , name : & str ) ->Result < String > { 157let env =self . call ( "GET" , & format! ( "/zones?name={name}" ), None ::< & ()>) ?; 158let zones: Vec < ZoneInfo > = serde_json:: from_value ( env. result . unwrap_or_default ()) 159. context ( "unexpected zones response" ) ?; 160let Some ( zone) = zones. first () else { 161bail! ( 162"zone {} not found; run `dns-ez zones` to list all zones on this account" , 163 name 164); 165}; 166Ok ( zone. id . clone ()) 167} 168 169pub fn list_zones ( & self ) ->Result < Vec < ZoneInfo >> { 170self . list ::< ZoneInfo >( "/zones?per_page=50" ) 171} 172 173pub fn list_records ( & self , zone_id : & str ) ->Result < Vec < Listed >> { 174let raw =self . list ::< ApiRecord >( & format! ( "/zones/{zone_id}/dns_records?per_page=100" )) ?; 175 raw. into_iter (). map ( ApiRecord :: listed). collect () 176} 177 178/// Fetch every page of a list endpoint. 179fn list < T : for < ' de > Deserialize < ' de >>( & self , path : & str ) ->Result < Vec < T >> { 180let mut items =Vec :: new (); 181let mut page =1 ; 182loop { 183let env =self . call ( "GET" , & format! ( "{path}&page={page}" ), None ::< & ()>) ?; 184let mut batch: Vec < T > = serde_json:: from_value ( env. result . unwrap_or_default ()) 185. with_context ( ||format! ( "unexpected response from {path}" )) ?; 186 items. append ( & mut batch); 187if page >= env. result_info . map ( |i| i. total_pages ). unwrap_or ( 1 ) { 188return Ok ( items); 189} 190 page +=1 ; 191} 192} 193 194pub fn create ( & self , zone_id : & str , rec : & Record ) ->Result <()> { 195self . call ( 196"POST" , 197& format! ( "/zones/{zone_id}/dns_records" ), 198Some ( & body_for ( rec) ?), 199) ?; 200Ok (()) 201} 202 203pub fn update ( & self , zone_id : & str , id : & str , rec : & Record ) ->Result <()> { 204self . call ( 205"PUT" , 206& format! ( "/zones/{zone_id}/dns_records/{id}" ), 207Some ( & body_for ( rec) ?), 208) ?; 209Ok (()) 210} 211 212pub fn delete ( & self , zone_id : & str , id : & str ) ->Result <()> { 213self . call ( 214"DELETE" , 215& format! ( "/zones/{zone_id}/dns_records/{id}" ), 216None ::< & ()>, 217) ?; 218Ok (()) 219} 220 221fn call < T : Serialize + ?Sized >( 222& self , 223method : & str , 224path : & str , 225body : Option < & T >, 226) ->Result < Envelope > { 227let url =format! ( "{BASE}{path}" ); 228let mut last_err =String :: new (); 229for attemptin 0 ..ATTEMPTS { 230if attempt >0 { 231 std:: thread:: sleep ( Duration :: from_millis ( 500 <<( attempt -1 ))); 232} 233let result =match self . send ( method, & url, body) { 234Ok ( resp) => resp, 235Err ( e) =>{ 236 last_err =format! ( "{method} {path}: {e}" ); 237continue ; 238} 239}; 240let status = result. status (). as_u16 (); 241if ( status ==429 || status >=500 ) && attempt +1 <ATTEMPTS { 242 last_err =format! ( "{method} {path}: HTTP {status}" ); 243continue ; 244} 245let mut result = result; 246let env: Envelope = result 247. body_mut () 248. read_json () 249. with_context ( ||format! ( "{method} {path}: unreadable response" )) ?; 250if env. success { 251return Ok ( env); 252} 253let detail = env 254. errors 255. iter () 256. map ( |e|format! ( "{} ({})" , e. message , e. code )) 257. collect ::< Vec < _ >>() 258. join ( ", " ); 259bail! ( "{} {}: {}" , method, path, detail); 260} 261Err ( anyhow! ( last_err)) 262} 263 264fn send < T : Serialize + ?Sized >( 265& self , 266method : & str , 267url : & str , 268body : Option < & T >, 269) ->Result < ureq:: http:: Response < ureq:: Body >, ureq:: Error > { 270let auth =format! ( "Bearer {}" , self . token ); 271match method{ 272"GET" =>self . agent . get ( url). header ( "Authorization" , & auth). call (), 273"DELETE" =>self . agent . delete ( url). header ( "Authorization" , & auth). call (), 274"POST" =>self 275. agent 276. post ( url) 277. header ( "Authorization" , & auth) 278. send_json ( body. expect ( "POST needs a body" )), 279"PUT" =>self 280. agent 281. put ( url) 282. header ( "Authorization" , & auth) 283. send_json ( body. expect ( "PUT needs a body" )), 284 _ =>unreachable! ( "unsupported method {method}" ), 285} 286} 287} 288 289# [ derive ( Deserialize )] 290pub struct ZoneInfo { 291pub id : String , 292pub name : String , 293} 294 295/// Build the create/update payload. Most types take a plain content string; 296/// MX adds a priority field, SRV and CAA take structured data. 297fn body_for ( rec : & Record ) ->Result < RecordPayload > { 298let mut payload =RecordPayload { 299rtype : rec. rtype (). to_string (), 300name : rec. name . bare (). to_string (), 301ttl : rec. ttl , 302proxied : false , 303content : None , 304priority : None , 305data : None , 306}; 307match & rec. data { 308RData :: Mx { 309 preference, 310 exchange, 311} =>{ 312 payload. content =Some ( exchange. bare (). to_string ()); 313 payload. priority =Some ( * preference); 314} 315RData :: Srv { 316 priority, 317 weight, 318 port, 319 target, 320} =>{ 321// Owner "_sip._tcp.example.com." supplies service/proto/zone. 322let labels: Vec < & str > = rec. name . bare (). splitn ( 3 , '.' ). collect (); 323let [ service, proto, zone] = labels. as_slice () else { 324bail! ( 325"{}: SRV owner must have the form _service._proto.name" , 326 rec. name 327); 328}; 329 payload. data =Some ( RecordData :: Srv { 330service : service. to_string (), 331proto : proto. to_string (), 332name : zone. to_string (), 333priority : * priority, 334weight : * weight, 335port : * port, 336target : target. bare (). to_string (), 337}); 338} 339RData :: Caa { flags, tag, value} =>{ 340 payload. data =Some ( RecordData :: Caa { 341flags : * flags, 342tag : tag. clone (), 343value : value. clone (), 344}); 345} 346RData :: Cname ( n) |RData :: Ns ( n) |RData :: Ptr ( n) =>{ 347 payload. content =Some ( n. bare (). to_string ()); 348} 349RData :: A ( a) => payload. content =Some ( a. to_string ()), 350RData :: Aaaa ( a) => payload. content =Some ( a. to_string ()), 351RData :: Txt ( s) => payload. content =Some ( s. clone ()), 352} 353Ok ( payload) 354} 355 356# [ derive ( Serialize )] 357struct RecordPayload { 358# [ serde ( rename = "type" )] 359rtype : String , 360name : String , 361ttl : u32 , 362proxied : bool , 363# [ serde ( skip_serializing_if = "Option::is_none" )] 364content : Option < String >, 365# [ serde ( skip_serializing_if = "Option::is_none" )] 366priority : Option < u16 >, 367# [ serde ( skip_serializing_if = "Option::is_none" )] 368data : Option < RecordData >, 369} 370 371# [ derive ( Serialize )] 372# [ serde ( untagged )] 373enum RecordData { 374Srv { 375service : String , 376proto : String , 377name : String , 378priority : u16 , 379weight : u16 , 380port : u16 , 381target : String , 382}, 383Caa { 384flags : u8 , 385tag : String , 386value : String , 387}, 388}