use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use crate::dns::{ForeignRecord, Name, RData, Record, SUPPORTED_TYPES}; const BASE: &str = "https://api.cloudflare.com/client/v4"; const ATTEMPTS: u32 = 4; pub struct Client { agent: ureq::Agent, token: String, } #[derive(Deserialize)] struct Envelope { success: bool, #[serde(default)] errors: Vec, result: Option, result_info: Option, } #[derive(Deserialize)] struct ApiError { code: i64, message: String, } #[derive(Deserialize)] struct ResultInfo { total_pages: u32, } /// The result of listing a zone's records: managed records carry their /// provider ID, everything else is kept only for display. pub enum Listed { Managed(LiveRecord), Foreign(ForeignRecord), } /// A managed record as it exists at the provider, with its provider ID. #[derive(Debug, Clone)] pub struct LiveRecord { pub id: String, pub record: Record, } /// Split the provider's records into the ones we manage and the ones we /// leave alone. Apex NS records belong to the provider's nameservers and /// are untouchable, so they count as foreign too. pub fn split_listed(listed: Vec, apex: &Name) -> (Vec, Vec) { let (mut managed, mut foreign) = (Vec::new(), Vec::new()); for l in listed { match l { Listed::Managed(live) if live.record.is_apex_ns(apex) => { foreign.push(ForeignRecord { name: live.record.name.to_string(), rtype: live.record.rtype().to_string(), ttl: live.record.ttl, content: live.record.data.to_string(), }); } Listed::Managed(live) => managed.push(live), Listed::Foreign(f) => foreign.push(f), } } (managed, foreign) } /// A DNS record as returned by the Cloudflare API. #[derive(Deserialize)] struct ApiRecord { id: String, #[serde(rename = "type")] rtype: String, name: String, #[serde(default)] content: String, #[serde(default)] ttl: u32, #[serde(default)] priority: Option, #[serde(default)] data: Option, } #[derive(Deserialize)] struct ApiRecordData { priority: u64, weight: u64, port: u64, target: String, flags: u64, tag: String, value: String, } impl ApiRecord { fn listed(self) -> Result { if !SUPPORTED_TYPES.contains(&self.rtype.as_str()) { return Ok(Listed::Foreign(ForeignRecord { name: self.name, rtype: self.rtype, ttl: self.ttl, content: self.content, })); } let rdata = self.presentation_rdata()?; let record = Record::parse(&self.name, &self.rtype, self.ttl, &rdata) .with_context(|| format!("{} {}", self.name, self.rtype))?; Ok(Listed::Managed(LiveRecord { id: self.id, record, })) } /// Rebuild presentation-format rdata from the API response. Most types /// use content verbatim; MX prepends the priority field, and SRV/CAA /// arrive as structured data with an empty content. fn presentation_rdata(&self) -> Result { Ok(match self.rtype.as_str() { "MX" => format!("{} {}", self.priority.unwrap_or(0), self.content), "SRV" if self.content.is_empty() => { let d = self .data .as_ref() .ok_or_else(|| anyhow!("{}: SRV record has no data", self.name))?; format!("{} {} {} {}", d.priority, d.weight, d.port, d.target) } "CAA" if self.content.is_empty() => { let d = self .data .as_ref() .ok_or_else(|| anyhow!("{}: CAA record has no data", self.name))?; format!("{} {} \"{}\"", d.flags, d.tag, d.value) } _ => self.content.clone(), }) } } impl Client { pub fn new(token: String) -> Self { let config = ureq::Agent::config_builder() .timeout_global(Some(Duration::from_secs(30))) .http_status_as_error(false) .build(); Client { agent: ureq::Agent::new_with_config(config), token, } } pub fn zone_id(&self, name: &str) -> Result { let env = self.call("GET", &format!("/zones?name={name}"), None::<&()>)?; let zones: Vec = serde_json::from_value(env.result.unwrap_or_default()) .context("unexpected zones response")?; let Some(zone) = zones.first() else { bail!( "zone {} not found; run `dns-ez zones` to list all zones on this account", name ); }; Ok(zone.id.clone()) } pub fn list_zones(&self) -> Result> { self.list::("/zones?per_page=50") } pub fn list_records(&self, zone_id: &str) -> Result> { let raw = self.list::(&format!("/zones/{zone_id}/dns_records?per_page=100"))?; raw.into_iter().map(ApiRecord::listed).collect() } /// Fetch every page of a list endpoint. fn list Deserialize<'de>>(&self, path: &str) -> Result> { let mut items = Vec::new(); let mut page = 1; loop { let env = self.call("GET", &format!("{path}&page={page}"), None::<&()>)?; let mut batch: Vec = serde_json::from_value(env.result.unwrap_or_default()) .with_context(|| format!("unexpected response from {path}"))?; items.append(&mut batch); if page >= env.result_info.map(|i| i.total_pages).unwrap_or(1) { return Ok(items); } page += 1; } } pub fn create(&self, zone_id: &str, rec: &Record) -> Result<()> { self.call( "POST", &format!("/zones/{zone_id}/dns_records"), Some(&body_for(rec)?), )?; Ok(()) } pub fn update(&self, zone_id: &str, id: &str, rec: &Record) -> Result<()> { self.call( "PUT", &format!("/zones/{zone_id}/dns_records/{id}"), Some(&body_for(rec)?), )?; Ok(()) } pub fn delete(&self, zone_id: &str, id: &str) -> Result<()> { self.call( "DELETE", &format!("/zones/{zone_id}/dns_records/{id}"), None::<&()>, )?; Ok(()) } fn call( &self, method: &str, path: &str, body: Option<&T>, ) -> Result { let url = format!("{BASE}{path}"); let mut last_err = String::new(); for attempt in 0..ATTEMPTS { if attempt > 0 { std::thread::sleep(Duration::from_millis(500 << (attempt - 1))); } let result = match self.send(method, &url, body) { Ok(resp) => resp, Err(e) => { last_err = format!("{method} {path}: {e}"); continue; } }; let status = result.status().as_u16(); if (status == 429 || status >= 500) && attempt + 1 < ATTEMPTS { last_err = format!("{method} {path}: HTTP {status}"); continue; } let mut result = result; let env: Envelope = result .body_mut() .read_json() .with_context(|| format!("{method} {path}: unreadable response"))?; if env.success { return Ok(env); } let detail = env .errors .iter() .map(|e| format!("{} ({})", e.message, e.code)) .collect::>() .join(", "); bail!("{} {}: {}", method, path, detail); } Err(anyhow!(last_err)) } fn send( &self, method: &str, url: &str, body: Option<&T>, ) -> Result, ureq::Error> { let auth = format!("Bearer {}", self.token); match method { "GET" => self.agent.get(url).header("Authorization", &auth).call(), "DELETE" => self.agent.delete(url).header("Authorization", &auth).call(), "POST" => self .agent .post(url) .header("Authorization", &auth) .send_json(body.expect("POST needs a body")), "PUT" => self .agent .put(url) .header("Authorization", &auth) .send_json(body.expect("PUT needs a body")), _ => unreachable!("unsupported method {method}"), } } } #[derive(Deserialize)] pub struct ZoneInfo { pub id: String, pub name: String, } /// Build the create/update payload. Most types take a plain content string; /// MX adds a priority field, SRV and CAA take structured data. fn body_for(rec: &Record) -> Result { let mut payload = RecordPayload { rtype: rec.rtype().to_string(), name: rec.name.bare().to_string(), ttl: rec.ttl, proxied: false, content: None, priority: None, data: None, }; match &rec.data { RData::Mx { preference, exchange, } => { payload.content = Some(exchange.bare().to_string()); payload.priority = Some(*preference); } RData::Srv { priority, weight, port, target, } => { // Owner "_sip._tcp.example.com." supplies service/proto/zone. let labels: Vec<&str> = rec.name.bare().splitn(3, '.').collect(); let [service, proto, zone] = labels.as_slice() else { bail!( "{}: SRV owner must have the form _service._proto.name", rec.name ); }; payload.data = Some(RecordData::Srv { service: service.to_string(), proto: proto.to_string(), name: zone.to_string(), priority: *priority, weight: *weight, port: *port, target: target.bare().to_string(), }); } RData::Caa { flags, tag, value } => { payload.data = Some(RecordData::Caa { flags: *flags, tag: tag.clone(), value: value.clone(), }); } RData::Cname(n) | RData::Ns(n) | RData::Ptr(n) => { payload.content = Some(n.bare().to_string()); } RData::A(a) => payload.content = Some(a.to_string()), RData::Aaaa(a) => payload.content = Some(a.to_string()), RData::Txt(s) => payload.content = Some(s.clone()), } Ok(payload) } #[derive(Serialize)] struct RecordPayload { #[serde(rename = "type")] rtype: String, name: String, ttl: u32, proxied: bool, #[serde(skip_serializing_if = "Option::is_none")] content: Option, #[serde(skip_serializing_if = "Option::is_none")] priority: Option, #[serde(skip_serializing_if = "Option::is_none")] data: Option, } #[derive(Serialize)] #[serde(untagged)] enum RecordData { Srv { service: String, proto: String, name: String, priority: u16, weight: u16, port: u16, target: String, }, Caa { flags: u8, tag: String, value: String, }, }