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
10.9 KiB364 linesraw
1use std::fmt;
2use std::net::{Ipv4Addr, Ipv6Addr};
3
4use anyhow::{anyhow, bail, Result};
5
6/// An absolute DNS name in canonical form: lowercase, with trailing dot.
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct Name(String);
9
10impl Name {
11    pub fn new(name: &str) -> Self {
12        // In rdata a free-standing "@" survives parsing as a literal first
13        // label; per RFC 1035 it denotes the origin.
14        let name = name.strip_prefix("@.").unwrap_or(name);
15        Name(format!("{}.", name.trim_end_matches('.').to_lowercase()))
16    }
17
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21
22    /// The name without its trailing dot, as provider APIs expect.
23    pub fn bare(&self) -> &str {
24        self.0.trim_end_matches('.')
25    }
26}
27
28impl fmt::Display for Name {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(&self.0)
31    }
32}
33
34/// Record types this tool understands; anything else at the provider is
35/// left alone.
36pub const SUPPORTED_TYPES: [&str; 9] =
37    ["A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SRV", "TXT"];
38
39/// Canonical rdata. Parsing from presentation format normalizes, so that a
40/// record read from a zone file and the same record fetched from a provider
41/// compare equal.
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub enum RData {
44    A(Ipv4Addr),
45    Aaaa(Ipv6Addr),
46    Caa {
47        flags: u8,
48        tag: String,
49        value: String,
50    },
51    Cname(Name),
52    Mx {
53        preference: u16,
54        exchange: Name,
55    },
56    Ns(Name),
57    Ptr(Name),
58    Srv {
59        priority: u16,
60        weight: u16,
61        port: u16,
62        target: Name,
63    },
64    Txt(String),
65}
66
67impl RData {
68    pub fn rtype(&self) -> &'static str {
69        match self {
70            RData::A(_) => "A",
71            RData::Aaaa(_) => "AAAA",
72            RData::Caa { .. } => "CAA",
73            RData::Cname(_) => "CNAME",
74            RData::Mx { .. } => "MX",
75            RData::Ns(_) => "NS",
76            RData::Ptr(_) => "PTR",
77            RData::Srv { .. } => "SRV",
78            RData::Txt(_) => "TXT",
79        }
80    }
81
82    pub fn parse(rtype: &str, rdata: &str) -> Result<RData> {
83        let s: String = rdata.split_whitespace().collect::<Vec<_>>().join(" ");
84        match rtype {
85            "A" => s
86                .parse()
87                .map(RData::A)
88                .map_err(|_| anyhow!("bad A rdata: {:?}", rdata)),
89            "AAAA" => s
90                .parse()
91                .map(RData::Aaaa)
92                .map_err(|_| anyhow!("bad AAAA rdata: {:?}", rdata)),
93            "CNAME" => Ok(RData::Cname(Name::new(&s))),
94            "NS" => Ok(RData::Ns(Name::new(&s))),
95            "PTR" => Ok(RData::Ptr(Name::new(&s))),
96            "MX" => Self::parse_mx(&s),
97            "SRV" => Self::parse_srv(&s),
98            "CAA" => Self::parse_caa(&s),
99            "TXT" => Ok(RData::Txt(normalize_txt(&s))),
100            _ => Err(anyhow!("{} records are not supported", rtype)),
101        }
102    }
103
104    fn parse_mx(s: &str) -> Result<RData> {
105        let bad = || anyhow!("bad MX rdata: {:?}", s);
106        let (pref, host) = s.split_once(' ').ok_or_else(bad)?;
107        Ok(RData::Mx {
108            preference: pref.parse().map_err(|_| bad())?,
109            exchange: Name::new(host),
110        })
111    }
112
113    fn parse_srv(s: &str) -> Result<RData> {
114        let bad = || anyhow!("bad SRV rdata: {:?}", s);
115        let fields: Vec<&str> = s.split_whitespace().collect();
116        let [prio, weight, port, target] = fields.as_slice() else {
117            return Err(bad());
118        };
119        Ok(RData::Srv {
120            priority: prio.parse().map_err(|_| bad())?,
121            weight: weight.parse().map_err(|_| bad())?,
122            port: port.parse().map_err(|_| bad())?,
123            target: Name::new(target),
124        })
125    }
126
127    fn parse_caa(s: &str) -> Result<RData> {
128        let bad = || anyhow!("bad CAA rdata: {:?}", s);
129        let fields: Vec<&str> = s.splitn(3, ' ').collect();
130        let [flags, tag, value] = fields.as_slice() else {
131            return Err(bad());
132        };
133        Ok(RData::Caa {
134            flags: flags.parse().map_err(|_| bad())?,
135            tag: tag.to_string(),
136            value: value.replace('"', ""),
137        })
138    }
139}
140
141impl fmt::Display for RData {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            RData::A(a) => write!(f, "{a}"),
145            RData::Aaaa(a) => write!(f, "{a}"),
146            RData::Caa { flags, tag, value } => write!(f, "{flags} {tag} {value}"),
147            RData::Cname(n) | RData::Ns(n) | RData::Ptr(n) => write!(f, "{n}"),
148            RData::Mx {
149                preference,
150                exchange,
151            } => write!(f, "{preference} {exchange}"),
152            RData::Srv {
153                priority,
154                weight,
155                port,
156                target,
157            } => {
158                write!(f, "{priority} {weight} {port} {target}")
159            }
160            RData::Txt(s) => f.write_str(s),
161        }
162    }
163}
164
165/// Zone files and APIs chunk TXT values over 255 bytes into adjacent quoted
166/// strings; compare the concatenated payload.
167fn normalize_txt(s: &str) -> String {
168    if !s.contains('"') {
169        return s.to_string();
170    }
171    let mut out = String::new();
172    let mut in_quote = false;
173    let mut chars = s.chars();
174    while let Some(c) = chars.next() {
175        match c {
176            '"' => in_quote = !in_quote,
177            // Keep escapes verbatim so both sides stay comparable.
178            '\\' => {
179                out.push(c);
180                out.extend(chars.next());
181            }
182            c if in_quote => out.push(c),
183            _ => {}
184        }
185    }
186    out
187}
188
189/// A DNS record in canonical form.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Record {
192    pub name: Name,
193    pub ttl: u32,
194    pub data: RData,
195}
196
197impl Record {
198    pub fn parse(name: &str, rtype: &str, ttl: u32, rdata: &str) -> Result<Record> {
199        Ok(Record {
200            name: Name::new(name),
201            ttl,
202            data: RData::parse(rtype, rdata)?,
203        })
204    }
205
206    pub fn rtype(&self) -> &'static str {
207        self.data.rtype()
208    }
209
210    /// Two records with equal keys are the same record for sync purposes;
211    /// only the TTL may legitimately differ.
212    pub fn key(&self) -> (&Name, &'static str, &RData) {
213        (&self.name, self.data.rtype(), &self.data)
214    }
215
216    /// Apex NS records belong to the provider's nameservers and are
217    /// untouchable.
218    pub fn is_apex_ns(&self, apex: &Name) -> bool {
219        self.rtype() == "NS" && self.name == *apex
220    }
221}
222
223impl fmt::Display for Record {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        write!(
226            f,
227            "{} {} {} {}",
228            self.name,
229            self.ttl,
230            self.rtype(),
231            self.data
232        )
233    }
234}
235
236/// A record at the provider of a type we don't manage; kept only for
237/// display.
238#[derive(Debug, Clone)]
239pub struct ForeignRecord {
240    pub name: String,
241    pub rtype: String,
242    pub ttl: u32,
243    pub content: String,
244}
245
246/// A zone: its name (the apex) and the records it contains.
247#[derive(Debug, Clone)]
248pub struct Zone {
249    pub name: Name,
250    pub records: Vec<Record>,
251}
252
253impl Zone {
254    /// Strip apex NS records, sort into canonical order, and remove exact
255    /// duplicates. An RRset (same name and type) must share one TTL.
256    pub fn normalize(&mut self) -> Result<()> {
257        self.records.retain(|r| !r.is_apex_ns(&self.name));
258        self.records.sort_by(|a, b| a.key().cmp(&b.key()));
259        self.records.dedup();
260        for pair in self.records.windows(2) {
261            let [a, b] = pair else { continue };
262            if a.name == b.name && a.rtype() == b.rtype() && a.ttl != b.ttl {
263                bail!(
264                    "{} {} records must share one TTL (found {} and {})",
265                    a.name,
266                    a.rtype(),
267                    a.ttl,
268                    b.ttl
269                );
270            }
271        }
272        Ok(())
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn apex_ns_is_detected() {
282        let apex = Name::new("example.com");
283        let apex_ns = Record::parse("example.com", "NS", 3600, "ns1.example.com").unwrap();
284        let non_apex_ns = Record::parse("sub.example.com", "NS", 3600, "ns1.example.com").unwrap();
285        assert!(apex_ns.is_apex_ns(&apex));
286        assert!(!non_apex_ns.is_apex_ns(&apex));
287    }
288
289    #[test]
290    fn normalize_strips_apex_ns_and_validates_ttls() {
291        let mut zone = Zone {
292            name: Name::new("example.com"),
293            records: vec![
294                Record::parse("example.com", "NS", 3600, "ns1.example.com").unwrap(),
295                Record::parse("www.example.com", "A", 300, "1.2.3.4").unwrap(),
296                Record::parse("www.example.com", "A", 300, "1.2.3.4").unwrap(), // dup
297            ],
298        };
299        zone.normalize().unwrap();
300        assert_eq!(zone.records.len(), 1);
301        assert!(
302            !zone
303                .records
304                .iter()
305                .any(|r| r.rtype() == "NS" && r.name == zone.name)
306        );
307    }
308
309    #[test]
310    fn normalize_rejects_mismatched_ttls_in_rrset() {
311        let mut zone = Zone {
312            name: Name::new("example.com"),
313            records: vec![
314                Record::parse("www.example.com", "A", 300, "1.2.3.4").unwrap(),
315                Record::parse("www.example.com", "A", 600, "1.2.3.4").unwrap(),
316            ],
317        };
318        assert!(zone.normalize().is_err());
319    }
320
321    #[test]
322    fn names_are_absolute_and_lowercase() {
323        assert_eq!(Name::new("WWW.Example.COM").as_str(), "www.example.com.");
324        assert_eq!(Name::new("www.example.com.").as_str(), "www.example.com.");
325    }
326
327    #[test]
328    fn at_label_denotes_origin() {
329        assert_eq!(Name::new("@.example.com.").as_str(), "example.com.");
330    }
331
332    #[test]
333    fn txt_quotes_are_stripped() {
334        let a = Record::parse("x", "TXT", 300, "\"v=spf1 -all\"").unwrap();
335        let b = Record::parse("x", "TXT", 300, "v=spf1 -all").unwrap();
336        assert_eq!(a, b);
337    }
338
339    #[test]
340    fn long_txt_chunks_compare_equal_to_single_string() {
341        let chunked = Record::parse("x", "TXT", 300, "\"aaa\" \"bbb\"").unwrap();
342        let single = Record::parse("x", "TXT", 300, "aaabbb").unwrap();
343        assert_eq!(chunked, single);
344    }
345
346    #[test]
347    fn ipv6_is_compressed() {
348        let a = Record::parse("x", "AAAA", 300, "2001:0DB8:0000:0000:0000:0000:0000:0001").unwrap();
349        assert_eq!(a.data.to_string(), "2001:db8::1");
350    }
351
352    #[test]
353    fn mx_target_is_normalized() {
354        let a = Record::parse("x", "MX", 300, "10 Mail.Example.com").unwrap();
355        assert_eq!(a.data.to_string(), "10 mail.example.com.");
356    }
357
358    #[test]
359    fn malformed_rdata_is_an_error() {
360        assert!(Record::parse("x", "MX", 300, "not-a-preference mail").is_err());
361        assert!(Record::parse("x", "A", 300, "999.1.1.1").is_err());
362        assert!(Record::parse("x", "SRV", 300, "0 5 5060").is_err());
363    }
364}