diff --git a/Cargo.toml b/Cargo.toml index a5aecb5..4177dec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ clap = { version = "4.4", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } hickory-resolver = "0.24" hickory-client = "0.24" +reqwest = { version = "0.12", features = ["json"] } +base64 = "0.22" +urlencoding = "2.1" socket2 = "0.5" pnet = "0.34" anyhow = "1.0" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d0d0f4b..2a01cf2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -113,6 +113,20 @@ pub enum DnsCommands { Comprehensive { domain: String }, #[command(about = "Test large DNS queries")] Large { domain: String }, + #[command(about = "Test DNS-over-HTTPS (DoH) providers")] + Doh { + domain: String, + #[arg( + short, + long, + help = "DoH provider name (google, google-json, cloudflare, cloudflare-family, cloudflare-security, cloudflare-json, cloudflare-family-json, cloudflare-security-json, quad9, quad9-unsecured, quad9-ecs, opendns, opendns-family, adguard, adguard-family, adguard-unfiltered)" + )] + provider: Option, + #[arg(short = 'r', long, value_enum, default_value = "a")] + record_type: RecordTypeArg, + }, + #[command(about = "List available DoH providers")] + DohProviders, } #[derive(Subcommand)] diff --git a/src/dns/doh.rs b/src/dns/doh.rs new file mode 100644 index 0000000..cfc29a9 --- /dev/null +++ b/src/dns/doh.rs @@ -0,0 +1,411 @@ +use crate::utils::{measure_time, NetworkError, Result, TestResult}; +use hickory_client::op::{Message, MessageType, OpCode, Query}; +use hickory_client::rr::{Name, RecordType}; +use reqwest::Client; +use serde_json::Value; +use std::str::FromStr; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct DohTest { + pub domain: String, + pub record_type: RecordType, + pub provider: DohProvider, + pub timeout: Duration, +} + +#[derive(Debug, Clone)] +pub struct DohProvider { + pub name: &'static str, + pub url: &'static str, + pub description: &'static str, + pub format: DohFormat, +} + +#[derive(Debug, Clone)] +pub enum DohFormat { + Json, // Google, Cloudflare style + WireFormat, // Quad9, AdGuard, OpenDNS style +} + +// Comprehensive DoH providers list +pub const DOH_PROVIDERS: &[DohProvider] = &[ + // Google DNS - Wire format (default/standard) + DohProvider { + name: "Google", + url: "https://dns.google/dns-query", + description: "Google Public DNS (8.8.8.8)", + format: DohFormat::WireFormat, + }, + // Google DNS - JSON format (special variant) + DohProvider { + name: "Google-JSON", + url: "https://dns.google/resolve", + description: "Google Public DNS (8.8.8.8) - JSON variant", + format: DohFormat::Json, + }, + // Cloudflare DNS - Wire format variants (default/standard) + DohProvider { + name: "Cloudflare", + url: "https://1.1.1.1/dns-query", + description: "Cloudflare DNS Primary (1.1.1.1)", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "Cloudflare-Family", + url: "https://1.1.1.2/dns-query", + description: "Cloudflare for Families (1.1.1.2) - Blocks malware/adult", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "Cloudflare-Security", + url: "https://1.1.1.3/dns-query", + description: "Cloudflare for Families (1.1.1.3) - Blocks malware only", + format: DohFormat::WireFormat, + }, + // Cloudflare DNS - JSON format variants (special variants) + DohProvider { + name: "Cloudflare-JSON", + url: "https://1.1.1.1/dns-query", + description: "Cloudflare DNS Primary (1.1.1.1) - JSON variant", + format: DohFormat::Json, + }, + DohProvider { + name: "Cloudflare-Family-JSON", + url: "https://1.1.1.2/dns-query", + description: "Cloudflare for Families (1.1.1.2) - Blocks malware/adult - JSON variant", + format: DohFormat::Json, + }, + DohProvider { + name: "Cloudflare-Security-JSON", + url: "https://1.1.1.3/dns-query", + description: "Cloudflare for Families (1.1.1.3) - Blocks malware only - JSON variant", + format: DohFormat::Json, + }, + // Quad9 DNS - All variants + DohProvider { + name: "Quad9", + url: "https://dns.quad9.net/dns-query", + description: "Quad9 Secure (9.9.9.9) - Blocks malicious domains", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "Quad9-Unsecured", + url: "https://dns10.quad9.net/dns-query", + description: "Quad9 Unsecured (9.9.9.10) - No domain blocking", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "Quad9-ECS", + url: "https://dns11.quad9.net/dns-query", + description: "Quad9 ECS (9.9.9.11) - Blocks malicious + EDNS Client Subnet", + format: DohFormat::WireFormat, + }, + // OpenDNS + DohProvider { + name: "OpenDNS", + url: "https://doh.opendns.com/dns-query", + description: "OpenDNS Standard (208.67.222.222)", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "OpenDNS-Family", + url: "https://doh.familyshield.opendns.com/dns-query", + description: "OpenDNS FamilyShield (208.67.222.123) - Blocks adult content", + format: DohFormat::WireFormat, + }, + // AdGuard DNS - All variants + DohProvider { + name: "AdGuard", + url: "https://dns.adguard.com/dns-query", + description: "AdGuard DNS (94.140.14.14) - Blocks ads and trackers", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "AdGuard-Family", + url: "https://dns-family.adguard.com/dns-query", + description: "AdGuard DNS Family (94.140.14.15) - Blocks ads, trackers, and adult content", + format: DohFormat::WireFormat, + }, + DohProvider { + name: "AdGuard-Unfiltered", + url: "https://dns-unfiltered.adguard.com/dns-query", + description: "AdGuard DNS Unfiltered (94.140.14.140) - No filtering", + format: DohFormat::WireFormat, + }, +]; + +impl DohTest { + pub fn new(domain: String, record_type: RecordType, provider: DohProvider) -> Self { + Self { + domain, + record_type, + provider, + timeout: Duration::from_secs(10), + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub async fn run(&self) -> TestResult { + let test_name = format!( + "DoH {:?} query for {} via {}", + self.record_type, self.domain, self.provider.name + ); + + let (duration, result) = measure_time(|| async { self.query_doh().await }).await; + + match result { + Ok(response) => TestResult::new(test_name).success(duration, response), + Err(error) => TestResult::new(test_name).failure(duration, error), + } + } + + async fn query_doh(&self) -> Result { + let client = Client::builder() + .timeout(self.timeout) + .user_agent("NetTest/1.0") + .build() + .map_err(|e| NetworkError::Other(format!("Failed to create HTTP client: {}", e)))?; + + match self.provider.format { + DohFormat::Json => self.query_doh_json(&client).await, + DohFormat::WireFormat => self.query_doh_wire_format(&client).await, + } + } + + async fn query_doh_json(&self, client: &Client) -> Result { + // Build URL with standard DoH parameters (Google/Cloudflare style) + let url = format!( + "{}?name={}&type={}", + self.provider.url, + self.domain, + self.record_type_to_number() + ); + + let response = client + .get(&url) + .header("Accept", "application/dns-json") + .send() + .await + .map_err(|e| NetworkError::Other(format!("DoH request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(NetworkError::Other(format!( + "DoH server returned status: {}", + response.status() + ))); + } + + let json_response: Value = response + .json() + .await + .map_err(|e| NetworkError::Other(format!("Failed to parse DoH response: {}", e)))?; + + self.parse_doh_response(&json_response) + } + + async fn query_doh_wire_format(&self, client: &Client) -> Result { + // Create DNS query packet + let dns_packet = self.create_dns_query_packet()?; + + let response = client + .post(self.provider.url) + .header("Content-Type", "application/dns-message") + .header("Accept", "application/dns-message") + .body(dns_packet) + .send() + .await + .map_err(|e| NetworkError::Other(format!("DoH request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(NetworkError::Other(format!( + "DoH server returned status: {}", + response.status() + ))); + } + + let response_bytes = response + .bytes() + .await + .map_err(|e| NetworkError::Other(format!("Failed to read DoH response: {}", e)))?; + + self.parse_dns_response_packet(&response_bytes) + } + + fn create_dns_query_packet(&self) -> Result> { + // Create a DNS query message + let name = Name::from_str(&self.domain) + .map_err(|e| NetworkError::Other(format!("Invalid domain name: {}", e)))?; + + let query = Query::query(name, self.record_type); + let mut message = Message::new(); + message + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Query) + .set_recursion_desired(true) + .add_query(query); + + // Serialize to bytes + message + .to_vec() + .map_err(|e| NetworkError::Other(format!("Failed to serialize DNS query: {}", e))) + } + + fn parse_dns_response_packet(&self, response_bytes: &[u8]) -> Result { + // Parse the DNS response packet + let message = Message::from_vec(response_bytes).map_err(|e| { + NetworkError::Other(format!("Failed to parse DNS response packet: {}", e)) + })?; + + // Check response code + if message.response_code() != hickory_client::op::ResponseCode::NoError { + return Err(NetworkError::DnsResolution(format!( + "DNS query failed with response code: {:?}", + message.response_code() + ))); + } + + let answers = message.answers(); + if answers.is_empty() { + return Ok(format!("{:?} records: (none found)", self.record_type)); + } + + let mut records = Vec::new(); + for answer in answers { + if answer.record_type() == self.record_type { + match answer.data() { + Some(rdata) => records.push(rdata.to_string()), + None => continue, + } + } + } + + if records.is_empty() { + Ok(format!("{:?} records: (none found)", self.record_type)) + } else { + Ok(format!( + "{:?} records: {}", + self.record_type, + records.join(", ") + )) + } + } + + fn record_type_to_number(&self) -> u16 { + match self.record_type { + RecordType::A => 1, + RecordType::NS => 2, + RecordType::CNAME => 5, + RecordType::SOA => 6, + RecordType::PTR => 12, + RecordType::MX => 15, + RecordType::TXT => 16, + RecordType::AAAA => 28, + _ => 1, // Default to A record + } + } + + fn parse_doh_response(&self, response: &Value) -> Result { + let status = response["Status"] + .as_u64() + .ok_or_else(|| NetworkError::Other("Invalid DoH response format".to_string()))?; + + if status != 0 { + let status_text = match status { + 1 => "Format Error", + 2 => "Server Failure", + 3 => "Name Error (NXDOMAIN)", + 4 => "Not Implemented", + 5 => "Refused", + _ => "Unknown Error", + }; + return Err(NetworkError::DnsResolution(format!( + "DoH query failed with status {}: {}", + status, status_text + ))); + } + + let empty_vec = Vec::new(); + let answers = response["Answer"].as_array().unwrap_or(&empty_vec); + + if answers.is_empty() { + return Ok(format!("{:?} records: (none found)", self.record_type)); + } + + let mut records = Vec::new(); + for answer in answers { + if let Some(data) = answer["data"].as_str() { + records.push(data.to_string()); + } + } + + if records.is_empty() { + Ok(format!("{:?} records: (none found)", self.record_type)) + } else { + Ok(format!( + "{:?} records: {}", + self.record_type, + records.join(", ") + )) + } + } +} + +pub async fn test_doh_providers(domain: &str, record_type: RecordType) -> Vec { + let mut results = Vec::new(); + + for provider in DOH_PROVIDERS { + let test = DohTest::new(domain.to_string(), record_type, provider.clone()); + results.push(test.run().await); + } + + results +} + +pub async fn test_doh_comprehensive(domain: &str) -> Vec { + let mut results = Vec::new(); + + // Test A records with JSON-compatible providers (Google and Cloudflare variants) + let json_compatible_providers = [ + &DOH_PROVIDERS[0], // Google + &DOH_PROVIDERS[1], // Cloudflare Primary + &DOH_PROVIDERS[2], // Cloudflare-Family + &DOH_PROVIDERS[3], // Cloudflare-Security + ]; + + for provider in json_compatible_providers { + let test = DohTest::new(domain.to_string(), RecordType::A, provider.clone()); + results.push(test.run().await); + } + + // Test AAAA records with the same JSON-compatible providers + for provider in json_compatible_providers { + let test = DohTest::new(domain.to_string(), RecordType::AAAA, provider.clone()); + results.push(test.run().await); + } + + results +} + +pub fn get_provider_by_name(name: &str) -> Option<&'static DohProvider> { + DOH_PROVIDERS + .iter() + .find(|provider| provider.name.to_lowercase() == name.to_lowercase()) +} + +pub fn list_doh_providers() -> Vec { + let mut results = Vec::new(); + + for provider in DOH_PROVIDERS { + let details = format!("{} - {}", provider.url, provider.description); + let result = TestResult::new(format!("DoH Provider: {}", provider.name)) + .success(Duration::from_millis(0), details); + results.push(result); + } + + results +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 607b579..fa2efa9 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -10,9 +10,11 @@ use tokio::net::TcpStream; use tokio::time::timeout; pub mod categories; +pub mod doh; pub mod queries; pub use categories::*; +pub use doh::*; pub use queries::*; #[derive(Debug, Clone)] @@ -614,18 +616,35 @@ pub async fn test_common_dns_servers(domain: &str, record_type: RecordType) -> V ); results.push(system_result); - // Then test external DNS servers + // Test all traditional DNS servers (UDP/TCP) let servers = [ - "8.8.8.8:53", // Google Primary - "8.8.4.4:53", // Google Secondary - "1.1.1.1:53", // Cloudflare Primary - "1.0.0.1:53", // Cloudflare Secondary - "9.9.9.9:53", // Quad9 Primary - "149.112.112.112:53", // Quad9 Secondary - "208.67.222.222:53", // OpenDNS Primary - "208.67.220.220:53", // OpenDNS Secondary - "1.1.1.2:53", // Cloudflare Family (blocks malware/adult) - "1.1.1.3:53", // Cloudflare Family (blocks malware) + // Google DNS + "8.8.8.8:53", // Google Primary + "8.8.4.4:53", // Google Secondary + // Cloudflare DNS - All variants + "1.1.1.1:53", // Cloudflare Primary (standard) + "1.0.0.1:53", // Cloudflare Secondary (standard) + "1.1.1.2:53", // Cloudflare Family (blocks malware/adult) + "1.1.1.3:53", // Cloudflare Family (blocks malware only) + // Quad9 DNS - All variants + "9.9.9.9:53", // Quad9 Primary (blocks malicious domains) + "149.112.112.112:53", // Quad9 Secondary (blocks malicious domains) + "9.9.9.10:53", // Quad9 Unsecured (no blocking) + "149.112.112.10:53", // Quad9 Unsecured Secondary (no blocking) + "9.9.9.11:53", // Quad9 Secured + ECS (blocks malicious + EDNS) + "149.112.112.11:53", // Quad9 Secured + ECS Secondary + // OpenDNS + "208.67.222.222:53", // OpenDNS Primary + "208.67.220.220:53", // OpenDNS Secondary + "208.67.222.123:53", // OpenDNS FamilyShield Primary + "208.67.220.123:53", // OpenDNS FamilyShield Secondary + // AdGuard DNS - All variants + "94.140.14.14:53", // AdGuard DNS Primary (blocks ads/trackers) + "94.140.15.15:53", // AdGuard DNS Secondary (blocks ads/trackers) + "94.140.14.15:53", // AdGuard DNS Family Primary (blocks ads/trackers/adult) + "94.140.15.16:53", // AdGuard DNS Family Secondary (blocks ads/trackers/adult) + "94.140.14.140:53", // AdGuard DNS Unfiltered Primary + "94.140.14.141:53", // AdGuard DNS Unfiltered Secondary ]; for server_str in &servers { @@ -635,6 +654,10 @@ pub async fn test_common_dns_servers(domain: &str, record_type: RecordType) -> V } } + // Add DNS-over-HTTPS tests (all available DoH providers) + let doh_results = crate::dns::doh::test_doh_providers(domain, record_type).await; + results.extend(doh_results); + results } diff --git a/src/dns/queries.rs b/src/dns/queries.rs index 313dc0c..24200ac 100644 --- a/src/dns/queries.rs +++ b/src/dns/queries.rs @@ -15,11 +15,25 @@ pub async fn comprehensive_dns_test(domain: &str) -> Vec { RecordType::SOA, ]; + // Test all record types using system DNS resolver + // This focuses on comprehensive record type analysis rather than server testing for record_type in &record_types { let test = DnsTest::new(domain.to_string(), *record_type); results.push(test.run().await); } + // Add a few key DoH tests for comparison (JSON-compatible providers only) + let key_doh_providers = [ + &crate::dns::doh::DOH_PROVIDERS[0], // Google + &crate::dns::doh::DOH_PROVIDERS[1], // Cloudflare Primary + ]; + + for provider in key_doh_providers { + let test = + crate::dns::doh::DohTest::new(domain.to_string(), RecordType::A, provider.clone()); + results.push(test.run().await); + } + results } diff --git a/src/main.rs b/src/main.rs index b3c26cc..0751966 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,6 +184,38 @@ async fn handle_dns_command(command: cli::DnsCommands, timeout: Duration) -> Vec dns::queries::comprehensive_dns_test(&domain).await } cli::DnsCommands::Large { domain } => dns::queries::test_large_dns_queries(&domain).await, + cli::DnsCommands::Doh { + domain, + provider, + record_type, + } => { + let mut results = Vec::new(); + for rt in record_type.to_record_type() { + match &provider { + Some(provider_name) => { + if let Some(provider) = dns::doh::get_provider_by_name(provider_name) { + let test = dns::doh::DohTest::new(domain.clone(), rt, provider.clone()); + results.push(test.run().await); + } else { + results.push(TestResult::new("DoH Provider Error".to_string()).failure( + Duration::from_millis(0), + NetworkError::Other(format!( + "Unknown DoH provider: {}. Available providers: google, google-json, cloudflare, cloudflare-family, cloudflare-security, cloudflare-json, cloudflare-family-json, cloudflare-security-json, quad9, quad9-unsecured, quad9-ecs, opendns, opendns-family, adguard, adguard-family, adguard-unfiltered", + provider_name + )), + )); + break; // Don't repeat error for each record type + } + } + None => { + let provider_results = dns::doh::test_doh_providers(&domain, rt).await; + results.extend(provider_results); + } + } + } + results + } + cli::DnsCommands::DohProviders => dns::doh::list_doh_providers(), } }