use anyhow::{Context, Result, bail}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use ureq::http::Method; use crate::api::Api; use crate::dns::{ForeignRecord, Name, RData, Record, SUPPORTED_TYPES}; use crate::provider::{Listed, Provider}; use crate::reconcile::Action; const BASE: &str = "https://www.wixapis.com/domains/v1"; pub struct Client { api: Api, } /// Wix holds one record set per name and type, with every value in it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct RecordSet { #[serde(rename = "type")] rtype: String, host_name: String, #[serde(default)] ttl: u32, #[serde(default)] values: Vec, } impl RecordSet { fn holds(&self, r: &Record) -> bool { self.rtype == r.rtype() && Name::new(&self.host_name) == r.name } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ZoneResponse { dns_zone: DnsZone, } #[derive(Deserialize)] struct DnsZone { #[serde(default)] records: Vec, } #[derive(Debug, PartialEq, Serialize)] struct Update { deletions: Vec, additions: Vec, } #[derive(Deserialize)] struct ApiError { message: String, } impl Client { pub fn new(key: String, account_id: String) -> Self { Client { api: Api::new( BASE, vec![("Authorization", key), ("wix-account-id", account_id)], ), } } fn zone(&self, zone: &Name) -> Result> { let path = format!("/dns-zones/{}", zone.bare()); let resp: ZoneResponse = self.call(Method::GET, &path, None::<&()>)?; Ok(resp.dns_zone.records) } fn call( &self, method: Method, path: &str, body: Option<&impl Serialize>, ) -> Result { let (status, text) = self.api.call(method.clone(), path, body)?; if !(200..300).contains(&status) { let detail = serde_json::from_str::(&text).map_or(text, |e| e.message); bail!("{method} {path}: HTTP {status}: {detail}"); } serde_json::from_str(&text).with_context(|| format!("{method} {path}: unexpected response")) } } impl Provider for Client { fn list_zones(&self) -> Result> { bail!("Wix has no API for listing domains; name the zones explicitly") } fn records(&self, zone: &Name) -> Result> { let mut listed = Vec::new(); for set in self.zone(zone)? { let managed = SUPPORTED_TYPES.contains(&set.rtype.as_str()); for value in &set.values { listed.push(if managed { Listed::Managed( Record::parse(&set.host_name, &set.rtype, set.ttl, value) .with_context(|| format!("{} {}", set.host_name, set.rtype))?, ) } else { Listed::Foreign(ForeignRecord { name: set.host_name.clone(), rtype: set.rtype.clone(), ttl: set.ttl, content: value.clone(), }) }); } } Ok(listed) } fn apply(&self, zone: &Name, actions: &[Action]) -> Result<()> { let path = format!("/dns-zones/{}", zone.bare()); let update = update(&self.zone(zone)?, actions); self.call::(Method::PATCH, &path, Some(&update))?; Ok(()) } } /// Fold record-level actions into one update that replaces every record set /// they touch: Wix can't edit a set in place, only delete it and re-add it /// whole in the same call. Values Wix already holds are kept verbatim, so /// each deletion matches the stored set exactly. fn update(live: &[RecordSet], actions: &[Action]) -> Update { let (mut deletions, mut additions) = (Vec::new(), Vec::::new()); for action in actions { let (Action::Create(r) | Action::Update { to: r, .. } | Action::Delete(r)) = action; let i = match additions.iter().position(|s| s.holds(r)) { Some(i) => i, None => { additions.push(match live.iter().find(|s| s.holds(r)) { Some(s) => { deletions.push(s.clone()); s.clone() } None => RecordSet { rtype: r.rtype().to_string(), host_name: r.name.bare().to_string(), ttl: r.ttl, values: Vec::new(), }, }); additions.len() - 1 } }; let set = &mut additions[i]; match action { Action::Create(r) => { set.values.push(value(&r.data)); set.ttl = r.ttl; } Action::Update { to, .. } => set.ttl = to.ttl, Action::Delete(r) => set .values .retain(|v| RData::parse(r.rtype(), v).ok().as_ref() != Some(&r.data)), } } additions.retain(|s| !s.values.is_empty()); Update { deletions, additions, } } /// Wix takes presentation-format values without trailing dots or TXT quoting. fn value(data: &RData) -> String { match data { RData::Cname(n) | RData::Ns(n) | RData::Ptr(n) => n.bare().to_string(), RData::Mx { preference, exchange, } => format!("{preference} {}", exchange.bare()), RData::Srv { priority, weight, port, target, } => format!("{priority} {weight} {port} {}", target.bare()), _ => data.to_string(), } } #[cfg(test)] mod tests { use super::*; fn set(rtype: &str, host_name: &str, ttl: u32, values: &[&str]) -> RecordSet { RecordSet { rtype: rtype.to_string(), host_name: host_name.to_string(), ttl, values: values.iter().map(|v| v.to_string()).collect(), } } fn rec(name: &str, rtype: &str, ttl: u32, rdata: &str) -> Record { Record::parse(name, rtype, ttl, rdata).unwrap() } #[test] fn adding_a_value_replaces_only_its_set() { let live = [ set("A", "example.com", 3600, &["192.0.2.1"]), set("TXT", "example.com", 3600, &["hello"]), ]; let actions = [Action::Create(rec("example.com", "A", 3600, "192.0.2.2"))]; assert_eq!( update(&live, &actions), Update { deletions: vec![live[0].clone()], additions: vec![set("A", "example.com", 3600, &["192.0.2.1", "192.0.2.2"])], } ); } #[test] fn removing_one_value_keeps_the_others_verbatim() { let live = [set( "MX", "example.com", 3600, &["10 mx1.example.net", "20 mx2.example.net"], )]; let actions = [Action::Delete(rec( "example.com", "MX", 3600, "20 mx2.example.net.", ))]; assert_eq!( update(&live, &actions).additions, [set("MX", "example.com", 3600, &["10 mx1.example.net"])] ); } #[test] fn removing_the_last_value_deletes_the_set() { let live = [set("TXT", "example.com", 300, &["v=spf1 -all"])]; let actions = [Action::Delete(rec( "example.com", "TXT", 300, "\"v=spf1 -all\"", ))]; assert_eq!( update(&live, &actions), Update { deletions: vec![live[0].clone()], additions: vec![], } ); } #[test] fn ttl_updates_apply_to_the_set() { let live = [set( "A", "www.example.com", 3600, &["192.0.2.1", "192.0.2.2"], )]; let actions = [ Action::Update { from_ttl: 3600, to: rec("www.example.com", "A", 300, "192.0.2.1"), }, Action::Update { from_ttl: 3600, to: rec("www.example.com", "A", 300, "192.0.2.2"), }, ]; assert_eq!( update(&live, &actions).additions, [set( "A", "www.example.com", 300, &["192.0.2.1", "192.0.2.2"] )] ); } #[test] fn a_set_can_be_replaced_by_one_of_another_type() { let live = [set("A", "x.example.com", 3600, &["192.0.2.1"])]; let actions = [ Action::Create(rec("x.example.com", "CNAME", 3600, "target.example.net.")), Action::Delete(rec("x.example.com", "A", 3600, "192.0.2.1")), ]; assert_eq!( update(&live, &actions), Update { deletions: vec![live[0].clone()], additions: vec![set("CNAME", "x.example.com", 3600, &["target.example.net"])], } ); } }