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 the zone name 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 zone.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    let zone = zone.trim_end_matches('.');
106
107    if record_part == zone {
108        return (zone.to_string(), "_unknown".to_string(), "_tcp".to_string());
109    }
110
111    let parts: Vec<&str> = record_part.split('.').collect();
112
113    let service = if !parts.is_empty() && parts[0].starts_with('_') {
114        parts[0][1..].to_string()
115    } else {
116        "_unknown".to_string()
117    };
118
119    let proto = if parts.len() >= 2 && parts[1].starts_with('_') {
120        parts[1][1..].to_string()
121    } else {
122        "_tcp".to_string()
123    };
124
125    let remaining = if parts.len() > 2 {
126        parts[2..].join(".")
127    } else {
128        "@".to_string()
129    };
130
131    (remaining, service, proto)
132}
133
134/// Parses a TLSA record name into (remaining record name, port, protocol).
135pub fn parse_tlsa_name(fqdn: &str, zone: &str) -> (String, u32, String) {
136    let record_part = strip_zone(fqdn, zone);
137    let zone = zone.trim_end_matches('.');
138
139    if record_part == zone {
140        return (zone.to_string(), 0, "tcp".to_string());
141    }
142
143    let parts: Vec<&str> = record_part.split('.').collect();
144
145    let port: u32 = if !parts.is_empty() && parts[0].starts_with('_') {
146        parts[0][1..].parse().unwrap_or(0)
147    } else {
148        0
149    };
150
151    let proto = if parts.len() >= 2 && parts[1].starts_with('_') {
152        parts[1][1..].to_string()
153    } else {
154        "tcp".to_string()
155    };
156
157    let remaining = if parts.len() > 2 {
158        parts[2..].join(".")
159    } else {
160        "@".to_string()
161    };
162
163    (remaining, port, proto)
164}
165
166/// Concatenates all quoted strings in TXT record data into a single string.
167pub fn parse_txt_data(raw: &str) -> String {
168    let mut result = String::new();
169
170    for cap in TXT.captures_iter(raw) {
171        result.push_str(&cap[1]);
172    }
173
174    result
175}
176
177/// Returns whether a record type supports Cloudflare proxying.
178pub fn can_proxy(rtype: &str) -> bool {
179    matches!(rtype, "A" | "AAAA" | "CNAME")
180}