char/dns-ez

cloudflare dns management via plaintext zonefiles

git clone https://git.t4t.associates/char/dns-ez

Charlotte Sommove to anyhow for error handling736d1a4

main
11.7 KiB388 linesraw
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 {
12    agent: ureq::Agent,
13    token: String,
14}
15
16#[derive(Deserialize)]
17struct Envelope {
18    success: bool,
19    #[serde(default)]
20    errors: Vec<ApiError>,
21    result: Option<serde_json::Value>,
22    result_info: Option<ResultInfo>,
23}
24
25#[derive(Deserialize)]
26struct ApiError {
27    code: i64,
28    message: String,
29}
30
31#[derive(Deserialize)]
32struct ResultInfo {
33    total_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 {
39    Managed(LiveRecord),
40    Foreign(ForeignRecord),
41}
42
43/// A managed record as it exists at the provider, with its provider ID.
44#[derive(Debug, Clone)]
45pub struct LiveRecord {
46    pub id: String,
47    pub 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>) {
54    let (mut managed, mut foreign) = (Vec::new(), Vec::new());
55    for l in listed {
56        match l {
57            Listed::Managed(live) if live.record.is_apex_ns(apex) => {
58                foreign.push(ForeignRecord {
59                    name: live.record.name.to_string(),
60                    rtype: live.record.rtype().to_string(),
61                    ttl: live.record.ttl,
62                    content: live.record.data.to_string(),
63                });
64            }
65            Listed::Managed(live) => managed.push(live),
66            Listed::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 {
75    id: String,
76    #[serde(rename = "type")]
77    rtype: String,
78    name: String,
79    #[serde(default)]
80    content: String,
81    #[serde(default)]
82    ttl: u32,
83    #[serde(default)]
84    priority: Option<u32>,
85    #[serde(default)]
86    data: Option<ApiRecordData>,
87}
88
89#[derive(Deserialize)]
90struct ApiRecordData {
91    priority: u64,
92    weight: u64,
93    port: u64,
94    target: String,
95    flags: u64,
96    tag: String,
97    value: String,
98}
99
100impl ApiRecord {
101    fn listed(self) -> Result<Listed> {
102        if !SUPPORTED_TYPES.contains(&self.rtype.as_str()) {
103            return Ok(Listed::Foreign(ForeignRecord {
104                name: self.name,
105                rtype: self.rtype,
106                ttl: self.ttl,
107                content: self.content,
108            }));
109        }
110        let rdata = self.presentation_rdata()?;
111        let record = Record::parse(&self.name, &self.rtype, self.ttl, &rdata)
112            .with_context(|| format!("{} {}", self.name, self.rtype))?;
113        Ok(Listed::Managed(LiveRecord {
114            id: 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.
122    fn presentation_rdata(&self) -> Result<String> {
123        Ok(match self.rtype.as_str() {
124            "MX" => format!("{} {}", self.priority.unwrap_or(0), self.content),
125            "SRV" if self.content.is_empty() => {
126                let d = self
127                    .data
128                    .as_ref()
129                    .ok_or_else(|| anyhow!("{}: SRV record has no data", self.name))?;
130                format!("{} {} {} {}", d.priority, d.weight, d.port, d.target)
131            }
132            "CAA" if self.content.is_empty() => {
133                let d = self
134                    .data
135                    .as_ref()
136                    .ok_or_else(|| anyhow!("{}: CAA record has no data", self.name))?;
137                format!("{} {} \"{}\"", d.flags, d.tag, d.value)
138            }
139            _ => self.content.clone(),
140        })
141    }
142}
143
144impl Client {
145    pub fn new(token: String) -> Self {
146        let config = ureq::Agent::config_builder()
147            .timeout_global(Some(Duration::from_secs(30)))
148            .http_status_as_error(false)
149            .build();
150        Client {
151            agent: ureq::Agent::new_with_config(config),
152            token,
153        }
154    }
155
156    pub fn zone_id(&self, name: &str) -> Result<String> {
157        let env = self.call("GET", &format!("/zones?name={name}"), None::<&()>)?;
158        let zones: Vec<ZoneInfo> = serde_json::from_value(env.result.unwrap_or_default())
159            .context("unexpected zones response")?;
160        let Some(zone) = zones.first() else {
161            bail!(
162                "zone {} not found; run `dns-ez zones` to list all zones on this account",
163                name
164            );
165        };
166        Ok(zone.id.clone())
167    }
168
169    pub fn list_zones(&self) -> Result<Vec<ZoneInfo>> {
170        self.list::<ZoneInfo>("/zones?per_page=50")
171    }
172
173    pub fn list_records(&self, zone_id: &str) -> Result<Vec<Listed>> {
174        let 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.
179    fn list<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<Vec<T>> {
180        let mut items = Vec::new();
181        let mut page = 1;
182        loop {
183            let env = self.call("GET", &format!("{path}&page={page}"), None::<&()>)?;
184            let 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);
187            if page >= env.result_info.map(|i| i.total_pages).unwrap_or(1) {
188                return Ok(items);
189            }
190            page += 1;
191        }
192    }
193
194    pub fn create(&self, zone_id: &str, rec: &Record) -> Result<()> {
195        self.call(
196            "POST",
197            &format!("/zones/{zone_id}/dns_records"),
198            Some(&body_for(rec)?),
199        )?;
200        Ok(())
201    }
202
203    pub fn update(&self, zone_id: &str, id: &str, rec: &Record) -> Result<()> {
204        self.call(
205            "PUT",
206            &format!("/zones/{zone_id}/dns_records/{id}"),
207            Some(&body_for(rec)?),
208        )?;
209        Ok(())
210    }
211
212    pub fn delete(&self, zone_id: &str, id: &str) -> Result<()> {
213        self.call(
214            "DELETE",
215            &format!("/zones/{zone_id}/dns_records/{id}"),
216            None::<&()>,
217        )?;
218        Ok(())
219    }
220
221    fn call<T: Serialize + ?Sized>(
222        &self,
223        method: &str,
224        path: &str,
225        body: Option<&T>,
226    ) -> Result<Envelope> {
227        let url = format!("{BASE}{path}");
228        let mut last_err = String::new();
229        for attempt in 0..ATTEMPTS {
230            if attempt > 0 {
231                std::thread::sleep(Duration::from_millis(500 << (attempt - 1)));
232            }
233            let result = match self.send(method, &url, body) {
234                Ok(resp) => resp,
235                Err(e) => {
236                    last_err = format!("{method} {path}: {e}");
237                    continue;
238                }
239            };
240            let status = result.status().as_u16();
241            if (status == 429 || status >= 500) && attempt + 1 < ATTEMPTS {
242                last_err = format!("{method} {path}: HTTP {status}");
243                continue;
244            }
245            let mut result = result;
246            let env: Envelope = result
247                .body_mut()
248                .read_json()
249                .with_context(|| format!("{method} {path}: unreadable response"))?;
250            if env.success {
251                return Ok(env);
252            }
253            let detail = env
254                .errors
255                .iter()
256                .map(|e| format!("{} ({})", e.message, e.code))
257                .collect::<Vec<_>>()
258                .join(", ");
259            bail!("{} {}: {}", method, path, detail);
260        }
261        Err(anyhow!(last_err))
262    }
263
264    fn send<T: Serialize + ?Sized>(
265        &self,
266        method: &str,
267        url: &str,
268        body: Option<&T>,
269    ) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
270        let auth = format!("Bearer {}", self.token);
271        match 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 {
291    pub id: String,
292    pub 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> {
298    let mut payload = RecordPayload {
299        rtype: rec.rtype().to_string(),
300        name: rec.name.bare().to_string(),
301        ttl: rec.ttl,
302        proxied: false,
303        content: None,
304        priority: None,
305        data: None,
306    };
307    match &rec.data {
308        RData::Mx {
309            preference,
310            exchange,
311        } => {
312            payload.content = Some(exchange.bare().to_string());
313            payload.priority = Some(*preference);
314        }
315        RData::Srv {
316            priority,
317            weight,
318            port,
319            target,
320        } => {
321            // Owner "_sip._tcp.example.com." supplies service/proto/zone.
322            let labels: Vec<&str> = rec.name.bare().splitn(3, '.').collect();
323            let [service, proto, zone] = labels.as_slice() else {
324                bail!(
325                    "{}: SRV owner must have the form _service._proto.name",
326                    rec.name
327                );
328            };
329            payload.data = Some(RecordData::Srv {
330                service: service.to_string(),
331                proto: proto.to_string(),
332                name: zone.to_string(),
333                priority: *priority,
334                weight: *weight,
335                port: *port,
336                target: target.bare().to_string(),
337            });
338        }
339        RData::Caa { flags, tag, value } => {
340            payload.data = Some(RecordData::Caa {
341                flags: *flags,
342                tag: tag.clone(),
343                value: value.clone(),
344            });
345        }
346        RData::Cname(n) | RData::Ns(n) | RData::Ptr(n) => {
347            payload.content = Some(n.bare().to_string());
348        }
349        RData::A(a) => payload.content = Some(a.to_string()),
350        RData::Aaaa(a) => payload.content = Some(a.to_string()),
351        RData::Txt(s) => payload.content = Some(s.clone()),
352    }
353    Ok(payload)
354}
355
356#[derive(Serialize)]
357struct RecordPayload {
358    #[serde(rename = "type")]
359    rtype: String,
360    name: String,
361    ttl: u32,
362    proxied: bool,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    content: Option<String>,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    priority: Option<u16>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    data: Option<RecordData>,
369}
370
371#[derive(Serialize)]
372#[serde(untagged)]
373enum RecordData {
374    Srv {
375        service: String,
376        proto: String,
377        name: String,
378        priority: u16,
379        weight: u16,
380        port: u16,
381        target: String,
382    },
383    Caa {
384        flags: u8,
385        tag: String,
386        value: String,
387    },
388}