Skip to main content

max / pom

6.1 KB · 213 lines History Blame Raw
1 //! DNS record verification — resolves hostnames and compares against expected values.
2
3 use std::collections::HashSet;
4
5 use hickory_resolver::TokioResolver;
6 use tracing::instrument;
7
8 use crate::config::DnsRecord;
9 use crate::types::{DnsCheckResult, DnsRecordType};
10
11 /// Resolve DNS records and compare against expected values.
12 /// Returns one `DnsCheckResult` per `DnsRecord` in the input.
13 #[instrument(skip_all)]
14 pub async fn check_dns(target: &str, records: &[DnsRecord]) -> Vec<DnsCheckResult> {
15 let resolver = match TokioResolver::builder_tokio() {
16 Ok(builder) => builder.build(),
17 Err(e) => {
18 return records
19 .iter()
20 .map(|r| DnsCheckResult {
21 target: target.to_string(),
22 name: r.name.clone(),
23 record_type: r.record_type,
24 expected: r.expected.clone(),
25 actual: vec![],
26 matches: false,
27 checked_at: chrono::Utc::now().to_rfc3339(),
28 error: Some(format!("failed to create resolver: {e}")),
29 })
30 .collect();
31 }
32 };
33
34 let mut results = Vec::with_capacity(records.len());
35 for record in records {
36 let result = resolve_record(target, &resolver, record).await;
37 results.push(result);
38 }
39 results
40 }
41
42 async fn resolve_record(
43 target: &str,
44 resolver: &TokioResolver,
45 record: &DnsRecord,
46 ) -> DnsCheckResult {
47 let now = chrono::Utc::now().to_rfc3339();
48
49 let actual = match record.record_type {
50 DnsRecordType::A => resolve_a(resolver, &record.name).await,
51 DnsRecordType::Aaaa => resolve_aaaa(resolver, &record.name).await,
52 DnsRecordType::Cname => resolve_cname(resolver, &record.name).await,
53 DnsRecordType::Mx => resolve_mx(resolver, &record.name).await,
54 DnsRecordType::Txt => resolve_txt(resolver, &record.name).await,
55 };
56
57 match actual {
58 Ok(actual_values) => {
59 let matches = check_match(&record.expected, &actual_values);
60 DnsCheckResult {
61 target: target.to_string(),
62 name: record.name.clone(),
63 record_type: record.record_type,
64 expected: record.expected.clone(),
65 actual: actual_values,
66 matches,
67 checked_at: now,
68 error: None,
69 }
70 }
71 Err(e) => DnsCheckResult {
72 target: target.to_string(),
73 name: record.name.clone(),
74 record_type: record.record_type,
75 expected: record.expected.clone(),
76 actual: vec![],
77 matches: false,
78 checked_at: now,
79 error: Some(e),
80 },
81 }
82 }
83
84 /// Check if all expected values are found in actual (expected ⊆ actual).
85 pub fn check_match(expected: &[String], actual: &[String]) -> bool {
86 let actual_set: HashSet<&str> = actual.iter().map(|s| s.as_str()).collect();
87 expected.iter().all(|e| actual_set.contains(e.as_str()))
88 }
89
90 async fn resolve_a(
91 resolver: &TokioResolver,
92 name: &str,
93 ) -> Result<Vec<String>, String> {
94 let response = resolver
95 .ipv4_lookup(name)
96 .await
97 .map_err(|e| format!("A lookup failed for {name}: {e}"))?;
98 Ok(response.iter().map(|ip| ip.to_string()).collect())
99 }
100
101 async fn resolve_aaaa(
102 resolver: &TokioResolver,
103 name: &str,
104 ) -> Result<Vec<String>, String> {
105 let response = resolver
106 .ipv6_lookup(name)
107 .await
108 .map_err(|e| format!("AAAA lookup failed for {name}: {e}"))?;
109 Ok(response.iter().map(|ip| ip.to_string()).collect())
110 }
111
112 async fn resolve_cname(
113 resolver: &TokioResolver,
114 name: &str,
115 ) -> Result<Vec<String>, String> {
116 let response = resolver
117 .lookup(name, hickory_resolver::proto::rr::RecordType::CNAME)
118 .await
119 .map_err(|e| format!("CNAME lookup failed for {name}: {e}"))?;
120 Ok(response
121 .iter()
122 .filter_map(|r| r.as_cname().map(|c| c.0.to_string().trim_end_matches('.').to_string()))
123 .collect())
124 }
125
126 async fn resolve_mx(
127 resolver: &TokioResolver,
128 name: &str,
129 ) -> Result<Vec<String>, String> {
130 let response = resolver
131 .mx_lookup(name)
132 .await
133 .map_err(|e| format!("MX lookup failed for {name}: {e}"))?;
134 Ok(response
135 .iter()
136 .map(|mx| mx.exchange().to_string().trim_end_matches('.').to_string())
137 .collect())
138 }
139
140 async fn resolve_txt(
141 resolver: &TokioResolver,
142 name: &str,
143 ) -> Result<Vec<String>, String> {
144 let response = resolver
145 .txt_lookup(name)
146 .await
147 .map_err(|e| format!("TXT lookup failed for {name}: {e}"))?;
148 Ok(response.iter().map(|txt| txt.to_string()).collect())
149 }
150
151 #[cfg(test)]
152 mod tests {
153 use super::*;
154
155 #[test]
156 fn check_match_exact() {
157 assert!(check_match(
158 &["1.2.3.4".to_string()],
159 &["1.2.3.4".to_string()],
160 ));
161 }
162
163 #[test]
164 fn check_match_subset() {
165 assert!(check_match(
166 &["1.2.3.4".to_string()],
167 &["1.2.3.4".to_string(), "5.6.7.8".to_string()],
168 ));
169 }
170
171 #[test]
172 fn check_match_mismatch() {
173 assert!(!check_match(
174 &["1.2.3.4".to_string()],
175 &["5.6.7.8".to_string()],
176 ));
177 }
178
179 #[test]
180 fn check_match_empty_expected() {
181 assert!(check_match(&[], &["1.2.3.4".to_string()]));
182 }
183
184 #[test]
185 fn check_match_empty_actual() {
186 assert!(!check_match(&["1.2.3.4".to_string()], &[]));
187 }
188
189 #[test]
190 fn check_match_order_independent() {
191 assert!(check_match(
192 &["b".to_string(), "a".to_string()],
193 &["a".to_string(), "b".to_string(), "c".to_string()],
194 ));
195 }
196
197 #[test]
198 fn check_match_multiple_expected_all_present() {
199 assert!(check_match(
200 &["1.2.3.4".to_string(), "5.6.7.8".to_string()],
201 &["5.6.7.8".to_string(), "1.2.3.4".to_string()],
202 ));
203 }
204
205 #[test]
206 fn check_match_multiple_expected_one_missing() {
207 assert!(!check_match(
208 &["1.2.3.4".to_string(), "5.6.7.8".to_string()],
209 &["1.2.3.4".to_string()],
210 ));
211 }
212 }
213