1use serde::{Deserialize, Serialize};
2use tracing::{debug, instrument};
3
4use crate::dns::{DnsRecord, DnsResolver, RecordType};
5use crate::error::Result;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServerResult {
10 pub nameserver: String,
11 pub records: Vec<DnsRecord>,
12 pub error: Option<String>,
13}
14
15impl ServerResult {
16 fn from_query(nameserver: &str, result: Result<Vec<DnsRecord>>) -> Self {
18 let (records, error) = match result {
19 Ok(records) => (records, None),
20 Err(e) => {
21 debug!(server = %nameserver, error = %e, "DNS compare query failed");
22 (vec![], Some(e.sanitized_message()))
24 }
25 };
26 Self {
27 nameserver: nameserver.to_string(),
28 records,
29 error,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DnsComparison {
40 pub domain: String,
41 pub record_type: RecordType,
42 pub server_a: ServerResult,
43 pub server_b: ServerResult,
44 pub matches: bool,
45 pub only_in_a: Vec<String>,
46 pub only_in_b: Vec<String>,
47 pub common: Vec<String>,
48}
49
50pub struct DnsComparator {
55 resolver: DnsResolver,
56}
57
58impl Default for DnsComparator {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl DnsComparator {
65 pub fn new() -> Self {
67 Self {
68 resolver: DnsResolver::new(),
69 }
70 }
71
72 #[instrument(skip(self), fields(domain = %domain, record_type = %record_type, server_a = %server_a, server_b = %server_b))]
84 pub async fn compare(
85 &self,
86 domain: &str,
87 record_type: RecordType,
88 server_a: &str,
89 server_b: &str,
90 ) -> Result<DnsComparison> {
91 let domain = crate::dns::resolver::prepare_query(domain, record_type)?;
95
96 let (result_a, result_b) = tokio::join!(
98 self.resolver.resolve(&domain, record_type, Some(server_a)),
99 self.resolver.resolve(&domain, record_type, Some(server_b))
100 );
101
102 let server_a_result = ServerResult::from_query(server_a, result_a);
103 let server_b_result = ServerResult::from_query(server_b, result_b);
104
105 let (values_equal, only_in_a, only_in_b) =
110 compare_server_values(&server_a_result, &server_b_result);
111
112 let mut common = common_values(&server_a_result, &server_b_result);
115 common.sort();
116
117 let matches =
118 values_equal && server_a_result.error.is_none() && server_b_result.error.is_none();
119
120 debug!(
121 matches = matches,
122 common = common.len(),
123 only_in_a = only_in_a.len(),
124 only_in_b = only_in_b.len(),
125 "DNS comparison complete"
126 );
127
128 Ok(DnsComparison {
129 domain: domain.to_string(),
130 record_type,
131 server_a: server_a_result,
132 server_b: server_b_result,
133 matches,
134 only_in_a,
135 only_in_b,
136 common,
137 })
138 }
139}
140
141fn compare_server_values(a: &ServerResult, b: &ServerResult) -> (bool, Vec<String>, Vec<String>) {
148 use std::collections::HashSet;
149
150 let keys_a: HashSet<String> = a.records.iter().map(|r| r.data.comparison_key()).collect();
151 let keys_b: HashSet<String> = b.records.iter().map(|r| r.data.comparison_key()).collect();
152
153 let mut only_in_a: Vec<String> = a
154 .records
155 .iter()
156 .filter(|r| !keys_b.contains(&r.data.comparison_key()))
157 .map(|r| r.format_short())
158 .collect();
159 let mut only_in_b: Vec<String> = b
160 .records
161 .iter()
162 .filter(|r| !keys_a.contains(&r.data.comparison_key()))
163 .map(|r| r.format_short())
164 .collect();
165 only_in_a.sort();
166 only_in_a.dedup();
167 only_in_b.sort();
168 only_in_b.dedup();
169
170 let values_equal = only_in_a.is_empty() && only_in_b.is_empty();
171 (values_equal, only_in_a, only_in_b)
172}
173
174fn common_values(a: &ServerResult, b: &ServerResult) -> Vec<String> {
177 use std::collections::HashSet;
178 let keys_b: HashSet<String> = b.records.iter().map(|r| r.data.comparison_key()).collect();
179 let mut seen: HashSet<String> = HashSet::new();
180 let mut common = Vec::new();
181 for r in &a.records {
182 let key = r.data.comparison_key();
183 if keys_b.contains(&key) && seen.insert(key) {
184 common.push(r.format_short());
185 }
186 }
187 common
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::dns::{RecordData, RecordType};
194
195 #[test]
196 fn test_dns_comparison_serialization() {
197 let comparison = DnsComparison {
198 domain: "example.com".to_string(),
199 record_type: RecordType::A,
200 server_a: ServerResult {
201 nameserver: "8.8.8.8".to_string(),
202 records: vec![DnsRecord {
203 name: "example.com".to_string(),
204 record_type: RecordType::A,
205 ttl: 300,
206 data: RecordData::A {
207 address: "93.184.216.34".to_string(),
208 },
209 }],
210 error: None,
211 },
212 server_b: ServerResult {
213 nameserver: "1.1.1.1".to_string(),
214 records: vec![DnsRecord {
215 name: "example.com".to_string(),
216 record_type: RecordType::A,
217 ttl: 300,
218 data: RecordData::A {
219 address: "93.184.216.34".to_string(),
220 },
221 }],
222 error: None,
223 },
224 matches: true,
225 only_in_a: vec![],
226 only_in_b: vec![],
227 common: vec!["93.184.216.34".to_string()],
228 };
229
230 let json = serde_json::to_string(&comparison).unwrap();
231 assert!(json.contains("example.com"));
232 assert!(json.contains("93.184.216.34"));
233 assert!(json.contains("\"matches\":true"));
234 }
235
236 #[test]
237 fn test_server_result_with_error() {
238 let result = ServerResult {
239 nameserver: "8.8.8.8".to_string(),
240 records: vec![],
241 error: Some("connection timed out".to_string()),
242 };
243
244 let json = serde_json::to_string(&result).unwrap();
245 assert!(json.contains("connection timed out"));
246 }
247
248 #[test]
253 fn compare_values_case_insensitive_match() {
254 let upper = ServerResult {
255 nameserver: "8.8.8.8".to_string(),
256 records: vec![DnsRecord {
257 name: "example.com".to_string(),
258 record_type: RecordType::NS,
259 ttl: 300,
260 data: RecordData::NS {
261 nameserver: "NS1.EXAMPLE.COM.".to_string(),
262 },
263 }],
264 error: None,
265 };
266 let lower = ServerResult {
267 nameserver: "1.1.1.1".to_string(),
268 records: vec![DnsRecord {
269 name: "example.com".to_string(),
270 record_type: RecordType::NS,
271 ttl: 300,
272 data: RecordData::NS {
273 nameserver: "ns1.example.com.".to_string(),
274 },
275 }],
276 error: None,
277 };
278
279 let (matches, only_in_a, only_in_b) = compare_server_values(&upper, &lower);
280 assert!(matches, "case-only difference must be a match");
281 assert!(
282 only_in_a.is_empty(),
283 "no records unique to A: {only_in_a:?}"
284 );
285 assert!(
286 only_in_b.is_empty(),
287 "no records unique to B: {only_in_b:?}"
288 );
289 }
290
291 #[test]
295 fn compare_values_txt_case_difference_is_a_mismatch() {
296 let txt = |ns: &str, text: &str| ServerResult {
297 nameserver: ns.to_string(),
298 records: vec![DnsRecord {
299 name: "example.com".to_string(),
300 record_type: RecordType::TXT,
301 ttl: 300,
302 data: RecordData::TXT {
303 text: text.to_string(),
304 },
305 }],
306 error: None,
307 };
308 let (matches, only_in_a, only_in_b) = compare_server_values(
309 &txt("8.8.8.8", "verify=AbCd"),
310 &txt("1.1.1.1", "verify=abcd"),
311 );
312 assert!(!matches);
313 assert_eq!(only_in_a, vec!["\"verify=AbCd\"".to_string()]);
314 assert_eq!(only_in_b, vec!["\"verify=abcd\"".to_string()]);
315 }
316
317 #[tokio::test]
322 async fn compare_uses_per_name_normalization() {
323 use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
324
325 let port = spawn_mock_dns(MockMode::Zone).await;
326 let comparator = DnsComparator {
327 resolver: mock_dns_resolver(port),
328 };
329
330 let cmp = comparator
331 .compare("www.seer.test", RecordType::CNAME, "127.0.0.1", "127.0.0.1")
332 .await
333 .expect("compare www");
334 assert_eq!(cmp.domain, "www.seer.test");
335 assert!(cmp.matches);
336 assert_eq!(cmp.common, vec!["edge.cdn.test.".to_string()]);
337
338 let cmp = comparator
339 .compare(
340 "2606:4700:4700::1111",
341 RecordType::PTR,
342 "127.0.0.1",
343 "127.0.0.1",
344 )
345 .await
346 .expect("IPv6 PTR literal must be accepted");
347 assert_eq!(cmp.domain, "2606:4700:4700::1111");
348 assert_eq!(cmp.common, vec!["one.one.one.one.".to_string()]);
349 }
350}