char/dns-ez

cloudflare dns management via plaintext zonefiles

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

Charlotte Somadd support for wix provider :(c6d896d

main
9.1 KiB304 linesraw
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 {
14    api: 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")]
22    rtype: String,
23    host_name: String,
24    #[serde(default)]
25    ttl: u32,
26    #[serde(default)]
27    values: Vec<String>,
28}
29
30impl RecordSet {
31    fn holds(&self, r: &Record) -> bool {
32        self.rtype == r.rtype() && Name::new(&self.host_name) == r.name
33    }
34}
35
36#[derive(Deserialize)]
37#[serde(rename_all = "camelCase")]
38struct ZoneResponse {
39    dns_zone: DnsZone,
40}
41
42#[derive(Deserialize)]
43struct DnsZone {
44    #[serde(default)]
45    records: Vec<RecordSet>,
46}
47
48#[derive(Debug, PartialEq, Serialize)]
49struct Update {
50    deletions: Vec<RecordSet>,
51    additions: Vec<RecordSet>,
52}
53
54#[derive(Deserialize)]
55struct ApiError {
56    message: String,
57}
58
59impl Client {
60    pub fn new(key: String, account_id: String) -> Self {
61        Client {
62            api: Api::new(
63                BASE,
64                vec![("Authorization", key), ("wix-account-id", account_id)],
65            ),
66        }
67    }
68
69    fn zone(&self, zone: &Name) -> Result<Vec<RecordSet>> {
70        let path = format!("/dns-zones/{}", zone.bare());
71        let resp: ZoneResponse = self.call(Method::GET, &path, None::<&()>)?;
72        Ok(resp.dns_zone.records)
73    }
74
75    fn call<R: DeserializeOwned>(
76        &self,
77        method: Method,
78        path: &str,
79        body: Option<&impl Serialize>,
80    ) -> Result<R> {
81        let (status, text) = self.api.call(method.clone(), path, body)?;
82        if !(200..300).contains(&status) {
83            let detail = serde_json::from_str::<ApiError>(&text).map_or(text, |e| e.message);
84            bail!("{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 {
91    fn list_zones(&self) -> Result<Vec<String>> {
92        bail!("Wix has no API for listing domains; name the zones explicitly")
93    }
94
95    fn records(&self, zone: &Name) -> Result<Vec<Listed>> {
96        let mut listed = Vec::new();
97        for set in self.zone(zone)? {
98            let managed = SUPPORTED_TYPES.contains(&set.rtype.as_str());
99            for value in &set.values {
100                listed.push(if managed {
101                    Listed::Managed(
102                        Record::parse(&set.host_name, &set.rtype, set.ttl, value)
103                            .with_context(|| format!("{} {}", set.host_name, set.rtype))?,
104                    )
105                } else {
106                    Listed::Foreign(ForeignRecord {
107                        name: set.host_name.clone(),
108                        rtype: set.rtype.clone(),
109                        ttl: set.ttl,
110                        content: value.clone(),
111                    })
112                });
113            }
114        }
115        Ok(listed)
116    }
117
118    fn apply(&self, zone: &Name, actions: &[Action]) -> Result<()> {
119        let path = format!("/dns-zones/{}", zone.bare());
120        let update = update(&self.zone(zone)?, actions);
121        self.call::<serde_json::Value>(Method::PATCH, &path, Some(&update))?;
122        Ok(())
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 {
131    let (mut deletions, mut additions) = (Vec::new(), Vec::<RecordSet>::new());
132    for action in actions {
133        let (Action::Create(r) | Action::Update { to: r, .. } | Action::Delete(r)) = action;
134        let i = match additions.iter().position(|s| s.holds(r)) {
135            Some(i) => i,
136            None => {
137                additions.push(match live.iter().find(|s| s.holds(r)) {
138                    Some(s) => {
139                        deletions.push(s.clone());
140                        s.clone()
141                    }
142                    None => RecordSet {
143                        rtype: r.rtype().to_string(),
144                        host_name: r.name.bare().to_string(),
145                        ttl: r.ttl,
146                        values: Vec::new(),
147                    },
148                });
149                additions.len() - 1
150            }
151        };
152        let set = &mut additions[i];
153        match action {
154            Action::Create(r) => {
155                set.values.push(value(&r.data));
156                set.ttl = r.ttl;
157            }
158            Action::Update { to, .. } => set.ttl = to.ttl,
159            Action::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());
165    Update {
166        deletions,
167        additions,
168    }
169}
170
171/// Wix takes presentation-format values without trailing dots or TXT quoting.
172fn value(data: &RData) -> String {
173    match data {
174        RData::Cname(n) | RData::Ns(n) | RData::Ptr(n) => n.bare().to_string(),
175        RData::Mx {
176            preference,
177            exchange,
178        } => format!("{preference} {}", exchange.bare()),
179        RData::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 {
191    use super::*;
192
193    fn set(rtype: &str, host_name: &str, ttl: u32, values: &[&str]) -> RecordSet {
194        RecordSet {
195            rtype: rtype.to_string(),
196            host_name: host_name.to_string(),
197            ttl,
198            values: values.iter().map(|v| v.to_string()).collect(),
199        }
200    }
201
202    fn rec(name: &str, rtype: &str, ttl: u32, rdata: &str) -> Record {
203        Record::parse(name, rtype, ttl, rdata).unwrap()
204    }
205
206    #[test]
207    fn adding_a_value_replaces_only_its_set() {
208        let live = [
209            set("A", "example.com", 3600, &["192.0.2.1"]),
210            set("TXT", "example.com", 3600, &["hello"]),
211        ];
212        let actions = [Action::Create(rec("example.com", "A", 3600, "192.0.2.2"))];
213        assert_eq!(
214            update(&live, &actions),
215            Update {
216                deletions: vec![live[0].clone()],
217                additions: vec![set("A", "example.com", 3600, &["192.0.2.1", "192.0.2.2"])],
218            }
219        );
220    }
221
222    #[test]
223    fn removing_one_value_keeps_the_others_verbatim() {
224        let live = [set(
225            "MX",
226            "example.com",
227            3600,
228            &["10 mx1.example.net", "20 mx2.example.net"],
229        )];
230        let actions = [Action::Delete(rec(
231            "example.com",
232            "MX",
233            3600,
234            "20 mx2.example.net.",
235        ))];
236        assert_eq!(
237            update(&live, &actions).additions,
238            [set("MX", "example.com", 3600, &["10 mx1.example.net"])]
239        );
240    }
241
242    #[test]
243    fn removing_the_last_value_deletes_the_set() {
244        let live = [set("TXT", "example.com", 300, &["v=spf1 -all"])];
245        let actions = [Action::Delete(rec(
246            "example.com",
247            "TXT",
248            300,
249            "\"v=spf1 -all\"",
250        ))];
251        assert_eq!(
252            update(&live, &actions),
253            Update {
254                deletions: vec![live[0].clone()],
255                additions: vec![],
256            }
257        );
258    }
259
260    #[test]
261    fn ttl_updates_apply_to_the_set() {
262        let live = [set(
263            "A",
264            "www.example.com",
265            3600,
266            &["192.0.2.1", "192.0.2.2"],
267        )];
268        let actions = [
269            Action::Update {
270                from_ttl: 3600,
271                to: rec("www.example.com", "A", 300, "192.0.2.1"),
272            },
273            Action::Update {
274                from_ttl: 3600,
275                to: rec("www.example.com", "A", 300, "192.0.2.2"),
276            },
277        ];
278        assert_eq!(
279            update(&live, &actions).additions,
280            [set(
281                "A",
282                "www.example.com",
283                300,
284                &["192.0.2.1", "192.0.2.2"]
285            )]
286        );
287    }
288
289    #[test]
290    fn a_set_can_be_replaced_by_one_of_another_type() {
291        let live = [set("A", "x.example.com", 3600, &["192.0.2.1"])];
292        let actions = [
293            Action::Create(rec("x.example.com", "CNAME", 3600, "target.example.net.")),
294            Action::Delete(rec("x.example.com", "A", 3600, "192.0.2.1")),
295        ];
296        assert_eq!(
297            update(&live, &actions),
298            Update {
299                deletions: vec![live[0].clone()],
300                additions: vec![set("CNAME", "x.example.com", 3600, &["target.example.net"])],
301            }
302        );
303    }
304}