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
9.4 KiB275 linesraw
1use std::path::Path;
2
3use anyhow::{Context, Result, anyhow, bail};
4use domain::rdata::ZoneRecordData;
5use domain::zonefile::inplace::{Entry, Zonefile};
6
7use crate::dns::{ForeignRecord, Name, RData, Record, Zone};
8
9/// Load a zone file, returning the zone name and its records in canonical form.
10///
11/// The zone name comes from `$ORIGIN` if the file sets one, otherwise from
12/// the file name (`example.com.zone` -> `example.com`).
13pub fn load(path: &Path) -> Result<Zone> {
14    let text = std::fs::read_to_string(path).with_context(|| path.display().to_string())?;
15    let origin = find_origin(&text)
16        .or_else(|| {
17            path.file_stem()
18                .and_then(|s| s.to_str())
19                .map(str::to_string)
20        })
21        .ok_or_else(|| anyhow!("{}: cannot determine zone name", path.display()))?;
22    Ok(Zone {
23        name: Name::new(&origin),
24        records: parse(&text, &origin)?,
25    })
26}
27
28fn find_origin(text: &str) -> Option<String> {
29    for line in text.lines() {
30        let line = line.split(';').next().unwrap_or("").trim();
31        let mut words = line.split_whitespace();
32        if words
33            .next()
34            .is_some_and(|w| w.eq_ignore_ascii_case("$ORIGIN"))
35        {
36            return words.next().map(|n| n.trim_end_matches('.').to_string());
37        }
38    }
39    None
40}
41
42fn parse(text: &str, origin: &str) -> Result<Vec<Record>> {
43    // Prepending an absolute origin lets files without $ORIGIN use relative names.
44    let mut zone = Zonefile::from(format!("$ORIGIN {origin}.\n{text}").as_str());
45    let mut records = Vec::new();
46    loop {
47        match zone.next_entry() {
48            Ok(Some(Entry::Record(rec))) => {
49                let rtype = match rec.data() {
50                    ZoneRecordData::A(_) => "A",
51                    ZoneRecordData::Aaaa(_) => "AAAA",
52                    ZoneRecordData::Caa(_) => "CAA",
53                    ZoneRecordData::Cname(_) => "CNAME",
54                    ZoneRecordData::Mx(_) => "MX",
55                    ZoneRecordData::Ns(_) => "NS",
56                    ZoneRecordData::Ptr(_) => "PTR",
57                    ZoneRecordData::Srv(_) => "SRV",
58                    ZoneRecordData::Txt(_) => "TXT",
59                    // The provider owns the SOA; nothing for us to sync.
60                    ZoneRecordData::Soa(_) => continue,
61                    _ => {
62                        bail!(
63                            "{}: {} records are not supported",
64                            rec.owner(),
65                            rec.rtype()
66                        );
67                    }
68                };
69                records.push(
70                    Record::parse(
71                        &rec.owner().to_string(),
72                        rtype,
73                        rec.ttl().as_secs(),
74                        &rec.data().to_string(),
75                    )
76                    .with_context(|| rec.owner().to_string())?,
77                );
78            }
79            Ok(Some(Entry::Include { .. })) => {
80                bail!("$INCLUDE is not supported; keep each zone in a single file");
81            }
82            Ok(None) => return Ok(records),
83            Err(e) => bail!("zone file error: {}", e),
84        }
85    }
86}
87
88/// Render records as an RFC 1035 master file for `zone`.
89/// `foreign` records (types we don't manage) are included as comments so
90/// they're visible without breaking a later parse.
91pub fn render(zone: &Zone, foreign: &[ForeignRecord]) -> String {
92    let apex = &zone.name;
93    let suffix = format!(".{apex}");
94    let relative = |name: &str| -> String {
95        if name == apex.as_str() {
96            "@".into()
97        } else {
98            name.strip_suffix(&suffix).unwrap_or(name).to_string()
99        }
100    };
101    let mut sorted: Vec<&Record> = zone.records.iter().collect();
102    sorted.sort_by(|a, b| {
103        name_key(a.name.as_str())
104            .cmp(&name_key(b.name.as_str()))
105            .then_with(|| a.rtype().cmp(b.rtype()))
106    });
107
108    let width = sorted
109        .iter()
110        .map(|r| relative(r.name.as_str()).len())
111        .max()
112        .unwrap_or(1);
113    let mut out = format!("$ORIGIN {apex}\n$TTL 3600\n\n");
114    for r in &sorted {
115        out += &format!(
116            "{:<width$} {:>5} IN {:<5} {}\n",
117            relative(r.name.as_str()),
118            r.ttl,
119            r.rtype(),
120            render_rdata(&r.data)
121        );
122    }
123    if !foreign.is_empty() {
124        let mut foreign_sorted: Vec<(Name, &ForeignRecord)> =
125            foreign.iter().map(|f| (Name::new(&f.name), f)).collect();
126        foreign_sorted.sort_by(|a, b| name_key(a.0.as_str()).cmp(&name_key(b.0.as_str())));
127        out += "\n; present at the provider, but of an unmanaged type:\n";
128        for (name, r) in foreign_sorted {
129            out += &format!(
130                "; {:<width$} {:>5} IN {:<5} {}\n",
131                relative(name.as_str()),
132                r.ttl,
133                r.rtype,
134                r.content
135            );
136        }
137    }
138    out
139}
140
141fn render_rdata(data: &RData) -> String {
142    match data {
143        RData::Txt(s) => quote_txt(s),
144        RData::Caa { flags, tag, value } => format!("{flags} {tag} \"{value}\""),
145        _ => data.to_string(),
146    }
147}
148
149/// Canonical DNS name order: compare labels from the rightmost, so records
150/// cluster by subdomain (`_mytxt.a` sorts before `_aaa.b`, and the apex —
151/// being a suffix of every other name — naturally comes first).
152fn name_key(name: &str) -> Vec<&str> {
153    name.trim_end_matches('.').split('.').rev().collect()
154}
155
156/// A character-string is at most 255 bytes, so long values (2048-bit DKIM
157/// keys are the usual culprit) must be split into quoted chunks.
158fn quote_txt(s: &str) -> String {
159    let mut chunks = Vec::new();
160    let mut rest = s;
161    while rest.len() > 255 {
162        let mut cut = 255;
163        while !rest.is_char_boundary(cut) {
164            cut -= 1;
165        }
166        // Don't orphan the backslash of an escape sequence.
167        if rest.as_bytes()[cut - 1] == b'\\' {
168            cut -= 1;
169        }
170        chunks.push(&rest[..cut]);
171        rest = &rest[cut..];
172    }
173    chunks.push(rest);
174    chunks
175        .iter()
176        .map(|c| format!("\"{c}\""))
177        .collect::<Vec<_>>()
178        .join(" ")
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    const ZONE: &str = "\
186$TTL 3600
187@   IN  SOA ns1.cloudflare.com. dns.example.com. (
188        2026072201 ; serial
189        7200 3600 86400 3600 )
190    IN  NS  ns1.cloudflare.com.
191    IN  A   203.0.113.10
192    IN  MX  10 mail
193www         IN  CNAME   @
194api 60      IN  A       203.0.113.20
195@           IN  TXT     \"v=spf1 mx -all\"
196_sip._tcp   IN  SRV     0 5 5060 sip.example.com.
197@           IN  CAA     0 issue \"letsencrypt.org\"
198";
199
200    #[test]
201    fn parses_a_realistic_zone() {
202        let records = parse(ZONE, "example.com").unwrap();
203        let has = |name: &str, rtype: &str, rdata: &str| {
204            records.iter().any(|r| {
205                r.name.as_str() == name && r.rtype() == rtype && r.data.to_string() == rdata
206            })
207        };
208        assert!(has("www.example.com.", "CNAME", "example.com."));
209        assert!(has("example.com.", "MX", "10 mail.example.com."));
210        assert!(has("example.com.", "TXT", "v=spf1 mx -all"));
211        assert!(has(
212            "_sip._tcp.example.com.",
213            "SRV",
214            "0 5 5060 sip.example.com."
215        ));
216        assert!(has("example.com.", "CAA", "0 issue letsencrypt.org"));
217        assert!(!records.iter().any(|r| r.rtype() == "SOA"));
218        assert!(records.iter().all(|r| r.ttl == 3600 || r.ttl == 60));
219        let api = records
220            .iter()
221            .find(|r| r.name.as_str() == "api.example.com.")
222            .unwrap();
223        assert_eq!(api.ttl, 60);
224    }
225
226    #[test]
227    fn render_then_parse_is_the_identity() {
228        let zone = Zone {
229            name: Name::new("example.com"),
230            records: parse(ZONE, "example.com").unwrap(),
231        };
232        let rendered = render(&zone, &[]);
233        let mut reparsed = parse(&rendered, "example.com").unwrap();
234        let by_key = |a: &Record, b: &Record| a.key().cmp(&b.key());
235        let mut records = zone.records.clone();
236        records.sort_by(by_key);
237        reparsed.sort_by(by_key);
238        assert_eq!(records, reparsed);
239    }
240
241    #[test]
242    fn render_sorts_by_reversed_labels() {
243        let records = vec![
244            Record::parse("www.example.com.", "A", 3600, "203.0.113.2").unwrap(),
245            Record::parse("_aaa.b.example.com.", "TXT", 3600, "1").unwrap(),
246            Record::parse("_mytxt.a.example.com.", "TXT", 3600, "2").unwrap(),
247            Record::parse("b.example.com.", "A", 3600, "203.0.113.3").unwrap(),
248            Record::parse("example.com.", "A", 3600, "203.0.113.1").unwrap(),
249        ];
250        let zone = Zone {
251            name: Name::new("example.com"),
252            records,
253        };
254        let rendered = render(&zone, &[]);
255        let names: Vec<&str> = rendered
256            .lines()
257            .filter(|l| !l.starts_with('$') && !l.is_empty())
258            .map(|l| l.split_whitespace().next().unwrap())
259            .collect();
260        assert_eq!(names, ["@", "_mytxt.a", "b", "_aaa.b", "www"]);
261    }
262
263    #[test]
264    fn long_txt_is_chunked_and_round_trips() {
265        let long = "x".repeat(600);
266        let records = vec![Record::parse("example.com.", "TXT", 300, &long).unwrap()];
267        let zone = Zone {
268            name: Name::new("example.com"),
269            records: records.clone(),
270        };
271        let rendered = render(&zone, &[]);
272        let reparsed = parse(&rendered, "example.com").unwrap();
273        assert_eq!(records, reparsed);
274    }
275}