1 unstable release
| 0.1.1 | Oct 24, 2025 |
|---|
#901 in HTTP server
49KB
723 lines
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#"
<title>HTTPX-RS Scan Report</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 2rem; } .container { max-width: 1400px; margin: 0 auto; } h1 { color: white; text-align: center; margin-bottom: 2rem; font-size: 2.5rem; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; } .stat-card { background: rgba(255, 255, 255, 0.9); border-radius: 12px; padding: 1.5rem; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.2); } .stat-number { font-size: 2rem; font-weight: bold; color: #667eea; } .stat-label { color: #666; margin-top: 0.5rem; } .results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } .result-card { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.2); transition: transform 0.3s, box-shadow 0.3s; } .result-card:hover { transform: translateY(-5px); box-shadow: 0 15px 40px rgba(0,0,0,0.3); } .screenshot { width: 100%; height: 200px; object-fit: cover; border-bottom: 2px solid #f0f0f0; } .result-info { padding: 1.5rem; } .url { font-weight: bold; color: #333; margin-bottom: 0.5rem; word-break: break-all; } .status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.875rem; font-weight: bold; margin-bottom: 0.5rem; } .status-200 { background: #10b981; color: white; } .status-300 { background: #f59e0b; color: white; } .status-400 { background: #ef4444; color: white; } .status-500 { background: #8b5cf6; color: white; } .title { color: #666; margin: 0.5rem 0; font-size: 0.9rem; } .techs { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; } .tech-badge { background: linear-gradient(135deg, #667eea, #764ba2); color: white; padding: 0.25rem 0.75rem; border-radius: 15px; font-size: 0.8rem; } .no-screenshot { width: 100%; height: 200px; background: linear-gradient(135deg, #e0e7ff, #c7d2fe); display: flex; align-items: center; justify-content: center; color: #6366f1; font-weight: bold; } </style>🚀 HTTPX-RS Scan Report
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(())
}
Dependencies
~19–43MB
~673K SLoC