use anyhow::Result; use clap::Parser; use colored::*; use indicatif::{ProgressBar, ProgressStyle}; use ipnet::IpNet; use once_cell::sync::Lazy; use regex::Regex; use reqwest::Client; use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs::{self, File}; use std::io::Write; use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; use tokio::sync::Semaphore;
static TECH_PATTERNS: Lazy<Vec<(String, Regex)>> = Lazy::new(|| { vec![ ("WordPress".to_string(), Regex::new(r"wp-content|wp-includes").unwrap()), ("Nginx".to_string(), Regex::new(r"nginx").unwrap()), ("Apache".to_string(), Regex::new(r"Apache").unwrap()), ("PHP".to_string(), Regex::new(r"X-Powered-By: PHP").unwrap()), ("ASP.NET".to_string(), Regex::new(r"ASP.NET|X-AspNet").unwrap()), ("Django".to_string(), Regex::new(r"csrfmiddlewaretoken|django").unwrap()), ("React".to_string(), Regex::new(r"_react|react-root").unwrap()), ("Vue.js".to_string(), Regex::new(r"Vue.js|v-cloak").unwrap()), ("Angular".to_string(), Regex::new(r"ng-version|angular").unwrap()), ("jQuery".to_string(), Regex::new(r"jquery|jQuery").unwrap()), ("Bootstrap".to_string(), Regex::new(r"bootstrap").unwrap()), ("CloudFlare".to_string(), Regex::new(r"cloudflare|cf-ray").unwrap()), ] });
#[derive(Parser, Debug)] #[command(name = "httpx-rs")] #[command(about = "ā” Fast web server scanner with beautiful output", long_about = None)] #[command(version)] struct Args { /// IP address or CIDR range to scan (e.g., 192.168.1.0/24 or 192.168.1.1) #[arg(required_unless_present = "list")] target: Option,
/// File containing list of IPs/CIDRs to scan (one per line)
#[arg(short = 'l', long, conflicts_with = "target")]
list: Option<String>,
/// Ports to scan (comma-separated)
#[arg(short, long, default_value = "80,443")]
ports: String,
/// Scan common ports (80,443,8000,8001,8443,8080,8081,9000,9001,2083,2087,8060,8090,8880,9043,10000,902,4343,5985,9389)
#[arg(short = 'c', long)]
common_ports: bool,
/// Follow redirects
#[arg(short = 'r', long)]
follow_redirects: bool,
/// Identify technologies
#[arg(short = 't', long)]
tech_detect: bool,
/// Save output to JSON file
#[arg(short = 'j', long)]
json: Option<String>,
/// Save output to text file
#[arg(short = 'o', long)]
output: Option<String>,
/// Take screenshots and save to HTML
#[arg(short = 's', long)]
screenshot: bool,
/// Number of concurrent requests
#[arg(short = 'n', long, default_value = "50")]
threads: usize,
/// Timeout for each request (in seconds)
#[arg(long, default_value = "10")]
timeout: u64,
/// Verbose output
#[arg(short, long)]
verbose: bool,
}
#[derive(Debug, Serialize, Deserialize)] struct ScanResult { url: String, status_code: u16, title: Option, technologies: Vec, redirect_url: Option, screenshot_path: Option, response_time_ms: u128, }
impl ScanResult { fn display(&self) { let status*color = match self.status_code { 200..=299 => "green", 300..=399 => "yellow", 400..=499 => "red", 500..=599 => "magenta",
-
=> "white", };
let status = format!("[{}]", self.status_code).color(status_color); let url = self.url.bright_cyan(); let title = self.title.as_ref() .map(|t| format!(" [{}]", t).bright_white()) .unwrap_or_default(); print!("{} {} {}", status, url, title); if !self.technologies.is_empty() { print!(" {}", format!("[{}]", self.technologies.join(", ")).bright_magenta()); } if let Some(redirect) = &self.redirect_url { print!(" {} {}", "ā".yellow(), redirect.yellow()); } print!(" {}ms", self.response_time_ms.to_string().bright_black()); println!();}
}
fn parse_targets(target: &str) -> Result<Vec> { let mut ips = Vec::new();
// Check if it's a CIDR range
if target.contains('/') {
let network: IpNet = target.parse()?;
for ip in network.hosts() {
ips.push(ip);
}
} else {
// Single IP
let ip: IpAddr = target.parse()?;
ips.push(ip);
}
Ok(ips)
}
fn read_targets_from_file(file_path: &str) -> Result<Vec> { let mut all_ips = Vec::new(); let content = fs::read_to_string(file_path)?;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
continue;
}
// Parse each line as target
match parse_targets(line) {
Ok(mut ips) => all_ips.append(&mut ips),
Err(e) => {
eprintln!("{} {} {}: {}",
"ā ļø".yellow(),
"Skipping invalid target".yellow(),
line.bright_black(),
e
);
}
}
}
// Remove duplicates
all_ips.sort();
all_ips.dedup();
Ok(all_ips)
}
fn parse_ports(ports_str: &str, use_common: bool) -> Vec { if use_common { vec![80, 443, 8000, 8001, 8443, 8080, 8081, 9000, 9001, 2083, 2087, 8060, 8090, 8880, 9043, 10000, 902, 4343, 5985, 9389] } else { ports_str .split(',') .filter_map(|p| p.trim().parse().ok()) .collect() } }
async fn check_http( client: &Client, ip: &IpAddr, port: u16, follow_redirects: bool, tech_detect: bool, ) -> Option { let protocols = if port == 443 || port == 8443 || port == 2083 || port == 2087 { vec!["https"] } else { vec!["http", "https"] };
for protocol in protocols {
let url = format!("{}://{}:{}", protocol, ip, port);
let start = std::time::Instant::now();
// Create a client with appropriate redirect policy
let temp_client = if !follow_redirects {
Client::builder()
.redirect(reqwest::redirect::Policy::none())
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10))
.build()
.ok()?
} else {
client.clone()
};
match temp_client.get(&url).send().await {
Ok(response) => {
let status = response.status();
let headers = response.headers().clone();
let final_url = response.url().to_string();
let redirect_url = if final_url != url {
Some(final_url.clone())
} else {
None
};
let body = response.text().await.unwrap_or_default();
let response_time_ms = start.elapsed().as_millis();
// Parse title
let title = extract_title(&body);
// Detect technologies
let mut technologies = Vec::new();
if tech_detect {
technologies = detect_technologies(&body, &headers);
}
return Some(ScanResult {
url,
status_code: status.as_u16(),
title,
technologies,
redirect_url,
screenshot_path: None,
response_time_ms,
});
}
Err(_) => continue,
}
}
None
}
fn extract_title(html: &str) -> Option { let document = Html::parse_document(html); let selector = Selector::parse("title").ok()?;
document
.select(&selector)
.next()
.map(|element| {
let title = element.text().collect::<String>().trim().to_string();
if title.len() > 100 {
format!("{}...", &title[..100])
} else {
title
}
})
}
fn detect_technologies(body: &str, headers: &reqwest::header::HeaderMap) -> Vec { let mut techs = HashSet::new();
// Check headers
let headers_str = format!("{:?}", headers);
// Check body and headers against patterns
for (tech, pattern) in TECH_PATTERNS.iter() {
if pattern.is_match(body) || pattern.is_match(&headers_str) {
techs.insert(tech.clone());
}
}
// Check for specific headers
if headers.get("x-powered-by").is_some() {
if let Some(value) = headers.get("x-powered-by") {
if let Ok(v) = value.to_str() {
techs.insert(v.to_string());
}
}
}
if headers.get("server").is_some() {
if let Some(value) = headers.get("server") {
if let Ok(v) = value.to_str() {
let server = v.split('/').next().unwrap_or(v);
techs.insert(server.to_string());
}
}
}
techs.into_iter().collect()
}
async fn take_screenshot(url: &str) -> Result { use headless_chrome::{Browser, LaunchOptions};
let browser = Browser::new(LaunchOptions {
headless: true,
sandbox: false,
window_size: Some((1920, 1080)),
..Default::default()
})?;
let tab = browser.new_tab()?;
tab.navigate_to(url)?;
tab.wait_until_navigated()?;
std::thread::sleep(Duration::from_secs(2));
// Use the simplified screenshot API
let screenshot_data = tab.capture_screenshot()?;
let filename = format!("screenshot_{}.png",
url.replace("://", "_").replace("/", "_").replace(":", "_"));
let path = format!("screenshots/{}", filename);
fs::create_dir_all("screenshots")?;
fs::write(&path, &screenshot_data)?;
Ok(path)
}
fn generate_html_report(results: &[ScanResult]) -> Result<()> { let mut html = String::from(r#"
html.push_str(&results.len().to_string());
html.push_str(r#"</div>
<div class="stat-label">Total Servers Found</div>
</div>
<div class="stat-card">
<div class="stat-number">"#);
let success_count = results.iter().filter(|r| r.status_code >= 200 && r.status_code < 300).count();
html.push_str(&success_count.to_string());
html.push_str(r#"</div>
<div class="stat-label">Success (2xx)</div>
</div>
<div class="stat-card">
<div class="stat-number">"#);
let redirect_count = results.iter().filter(|r| r.status_code >= 300 && r.status_code < 400).count();
html.push_str(&redirect_count.to_string());
html.push_str(r#"</div>
<div class="stat-label">Redirects (3xx)</div>
</div>
<div class="stat-card">
<div class="stat-number">"#);
let error_count = results.iter().filter(|r| r.status_code >= 400).count();
html.push_str(&error_count.to_string());
html.push_str(r#"</div>
<div class="stat-label">Errors (4xx/5xx)</div>
</div>
</div>
<div class="results-grid">"#);
for result in results {
let status_class = match result.status_code {
200..=299 => "status-200",
300..=399 => "status-300",
400..=499 => "status-400",
500..=599 => "status-500",
_ => "",
};
html.push_str(&format!(r#"
<div class="result-card">
"#));
if let Some(screenshot_path) = &result.screenshot_path {
html.push_str(&format!(r#"<img src="{}" alt="Screenshot" class="screenshot">"#, screenshot_path));
} else {
html.push_str(r#"<div class="no-screenshot">No Screenshot</div>"#);
}
html.push_str(&format!(r#"
<div class="result-info">
<div class="url">{}</div>
<span class="status {}">{}</span>
"#, result.url, status_class, result.status_code));
if let Some(title) = &result.title {
html.push_str(&format!(r#"<div class="title">š {}</div>"#, title));
}
if !result.technologies.is_empty() {
html.push_str(r#"<div class="techs">"#);
for tech in &result.technologies {
html.push_str(&format!(r#"<span class="tech-badge">{}</span>"#, tech));
}
html.push_str(r#"</div>"#);
}
html.push_str(r#"
</div>
</div>"#);
}
html.push_str(r#"
</div>
</div>
fs::write("scan_report.html", html)?;
Ok(())
}
#[tokio::main] async fn main() -> Result<()> { let args = Args::parse();
// Print banner
println!("{}", r#"
⦠ā¦āā¦āāā¦āāāāāā ⦠ā¦āāāāā
ā ā⣠ā ā ā āāāā©ā¦āāāāā ā¦āāāā
ā© ā© ā© ā© ā© ā© āā ā©āāāāā
"#.bright_magenta());
println!("{}\n", "ā” Fast Web Server Scanner".bright_cyan());
// Parse targets from either single target or file
let ips = if let Some(list_file) = &args.list {
println!("š {} {}", "Reading targets from:".bright_yellow(), list_file.bright_white());
read_targets_from_file(list_file)?
} else if let Some(target) = &args.target {
println!("šÆ {} {}", "Target:".bright_yellow(), target.bright_white());
parse_targets(target)?
} else {
eprintln!("{} No target specified. Use -h for help.", "ā".red());
std::process::exit(1);
};
let ports = parse_ports(&args.ports, args.common_ports);
println!("š {} {:?}", "Ports:".bright_yellow(), ports);
println!("š {} {} IPs\n", "Scanning".bright_yellow(), ips.len().to_string().bright_white());
// Create HTTP client
let client = Client::builder()
.timeout(Duration::from_secs(args.timeout))
.danger_accept_invalid_certs(true)
.build()?;
let client = Arc::new(client);
let semaphore = Arc::new(Semaphore::new(args.threads));
// Create progress bar
let total_scans = ips.len() * ports.len();
let pb = ProgressBar::new(total_scans as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
.progress_chars("#>-"),
);
// Scan all targets
let mut tasks = Vec::new();
for ip in ips {
for port in &ports {
let client = client.clone();
let semaphore = semaphore.clone();
let pb = pb.clone();
let port = *port;
let follow = args.follow_redirects;
let tech = args.tech_detect;
let task = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
let result = check_http(&client, &ip, port, follow, tech).await;
pb.inc(1);
result
});
tasks.push(task);
}
}
// Collect results
let mut results = Vec::new();
for task in tasks {
if let Ok(Some(result)) = task.await {
result.display();
results.push(result);
}
}
pb.finish_and_clear();
// Take screenshots if requested
if args.screenshot && !results.is_empty() {
println!("\nšø {} screenshots...", "Taking".bright_yellow());
let screenshot_pb = ProgressBar::new(results.len() as u64);
for result in &mut results {
if let Ok(path) = take_screenshot(&result.url).await {
result.screenshot_path = Some(path);
}
screenshot_pb.inc(1);
}
screenshot_pb.finish_and_clear();
}
// Save results
if let Some(json_file) = args.json {
let json = serde_json::to_string_pretty(&results)?;
fs::write(&json_file, json)?;
println!("š¾ {} {}", "JSON saved to:".bright_green(), json_file.bright_white());
}
if let Some(text_file) = args.output {
let mut file = File::create(&text_file)?;
for result in &results {
writeln!(file, "{} {} {} {}",
result.status_code,
result.url,
result.title.as_ref().unwrap_or(&String::new()),
result.technologies.join(", ")
)?;
}
println!("š¾ {} {}", "Text saved to:".bright_green(), text_file.bright_white());
}
// Generate HTML report if screenshots were taken
if args.screenshot && !results.is_empty() {
generate_html_report(&results)?;
println!("š {} scan_report.html", "HTML report saved to:".bright_green());
// Try to open the HTML file
#[cfg(target_os = "macos")]
std::process::Command::new("open").arg("scan_report.html").spawn().ok();
#[cfg(target_os = "linux")]
std::process::Command::new("xdg-open").arg("scan_report.html").spawn().ok();
#[cfg(target_os = "windows")]
std::process::Command::new("cmd").args(&["/C", "start", "scan_report.html"]).spawn().ok();
}
// Print summary
println!("\n{}", "ā".repeat(50).bright_black());
println!("⨠{} {}", "Scan Complete!".bright_green().bold(),
format!("Found {} servers", results.len()).bright_white());
Ok(())
}