//! DNS diagnostics (port of `diagnostics/dnsDiagnostics.js`): OS resolver for address //! lookups plus an own UDP/TCP wire client for record queries (A/AAAA/CNAME/MX/TXT/NS/SOA/ //! PTR/SRV/CAA/DS/DNSKEY), TCP fallback on truncation, CNAME chains and consistency checks. //! DNSSEC is reported as "not-validated"; AD/DS/DNSKEY are observations only. use crate::core::diagnostics::common::{error_result, hostname, map_limit, now_ms, DiagError, Options}; use rand::RngCore; use serde_json::{json, Value}; use std::collections::HashSet; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_util::sync::CancellationToken; pub const TYPES: [(&str, u16); 12] = [ ("A", 1), ("NS", 2), ("CNAME", 5), ("SOA", 6), ("PTR", 12), ("MX", 15), ("TXT", 16), ("AAAA", 28), ("SRV", 33), ("DS", 43), ("DNSKEY", 48), ("CAA", 257), ]; pub fn type_code(name: &str) -> u16 { TYPES.iter().find(|(n, _)| *n == name).map(|(_, c)| *c).unwrap_or(0) } fn type_name(code: u16) -> Option<&'static str> { TYPES.iter().find(|(_, c)| *c == code).map(|(n, _)| *n) } pub fn provider(name: &str) -> Option<[&'static str; 2]> { match name { "cloudflare" => Some(["1.1.1.1", "2606:4700:4700::1111"]), "google" => Some(["8.8.8.8", "2001:4860:4860::8888"]), "quad9" => Some(["9.9.9.9", "2620:fe::fe"]), _ => None, } } fn malformed() -> DiagError { DiagError::new("DNS_MALFORMED") } fn u16_at(b: &[u8], i: usize) -> Result { b.get(i..i + 2).map(|s| u16::from_be_bytes([s[0], s[1]])).ok_or_else(malformed) } fn u32_at(b: &[u8], i: usize) -> Result { b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).ok_or_else(malformed) } pub fn read_name(buffer: &[u8], position: usize) -> Result<(String, usize), DiagError> { let mut cursor = position; let mut end: Option = None; let mut labels: Vec = Vec::new(); let mut seen = HashSet::new(); for _ in 0..128 { if cursor >= buffer.len() || !seen.insert(cursor) { return Err(malformed()); } let length = buffer[cursor] as usize; cursor += 1; if length & 0xc0 == 0xc0 { if cursor >= buffer.len() { return Err(malformed()); } end.get_or_insert(cursor + 1); cursor = ((length & 0x3f) << 8) | buffer[cursor] as usize; continue; } if length & 0xc0 != 0 || length > 63 || cursor + length > buffer.len() { return Err(malformed()); } if length == 0 { return Ok((labels.join("."), end.unwrap_or(cursor))); } labels.push(buffer[cursor..cursor + length].iter().map(|b| *b as char).collect()); cursor += length; } Err(malformed()) } pub fn parse_response(buffer: &[u8], id: u16, qtype: &str) -> Result { if buffer.len() < 12 || u16_at(buffer, 0)? != id || buffer[2] & 0x80 == 0 { return Err(malformed()); } let rcode = buffer[3] & 15; let status = match rcode { 0 => "success", 2 => "servfail", 3 => "nxdomain", 5 => "refused", _ => "error" }; let questions = u16_at(buffer, 4)?; let answers = u16_at(buffer, 6)? as usize; let total = answers + u16_at(buffer, 8)? as usize + u16_at(buffer, 10)? as usize; if questions != 1 || total > 1024 { return Err(malformed()); } let (question_name, mut cursor) = read_name(buffer, 12)?; if cursor + 4 > buffer.len() || u16_at(buffer, cursor)? != type_code(qtype) || u16_at(buffer, cursor + 2)? != 1 { return Err(malformed()); } cursor += 4; let mut records = Vec::new(); for index in 0..total { let (owner, next) = read_name(buffer, cursor)?; cursor = next; if cursor + 10 > buffer.len() { return Err(malformed()); } let number = u16_at(buffer, cursor)?; let class = u16_at(buffer, cursor + 2)?; let ttl = u32_at(buffer, cursor + 4)?; let length = u16_at(buffer, cursor + 8)? as usize; cursor += 10; let end = cursor + length; if end > buffer.len() { return Err(malformed()); } let data = &buffer[cursor..end]; let value: Option = match number { 1 if length == 4 => Some(json!(data.iter().map(|b| b.to_string()).collect::>().join("."))), 28 if length == 16 => Some(json!((0..8).map(|n| format!("{:x}", u16::from_be_bytes([data[n * 2], data[n * 2 + 1]]))).collect::>().join(":"))), 2 | 5 | 12 => Some(json!(read_name(buffer, cursor)?.0)), 15 if length >= 3 => Some(json!({ "priority": u16_at(buffer, cursor)?, "exchange": read_name(buffer, cursor + 2)?.0 })), 33 if length >= 7 => Some(json!({ "priority": u16_at(buffer, cursor)?, "weight": u16_at(buffer, cursor + 2)?, "port": u16_at(buffer, cursor + 4)?, "name": read_name(buffer, cursor + 6)?.0 })), 16 => { let mut parts = Vec::new(); let mut p = cursor; while p < end { let size = buffer[p] as usize; p += 1; if p + size > end { return Err(malformed()); } parts.push(Value::String(String::from_utf8_lossy(&buffer[p..p + size]).to_string())); p += size; } Some(Value::Array(parts)) } 6 => { let (ns, after_ns) = read_name(buffer, cursor)?; let (host, after_host) = read_name(buffer, after_ns)?; if after_host + 20 > end { return Err(malformed()); } Some(json!({ "nsname": ns, "hostmaster": host, "serial": u32_at(buffer, after_host)?, "refresh": u32_at(buffer, after_host + 4)?, "retry": u32_at(buffer, after_host + 8)?, "expire": u32_at(buffer, after_host + 12)?, "minttl": u32_at(buffer, after_host + 16)? })) } 43 if length >= 4 => Some(json!({ "keyTag": u16_at(buffer, cursor)?, "algorithm": data[2], "digestType": data[3], "digest": hex::encode(&data[4..]) })), 48 if length >= 4 => { use base64::Engine; Some(json!({ "flags": u16_at(buffer, cursor)?, "protocol": data[2], "algorithm": data[3], "publicKey": base64::engine::general_purpose::STANDARD.encode(&data[4..]) })) } 257 if length >= 2 && 2 + data[1] as usize <= length => { let tag_len = data[1] as usize; Some(json!({ "flags": data[0], "tag": String::from_utf8_lossy(&data[2..2 + tag_len]), "value": String::from_utf8_lossy(&data[2 + tag_len..]) })) } _ => None, }; if let (Some(name), 1, Some(value)) = (type_name(number), class, value) { records.push(json!({ "name": owner, "type": name, "ttl": ttl, "value": value, "section": if index < answers { "answer" } else { "authority/additional" } })); } cursor = end; } let has_answer = records.iter().any(|r| r["type"] == qtype && r["section"] == "answer"); Ok(json!({ "questionName": question_name, "status": if status == "success" && !has_answer { "nodata" } else { status }, "records": records, "truncated": buffer[2] & 2 != 0, "dnssec": "not-validated", "resolverAdFlag": buffer[3] & 32 != 0 })) } /// Reverse lookup name; IPv6 is expanded to 32 nibbles (embedded IPv4 becomes hex groups). pub fn reverse_name(address: &str) -> Result { match address.parse::() { Ok(IpAddr::V4(v4)) => Ok(format!("{}.in-addr.arpa", v4.octets().iter().rev().map(|o| o.to_string()).collect::>().join("."))), Ok(IpAddr::V6(v6)) => { let hex = v6.octets().iter().map(|b| format!("{b:02x}")).collect::(); Ok(format!("{}.ip6.arpa", hex.chars().rev().map(|c| c.to_string()).collect::>().join("."))) } Err(_) => Err(DiagError::new("INVALID_IP")), } } pub fn server_address(value: &str) -> Result { if let Some(rest) = value.strip_prefix('[') { let (host, port) = rest.split_once(']').ok_or_else(|| DiagError::new("INVALID_DNS_SERVER"))?; let ip: Ipv6Addr = host.parse().map_err(|_| DiagError::new("INVALID_DNS_SERVER"))?; let port = port.strip_prefix(':').map(|p| p.parse().map_err(|_| DiagError::new("INVALID_DNS_SERVER"))).transpose()?.unwrap_or(53); return Ok(SocketAddr::new(IpAddr::V6(ip), port)); } if let Ok(ip) = value.parse::() { return Ok(SocketAddr::new(ip, 53)); } if let Some((host, port)) = value.rsplit_once(':') { if let (Ok(IpAddr::V4(ip)), Ok(port)) = (host.parse::(), port.parse::()) { return Ok(SocketAddr::new(IpAddr::V4(ip), port)); } } Err(DiagError::new("INVALID_DNS_SERVER")) } async fn exchange(packet: &[u8], server: &str, cancel: &CancellationToken, tcp: bool) -> Result, DiagError> { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } let address = server_address(server)?; let work = async { if tcp { let mut stream = tokio::net::TcpStream::connect(address).await.map_err(DiagError::from_io)?; let mut framed = (packet.len() as u16).to_be_bytes().to_vec(); framed.extend_from_slice(packet); stream.write_all(&framed).await.map_err(DiagError::from_io)?; let mut size = [0u8; 2]; stream.read_exact(&mut size).await.map_err(|_| DiagError::new("DNS_INCOMPLETE"))?; let mut response = vec![0u8; u16::from_be_bytes(size) as usize]; stream.read_exact(&mut response).await.map_err(|_| DiagError::new("DNS_INCOMPLETE"))?; Ok(response) } else { let bind: SocketAddr = if address.is_ipv6() { "[::]:0".parse().unwrap() } else { "0.0.0.0:0".parse().unwrap() }; let socket = tokio::net::UdpSocket::bind(bind).await.map_err(DiagError::from_io)?; socket.connect(address).await.map_err(DiagError::from_io)?; socket.send(packet).await.map_err(DiagError::from_io)?; let mut buffer = vec![0u8; 65535]; let n = socket.recv(&mut buffer).await.map_err(DiagError::from_io)?; buffer.truncate(n); Ok(buffer) } }; tokio::select! { result = work => result, _ = cancel.cancelled() => Err(DiagError::new("CANCELLED")), } } /// Configured system DNS servers (resolv.conf / Windows adapter settings). pub fn system_servers() -> Vec { match hickory_resolver::system_conf::read_system_conf() { Ok((config, _)) => { let mut servers: Vec = Vec::new(); for ns in config.name_servers() { let ip = ns.socket_addr.ip().to_string(); if !servers.contains(&ip) { servers.push(ip); } } servers } Err(_) => Vec::new(), } } fn servers(options: &Options) -> Vec { if options.resolver == "custom" { return vec![options.custom_dns.clone()]; } if let Some(list) = provider(&options.resolver) { return list.iter().map(|s| s.to_string()).collect(); } system_servers() } pub async fn query(name: &str, qtype: &str, options: &Options, cancel: &CancellationToken) -> Result { let started = now_ms(); let query_name = hostname(name)?; let mut id_bytes = [0u8; 2]; rand::thread_rng().fill_bytes(&mut id_bytes); let id = u16::from_be_bytes(id_bytes); let mut packet = Vec::with_capacity(64); packet.extend_from_slice(&id.to_be_bytes()); packet.extend_from_slice(&[0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); for part in query_name.split('.').filter(|p| !p.is_empty()) { packet.push(part.len() as u8); packet.extend_from_slice(part.as_bytes()); } let code = type_code(qtype); packet.extend_from_slice(&[0, (code >> 8) as u8, (code & 0xff) as u8, 0, 1]); let limit = cancel.child_token(); let deadline = tokio::time::sleep(Duration::from_millis(options.timeout_ms)); tokio::pin!(deadline); let mut failure: Option = None; for server in servers(options) { let attempt = async { let mut response = exchange(&packet, &server, &limit, false).await?; if response.len() > 2 && response[2] & 2 != 0 { response = exchange(&packet, &server, &limit, true).await?; } let result = parse_response(&response, id, qtype)?; if !result["questionName"].as_str().unwrap_or("").is_empty() && result["questionName"].as_str() != Some(query_name.as_str()) { return Err(malformed()); } Ok(result) }; let outcome = tokio::select! { result = attempt => result, _ = &mut deadline => { limit.cancel(); Err(DiagError::new("TIMEOUT")) } }; match outcome { Ok(result) => { let mut value = json!({ "name": query_name, "type": qtype, "server": server, "durationMs": now_ms() - started }); if let (Some(target), Value::Object(fields)) = (value.as_object_mut(), result) { target.extend(fields); } return Ok(value); } Err(error) => { let stop = error.code == "TIMEOUT" || cancel.is_cancelled(); failure = Some(error); if stop { break; } } } } Err(failure.unwrap_or_else(|| DiagError::new("DNS_UNAVAILABLE"))) } pub async fn system_lookup(host: &str, mode: &str, timeout_ms: u64, cancel: &CancellationToken) -> Result, DiagError> { let started = now_ms(); let lookup = tokio::net::lookup_host((host.to_string(), 0u16)); let addresses = tokio::select! { result = tokio::time::timeout(Duration::from_millis(timeout_ms), lookup) => match result { Ok(Ok(list)) => list.collect::>(), Ok(Err(error)) => return Err(DiagError::with_message("ENOTFOUND", error.to_string())), Err(_) => return Err(DiagError::new("TIMEOUT")), }, _ = cancel.cancelled() => return Err(DiagError::new("CANCELLED")), }; let mut seen = HashSet::new(); Ok(addresses .into_iter() .map(|a| a.ip()) .filter(|ip| match mode { "ipv4" => ip.is_ipv4(), "ipv6" => ip.is_ipv6(), _ => true }) .filter(|ip| seen.insert(*ip)) .map(|ip| json!({ "address": ip.to_string(), "family": if ip.is_ipv4() { 4 } else { 6 }, "dnsMs": now_ms() - started, "method": "OS resolver (hosts file / system DNS); TTL unavailable" })) .collect()) } pub async fn resolve(host: &str, options: &Options, cancel: &CancellationToken) -> Result, DiagError> { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } if let Ok(ip) = host.parse::() { return Ok(vec![json!({ "address": host, "family": if ip.is_ipv4() { 4 } else { 6 }, "dnsMs": 0 })]); } if options.resolver == "system" { let list = system_lookup(host, &options.mode, options.timeout_ms, cancel).await?; if list.is_empty() { return Err(DiagError::new("DNS_NO_ADDRESS")); } return Ok(list); } let types: Vec<&str> = match options.mode.as_str() { "ipv4" => vec!["A"], "ipv6" => vec!["AAAA"], _ => vec!["A", "AAAA"] }; let mut all = Vec::new(); for qtype in types { let lookup = async { let mut name = host.to_string(); let mut duration = 0.0; let mut seen = HashSet::new(); for _ in 0..8 { if !seen.insert(name.clone()) { return Err(DiagError::new("DNS_CNAME_LOOP")); } let result = query(&name, qtype, options, cancel).await?; duration += result["durationMs"].as_f64().unwrap_or(0.0); let records = result["records"].as_array().cloned().unwrap_or_default(); let addresses: Vec = records .iter() .filter(|r| r["type"] == qtype && r["section"] == "answer") .map(|r| json!({ "address": r["value"], "family": if qtype == "A" { 4 } else { 6 }, "dnsMs": duration, "ttl": r["ttl"] })) .collect(); if !addresses.is_empty() { return Ok(addresses); } match records.iter().find(|r| r["type"] == "CNAME" && r["section"] == "answer") { Some(cname) => name = cname["value"].as_str().unwrap_or("").to_string(), None => return Ok(Vec::new()), } } Err(DiagError::new("DNS_CNAME_LIMIT")) }; match lookup.await { Ok(list) => all.extend(list), Err(error) => { if cancel.is_cancelled() { return Err(error); } } } } if all.is_empty() { return Err(DiagError::new("DNS_NO_ADDRESS")); } Ok(all) } /// Fallback for SSH name resolution when the OS resolver returns nothing (like Node resolve4/resolve6). pub async fn resolve_both(host: &str) -> Vec { let options = Options { resolver: "system-wire".into(), mode: "both".into(), timeout_ms: 5000, ..Options::default() }; let cancel = CancellationToken::new(); let mut result = Vec::new(); for qtype in ["A", "AAAA"] { if let Ok(answer) = query(host, qtype, &options, &cancel).await { for record in answer["records"].as_array().cloned().unwrap_or_default() { if record["type"] == qtype && record["section"] == "answer" { if let Some(ip) = record["value"].as_str().and_then(|v| v.parse().ok()) { result.push(ip); } } } } } result } fn canonical_ip(value: &str) -> String { value.parse::().map(|ip| ip.to_string()).unwrap_or_else(|_| value.to_string()) } pub async fn run(host: &str, options: &Options, cancel: &CancellationToken, progress: &(dyn Fn(Value) + Send + Sync)) -> Result { let is_ip = host.parse::().is_ok(); let name = if is_ip { reverse_name(host)? } else { host.to_string() }; let types: Vec = if is_ip { vec!["PTR".to_string()] } else { TYPES.iter().map(|(n, _)| n.to_string()).collect() }; let queries: Vec = map_limit(types, 3, |qtype: String| { let name = name.clone(); async move { let qtype = qtype.as_str(); if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } let result = match query(&name, qtype, options, cancel).await { Ok(result) => result, Err(error) => { let mut value = json!({ "type": qtype, "name": name }); if let (Some(t), Value::Object(e)) = (value.as_object_mut(), error_result(&error)) { t.extend(e); } value } }; let mut event = json!({ "kind": "dns-record" }); if let (Some(t), Some(r)) = (event.as_object_mut(), result.as_object()) { t.extend(r.clone()); } progress(event); Ok(result) } }) .await?; let mut cnames: Vec = queries .iter() .flat_map(|q| q["records"].as_array().cloned().unwrap_or_default()) .filter(|r| r["type"] == "CNAME" && r["section"] == "answer") .collect(); let mut seen: HashSet = HashSet::from([host.to_string()]); let mut next = cnames.iter().find(|r| r["name"] == host).and_then(|r| r["value"].as_str().map(str::to_string)); let mut cname_error: Option = None; let mut index = 0; while let Some(current) = next.clone() { if index >= 8 { break; } index += 1; if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } if !seen.insert(current.clone()) { cname_error = Some("DNS_CNAME_LOOP".into()); break; } match query(¤t, "CNAME", options, cancel).await { Ok(answer) => { let record = answer["records"].as_array().and_then(|r| r.iter().find(|r| r["type"] == "CNAME" && r["section"] == "answer" && r["name"] == current.as_str()).cloned()); match record { None => { next = None; break; } Some(record) => { next = record["value"].as_str().map(str::to_string); cnames.push(record); } } } Err(error) => { if cancel.is_cancelled() { return Err(error); } cname_error = Some(error.code.clone()); break; } } } if next.is_some() && cname_error.is_none() { cname_error = Some("DNS_CNAME_LIMIT".into()); } let mut consistency = Vec::new(); if !is_ip { for qtype in ["A", "AAAA"] { if cancel.is_cancelled() { return Err(DiagError::new("CANCELLED")); } let first = queries.iter().find(|q| q["type"] == qtype).cloned().unwrap_or(json!({})); match query(host, qtype, options, cancel).await { Ok(second) => { let values = |q: &Value| { let mut list: Vec = q["records"] .as_array() .cloned() .unwrap_or_default() .iter() .filter(|r| (r["type"] == qtype || r["type"] == "CNAME") && r["section"] == "answer") .map(|r| json!([r["name"], r["type"], r["value"]]).to_string()) .collect(); list.sort(); list }; let (a, b) = (values(&first), values(&second)); consistency.push(json!({ "type": qtype, "status": if first["status"] == second["status"] && a == b { "consistent" } else { "changed" }, "first": a, "second": b })); } Err(error) => { if cancel.is_cancelled() { return Err(error); } let mut entry = json!({ "type": qtype }); if let (Some(t), Value::Object(e)) = (entry.as_object_mut(), error_result(&error)) { t.extend(e); } consistency.push(entry); } } } } else { let family_mode = if host.parse::().map(|ip| ip.is_ipv4()).unwrap_or(true) { "ipv4" } else { "ipv6" }; let ptrs: Vec = queries.first().and_then(|q| q["records"].as_array().cloned()).unwrap_or_default().into_iter().filter(|r| r["type"] == "PTR").take(4).collect(); for record in ptrs { let target = record["value"].as_str().unwrap_or("").to_string(); let options = Options { mode: family_mode.into(), ..options.clone() }; match resolve(&target, &options, cancel).await { Ok(answers) => { let matches = answers.iter().any(|a| canonical_ip(a["address"].as_str().unwrap_or("")) == canonical_ip(host)); consistency.push(json!({ "type": "forward-confirmed-PTR", "name": target, "status": if matches { "consistent" } else { "changed" } })); } Err(error) => { if cancel.is_cancelled() { return Err(error); } let mut entry = json!({ "type": "forward-confirmed-PTR", "name": target }); if let (Some(t), Value::Object(e)) = (entry.as_object_mut(), error_result(&error)) { t.extend(e); } consistency.push(entry); } } } } let mut chain: Vec = Vec::new(); for record in &cnames { let line = format!("{} → {}", record["name"].as_str().unwrap_or(""), record["value"].as_str().unwrap_or("")); if !chain.contains(&line) { chain.push(line); } } Ok(json!({ "status": if queries.iter().any(|q| q["status"] == "success") { "success" } else { "warning" }, "queries": queries, "cnameChain": chain, "cnameError": cname_error, "consistency": consistency, "dnssec": "not-validated", "note": "Repeated answers / forward-confirmed PTR from the selected resolver. Changes can reflect load balancing; this is not authoritative consistency or DNSSEC validation." })) } #[cfg(test)] mod tests { use super::*; #[test] fn reverse_names() { assert_eq!(reverse_name("192.0.2.1").unwrap(), "1.2.0.192.in-addr.arpa"); assert!(reverse_name("2001:db8::1").unwrap().starts_with("1.0.0.0.0.0.0.0")); assert!(reverse_name("2001:db8::1").unwrap().ends_with("8.b.d.0.1.0.0.2.ip6.arpa")); assert_eq!(reverse_name("::ffff:1.2.3.4").unwrap().split('.').take(8).collect::>(), vec!["4", "0", "3", "0", "2", "0", "1", "0"]); } #[test] fn parses_a_response_with_compression() { // Query id 0x1234 for example.com A, one answer 93.184.216.34 with a compression pointer. let mut packet = vec![0x12, 0x34, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0]; packet.extend_from_slice(&[7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0, 0, 1, 0, 1]); packet.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0x0e, 0x10, 0, 4, 93, 184, 216, 34]); let parsed = parse_response(&packet, 0x1234, "A").unwrap(); assert_eq!(parsed["status"], "success"); assert_eq!(parsed["records"][0]["value"], "93.184.216.34"); assert_eq!(parsed["records"][0]["ttl"], 3600); assert!(parse_response(&packet, 0x9999, "A").is_err()); let mut looped = packet.clone(); looped[12] = 0xc0; looped[13] = 0x0c; assert!(parse_response(&looped, 0x1234, "A").is_err()); } #[test] fn server_addresses() { assert_eq!(server_address("1.1.1.1").unwrap().port(), 53); assert_eq!(server_address("[2606:4700:4700::1111]:5353").unwrap().port(), 5353); assert_eq!(server_address("127.0.0.1:5300").unwrap().port(), 5300); assert!(server_address("example.com").is_err()); } }