use crate::model::DnsSummary;
use anyhow::{Context, Result};
use std::net::IpAddr;
use std::time::Instant;
use tokio::net::lookup_host;
pub async fn measure_dns_resolution(hostname: &str) -> Result<DnsSummary> {
let dns_servers = get_system_dns_servers();
let lookup_target = if hostname.contains(':') {
hostname.to_string()
} else {
format!("{}:443", hostname)
};
let start = Instant::now();
let addrs: Vec<std::net::SocketAddr> = lookup_host(&lookup_target)
.await
.with_context(|| format!("DNS lookup failed for {}", hostname))?
.collect();
let elapsed = start.elapsed();
let mut ipv4_count = 0;
let mut ipv6_count = 0;
let mut resolved_ips = Vec::new();
for addr in &addrs {
let ip = addr.ip();
resolved_ips.push(ip.to_string());
match ip {
IpAddr::V4(_) => ipv4_count += 1,
IpAddr::V6(_) => ipv6_count += 1,
}
}
resolved_ips.sort();
resolved_ips.dedup();
Ok(DnsSummary {
hostname: hostname.to_string(),
resolution_time_ms: elapsed.as_secs_f64() * 1000.0,
resolved_ips,
ipv4_count,
ipv6_count,
dns_servers,
})
}
fn get_system_dns_servers() -> Vec<String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
get_dns_from_resolv_conf()
}
#[cfg(target_os = "windows")]
{
get_dns_from_windows()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Vec::new()
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn get_dns_from_resolv_conf() -> Vec<String> {
use std::fs;
let content = match fs::read_to_string("/etc/resolv.conf") {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let servers: Vec<String> = content
.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
return None;
}
if line.starts_with("nameserver") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if parts[1].parse::<IpAddr>().is_ok() {
return Some(parts[1].to_string());
}
}
}
None
})
.collect();
if servers.len() == 1 && servers[0] == "127.0.0.53" {
if let Some(upstream) = get_systemd_resolved_servers() {
if !upstream.is_empty() {
return upstream;
}
}
}
servers
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn get_systemd_resolved_servers() -> Option<Vec<String>> {
use std::process::Command;
let output = Command::new("resolvectl").arg("status").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut servers = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.starts_with("DNS Servers:") || line.starts_with("Current DNS Server:") {
if let Some(pos) = line.find(':') {
let server_part = line[pos + 1..].trim();
for server in server_part.split_whitespace() {
if server.parse::<IpAddr>().is_ok() && !servers.contains(&server.to_string()) {
servers.push(server.to_string());
}
}
}
}
}
if servers.is_empty() {
None
} else {
Some(servers)
}
}
#[cfg(target_os = "windows")]
fn get_dns_from_windows() -> Vec<String> {
use std::process::Command;
let output = match Command::new("ipconfig").arg("/all").output() {
Ok(o) => o,
Err(_) => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut dns_servers = Vec::new();
let mut in_dns_section = false;
for line in stdout.lines() {
let line = line.trim();
if line.contains("DNS Servers") {
in_dns_section = true;
if let Some(pos) = line.find(':') {
let ip_part = line[pos + 1..].trim();
if !ip_part.is_empty() && ip_part.parse::<IpAddr>().is_ok() {
dns_servers.push(ip_part.to_string());
}
}
} else if in_dns_section {
if line.is_empty() || line.contains(':') {
in_dns_section = false;
} else if line.parse::<IpAddr>().is_ok() {
dns_servers.push(line.to_string());
}
}
}
dns_servers.sort();
dns_servers.dedup();
dns_servers
}
pub fn extract_hostname(url: &str) -> Option<String> {
reqwest::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
}
pub async fn fetch_external_ips(
base_url: &str,
bind_ip: Option<std::net::IpAddr>,
cert_path: Option<&std::path::Path>,
) -> (Option<String>, Option<String>) {
let hostname = match extract_hostname(base_url) {
Some(h) => h,
None => return (None, None),
};
let url = format!("{}/__down?bytes=0", base_url);
let (ipv4, ipv6) = tokio::join!(
fetch_external_ip_version(&url, &hostname, IpVersion::V4, bind_ip, cert_path),
fetch_external_ip_version(&url, &hostname, IpVersion::V6, bind_ip, cert_path)
);
(ipv4, ipv6)
}
#[derive(Clone, Copy)]
enum IpVersion {
V4,
V6,
}
async fn fetch_external_ip_version(
url: &str,
hostname: &str,
version: IpVersion,
bind_ip: Option<std::net::IpAddr>,
cert_path: Option<&std::path::Path>,
) -> Option<String> {
use super::network_bind;
use std::net::SocketAddr;
use std::time::Duration;
let lookup_target = format!("{}:443", hostname);
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&lookup_target)
.await
.ok()?
.collect();
let target_addr = addrs.into_iter().find(|addr| match version {
IpVersion::V4 => addr.is_ipv4(),
IpVersion::V6 => addr.is_ipv6(),
})?;
let mut builder = reqwest::Client::builder()
.resolve(hostname, target_addr)
.timeout(Duration::from_secs(5));
if let Some(path) = cert_path {
builder = builder.add_root_certificate(super::cert::load_reqwest_certificate(path).ok()?);
}
let client = network_bind::apply_local_address(builder, bind_ip)
.build()
.ok()?;
let resp = client.get(url).send().await.ok()?;
resp.headers()
.get("cf-meta-ip")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_hostname() {
assert_eq!(
extract_hostname("https://speed.cloudflare.com"),
Some("speed.cloudflare.com".to_string())
);
assert_eq!(
extract_hostname("https://example.com:8080/path"),
Some("example.com".to_string())
);
assert_eq!(extract_hostname("not a url"), None);
}
}