#!/usr/bin/env cargo run
use bytes::Bytes;
use clap::Parser;
use http::header::{HeaderMap, HeaderName, HeaderValue};
use http::StatusCode;
use http::Uri;
use http_stat::{
connect, format_duration, request, BenchmarkSummary, ConnectTo, HttpRequest, HttpStat,
ALPN_HTTP1, ALPN_HTTP2, ALPN_HTTP3,
};
use std::net::IpAddr;
use tokio::fs;
#[cfg(target_env = "musl")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long)]
url: Option<String>,
#[arg(
short = 'H',
help = "set HTTP header; repeatable: -H 'Accept: ...' -H 'Range: ...'"
)]
headers: Vec<String>,
#[arg(short = '4', help = "resolve host to ipv4 only")]
ipv4: bool,
#[arg(short = '6', help = "resolve host to ipv6 only")]
ipv6: bool,
#[arg(short = 'k', help = "skip verify tls certificate")]
skip_verify: bool,
#[arg(short = 'o', help = "output file")]
output: Option<String>,
#[arg(short = 'L', help = "follow 30x redirects")]
follow_redirect: bool,
#[arg(short = 'X', help = "HTTP method to use (default GET)")]
method: Option<String>,
#[arg(
short = 'd',
long = "data",
help = "the body of a POST or PUT request; from file use @filename, from stdin use @-"
)]
data: Option<String>,
#[arg(help = "url to request")]
url_arg: Option<String>,
#[arg(
long = "resolve",
help = "resolve the request host to specific ip address (e.g. 1.2.3.4,1.2.3.5)"
)]
resolve: Option<String>,
#[arg(
long = "compressed",
help = "request compressed response: gzip, br, zstd"
)]
compressed: bool,
#[arg(long = "http3", help = "use http/3")]
http3: bool,
#[arg(long = "http2", help = "use http/2")]
http2: bool,
#[arg(long = "http1", help = "use http/1.1")]
http1: bool,
#[arg(
short = 's',
help = "silent mode, only output the connect address and result"
)]
silent: bool,
#[arg(
long = "dns-servers",
help = "dns server address to use, format: 8.8.8.8,8.8.4.4"
)]
dns_servers: Option<String>,
#[arg(short = 'v', long = "verbose", help = "verbose mode")]
verbose: bool,
#[arg(long = "pretty", help = "pretty mode")]
pretty: bool,
#[arg(long = "waterfall", help = "show timing as a waterfall bar chart")]
waterfall: bool,
#[arg(long = "timeout", help = "timeout")]
timeout: Option<String>,
#[arg(
short = 'n',
long = "count",
help = "number of requests for benchmarking, show min/max/avg/p50/p95/p99 stats"
)]
count: Option<usize>,
#[arg(
short = 'K',
long = "reuse",
help = "reuse connection in benchmark mode (requires -n), test warm request performance"
)]
reuse: bool,
#[arg(
short = 'b',
long = "cookie",
help = "send cookies: 'name=value; name2=value2' or from file use @filename"
)]
cookie: Option<String>,
#[arg(long = "json", help = "output results as JSON for scripting and CI/CD")]
json: bool,
#[arg(
long = "connect-to",
help = "redirect HOST1:PORT1 to HOST2:PORT2 (repeatable); TLS SNI and Host header stay unchanged"
)]
connect_to: Vec<String>,
#[arg(
long = "proxy",
help = "proxy URL: http://host:port, https://host:port, socks5://host:port"
)]
proxy: Option<String>,
#[arg(long = "cert", help = "client certificate for mTLS (PEM file)")]
cert: Option<String>,
#[arg(long = "key", help = "client private key for mTLS (PEM file)")]
key: Option<String>,
#[arg(
long = "bind",
help = "bind to a specific local IP address (e.g. 192.168.1.100 or ::1)"
)]
bind: Option<String>,
#[arg(
long = "jq",
help = "filter JSON response body with a jq-style selector (e.g. \".items[].name\")"
)]
jq: Option<String>,
#[arg(
long = "include-header",
help = "only show these response headers (repeatable, case-insensitive)"
)]
include_header: Vec<String>,
#[arg(
long = "exclude-header",
help = "hide these response headers (repeatable, case-insensitive)"
)]
exclude_header: Vec<String>,
}
fn load_config() -> serde_json::Map<String, serde_json::Value> {
let path = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(|h| std::path::PathBuf::from(h).join(".httpstatrc"));
let Some(path) = path else {
return serde_json::Map::new();
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return serde_json::Map::new(),
};
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(serde_json::Value::Object(map)) => map,
Ok(_) => {
eprintln!("httpstat: ~/.httpstatrc must be a JSON object, ignoring");
serde_json::Map::new()
}
Err(e) => {
eprintln!("httpstat: failed to parse ~/.httpstatrc: {e}, ignoring");
serde_json::Map::new()
}
}
}
fn apply_config(args: &mut Args, cfg: &serde_json::Map<String, serde_json::Value>) {
macro_rules! cfg_bool {
($field:ident) => {
if !args.$field {
if let Some(true) = cfg.get(stringify!($field)).and_then(|v| v.as_bool()) {
args.$field = true;
}
}
};
}
macro_rules! cfg_opt_str {
($field:ident) => {
if args.$field.is_none() {
if let Some(s) = cfg.get(stringify!($field)).and_then(|v| v.as_str()) {
args.$field = Some(s.to_string());
}
}
};
}
cfg_bool!(compressed);
cfg_bool!(verbose);
cfg_bool!(pretty);
cfg_bool!(silent);
cfg_bool!(follow_redirect);
cfg_bool!(skip_verify);
cfg_bool!(http1);
cfg_bool!(http2);
cfg_bool!(http3);
cfg_bool!(json);
cfg_opt_str!(dns_servers);
cfg_opt_str!(timeout);
cfg_opt_str!(cookie);
cfg_opt_str!(output);
for key in &["headers", "include_header", "exclude_header"] {
if let Some(arr) = cfg.get(*key).and_then(|v| v.as_array()) {
let defaults: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect();
if !defaults.is_empty() {
let field = match *key {
"headers" => &mut args.headers,
"include_header" => &mut args.include_header,
_ => &mut args.exclude_header,
};
let mut merged = defaults;
merged.append(field);
*field = merged;
}
}
}
}
fn collect_cookies(stat: &HttpStat, existing: &str) -> String {
let mut cookies = std::collections::HashMap::new();
for pair in existing.split(';') {
let pair = pair.trim();
if let Some((name, value)) = pair.split_once('=') {
cookies.insert(name.trim().to_string(), value.trim().to_string());
}
}
if let Some(headers) = &stat.headers {
for value in headers.get_all(http::header::SET_COOKIE).iter() {
let value = value.to_str().unwrap_or_default();
let cookie_part = value.split(';').next().unwrap_or_default().trim();
if let Some((name, val)) = cookie_part.split_once('=') {
cookies.insert(name.trim().to_string(), val.trim().to_string());
}
}
}
cookies
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ")
}
async fn do_request(mut req: HttpRequest, follow_redirect: bool) -> HttpStat {
let mut stat = request(req.clone()).await;
if follow_redirect {
for _ in 0..10 {
let status = stat.status.unwrap_or(StatusCode::OK);
if ![
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
]
.contains(&status)
{
break;
}
let redirect_url = stat
.headers
.as_ref()
.and_then(|header| header.get("Location"))
.map(|value| value.to_str().unwrap_or(""))
.unwrap_or("");
if redirect_url.is_empty() {
break;
}
let existing_cookie = req
.headers
.as_ref()
.and_then(|h| h.get(http::header::COOKIE))
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let merged = collect_cookies(&stat, &existing_cookie);
if !merged.is_empty() {
if let Ok(value) = merged.parse::<HeaderValue>() {
let header_map = req.headers.get_or_insert_with(HeaderMap::new);
header_map.insert(http::header::COOKIE, value);
}
}
if let Ok(uri) = redirect_url.parse::<Uri>() {
req.uri = uri;
stat = request(req.clone()).await;
}
}
}
stat
}
fn benchmark_to_json(stats: &[HttpStat], connect_stat: Option<&HttpStat>) -> serde_json::Value {
let dur_us = |d: Option<std::time::Duration>| -> serde_json::Value {
d.map_or(serde_json::Value::Null, |d| {
serde_json::json!(d.as_micros() as u64)
})
};
let calc = |f: fn(&HttpStat) -> Option<std::time::Duration>| -> Vec<std::time::Duration> {
let mut v: Vec<std::time::Duration> = stats.iter().filter_map(f).collect();
v.sort();
v
};
let stat_obj = |sorted: &[std::time::Duration]| -> serde_json::Value {
if sorted.is_empty() {
return serde_json::Value::Null;
}
let sum: std::time::Duration = sorted.iter().sum();
let avg = sum / sorted.len() as u32;
let p = |pct: f64| -> u64 {
let idx = ((pct * sorted.len() as f64).ceil() as usize)
.saturating_sub(1)
.min(sorted.len() - 1);
sorted[idx].as_micros() as u64
};
serde_json::json!({
"min_us": sorted.first().unwrap().as_micros() as u64,
"max_us": sorted.last().unwrap().as_micros() as u64,
"avg_us": avg.as_micros() as u64,
"p50_us": p(0.5),
"p95_us": p(0.95),
"p99_us": p(0.99),
})
};
let success = stats.iter().filter(|s| s.is_success()).count();
let total = stats.len();
let mut obj = serde_json::json!({
"count": total,
"success": success,
"timing": {
"dns_lookup": stat_obj(&calc(|s| s.dns_lookup)),
"tcp_connect": stat_obj(&calc(|s| s.tcp_connect)),
"tls_handshake": stat_obj(&calc(|s| s.tls_handshake)),
"quic_connect": stat_obj(&calc(|s| s.quic_connect)),
"server_processing": stat_obj(&calc(|s| s.server_processing)),
"content_transfer": stat_obj(&calc(|s| s.content_transfer)),
"total": stat_obj(&calc(|s| s.total)),
},
});
if let Some(cs) = connect_stat {
obj["cold_connect"] = serde_json::json!({
"dns_lookup_us": dur_us(cs.dns_lookup),
"tcp_connect_us": dur_us(cs.tcp_connect),
"tls_handshake_us": dur_us(cs.tls_handshake),
"total_us": dur_us(cs.total),
});
}
obj
}
async fn handle_output(body: Option<Bytes>, output: Option<String>) {
let Some(output) = output else {
return;
};
let Some(body) = body else {
return;
};
if let Err(e) = fs::write(output, body).await {
println!("write output error: {e}");
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut args = Args::parse();
let config = load_config();
apply_config(&mut args, &config);
let Some(url) = args.url.or(args.url_arg) else {
println!("httpstat: try 'httpstat -h' or 'httpstat --help' for more information");
std::process::exit(1);
};
let mut req: HttpRequest = match url.as_str().try_into() {
Ok(req) => req,
Err(e) => {
eprintln!("httpstat: invalid URL: {e}");
std::process::exit(1);
}
};
if args.ipv4 {
req.ip_version = Some(4);
}
if args.ipv6 {
req.ip_version = Some(6);
}
req.skip_verify = args.skip_verify;
if let Some(bind_str) = args.bind {
match bind_str.parse::<std::net::IpAddr>() {
Ok(ip) => req.bind_addr = Some(ip),
Err(_) => {
eprintln!("httpstat: invalid --bind address '{bind_str}'");
std::process::exit(1);
}
}
}
if let Some(dns_servers) = args.dns_servers {
req.dns_servers = Some(dns_servers.split(',').map(|s| s.to_string()).collect());
}
if let Some(timeout_str) = args.timeout {
let timeout: std::time::Duration = match timeout_str.parse::<humantime::Duration>() {
Ok(d) => d.into(),
Err(e) => {
eprintln!("httpstat: invalid timeout '{timeout_str}': {e}");
std::process::exit(1);
}
};
req.dns_timeout = Some(timeout);
req.tcp_timeout = Some(timeout);
req.tls_timeout = Some(timeout);
req.request_timeout = Some(timeout);
req.quic_timeout = Some(timeout);
}
if !args.headers.is_empty() {
let mut header_map = HeaderMap::new();
for header in args.headers {
if let Some((name, value)) = header.split_once(':') {
let name = name.trim();
let value = value.trim();
if let Ok(header_name) = name.parse::<HeaderName>() {
if let Ok(header_value) = value.parse::<HeaderValue>() {
header_map.insert(header_name, header_value);
}
}
}
}
req.headers = Some(header_map);
}
if args.compressed {
let value = HeaderValue::from_static("gzip, br, zstd");
if let Some(header_map) = req.headers.as_mut() {
header_map.insert(http::header::ACCEPT_ENCODING, value);
} else {
let mut header_map = HeaderMap::new();
header_map.insert(http::header::ACCEPT_ENCODING, value);
req.headers = Some(header_map);
}
}
if let Some(cookie) = args.cookie {
let cookie_value = if let Some(file_path) = cookie.strip_prefix('@') {
match fs::read_to_string(file_path).await {
Ok(content) => content.trim().to_string(),
Err(e) => {
eprintln!("httpstat: failed to read cookie file '{}': {e}", file_path);
std::process::exit(1);
}
}
} else {
cookie
};
if let Ok(value) = cookie_value.parse::<HeaderValue>() {
let header_map = req.headers.get_or_insert_with(HeaderMap::new);
header_map.insert(http::header::COOKIE, value);
}
}
req.method = args.method;
if let Some(data) = args.data {
if let Some(file_path) = data.strip_prefix('@') {
if file_path == "-" {
let mut buf = Vec::new();
if let Err(e) = std::io::Read::read_to_end(&mut std::io::stdin(), &mut buf) {
eprintln!("httpstat: failed to read stdin: {e}");
std::process::exit(1);
}
req.body = Some(Bytes::from(buf));
} else {
match fs::read(file_path).await {
Ok(content) => req.body = Some(Bytes::from(content)),
Err(e) => {
eprintln!("httpstat: failed to read file '{}': {e}", file_path);
std::process::exit(1);
}
}
}
} else {
req.body = Some(Bytes::from(data));
}
}
if !args.connect_to.is_empty() {
for entry in &args.connect_to {
if ConnectTo::parse(entry).is_none() {
eprintln!(
"httpstat: invalid --connect-to '{}': expected HOST1:PORT1:HOST2:PORT2",
entry
);
std::process::exit(1);
}
}
req.connect_to = args.connect_to;
}
let proxy = args.proxy.or_else(|| {
let scheme = req.uri.scheme_str().unwrap_or("http");
let from_env = if scheme == "https" {
std::env::var("HTTPS_PROXY")
.or_else(|_| std::env::var("https_proxy"))
.ok()
} else {
std::env::var("HTTP_PROXY")
.or_else(|_| std::env::var("http_proxy"))
.ok()
};
from_env.or_else(|| {
std::env::var("ALL_PROXY")
.or_else(|_| std::env::var("all_proxy"))
.ok()
})
});
req.proxy = proxy;
match (args.cert, args.key) {
(Some(cert_path), Some(key_path)) => {
match (std::fs::read(&cert_path), std::fs::read(&key_path)) {
(Ok(cert), Ok(key)) => {
req.client_cert = Some(cert);
req.client_key = Some(key);
}
(Err(e), _) => {
eprintln!("httpstat: failed to read cert file '{}': {e}", cert_path);
std::process::exit(1);
}
(_, Err(e)) => {
eprintln!("httpstat: failed to read key file '{}': {e}", key_path);
std::process::exit(1);
}
}
}
(Some(_), None) => {
eprintln!("httpstat: --cert requires --key");
std::process::exit(1);
}
(None, Some(_)) => {
eprintln!("httpstat: --key requires --cert");
std::process::exit(1);
}
(None, None) => {}
}
if args.http1 {
req.alpn_protocols = vec![ALPN_HTTP1.to_string()];
}
if args.http2 {
req.alpn_protocols = vec![ALPN_HTTP2.to_string()];
}
if args.http3 {
req.alpn_protocols = vec![ALPN_HTTP3.to_string()];
}
let output = args.output;
let count = args.count.unwrap_or(1).max(1);
let include_headers: Option<Vec<String>> = if args.include_header.is_empty() {
None
} else {
Some(
args.include_header
.iter()
.map(|h| h.to_lowercase())
.collect(),
)
};
let exclude_headers: Option<Vec<String>> = if args.exclude_header.is_empty() {
None
} else {
Some(
args.exclude_header
.iter()
.map(|h| h.to_lowercase())
.collect(),
)
};
let json_output = args.json;
let mut exit_code = 0i32;
if let Some(resolve) = args.resolve {
let ips = resolve.split(',').collect::<Vec<&str>>();
let mut futs = vec![];
for ip in ips {
let mut req = req.clone();
let Ok(ip) = ip.parse::<IpAddr>() else {
continue;
};
req.resolve = Some(ip);
futs.push(do_request(req, args.follow_redirect));
}
let mut stats_list = futures::future::join_all(futs).await;
stats_list.sort_by(|item1, item2| {
let value1 = item1.error.is_some();
let value2 = item2.error.is_some();
value1.cmp(&value2)
});
if json_output {
let arr: Vec<_> = stats_list.iter().map(|s| s.to_json()).collect();
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
for s in &stats_list {
let code = s.exit_code();
if code != 0 && exit_code == 0 {
exit_code = code;
}
}
} else {
for mut stat in stats_list {
stat.verbose = args.verbose;
stat.silent = args.silent;
stat.pretty = args.pretty;
stat.waterfall = args.waterfall;
stat.jq_filter.clone_from(&args.jq);
stat.include_headers.clone_from(&include_headers);
stat.exclude_headers.clone_from(&exclude_headers);
let body = stat.body.clone();
handle_output(body, output.clone()).await;
if output.is_some() {
stat.body = None;
}
println!("{stat}");
if exit_code == 0 {
exit_code = stat.exit_code();
}
}
}
} else if count > 1 && args.reuse {
let (connect_stat, conn) = connect(&req).await;
if let Some(mut conn) = conn {
let width = count.to_string().len();
let mut stats = Vec::with_capacity(count);
for i in 0..count {
let mut stat = conn.send(&req).await;
stat.addr.clone_from(&connect_stat.addr);
stat.alpn.clone_from(&connect_stat.alpn);
stat.silent = true;
if !json_output {
print!("[{:>width$}/{count}] {stat}", i + 1);
}
if exit_code == 0 {
exit_code = stat.exit_code();
}
stat.body = None;
stats.push(stat);
}
if json_output {
let json_val = benchmark_to_json(&stats, Some(&connect_stat));
println!(
"{}",
serde_json::to_string_pretty(&json_val).unwrap_or_default()
);
} else {
let summary = BenchmarkSummary { stats };
println!("{summary}");
let mut parts = vec![];
if let Some(d) = connect_stat.dns_lookup {
parts.push(format!("DNS {}", format_duration(d)));
}
if let Some(d) = connect_stat.tcp_connect {
parts.push(format!("TCP {}", format_duration(d)));
}
if let Some(d) = connect_stat.tls_handshake {
parts.push(format!("TLS {}", format_duration(d)));
}
println!(
" Cold connect: {} ({})",
format_duration(connect_stat.total.unwrap_or_default()),
parts.join(" + ")
);
}
} else {
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&connect_stat.to_json()).unwrap_or_default()
);
} else {
println!("{connect_stat}");
}
exit_code = connect_stat.exit_code();
}
} else if count > 1 {
let width = count.to_string().len();
let mut stats = Vec::with_capacity(count);
for i in 0..count {
let mut stat = do_request(req.clone(), args.follow_redirect).await;
stat.silent = true;
if !json_output {
print!("[{:>width$}/{count}] {stat}", i + 1);
}
if exit_code == 0 {
exit_code = stat.exit_code();
}
stat.body = None;
stats.push(stat);
}
if json_output {
let json_val = benchmark_to_json(&stats, None);
println!(
"{}",
serde_json::to_string_pretty(&json_val).unwrap_or_default()
);
} else {
let summary = BenchmarkSummary { stats };
println!("{summary}");
}
} else {
let mut stat = do_request(req, args.follow_redirect).await;
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&stat.to_json()).unwrap_or_default()
);
} else {
stat.verbose = args.verbose;
stat.silent = args.silent;
stat.pretty = args.pretty;
stat.waterfall = args.waterfall;
stat.jq_filter = args.jq;
stat.include_headers = include_headers;
stat.exclude_headers = exclude_headers;
let body = stat.body.clone();
handle_output(body, output.clone()).await;
if output.is_some() {
stat.body = None;
}
println!("{stat}");
}
exit_code = stat.exit_code();
}
if exit_code != 0 {
std::process::exit(exit_code);
}
}