Skip to main content

lab_ops/cmd/
dns_parser.rs

1//! Shared DNS parsing logic for zone files.
2
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7/// Matches the SOA record line to extract the zone name.
8pub static SOA: LazyLock<Regex> =
9    LazyLock::new(|| Regex::new(r"^\S+\s+\d+\s+IN\s+SOA\s+").unwrap());
10/// Matches a standard DNS resource record line.
11pub static ZONE: LazyLock<Regex> =
12    LazyLock::new(|| Regex::new(r"^(\S+)\s+(\d+)\s+IN\s+(\S+)\s+(.*)$").unwrap());
13/// Matches an inline `cf-proxied` comment annotation.
14pub static PROXIED: LazyLock<Regex> =
15    LazyLock::new(|| Regex::new(r"\s*;\s*cf_tags=cf-proxied:(true|false)\s*$").unwrap());
16/// Extracts quoted strings from TXT record data.
17pub static TXT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#""([^"]*)""#).unwrap());
18
19/// A parsed DNS resource record.
20#[derive(Debug, Clone)]
21pub struct DnsRecord {
22    /// Fully-qualified domain name (trailing dot).
23    pub name: String,
24    /// Time-to-live in seconds.
25    pub ttl: u32,
26    /// Record type (A, AAAA, CNAME, MX, TXT, SRV, TLSA, NS).
27    pub rtype: String,
28    /// Record data (the RDATA portion).
29    pub data: String,
30    /// Whether Cloudflare proxying is enabled, if annotated.
31    pub proxied: Option<bool>,
32}
33
34/// Parses BIND zone file content into a vector of [`DnsRecord`].
35///
36/// Skips empty lines, comments, and SOA records.
37pub fn parse_zone(content: &str) -> Vec<DnsRecord> {
38    let mut records = Vec::new();
39
40    for line in content.lines() {
41        let line = line.trim();
42        if line.is_empty() || line.starts_with(';') {
43            continue;
44        }
45
46        let caps = match ZONE.captures(line) {
47            Some(c) => c,
48            None => continue,
49        };
50
51        let name = caps[1].to_string();
52        let ttl: u32 = caps[2].parse().unwrap_or(1);
53        let rtype = caps[3].to_uppercase();
54        if rtype == "SOA" {
55            continue;
56        }
57
58        let raw_data = caps[4].to_string();
59
60        let (data, proxied) = split_data_and_proxied(&raw_data);
61
62        records.push(DnsRecord {
63            name,
64            ttl,
65            rtype,
66            data,
67            proxied,
68        });
69    }
70
71    records
72}
73
74/// Splits raw record data into its value and an optional Cloudflare proxied flag.
75pub fn split_data_and_proxied(raw: &str) -> (String, Option<bool>) {
76    let proxied = PROXIED.captures(raw).map(|c| c[1].to_lowercase() == "true");
77    let data = PROXIED.replace(raw, "").trim().to_string();
78
79    (data, proxied)
80}
81
82/// Strips the zone suffix from a fully-qualified domain name.
83///
84/// Returns `"@"` when the FQDN matches the zone (apex).
85pub fn strip_zone(fqdn: &str, zone: &str) -> String {
86    let fqdn = fqdn.trim_end_matches('.');
87    let zone = zone.trim_end_matches('.');
88
89    if fqdn == zone {
90        return "@".to_string();
91    }
92
93    let suffix = format!(".{zone}");
94    if fqdn.to_lowercase().ends_with(&suffix.to_lowercase()) {
95        let end = fqdn.len() - suffix.len();
96        return fqdn[..end].to_string();
97    }
98
99    fqdn.to_string()
100}
101
102/// Parses an SRV record name into (remaining record name, service, protocol).
103pub fn parse_srv_name(fqdn: &str, zone: &str) -> (String, String, String) {
104    let record_part = strip_zone(fqdn, zone);
105
106    if record_part == "@" {
107        return ("@".to_string(), "_unknown".to_string(), "_tcp".to_string());
108    }
109
110    let parts: Vec<&str> = record_part.split('.').collect();
111
112    let service = if !parts.is_empty() && parts[0].starts_with('_') {
113        parts[0][1..].to_string()
114    } else {
115        "_unknown".to_string()
116    };
117
118    let proto = if parts.len() >= 2 && parts[1].starts_with('_') {
119        parts[1][1..].to_string()
120    } else {
121        "_tcp".to_string()
122    };
123
124    let remaining = if parts.len() > 2 {
125        parts[2..].join(".")
126    } else {
127        "@".to_string()
128    };
129
130    (remaining, service, proto)
131}
132
133/// Parses a TLSA record name into (remaining record name, port, protocol).
134pub fn parse_tlsa_name(fqdn: &str, zone: &str) -> (String, u32, String) {
135    let record_part = strip_zone(fqdn, zone);
136
137    if record_part == "@" {
138        return ("@".to_string(), 0, "tcp".to_string());
139    }
140
141    let parts: Vec<&str> = record_part.split('.').collect();
142
143    let port: u32 = if !parts.is_empty() && parts[0].starts_with('_') {
144        parts[0][1..].parse().unwrap_or(0)
145    } else {
146        0
147    };
148
149    let proto = if parts.len() >= 2 && parts[1].starts_with('_') {
150        parts[1][1..].to_string()
151    } else {
152        "tcp".to_string()
153    };
154
155    let remaining = if parts.len() > 2 {
156        parts[2..].join(".")
157    } else {
158        "@".to_string()
159    };
160
161    (remaining, port, proto)
162}
163
164/// Concatenates all quoted strings in TXT record data into a single string.
165pub fn parse_txt_data(raw: &str) -> String {
166    let mut result = String::new();
167
168    for cap in TXT.captures_iter(raw) {
169        result.push_str(&cap[1]);
170    }
171
172    result
173}
174
175/// Returns whether a record type supports Cloudflare proxying.
176pub fn can_proxy(rtype: &str) -> bool {
177    matches!(rtype, "A" | "AAAA" | "CNAME")
178}