Add: H3 support - incomplete
This commit is contained in:
@@ -11,6 +11,7 @@ path = "src/main.rs"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
wtfnet-core = { path = "../wtfnet-core" }
|
||||
wtfnet-calc = { path = "../wtfnet-calc" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::path::PathBuf;
|
||||
use wtfnet_core::{
|
||||
@@ -97,6 +97,7 @@ enum SysCommand {
|
||||
enum PortsCommand {
|
||||
Listen(PortsListenArgs),
|
||||
Who(PortsWhoArgs),
|
||||
Conns(PortsConnsArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -107,6 +108,8 @@ enum NeighCommand {
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum CertCommand {
|
||||
Roots,
|
||||
Baseline(CertBaselineArgs),
|
||||
Diff(CertDiffArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -190,6 +193,14 @@ struct PortsWhoArgs {
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct PortsConnsArgs {
|
||||
#[arg(long)]
|
||||
top: Option<usize>,
|
||||
#[arg(long)]
|
||||
by_process: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct NeighListArgs {
|
||||
#[arg(long)]
|
||||
@@ -205,6 +216,16 @@ struct GeoIpLookupArgs {
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct CertBaselineArgs {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct CertDiffArgs {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct ProbePingArgs {
|
||||
target: String,
|
||||
@@ -339,6 +360,10 @@ struct HttpRequestArgs {
|
||||
#[arg(long)]
|
||||
http2_only: bool,
|
||||
#[arg(long)]
|
||||
http3: bool,
|
||||
#[arg(long)]
|
||||
http3_only: bool,
|
||||
#[arg(long)]
|
||||
geoip: bool,
|
||||
#[arg(long)]
|
||||
socks5: Option<String>,
|
||||
@@ -359,6 +384,10 @@ struct TlsArgs {
|
||||
socks5: Option<String>,
|
||||
#[arg(long)]
|
||||
prefer_ipv4: bool,
|
||||
#[arg(long)]
|
||||
show_extensions: bool,
|
||||
#[arg(long)]
|
||||
ocsp: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
@@ -450,6 +479,7 @@ struct HttpReportGeoIp {
|
||||
pub geoip: Vec<wtfnet_geoip::GeoIpRecord>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub timing: wtfnet_http::HttpTiming,
|
||||
}
|
||||
|
||||
@@ -481,12 +511,21 @@ async fn main() {
|
||||
Commands::Ports {
|
||||
command: PortsCommand::Who(args),
|
||||
} => handle_ports_who(&cli, args.clone()).await,
|
||||
Commands::Ports {
|
||||
command: PortsCommand::Conns(args),
|
||||
} => handle_ports_conns(&cli, args.clone()).await,
|
||||
Commands::Neigh {
|
||||
command: NeighCommand::List(args),
|
||||
} => handle_neigh_list(&cli, args.clone()).await,
|
||||
Commands::Cert {
|
||||
command: CertCommand::Roots,
|
||||
} => handle_cert_roots(&cli).await,
|
||||
Commands::Cert {
|
||||
command: CertCommand::Baseline(args),
|
||||
} => handle_cert_baseline(&cli, args.clone()).await,
|
||||
Commands::Cert {
|
||||
command: CertCommand::Diff(args),
|
||||
} => handle_cert_diff(&cli, args.clone()).await,
|
||||
Commands::Geoip {
|
||||
command: GeoIpCommand::Lookup(args),
|
||||
} => handle_geoip_lookup(&cli, args.clone()).await,
|
||||
@@ -820,6 +859,99 @@ async fn handle_ports_who(cli: &Cli, args: PortsWhoArgs) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ports_conns(cli: &Cli, args: PortsConnsArgs) -> i32 {
|
||||
let result = platform().ports.connections().await;
|
||||
match result {
|
||||
Ok(conns) => {
|
||||
if cli.json {
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let mut command_args = Vec::new();
|
||||
if let Some(top) = args.top {
|
||||
command_args.push("--top".to_string());
|
||||
command_args.push(top.to_string());
|
||||
}
|
||||
if args.by_process {
|
||||
command_args.push("--by-process".to_string());
|
||||
}
|
||||
let command = CommandInfo::new("ports conns", command_args);
|
||||
let envelope = CommandEnvelope::new(meta, command, conns);
|
||||
emit_json(cli, &envelope)
|
||||
} else if args.by_process {
|
||||
let summary = summarize_by_process(&conns);
|
||||
for (name, count) in summary {
|
||||
println!("{name} {count}");
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
} else if let Some(top) = args.top {
|
||||
let summary = summarize_top_remote(&conns, top);
|
||||
for (addr, count) in summary {
|
||||
println!("{addr} {count}");
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
} else {
|
||||
for conn in conns {
|
||||
let state = conn.state.unwrap_or_else(|| "-".to_string());
|
||||
let pid = conn
|
||||
.pid
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let proc = conn
|
||||
.process_name
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
println!(
|
||||
"{} {} -> {} {} pid={} proc={}",
|
||||
conn.proto, conn.local_addr, conn.remote_addr, state, pid, proc
|
||||
);
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
Err(err) => emit_platform_error(cli, err),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_top_remote(
|
||||
conns: &[wtfnet_platform::ConnSocket],
|
||||
top: usize,
|
||||
) -> Vec<(String, usize)> {
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for conn in conns {
|
||||
let host = parse_host_from_socket(&conn.remote_addr);
|
||||
*counts.entry(host).or_insert(0usize) += 1;
|
||||
}
|
||||
let mut items = counts.into_iter().collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
items.truncate(top);
|
||||
items
|
||||
}
|
||||
|
||||
fn summarize_by_process(conns: &[wtfnet_platform::ConnSocket]) -> Vec<(String, usize)> {
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for conn in conns {
|
||||
let name = conn
|
||||
.process_name
|
||||
.clone()
|
||||
.or_else(|| conn.pid.map(|value| format!("pid:{value}")))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
*counts.entry(name).or_insert(0usize) += 1;
|
||||
}
|
||||
let mut items = counts.into_iter().collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
items
|
||||
}
|
||||
|
||||
fn parse_host_from_socket(value: &str) -> String {
|
||||
if let Some(stripped) = value.strip_prefix('[') {
|
||||
if let Some(end) = stripped.find(']') {
|
||||
return stripped[..end].to_string();
|
||||
}
|
||||
}
|
||||
if let Some((host, _port)) = value.rsplit_once(':') {
|
||||
return host.to_string();
|
||||
}
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
async fn handle_neigh_list(cli: &Cli, args: NeighListArgs) -> i32 {
|
||||
let result = platform().neigh.neighbors().await;
|
||||
match result {
|
||||
@@ -882,6 +1014,212 @@ async fn handle_cert_roots(cli: &Cli) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CertBaseline {
|
||||
schema_version: u32,
|
||||
created_at: String,
|
||||
roots: Vec<wtfnet_platform::RootCert>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct CertChange {
|
||||
sha256: String,
|
||||
field: String,
|
||||
baseline: String,
|
||||
current: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct CertDiffReport {
|
||||
baseline_path: String,
|
||||
baseline_count: usize,
|
||||
current_count: usize,
|
||||
added: Vec<wtfnet_platform::RootCert>,
|
||||
removed: Vec<wtfnet_platform::RootCert>,
|
||||
changed: Vec<CertChange>,
|
||||
newly_expired: Vec<wtfnet_platform::RootCert>,
|
||||
schema_version: u32,
|
||||
}
|
||||
|
||||
async fn handle_cert_baseline(cli: &Cli, args: CertBaselineArgs) -> i32 {
|
||||
let result = platform().cert.trusted_roots().await;
|
||||
match result {
|
||||
Ok(roots) => {
|
||||
let baseline = CertBaseline {
|
||||
schema_version: 1,
|
||||
created_at: now_rfc3339(),
|
||||
roots,
|
||||
};
|
||||
match serde_json::to_string_pretty(&baseline) {
|
||||
Ok(payload) => match std::fs::write(&args.path, payload) {
|
||||
Ok(()) => ExitKind::Ok.code(),
|
||||
Err(err) => {
|
||||
eprintln!("failed to write baseline: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("failed to serialize baseline: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => emit_platform_error(cli, err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_cert_diff(cli: &Cli, args: CertDiffArgs) -> i32 {
|
||||
let baseline = match std::fs::read_to_string(&args.path) {
|
||||
Ok(contents) => match serde_json::from_str::<CertBaseline>(&contents) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("failed to parse baseline: {err}");
|
||||
return ExitKind::Failed.code();
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
eprintln!("failed to read baseline: {err}");
|
||||
return ExitKind::Failed.code();
|
||||
}
|
||||
};
|
||||
|
||||
let current = match platform().cert.trusted_roots().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => return emit_platform_error(cli, err),
|
||||
};
|
||||
|
||||
let baseline_map = baseline
|
||||
.roots
|
||||
.iter()
|
||||
.map(|cert| (cert.sha256.clone(), cert))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let current_map = current
|
||||
.iter()
|
||||
.map(|cert| (cert.sha256.clone(), cert))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
let mut newly_expired = Vec::new();
|
||||
|
||||
for cert in ¤t {
|
||||
if !baseline_map.contains_key(&cert.sha256) {
|
||||
added.push(cert.clone());
|
||||
}
|
||||
}
|
||||
for cert in &baseline.roots {
|
||||
if !current_map.contains_key(&cert.sha256) {
|
||||
removed.push(cert.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (sha, base) in &baseline_map {
|
||||
if let Some(curr) = current_map.get(sha) {
|
||||
if base.subject != curr.subject {
|
||||
changed.push(CertChange {
|
||||
sha256: sha.clone(),
|
||||
field: "subject".to_string(),
|
||||
baseline: base.subject.clone(),
|
||||
current: curr.subject.clone(),
|
||||
});
|
||||
}
|
||||
if base.issuer != curr.issuer {
|
||||
changed.push(CertChange {
|
||||
sha256: sha.clone(),
|
||||
field: "issuer".to_string(),
|
||||
baseline: base.issuer.clone(),
|
||||
current: curr.issuer.clone(),
|
||||
});
|
||||
}
|
||||
if base.not_after != curr.not_after {
|
||||
changed.push(CertChange {
|
||||
sha256: sha.clone(),
|
||||
field: "not_after".to_string(),
|
||||
baseline: base.not_after.clone(),
|
||||
current: curr.not_after.clone(),
|
||||
});
|
||||
}
|
||||
if base.not_before != curr.not_before {
|
||||
changed.push(CertChange {
|
||||
sha256: sha.clone(),
|
||||
field: "not_before".to_string(),
|
||||
baseline: base.not_before.clone(),
|
||||
current: curr.not_before.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let (Some(created), Some(expiry)) = (
|
||||
parse_cert_time(&baseline.created_at),
|
||||
parse_cert_time(&curr.not_after),
|
||||
) {
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
if created < expiry && now >= expiry {
|
||||
newly_expired.push((*curr).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let report = CertDiffReport {
|
||||
baseline_path: args.path.to_string_lossy().to_string(),
|
||||
baseline_count: baseline.roots.len(),
|
||||
current_count: current.len(),
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
newly_expired,
|
||||
schema_version: baseline.schema_version,
|
||||
};
|
||||
|
||||
if cli.json {
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let command = CommandInfo::new("cert diff", vec![report.baseline_path.clone()]);
|
||||
let envelope = CommandEnvelope::new(meta, command, report);
|
||||
emit_json(cli, &envelope)
|
||||
} else {
|
||||
println!(
|
||||
"baseline_count={} current_count={} added={} removed={} changed={} newly_expired={}",
|
||||
report.baseline_count,
|
||||
report.current_count,
|
||||
report.added.len(),
|
||||
report.removed.len(),
|
||||
report.changed.len(),
|
||||
report.newly_expired.len()
|
||||
);
|
||||
for cert in report.added {
|
||||
println!("added {} {}", cert.sha256, cert.subject);
|
||||
}
|
||||
for cert in report.removed {
|
||||
println!("removed {} {}", cert.sha256, cert.subject);
|
||||
}
|
||||
for change in report.changed {
|
||||
println!(
|
||||
"changed {} {} {} -> {}",
|
||||
change.sha256, change.field, change.baseline, change.current
|
||||
);
|
||||
}
|
||||
for cert in report.newly_expired {
|
||||
println!("expired {} {}", cert.sha256, cert.subject);
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cert_time(value: &str) -> Option<time::OffsetDateTime> {
|
||||
if let Ok(dt) = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) {
|
||||
return Some(dt);
|
||||
}
|
||||
let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second] UTC").ok()?;
|
||||
time::OffsetDateTime::parse(value, &format).ok()
|
||||
}
|
||||
|
||||
fn now_rfc3339() -> String {
|
||||
time::OffsetDateTime::now_utc()
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
async fn handle_geoip_lookup(cli: &Cli, args: GeoIpLookupArgs) -> i32 {
|
||||
let ip = match args.target.parse::<std::net::IpAddr>() {
|
||||
Ok(ip) => ip,
|
||||
@@ -1821,6 +2159,8 @@ async fn handle_http_request(
|
||||
show_body: args.show_body,
|
||||
http1_only: args.http1_only,
|
||||
http2_only: args.http2_only,
|
||||
http3: args.http3,
|
||||
http3_only: args.http3_only,
|
||||
proxy: args.socks5.clone(),
|
||||
};
|
||||
|
||||
@@ -1844,6 +2184,7 @@ async fn handle_http_request(
|
||||
geoip,
|
||||
headers: report.headers.clone(),
|
||||
body: report.body.clone(),
|
||||
warnings: report.warnings.clone(),
|
||||
timing: report.timing.clone(),
|
||||
}
|
||||
} else {
|
||||
@@ -1857,6 +2198,7 @@ async fn handle_http_request(
|
||||
geoip: Vec::new(),
|
||||
headers: report.headers.clone(),
|
||||
body: report.body.clone(),
|
||||
warnings: report.warnings.clone(),
|
||||
timing: report.timing.clone(),
|
||||
}
|
||||
};
|
||||
@@ -1883,6 +2225,11 @@ async fn handle_http_request(
|
||||
if !report.resolved_ips.is_empty() {
|
||||
println!("resolved: {}", report.resolved_ips.join(", "));
|
||||
}
|
||||
if !report.warnings.is_empty() {
|
||||
for warning in &report.warnings {
|
||||
println!("warning: {warning}");
|
||||
}
|
||||
}
|
||||
println!("total_ms: {}", report.timing.total_ms);
|
||||
if let Some(ms) = report.timing.dns_ms {
|
||||
println!("dns_ms: {ms}");
|
||||
@@ -1973,6 +2320,8 @@ fn build_tls_options(args: &TlsArgs) -> wtfnet_tls::TlsOptions {
|
||||
insecure: args.insecure,
|
||||
socks5: args.socks5.clone(),
|
||||
prefer_ipv4: args.prefer_ipv4,
|
||||
show_extensions: args.show_extensions,
|
||||
ocsp: args.ocsp,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,21 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["rustls-tls"] }
|
||||
rustls = "0.21"
|
||||
rustls-native-certs = "0.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["net", "time"] }
|
||||
tokio-rustls = "0.24"
|
||||
tokio-socks = "0.5"
|
||||
url = "2"
|
||||
tracing = "0.1"
|
||||
h3 = { version = "0.0.8", optional = true }
|
||||
h3-quinn = { version = "0.0.10", optional = true }
|
||||
quinn = { version = "0.11", optional = true }
|
||||
http = "1"
|
||||
webpki-roots = "1"
|
||||
bytes = "1"
|
||||
|
||||
[features]
|
||||
http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn"]
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
use reqwest::{Client, Method, Proxy, StatusCode};
|
||||
use rustls::{Certificate, ClientConfig, RootCertStore, ServerName};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::lookup_host;
|
||||
use thiserror::Error;
|
||||
use tokio::time::timeout;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_socks::tcp::Socks5Stream;
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
use bytes::Buf;
|
||||
#[cfg(feature = "http3")]
|
||||
use http::Request;
|
||||
#[cfg(feature = "http3")]
|
||||
use quinn::ClientConfig as QuinnClientConfig;
|
||||
#[cfg(feature = "http3")]
|
||||
use quinn::Endpoint;
|
||||
#[cfg(feature = "http3")]
|
||||
use webpki_roots::TLS_SERVER_ROOTS;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HttpError {
|
||||
#[error("invalid url: {0}")]
|
||||
@@ -36,6 +52,7 @@ pub struct HttpReport {
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub timing: HttpTiming,
|
||||
}
|
||||
|
||||
@@ -64,6 +81,8 @@ pub struct HttpRequestOptions {
|
||||
pub show_body: bool,
|
||||
pub http1_only: bool,
|
||||
pub http2_only: bool,
|
||||
pub http3: bool,
|
||||
pub http3_only: bool,
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
|
||||
@@ -105,6 +124,39 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
}
|
||||
let dns_ms = dns_start.elapsed().as_millis();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if opts.http3 || opts.http3_only {
|
||||
if !cfg!(feature = "http3") {
|
||||
warnings.push("http3 feature not enabled in build".to_string());
|
||||
if opts.http3_only {
|
||||
return Err(HttpError::Request(
|
||||
"http3-only requested but feature is not enabled".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
{
|
||||
if opts.http3 || opts.http3_only {
|
||||
match http3_request(url, &opts, &resolved_ips, dns_ms).await {
|
||||
Ok((report, mut h3_warnings)) => {
|
||||
warnings.append(&mut h3_warnings);
|
||||
return Ok(HttpReport {
|
||||
warnings,
|
||||
..report
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("http3 failed: {err}"));
|
||||
if opts.http3_only {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut builder = Client::builder().timeout(Duration::from_millis(opts.timeout_ms));
|
||||
builder = if let Some(max) = opts.follow_redirects {
|
||||
builder.redirect(reqwest::redirect::Policy::limited(max as usize))
|
||||
@@ -132,6 +184,16 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
}
|
||||
|
||||
let client = builder.build().map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let (connect_ms, tls_ms, timing_warnings) = measure_connect_tls(
|
||||
&parsed,
|
||||
host,
|
||||
port,
|
||||
&resolved_ips,
|
||||
opts.proxy.as_deref(),
|
||||
opts.timeout_ms,
|
||||
)
|
||||
.await;
|
||||
warnings.extend(timing_warnings);
|
||||
let start = Instant::now();
|
||||
let response = client
|
||||
.request(opts.method.to_reqwest(), parsed.clone())
|
||||
@@ -184,11 +246,12 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
resolved_ips,
|
||||
headers,
|
||||
body,
|
||||
warnings,
|
||||
timing: HttpTiming {
|
||||
total_ms,
|
||||
dns_ms: Some(dns_ms),
|
||||
connect_ms: None,
|
||||
tls_ms: None,
|
||||
connect_ms,
|
||||
tls_ms,
|
||||
ttfb_ms: Some(ttfb_ms),
|
||||
},
|
||||
})
|
||||
@@ -197,3 +260,311 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
fn status_code(status: StatusCode) -> Option<u16> {
|
||||
Some(status.as_u16())
|
||||
}
|
||||
|
||||
struct Socks5Proxy {
|
||||
addr: String,
|
||||
remote_dns: bool,
|
||||
}
|
||||
|
||||
fn parse_socks5_proxy(value: &str) -> Result<Socks5Proxy, HttpError> {
|
||||
let url = Url::parse(value).map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let scheme = url.scheme();
|
||||
let remote_dns = match scheme {
|
||||
"socks5" => false,
|
||||
"socks5h" => true,
|
||||
_ => {
|
||||
return Err(HttpError::Request(format!(
|
||||
"unsupported proxy scheme: {scheme}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| HttpError::Request("invalid proxy host".to_string()))?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| HttpError::Request("invalid proxy port".to_string()))?;
|
||||
Ok(Socks5Proxy {
|
||||
addr: format!("{host}:{port}"),
|
||||
remote_dns,
|
||||
})
|
||||
}
|
||||
|
||||
async fn measure_connect_tls(
|
||||
parsed: &Url,
|
||||
host: &str,
|
||||
port: u16,
|
||||
resolved_ips: &[String],
|
||||
proxy: Option<&str>,
|
||||
timeout_ms: u64,
|
||||
) -> (Option<u128>, Option<u128>, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
warnings.push(format!("timing unavailable for scheme: {scheme}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
|
||||
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||
let connect_start = Instant::now();
|
||||
let tcp = if let Some(proxy) = proxy {
|
||||
match parse_socks5_proxy(proxy) {
|
||||
Ok(proxy) => {
|
||||
let target = if proxy.remote_dns {
|
||||
(host, port)
|
||||
} else if let Some(ip) = resolved_ips.first() {
|
||||
(ip.as_str(), port)
|
||||
} else {
|
||||
warnings.push("no resolved IPs for proxy connect".to_string());
|
||||
return (None, None, warnings);
|
||||
};
|
||||
match timeout(timeout_dur, Socks5Stream::connect(proxy.addr.as_str(), target))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(stream)) => stream.into_inner(),
|
||||
Ok(Err(err)) => {
|
||||
warnings.push(format!("proxy connect failed: {err}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
Err(_) => {
|
||||
warnings.push("proxy connect timed out".to_string());
|
||||
return (None, None, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("proxy timing skipped: {err}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let addr = match resolved_ips.first().and_then(|ip| ip.parse::<IpAddr>().ok()) {
|
||||
Some(ip) => SocketAddr::new(ip, port),
|
||||
None => {
|
||||
warnings.push("no resolved IPs for connect timing".to_string());
|
||||
return (None, None, warnings);
|
||||
}
|
||||
};
|
||||
match timeout(timeout_dur, tokio::net::TcpStream::connect(addr)).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(err)) => {
|
||||
warnings.push(format!("connect failed: {err}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
Err(_) => {
|
||||
warnings.push("connect timed out".to_string());
|
||||
return (None, None, warnings);
|
||||
}
|
||||
}
|
||||
};
|
||||
let connect_ms = connect_start.elapsed().as_millis();
|
||||
|
||||
if scheme == "http" {
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
|
||||
let tls_start = Instant::now();
|
||||
let tls = match build_tls_connector() {
|
||||
Ok(connector) => connector,
|
||||
Err(err) => {
|
||||
warnings.push(format!("tls timing skipped: {err}"));
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
};
|
||||
let server_name = match ServerName::try_from(host) {
|
||||
Ok(name) => name,
|
||||
Err(_) => {
|
||||
warnings.push("invalid tls server name".to_string());
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
};
|
||||
match timeout(timeout_dur, tls.connect(server_name, tcp)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => {
|
||||
warnings.push(format!("tls handshake failed: {err}"));
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
Err(_) => {
|
||||
warnings.push("tls handshake timed out".to_string());
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
}
|
||||
let tls_ms = tls_start.elapsed().as_millis();
|
||||
|
||||
(Some(connect_ms), Some(tls_ms), warnings)
|
||||
}
|
||||
|
||||
fn build_tls_connector() -> Result<TlsConnector, HttpError> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
let store = rustls_native_certs::load_native_certs()
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
for cert in store {
|
||||
roots
|
||||
.add(&Certificate(cert.0))
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
}
|
||||
let config = ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
async fn http3_request(
|
||||
url: &str,
|
||||
opts: &HttpRequestOptions,
|
||||
resolved_ips: &[String],
|
||||
dns_ms: u128,
|
||||
) -> Result<(HttpReport, Vec<String>), HttpError> {
|
||||
let mut warnings = Vec::new();
|
||||
let parsed = Url::parse(url).map_err(|err| HttpError::Url(err.to_string()))?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(HttpError::Request("http3 requires https scheme".to_string()));
|
||||
}
|
||||
if opts.proxy.is_some() {
|
||||
return Err(HttpError::Request(
|
||||
"http3 proxying is not supported".to_string(),
|
||||
));
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| HttpError::Url("missing host".to_string()))?;
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
|
||||
let ip = resolved_ips
|
||||
.first()
|
||||
.and_then(|value| value.parse::<IpAddr>().ok())
|
||||
.ok_or_else(|| HttpError::Request("no resolved IPs for http3".to_string()))?;
|
||||
|
||||
let quinn_config = build_quinn_config()?;
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
endpoint.set_default_client_config(quinn_config);
|
||||
|
||||
let connect_start = Instant::now();
|
||||
let connecting = endpoint
|
||||
.connect(SocketAddr::new(ip, port), host)
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let connection = timeout(Duration::from_millis(opts.timeout_ms), connecting)
|
||||
.await
|
||||
.map_err(|_| HttpError::Request("http3 connect timed out".to_string()))?
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let connect_ms = connect_start.elapsed().as_millis();
|
||||
|
||||
let conn = h3_quinn::Connection::new(connection);
|
||||
let (mut driver, mut send_request) = h3::client::new(conn)
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = driver.wait_idle().await;
|
||||
});
|
||||
|
||||
let start = Instant::now();
|
||||
let method = match opts.method {
|
||||
HttpMethod::Head => http::Method::HEAD,
|
||||
HttpMethod::Get => http::Method::GET,
|
||||
};
|
||||
let request = Request::builder()
|
||||
.method(method)
|
||||
.uri(parsed.as_str())
|
||||
.header("user-agent", "wtfnet")
|
||||
.body(())
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let mut stream = send_request
|
||||
.send_request(request)
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
stream
|
||||
.finish()
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
|
||||
let response = stream
|
||||
.recv_response()
|
||||
.await
|
||||
.map_err(|err| HttpError::Response(err.to_string()))?;
|
||||
let ttfb_ms = start.elapsed().as_millis();
|
||||
|
||||
let status = response.status();
|
||||
let final_url = parsed.to_string();
|
||||
let headers = if opts.show_headers {
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
let value = value.to_str().unwrap_or("-").to_string();
|
||||
(name.to_string(), value)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let body = if opts.show_body {
|
||||
let mut buf = Vec::new();
|
||||
while let Some(chunk) = stream
|
||||
.recv_data()
|
||||
.await
|
||||
.map_err(|err| HttpError::Response(err.to_string()))?
|
||||
{
|
||||
let mut chunk = chunk;
|
||||
while chunk.has_remaining() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
buf.extend_from_slice(&bytes);
|
||||
}
|
||||
if buf.len() >= opts.max_body_bytes {
|
||||
buf.truncate(opts.max_body_bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(String::from_utf8_lossy(&buf).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let total_ms = start.elapsed().as_millis();
|
||||
|
||||
warnings.push("http3 timing for tls/connect is best-effort".to_string());
|
||||
|
||||
Ok((
|
||||
HttpReport {
|
||||
url: url.to_string(),
|
||||
final_url: Some(final_url),
|
||||
method: match opts.method {
|
||||
HttpMethod::Head => "HEAD".to_string(),
|
||||
HttpMethod::Get => "GET".to_string(),
|
||||
},
|
||||
status: Some(status.as_u16()),
|
||||
http_version: Some("HTTP/3".to_string()),
|
||||
resolved_ips: resolved_ips.to_vec(),
|
||||
headers,
|
||||
body,
|
||||
warnings: Vec::new(),
|
||||
timing: HttpTiming {
|
||||
total_ms,
|
||||
dns_ms: Some(dns_ms),
|
||||
connect_ms: Some(connect_ms),
|
||||
tls_ms: None,
|
||||
ttfb_ms: Some(ttfb_ms),
|
||||
},
|
||||
},
|
||||
warnings,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
fn build_quinn_config() -> Result<QuinnClientConfig, HttpError> {
|
||||
let mut roots = quinn::rustls::RootCertStore::empty();
|
||||
roots.extend(TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let mut client_config =
|
||||
QuinnClientConfig::with_root_certificates(Arc::new(roots)).map_err(|err| {
|
||||
HttpError::Request(format!("quinn config error: {err}"))
|
||||
})?;
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
Ok(client_config)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use wtfnet_core::ErrorCode;
|
||||
use wtfnet_platform::{
|
||||
CertProvider, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider, NetInterface,
|
||||
Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||
CertProvider, ConnSocket, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider,
|
||||
NetInterface, Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||
};
|
||||
use x509_parser::oid_registry::{
|
||||
OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256,
|
||||
@@ -240,6 +240,63 @@ fn parse_linux_tcp_with_inode_map(
|
||||
Ok(sockets)
|
||||
}
|
||||
|
||||
fn parse_linux_tcp_conns(
|
||||
path: &str,
|
||||
is_v6: bool,
|
||||
inode_map: &HashMap<String, ProcInfo>,
|
||||
) -> Result<Vec<ConnSocket>, PlatformError> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||
let mut sockets = Vec::new();
|
||||
for (idx, line) in contents.lines().enumerate() {
|
||||
if idx == 0 {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let local = parts[1];
|
||||
let remote = parts[2];
|
||||
let state = parts[3];
|
||||
let inode = parts.get(9).copied();
|
||||
if state == "0A" {
|
||||
continue;
|
||||
}
|
||||
let local_addr = match parse_proc_socket_addr(local, is_v6) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
let remote_addr = match parse_proc_socket_addr(remote, is_v6) {
|
||||
Some(addr) => addr,
|
||||
None => continue,
|
||||
};
|
||||
let (pid, ppid, process_name, process_path) =
|
||||
inode.and_then(|value| inode_map.get(value)).map_or(
|
||||
(None, None, None, None),
|
||||
|info| {
|
||||
(
|
||||
Some(info.pid),
|
||||
info.ppid,
|
||||
info.name.clone(),
|
||||
info.path.clone(),
|
||||
)
|
||||
},
|
||||
);
|
||||
sockets.push(ConnSocket {
|
||||
proto: "tcp".to_string(),
|
||||
local_addr,
|
||||
remote_addr,
|
||||
state: Some(map_tcp_state(state)),
|
||||
pid,
|
||||
ppid,
|
||||
process_name,
|
||||
process_path,
|
||||
});
|
||||
}
|
||||
Ok(sockets)
|
||||
}
|
||||
|
||||
fn parse_linux_udp_with_inode_map(
|
||||
path: &str,
|
||||
is_v6: bool,
|
||||
@@ -286,6 +343,24 @@ fn parse_linux_udp_with_inode_map(
|
||||
Ok(sockets)
|
||||
}
|
||||
|
||||
fn map_tcp_state(value: &str) -> String {
|
||||
match value {
|
||||
"01" => "ESTABLISHED",
|
||||
"02" => "SYN_SENT",
|
||||
"03" => "SYN_RECV",
|
||||
"04" => "FIN_WAIT1",
|
||||
"05" => "FIN_WAIT2",
|
||||
"06" => "TIME_WAIT",
|
||||
"07" => "CLOSE",
|
||||
"08" => "CLOSE_WAIT",
|
||||
"09" => "LAST_ACK",
|
||||
"0A" => "LISTEN",
|
||||
"0B" => "CLOSING",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_proc_socket_addr(value: &str, is_v6: bool) -> Option<String> {
|
||||
let mut parts = value.split(':');
|
||||
let addr_hex = parts.next()?;
|
||||
@@ -518,6 +593,22 @@ impl PortsProvider for LinuxPortsProvider {
|
||||
.filter(|socket| extract_port(&socket.local_addr) == Some(port))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn connections(&self) -> Result<Vec<ConnSocket>, PlatformError> {
|
||||
let inode_map = build_inode_map();
|
||||
let mut sockets = Vec::new();
|
||||
sockets.extend(parse_linux_tcp_conns(
|
||||
"/proc/net/tcp",
|
||||
false,
|
||||
&inode_map,
|
||||
)?);
|
||||
sockets.extend(parse_linux_tcp_conns(
|
||||
"/proc/net/tcp6",
|
||||
true,
|
||||
&inode_map,
|
||||
)?);
|
||||
Ok(sockets)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -10,8 +10,8 @@ use x509_parser::oid_registry::{
|
||||
use std::sync::Arc;
|
||||
use wtfnet_core::ErrorCode;
|
||||
use wtfnet_platform::{
|
||||
CertProvider, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider, NetInterface,
|
||||
Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||
CertProvider, ConnSocket, DnsConfigSnapshot, ListenSocket, NeighborEntry, NeighProvider,
|
||||
NetInterface, Platform, PlatformError, PortsProvider, RootCert, RouteEntry, SysProvider,
|
||||
};
|
||||
|
||||
pub fn platform() -> Platform {
|
||||
@@ -333,6 +333,33 @@ fn parse_windows_listeners() -> Result<Vec<ListenSocket>, PlatformError> {
|
||||
Ok(sockets)
|
||||
}
|
||||
|
||||
fn parse_windows_connections() -> Result<Vec<ConnSocket>, PlatformError> {
|
||||
let proc_map = load_windows_process_map();
|
||||
let output = std::process::Command::new("netstat")
|
||||
.arg("-ano")
|
||||
.output()
|
||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||
if !output.status.success() {
|
||||
return Err(PlatformError::new(ErrorCode::IoError, "netstat -ano failed"));
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut sockets = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("TCP") {
|
||||
continue;
|
||||
}
|
||||
if let Some(mut socket) = parse_netstat_tcp_conn_line(trimmed) {
|
||||
enrich_conn_socket(&mut socket, &proc_map);
|
||||
sockets.push(socket);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sockets)
|
||||
}
|
||||
|
||||
fn parse_netstat_tcp_line(line: &str) -> Option<ListenSocket> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 5 {
|
||||
@@ -358,6 +385,32 @@ fn parse_netstat_tcp_line(line: &str) -> Option<ListenSocket> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_netstat_tcp_conn_line(line: &str) -> Option<ConnSocket> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let local = parts[1];
|
||||
let remote = parts[2];
|
||||
let state = parts[3];
|
||||
let pid = parts[4].parse::<u32>().ok();
|
||||
|
||||
if state == "LISTENING" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ConnSocket {
|
||||
proto: "tcp".to_string(),
|
||||
local_addr: local.to_string(),
|
||||
remote_addr: remote.to_string(),
|
||||
state: Some(state.to_string()),
|
||||
pid,
|
||||
ppid: None,
|
||||
process_name: None,
|
||||
process_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_netstat_udp_line(line: &str) -> Option<ListenSocket> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
@@ -429,6 +482,17 @@ fn enrich_socket(socket: &mut ListenSocket, map: &HashMap<u32, ProcInfo>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_conn_socket(socket: &mut ConnSocket, map: &HashMap<u32, ProcInfo>) {
|
||||
let pid = match socket.pid {
|
||||
Some(pid) => pid,
|
||||
None => return,
|
||||
};
|
||||
if let Some(info) = map.get(&pid) {
|
||||
socket.process_name = info.name.clone();
|
||||
socket.process_path = info.path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProcInfo {
|
||||
name: Option<String>,
|
||||
@@ -605,6 +669,10 @@ impl PortsProvider for WindowsPortsProvider {
|
||||
.filter(|socket| extract_port(&socket.local_addr) == Some(port))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn connections(&self) -> Result<Vec<ConnSocket>, PlatformError> {
|
||||
parse_windows_connections()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -46,6 +46,18 @@ pub struct ListenSocket {
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnSocket {
|
||||
pub proto: String,
|
||||
pub local_addr: String,
|
||||
pub remote_addr: String,
|
||||
pub state: Option<String>,
|
||||
pub pid: Option<u32>,
|
||||
pub ppid: Option<u32>,
|
||||
pub process_name: Option<String>,
|
||||
pub process_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RootCert {
|
||||
pub subject: String,
|
||||
@@ -98,6 +110,7 @@ pub trait SysProvider: Send + Sync {
|
||||
pub trait PortsProvider: Send + Sync {
|
||||
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError>;
|
||||
async fn who_owns(&self, port: u16) -> Result<Vec<ListenSocket>, PlatformError>;
|
||||
async fn connections(&self) -> Result<Vec<ConnSocket>, PlatformError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -35,6 +35,9 @@ pub struct TlsCertSummary {
|
||||
pub not_before: String,
|
||||
pub not_after: String,
|
||||
pub san: Vec<String>,
|
||||
pub signature_algorithm: Option<String>,
|
||||
pub key_usage: Option<Vec<String>>,
|
||||
pub extended_key_usage: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -45,6 +48,7 @@ pub struct TlsHandshakeReport {
|
||||
pub alpn_negotiated: Option<String>,
|
||||
pub tls_version: Option<String>,
|
||||
pub cipher: Option<String>,
|
||||
pub ocsp_stapled: Option<bool>,
|
||||
pub cert_chain: Vec<TlsCertSummary>,
|
||||
}
|
||||
|
||||
@@ -56,6 +60,7 @@ pub struct TlsVerifyReport {
|
||||
pub alpn_negotiated: Option<String>,
|
||||
pub tls_version: Option<String>,
|
||||
pub cipher: Option<String>,
|
||||
pub ocsp_stapled: Option<bool>,
|
||||
pub verified: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -64,6 +69,7 @@ pub struct TlsVerifyReport {
|
||||
pub struct TlsCertReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub ocsp_stapled: Option<bool>,
|
||||
pub cert_chain: Vec<TlsCertSummary>,
|
||||
}
|
||||
|
||||
@@ -83,6 +89,8 @@ pub struct TlsOptions {
|
||||
pub insecure: bool,
|
||||
pub socks5: Option<String>,
|
||||
pub prefer_ipv4: bool,
|
||||
pub show_extensions: bool,
|
||||
pub ocsp: bool,
|
||||
}
|
||||
|
||||
pub async fn handshake(target: &str, options: TlsOptions) -> Result<TlsHandshakeReport, TlsError> {
|
||||
@@ -120,7 +128,8 @@ pub async fn handshake(target: &str, options: TlsOptions) -> Result<TlsHandshake
|
||||
cipher: session
|
||||
.negotiated_cipher_suite()
|
||||
.map(|suite| format!("{suite:?}")),
|
||||
cert_chain: extract_cert_chain(session.peer_certificates())?,
|
||||
ocsp_stapled: ocsp_status(session, options.ocsp),
|
||||
cert_chain: extract_cert_chain(session.peer_certificates(), options.show_extensions)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,6 +169,7 @@ pub async fn verify(target: &str, options: TlsOptions) -> Result<TlsVerifyReport
|
||||
cipher: session
|
||||
.negotiated_cipher_suite()
|
||||
.map(|suite| format!("{suite:?}")),
|
||||
ocsp_stapled: ocsp_status(session, options.ocsp),
|
||||
verified: true,
|
||||
error: None,
|
||||
})
|
||||
@@ -171,6 +181,7 @@ pub async fn verify(target: &str, options: TlsOptions) -> Result<TlsVerifyReport
|
||||
alpn_negotiated: None,
|
||||
tls_version: None,
|
||||
cipher: None,
|
||||
ocsp_stapled: None,
|
||||
verified: false,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
@@ -203,7 +214,8 @@ pub async fn certs(target: &str, options: TlsOptions) -> Result<TlsCertReport, T
|
||||
Ok(TlsCertReport {
|
||||
target: target.to_string(),
|
||||
sni: options.sni,
|
||||
cert_chain: extract_cert_chain(session.peer_certificates())?,
|
||||
ocsp_stapled: ocsp_status(session, options.ocsp),
|
||||
cert_chain: extract_cert_chain(session.peer_certificates(), options.show_extensions)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -427,26 +439,41 @@ fn socks5_target_host(proxy: &str, host: &str) -> (String, bool) {
|
||||
(host.to_string(), remote_dns)
|
||||
}
|
||||
|
||||
fn extract_cert_chain(certs: Option<&[Certificate]>) -> Result<Vec<TlsCertSummary>, TlsError> {
|
||||
fn extract_cert_chain(
|
||||
certs: Option<&[Certificate]>,
|
||||
show_extensions: bool,
|
||||
) -> Result<Vec<TlsCertSummary>, TlsError> {
|
||||
let mut results = Vec::new();
|
||||
if let Some(certs) = certs {
|
||||
for cert in certs {
|
||||
let summary = parse_cert(&cert.0)?;
|
||||
let summary = parse_cert(&cert.0, show_extensions)?;
|
||||
results.push(summary);
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn parse_cert(der: &[u8]) -> Result<TlsCertSummary, TlsError> {
|
||||
fn parse_cert(der: &[u8], show_extensions: bool) -> Result<TlsCertSummary, TlsError> {
|
||||
let (_, cert) =
|
||||
X509Certificate::from_der(der).map_err(|err| TlsError::Parse(err.to_string()))?;
|
||||
let (key_usage, extended_key_usage, signature_algorithm) = if show_extensions {
|
||||
(
|
||||
extract_key_usage(&cert),
|
||||
extract_extended_key_usage(&cert),
|
||||
Some(cert.signature_algorithm.algorithm.to_string()),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
Ok(TlsCertSummary {
|
||||
subject: cert.subject().to_string(),
|
||||
issuer: cert.issuer().to_string(),
|
||||
not_before: cert.validity().not_before.to_string(),
|
||||
not_after: cert.validity().not_after.to_string(),
|
||||
san: extract_san(&cert),
|
||||
signature_algorithm,
|
||||
key_usage,
|
||||
extended_key_usage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -460,6 +487,85 @@ fn extract_san(cert: &X509Certificate<'_>) -> Vec<String> {
|
||||
result
|
||||
}
|
||||
|
||||
fn extract_key_usage(cert: &X509Certificate<'_>) -> Option<Vec<String>> {
|
||||
let ext = cert.key_usage().ok()??;
|
||||
let mut result = Vec::new();
|
||||
if ext.value.digital_signature() {
|
||||
result.push("digitalSignature".to_string());
|
||||
}
|
||||
if ext.value.non_repudiation() {
|
||||
result.push("nonRepudiation".to_string());
|
||||
}
|
||||
if ext.value.key_encipherment() {
|
||||
result.push("keyEncipherment".to_string());
|
||||
}
|
||||
if ext.value.data_encipherment() {
|
||||
result.push("dataEncipherment".to_string());
|
||||
}
|
||||
if ext.value.key_agreement() {
|
||||
result.push("keyAgreement".to_string());
|
||||
}
|
||||
if ext.value.key_cert_sign() {
|
||||
result.push("keyCertSign".to_string());
|
||||
}
|
||||
if ext.value.crl_sign() {
|
||||
result.push("cRLSign".to_string());
|
||||
}
|
||||
if ext.value.encipher_only() {
|
||||
result.push("encipherOnly".to_string());
|
||||
}
|
||||
if ext.value.decipher_only() {
|
||||
result.push("decipherOnly".to_string());
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_extended_key_usage(cert: &X509Certificate<'_>) -> Option<Vec<String>> {
|
||||
let ext = cert.extended_key_usage().ok()??;
|
||||
let mut result = Vec::new();
|
||||
if ext.value.any {
|
||||
result.push("any".to_string());
|
||||
}
|
||||
if ext.value.server_auth {
|
||||
result.push("serverAuth".to_string());
|
||||
}
|
||||
if ext.value.client_auth {
|
||||
result.push("clientAuth".to_string());
|
||||
}
|
||||
if ext.value.code_signing {
|
||||
result.push("codeSigning".to_string());
|
||||
}
|
||||
if ext.value.email_protection {
|
||||
result.push("emailProtection".to_string());
|
||||
}
|
||||
if ext.value.time_stamping {
|
||||
result.push("timeStamping".to_string());
|
||||
}
|
||||
if ext.value.ocsp_signing {
|
||||
result.push("ocspSigning".to_string());
|
||||
}
|
||||
for oid in &ext.value.other {
|
||||
result.push(oid.to_string());
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn ocsp_status(_session: &rustls::ClientConnection, enabled: bool) -> Option<bool> {
|
||||
if enabled {
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct NoVerifier;
|
||||
|
||||
impl rustls::client::ServerCertVerifier for NoVerifier {
|
||||
|
||||
Reference in New Issue
Block a user