Add multiple features
This commit is contained in:
@@ -200,3 +200,32 @@ fn overlap_v6(a: Ipv6Net, b: Ipv6Net) -> bool {
|
||||
let b_end = u128::from(b.broadcast());
|
||||
a_start <= b_end && b_start <= a_end
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subnet_v4_from_mask() {
|
||||
let info = subnet_info("192.168.1.10 255.255.255.0").expect("subnet");
|
||||
assert_eq!(info.cidr, "192.168.1.10/24");
|
||||
assert_eq!(info.network, "192.168.1.0");
|
||||
assert_eq!(info.broadcast.as_deref(), Some("192.168.1.255"));
|
||||
assert_eq!(info.usable_addresses, "254");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_and_overlap() {
|
||||
assert!(contains("192.168.0.0/16", "192.168.1.0/24").unwrap());
|
||||
assert!(overlap("10.0.0.0/24", "10.0.0.128/25").unwrap());
|
||||
assert!(!overlap("10.0.0.0/24", "10.0.1.0/24").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_ipv4() {
|
||||
let inputs = vec!["10.0.0.0/24".to_string(), "10.0.1.0/24".to_string()];
|
||||
let result = summarize(&inputs).expect("summarize");
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].to_string(), "10.0.0.0/23");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ wtfnet-geoip = { path = "../wtfnet-geoip" }
|
||||
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||
wtfnet-probe = { path = "../wtfnet-probe" }
|
||||
wtfnet-dns = { path = "../wtfnet-dns", features = ["pcap"] }
|
||||
wtfnet-http = { path = "../wtfnet-http" }
|
||||
wtfnet-tls = { path = "../wtfnet-tls" }
|
||||
wtfnet-discover = { path = "../wtfnet-discover" }
|
||||
wtfnet-diag = { path = "../wtfnet-diag" }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
wtfnet-platform-windows = { path = "../wtfnet-platform-windows" }
|
||||
|
||||
@@ -70,6 +70,19 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
command: CalcCommand,
|
||||
},
|
||||
Http {
|
||||
#[command(subcommand)]
|
||||
command: HttpCommand,
|
||||
},
|
||||
Tls {
|
||||
#[command(subcommand)]
|
||||
command: TlsCommand,
|
||||
},
|
||||
Discover {
|
||||
#[command(subcommand)]
|
||||
command: DiscoverCommand,
|
||||
},
|
||||
Diag(DiagArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -124,6 +137,26 @@ enum CalcCommand {
|
||||
Summarize(CalcSummarizeArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum HttpCommand {
|
||||
Head(HttpRequestArgs),
|
||||
Get(HttpRequestArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum TlsCommand {
|
||||
Handshake(TlsArgs),
|
||||
Cert(TlsArgs),
|
||||
Verify(TlsArgs),
|
||||
Alpn(TlsArgs),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum DiscoverCommand {
|
||||
Mdns(DiscoverMdnsArgs),
|
||||
Ssdp(DiscoverSsdpArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct SysIpArgs {
|
||||
#[arg(long)]
|
||||
@@ -276,6 +309,68 @@ struct CalcSummarizeArgs {
|
||||
cidrs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct HttpRequestArgs {
|
||||
url: String,
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
timeout_ms: u64,
|
||||
#[arg(long)]
|
||||
follow_redirects: Option<u32>,
|
||||
#[arg(long)]
|
||||
show_headers: bool,
|
||||
#[arg(long)]
|
||||
show_body: bool,
|
||||
#[arg(long, default_value_t = 8192)]
|
||||
max_body_bytes: usize,
|
||||
#[arg(long)]
|
||||
http1_only: bool,
|
||||
#[arg(long)]
|
||||
http2_only: bool,
|
||||
#[arg(long)]
|
||||
geoip: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct TlsArgs {
|
||||
target: String,
|
||||
#[arg(long)]
|
||||
sni: Option<String>,
|
||||
#[arg(long)]
|
||||
alpn: Option<String>,
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
timeout_ms: u64,
|
||||
#[arg(long)]
|
||||
insecure: bool,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct DiscoverMdnsArgs {
|
||||
#[arg(long, default_value = "3s")]
|
||||
duration: String,
|
||||
#[arg(long)]
|
||||
service: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct DiscoverSsdpArgs {
|
||||
#[arg(long, default_value = "3s")]
|
||||
duration: String,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
struct DiagArgs {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
bundle: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
dns_detect: Option<String>,
|
||||
#[arg(long, default_value_t = 2000)]
|
||||
dns_timeout_ms: u64,
|
||||
#[arg(long, default_value_t = 3)]
|
||||
dns_repeat: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct DnsAnswerGeoIp {
|
||||
pub name: String,
|
||||
@@ -326,6 +421,20 @@ struct CalcSummarizeReport {
|
||||
pub result: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HttpReportGeoIp {
|
||||
pub url: String,
|
||||
pub final_url: Option<String>,
|
||||
pub method: String,
|
||||
pub status: Option<u16>,
|
||||
pub http_version: Option<String>,
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub geoip: Vec<wtfnet_geoip::GeoIpRecord>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
pub timing: wtfnet_http::HttpTiming,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
@@ -396,6 +505,31 @@ async fn main() {
|
||||
Commands::Calc {
|
||||
command: CalcCommand::Summarize(args),
|
||||
} => handle_calc_summarize(&cli, args.clone()).await,
|
||||
Commands::Http {
|
||||
command: HttpCommand::Head(args),
|
||||
} => handle_http_request(&cli, args.clone(), wtfnet_http::HttpMethod::Head).await,
|
||||
Commands::Http {
|
||||
command: HttpCommand::Get(args),
|
||||
} => handle_http_request(&cli, args.clone(), wtfnet_http::HttpMethod::Get).await,
|
||||
Commands::Tls {
|
||||
command: TlsCommand::Handshake(args),
|
||||
} => handle_tls_handshake(&cli, args.clone()).await,
|
||||
Commands::Tls {
|
||||
command: TlsCommand::Cert(args),
|
||||
} => handle_tls_cert(&cli, args.clone()).await,
|
||||
Commands::Tls {
|
||||
command: TlsCommand::Verify(args),
|
||||
} => handle_tls_verify(&cli, args.clone()).await,
|
||||
Commands::Tls {
|
||||
command: TlsCommand::Alpn(args),
|
||||
} => handle_tls_alpn(&cli, args.clone()).await,
|
||||
Commands::Discover {
|
||||
command: DiscoverCommand::Mdns(args),
|
||||
} => handle_discover_mdns(&cli, args.clone()).await,
|
||||
Commands::Discover {
|
||||
command: DiscoverCommand::Ssdp(args),
|
||||
} => handle_discover_ssdp(&cli, args.clone()).await,
|
||||
Commands::Diag(args) => handle_diag(&cli, args.clone()).await,
|
||||
};
|
||||
|
||||
std::process::exit(exit_code);
|
||||
@@ -1553,6 +1687,378 @@ fn split_host_port_with_default(value: &str, default_port: u16) -> Result<(Strin
|
||||
Ok((value.to_string(), default_port))
|
||||
}
|
||||
|
||||
async fn handle_http_request(
|
||||
cli: &Cli,
|
||||
args: HttpRequestArgs,
|
||||
method: wtfnet_http::HttpMethod,
|
||||
) -> i32 {
|
||||
let opts = wtfnet_http::HttpRequestOptions {
|
||||
method,
|
||||
timeout_ms: args.timeout_ms,
|
||||
follow_redirects: args.follow_redirects,
|
||||
max_body_bytes: args.max_body_bytes,
|
||||
show_headers: args.show_headers,
|
||||
show_body: args.show_body,
|
||||
http1_only: args.http1_only,
|
||||
http2_only: args.http2_only,
|
||||
};
|
||||
|
||||
match wtfnet_http::request(&args.url, opts).await {
|
||||
Ok(report) => {
|
||||
let enriched = if args.geoip {
|
||||
let service = geoip_service();
|
||||
let geoip = report
|
||||
.resolved_ips
|
||||
.iter()
|
||||
.filter_map(|value| value.parse::<std::net::IpAddr>().ok())
|
||||
.map(|ip| service.lookup(ip))
|
||||
.collect::<Vec<_>>();
|
||||
HttpReportGeoIp {
|
||||
url: report.url.clone(),
|
||||
final_url: report.final_url.clone(),
|
||||
method: report.method.clone(),
|
||||
status: report.status,
|
||||
http_version: report.http_version.clone(),
|
||||
resolved_ips: report.resolved_ips.clone(),
|
||||
geoip,
|
||||
headers: report.headers.clone(),
|
||||
body: report.body.clone(),
|
||||
timing: report.timing.clone(),
|
||||
}
|
||||
} else {
|
||||
HttpReportGeoIp {
|
||||
url: report.url.clone(),
|
||||
final_url: report.final_url.clone(),
|
||||
method: report.method.clone(),
|
||||
status: report.status,
|
||||
http_version: report.http_version.clone(),
|
||||
resolved_ips: report.resolved_ips.clone(),
|
||||
geoip: Vec::new(),
|
||||
headers: report.headers.clone(),
|
||||
body: report.body.clone(),
|
||||
timing: report.timing.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
if cli.json {
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let command = CommandInfo::new("http request", vec![args.url]);
|
||||
let envelope = CommandEnvelope::new(meta, command, enriched);
|
||||
emit_json(cli, &envelope)
|
||||
} else {
|
||||
println!(
|
||||
"status: {}",
|
||||
report
|
||||
.status
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
);
|
||||
if let Some(url) = report.final_url.as_ref() {
|
||||
println!("final_url: {url}");
|
||||
}
|
||||
if let Some(version) = report.http_version.as_ref() {
|
||||
println!("version: {version}");
|
||||
}
|
||||
if !report.resolved_ips.is_empty() {
|
||||
println!("resolved: {}", report.resolved_ips.join(", "));
|
||||
}
|
||||
println!("total_ms: {}", report.timing.total_ms);
|
||||
if let Some(ms) = report.timing.dns_ms {
|
||||
println!("dns_ms: {ms}");
|
||||
}
|
||||
if let Some(ms) = report.timing.connect_ms {
|
||||
println!("connect_ms: {ms}");
|
||||
}
|
||||
if let Some(ms) = report.timing.tls_ms {
|
||||
println!("tls_ms: {ms}");
|
||||
}
|
||||
if let Some(ms) = report.timing.ttfb_ms {
|
||||
println!("ttfb_ms: {ms}");
|
||||
}
|
||||
if args.geoip && !enriched.geoip.is_empty() {
|
||||
for entry in &enriched.geoip {
|
||||
println!("geoip {}: {}", entry.ip, format_geoip(entry));
|
||||
}
|
||||
}
|
||||
if !report.headers.is_empty() {
|
||||
println!("headers:");
|
||||
for (name, value) in report.headers {
|
||||
println!(" {name}: {value}");
|
||||
}
|
||||
}
|
||||
if let Some(body) = report.body.as_ref() {
|
||||
println!("body:");
|
||||
println!("{body}");
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("http request failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tls_handshake(cli: &Cli, args: TlsArgs) -> i32 {
|
||||
let options = build_tls_options(&args);
|
||||
match wtfnet_tls::handshake(&args.target, options).await {
|
||||
Ok(report) => emit_tls_report(cli, "tls handshake", report),
|
||||
Err(err) => {
|
||||
eprintln!("tls handshake failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tls_cert(cli: &Cli, args: TlsArgs) -> i32 {
|
||||
let options = build_tls_options(&args);
|
||||
match wtfnet_tls::certs(&args.target, options).await {
|
||||
Ok(report) => emit_tls_report(cli, "tls cert", report),
|
||||
Err(err) => {
|
||||
eprintln!("tls cert failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tls_verify(cli: &Cli, args: TlsArgs) -> i32 {
|
||||
let options = build_tls_options(&args);
|
||||
match wtfnet_tls::verify(&args.target, options).await {
|
||||
Ok(report) => emit_tls_report(cli, "tls verify", report),
|
||||
Err(err) => {
|
||||
eprintln!("tls verify failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_tls_alpn(cli: &Cli, args: TlsArgs) -> i32 {
|
||||
let options = build_tls_options(&args);
|
||||
match wtfnet_tls::alpn(&args.target, options).await {
|
||||
Ok(report) => emit_tls_report(cli, "tls alpn", report),
|
||||
Err(err) => {
|
||||
eprintln!("tls alpn failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tls_options(args: &TlsArgs) -> wtfnet_tls::TlsOptions {
|
||||
wtfnet_tls::TlsOptions {
|
||||
sni: args.sni.clone(),
|
||||
alpn: parse_alpn(args.alpn.as_deref()),
|
||||
timeout_ms: args.timeout_ms,
|
||||
insecure: args.insecure,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_alpn(value: Option<&str>) -> Vec<String> {
|
||||
let Some(value) = value else { return Vec::new() };
|
||||
value
|
||||
.split(',')
|
||||
.map(|part| part.trim())
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| part.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn emit_tls_report<T: serde::Serialize>(cli: &Cli, name: &str, report: T) -> i32 {
|
||||
if cli.json {
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let command = CommandInfo::new(name, Vec::new());
|
||||
let envelope = CommandEnvelope::new(meta, command, report);
|
||||
emit_json(cli, &envelope)
|
||||
} else {
|
||||
let value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null);
|
||||
println!("{}", serde_json::to_string_pretty(&value).unwrap_or_default());
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_discover_mdns(cli: &Cli, args: DiscoverMdnsArgs) -> i32 {
|
||||
let duration_ms = match parse_duration_ms(&args.duration) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
return ExitKind::Usage.code();
|
||||
}
|
||||
};
|
||||
let options = wtfnet_discover::MdnsOptions {
|
||||
duration_ms,
|
||||
service_type: args.service.clone(),
|
||||
};
|
||||
match wtfnet_discover::mdns_discover(options).await {
|
||||
Ok(report) => {
|
||||
if cli.json {
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let mut command_args = vec!["--duration".to_string(), args.duration];
|
||||
if let Some(service) = args.service {
|
||||
command_args.push("--service".to_string());
|
||||
command_args.push(service);
|
||||
}
|
||||
let command = CommandInfo::new("discover mdns", command_args);
|
||||
let envelope = CommandEnvelope::new(meta, command, report);
|
||||
emit_json(cli, &envelope)
|
||||
} else {
|
||||
for service in report.services {
|
||||
println!("{}", service.fullname);
|
||||
println!(" type: {}", service.service_type);
|
||||
if let Some(hostname) = service.hostname {
|
||||
println!(" host: {hostname}");
|
||||
}
|
||||
if !service.addresses.is_empty() {
|
||||
println!(" addrs: {}", service.addresses.join(", "));
|
||||
}
|
||||
if let Some(port) = service.port {
|
||||
println!(" port: {port}");
|
||||
}
|
||||
if !service.properties.is_empty() {
|
||||
println!(" props:");
|
||||
for (key, value) in service.properties {
|
||||
println!(" {key}={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("mdns discover failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_discover_ssdp(cli: &Cli, args: DiscoverSsdpArgs) -> i32 {
|
||||
let duration_ms = match parse_duration_ms(&args.duration) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
return ExitKind::Usage.code();
|
||||
}
|
||||
};
|
||||
let options = wtfnet_discover::SsdpOptions { duration_ms };
|
||||
match wtfnet_discover::ssdp_discover(options).await {
|
||||
Ok(report) => {
|
||||
if cli.json {
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let command = CommandInfo::new(
|
||||
"discover ssdp",
|
||||
vec!["--duration".to_string(), args.duration],
|
||||
);
|
||||
let envelope = CommandEnvelope::new(meta, command, report);
|
||||
emit_json(cli, &envelope)
|
||||
} else {
|
||||
for service in report.services {
|
||||
println!("from: {}", service.from);
|
||||
if let Some(st) = service.st {
|
||||
println!(" st: {st}");
|
||||
}
|
||||
if let Some(usn) = service.usn {
|
||||
println!(" usn: {usn}");
|
||||
}
|
||||
if let Some(location) = service.location {
|
||||
println!(" location: {location}");
|
||||
}
|
||||
if let Some(server) = service.server {
|
||||
println!(" server: {server}");
|
||||
}
|
||||
}
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("ssdp discover failed: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_diag(cli: &Cli, args: DiagArgs) -> i32 {
|
||||
let options = wtfnet_diag::DiagOptions {
|
||||
dns_detect_domain: args.dns_detect.clone(),
|
||||
dns_detect_timeout_ms: args.dns_timeout_ms,
|
||||
dns_detect_repeat: args.dns_repeat,
|
||||
};
|
||||
let platform = platform();
|
||||
let report = match wtfnet_diag::run(&platform, options).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("diag failed: {err}");
|
||||
return ExitKind::Failed.code();
|
||||
}
|
||||
};
|
||||
|
||||
let meta = Meta::new("wtfnet", env!("CARGO_PKG_VERSION"), false);
|
||||
let command = CommandInfo::new("diag", Vec::new());
|
||||
let envelope = CommandEnvelope::new(meta.clone(), command, report.clone());
|
||||
let json = if cli.pretty {
|
||||
serde_json::to_string_pretty(&envelope)
|
||||
} else {
|
||||
serde_json::to_string(&envelope)
|
||||
};
|
||||
|
||||
if let Some(out) = args.out.as_ref() {
|
||||
if let Ok(payload) = json.as_ref() {
|
||||
if let Err(err) = std::fs::write(out, payload) {
|
||||
eprintln!("failed to write diag output: {err}");
|
||||
return ExitKind::Failed.code();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bundle) = args.bundle.as_ref() {
|
||||
let meta_json =
|
||||
serde_json::to_value(&meta).unwrap_or_else(|_| serde_json::Value::Null);
|
||||
let report_json =
|
||||
serde_json::to_value(&report).unwrap_or_else(|_| serde_json::Value::Null);
|
||||
if let Err(err) = wtfnet_diag::write_bundle(bundle, &meta_json, &report_json) {
|
||||
eprintln!("failed to write bundle: {err}");
|
||||
return ExitKind::Failed.code();
|
||||
}
|
||||
}
|
||||
|
||||
if cli.json {
|
||||
match json {
|
||||
Ok(payload) => {
|
||||
println!("{payload}");
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("failed to serialize json: {err}");
|
||||
ExitKind::Failed.code()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"ifaces: {} routes: {} ports: {} neigh: {} warnings: {}",
|
||||
report
|
||||
.interfaces
|
||||
.as_ref()
|
||||
.map(|value| value.len().to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
report
|
||||
.routes
|
||||
.as_ref()
|
||||
.map(|value| value.len().to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
report
|
||||
.ports_listen
|
||||
.as_ref()
|
||||
.map(|value| value.len().to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
report
|
||||
.neighbors
|
||||
.as_ref()
|
||||
.map(|value| value.len().to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
report.warnings.len()
|
||||
);
|
||||
ExitKind::Ok.code()
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_tls_name(value: &str) -> Option<String> {
|
||||
if let Ok(_addr) = value.parse::<std::net::SocketAddr>() {
|
||||
return None;
|
||||
|
||||
12
crates/wtfnet-diag/Cargo.toml
Normal file
12
crates/wtfnet-diag/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "wtfnet-diag"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||
wtfnet-dns = { path = "../wtfnet-dns" }
|
||||
zip = "0.6"
|
||||
142
crates/wtfnet-diag/src/lib.rs
Normal file
142
crates/wtfnet-diag/src/lib.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
use wtfnet_dns::{DnsDetectResult, DnsTransport};
|
||||
use wtfnet_platform::{DnsConfigSnapshot, ListenSocket, NetInterface, NeighborEntry, RouteEntry};
|
||||
use wtfnet_platform::{Platform, PlatformError};
|
||||
use zip::write::FileOptions;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DiagError {
|
||||
#[error("platform error: {0}")]
|
||||
Platform(String),
|
||||
#[error("dns error: {0}")]
|
||||
Dns(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
#[error("zip error: {0}")]
|
||||
Zip(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagOptions {
|
||||
pub dns_detect_domain: Option<String>,
|
||||
pub dns_detect_timeout_ms: u64,
|
||||
pub dns_detect_repeat: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagReport {
|
||||
pub interfaces: Option<Vec<NetInterface>>,
|
||||
pub routes: Option<Vec<RouteEntry>>,
|
||||
pub dns_config: Option<DnsConfigSnapshot>,
|
||||
pub ports_listen: Option<Vec<ListenSocket>>,
|
||||
pub neighbors: Option<Vec<NeighborEntry>>,
|
||||
pub dns_detect: Option<DnsDetectResult>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn run(platform: &Platform, options: DiagOptions) -> Result<DiagReport, DiagError> {
|
||||
let mut warnings = Vec::new();
|
||||
let interfaces = match platform.sys.interfaces().await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
warnings.push(format_platform_error("interfaces", err));
|
||||
None
|
||||
}
|
||||
};
|
||||
let routes = match platform.sys.routes().await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
warnings.push(format_platform_error("routes", err));
|
||||
None
|
||||
}
|
||||
};
|
||||
let dns_config = match platform.sys.dns_config().await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
warnings.push(format_platform_error("dns_config", err));
|
||||
None
|
||||
}
|
||||
};
|
||||
let ports_listen = match platform.ports.listening().await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
warnings.push(format_platform_error("ports_listen", err));
|
||||
None
|
||||
}
|
||||
};
|
||||
let neighbors = match platform.neigh.neighbors().await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
warnings.push(format_platform_error("neighbors", err));
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let dns_detect = if let Some(domain) = options.dns_detect_domain.as_ref() {
|
||||
match wtfnet_dns::detect(
|
||||
domain,
|
||||
&wtfnet_dns::default_detect_servers(DnsTransport::Udp),
|
||||
DnsTransport::Udp,
|
||||
None,
|
||||
options.dns_detect_repeat,
|
||||
options.dns_detect_timeout_ms,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
warnings.push(format!("dns_detect: {err}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(DiagReport {
|
||||
interfaces,
|
||||
routes,
|
||||
dns_config,
|
||||
ports_listen,
|
||||
neighbors,
|
||||
dns_detect,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_bundle(
|
||||
path: &Path,
|
||||
meta_json: &Value,
|
||||
report_json: &Value,
|
||||
) -> Result<(), DiagError> {
|
||||
let file = File::create(path).map_err(|err| DiagError::Io(err.to_string()))?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
zip.start_file("meta.json", options)
|
||||
.map_err(|err| DiagError::Zip(err.to_string()))?;
|
||||
let meta_bytes = serde_json::to_vec_pretty(meta_json)
|
||||
.map_err(|err| DiagError::Io(err.to_string()))?;
|
||||
zip.write_all(&meta_bytes)
|
||||
.map_err(|err| DiagError::Io(err.to_string()))?;
|
||||
|
||||
zip.start_file("report.json", options)
|
||||
.map_err(|err| DiagError::Zip(err.to_string()))?;
|
||||
let report_bytes = serde_json::to_vec_pretty(report_json)
|
||||
.map_err(|err| DiagError::Io(err.to_string()))?;
|
||||
zip.write_all(&report_bytes)
|
||||
.map_err(|err| DiagError::Io(err.to_string()))?;
|
||||
|
||||
zip.finish()
|
||||
.map_err(|err| DiagError::Zip(err.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_platform_error(section: &str, err: PlatformError) -> String {
|
||||
format!("{section}: {} ({:?})", err.message, err.code)
|
||||
}
|
||||
10
crates/wtfnet-discover/Cargo.toml
Normal file
10
crates/wtfnet-discover/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "wtfnet-discover"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mdns-sd = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
209
crates/wtfnet-discover/src/lib.rs
Normal file
209
crates/wtfnet-discover/src/lib.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DiscoverError {
|
||||
#[error("mdns error: {0}")]
|
||||
Mdns(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdnsOptions {
|
||||
pub duration_ms: u64,
|
||||
pub service_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SsdpOptions {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdnsService {
|
||||
pub service_type: String,
|
||||
pub fullname: String,
|
||||
pub hostname: Option<String>,
|
||||
pub addresses: Vec<String>,
|
||||
pub port: Option<u16>,
|
||||
pub properties: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdnsReport {
|
||||
pub duration_ms: u64,
|
||||
pub service_type: Option<String>,
|
||||
pub services: Vec<MdnsService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SsdpService {
|
||||
pub from: String,
|
||||
pub st: Option<String>,
|
||||
pub usn: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SsdpReport {
|
||||
pub duration_ms: u64,
|
||||
pub services: Vec<SsdpService>,
|
||||
}
|
||||
|
||||
pub async fn mdns_discover(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || mdns_discover_blocking(options))
|
||||
.await
|
||||
.map_err(|err| DiscoverError::Mdns(err.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn ssdp_discover(options: SsdpOptions) -> Result<SsdpReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || ssdp_discover_blocking(options))
|
||||
.await
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
fn mdns_discover_blocking(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
let daemon = ServiceDaemon::new().map_err(|err| DiscoverError::Mdns(err.to_string()))?;
|
||||
let mut service_types = BTreeSet::new();
|
||||
if let Some(service_type) = options.service_type.as_ref() {
|
||||
service_types.insert(service_type.clone());
|
||||
} else {
|
||||
let receiver = daemon
|
||||
.browse("_services._dns-sd._udp.local.")
|
||||
.map_err(|err| DiscoverError::Mdns(err.to_string()))?;
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms / 2);
|
||||
while Instant::now() < deadline {
|
||||
match receiver.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(ServiceEvent::ServiceFound(service_type, _)) => {
|
||||
service_types.insert(service_type);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut services = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||
for service_type in service_types.iter() {
|
||||
let receiver = daemon
|
||||
.browse(service_type)
|
||||
.map_err(|err| DiscoverError::Mdns(err.to_string()))?;
|
||||
while Instant::now() < deadline {
|
||||
match receiver.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(ServiceEvent::ServiceResolved(info)) => {
|
||||
services.push(format_service_info(service_type, &info));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MdnsReport {
|
||||
duration_ms: options.duration_ms,
|
||||
service_type: options.service_type,
|
||||
services,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_service_info(service_type: &str, info: &ServiceInfo) -> MdnsService {
|
||||
let mut addresses = Vec::new();
|
||||
for addr in info.get_addresses().iter() {
|
||||
addresses.push(addr.to_string());
|
||||
}
|
||||
let mut properties = BTreeMap::new();
|
||||
for prop in info.get_properties().iter() {
|
||||
properties.insert(prop.key().to_string(), prop.val_str().to_string());
|
||||
}
|
||||
MdnsService {
|
||||
service_type: service_type.to_string(),
|
||||
fullname: info.get_fullname().to_string(),
|
||||
hostname: Some(info.get_hostname().to_string()),
|
||||
addresses,
|
||||
port: Some(info.get_port()),
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
fn ssdp_discover_blocking(options: SsdpOptions) -> Result<SsdpReport, DiscoverError> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
|
||||
let request = [
|
||||
"M-SEARCH * HTTP/1.1",
|
||||
"HOST: 239.255.255.250:1900",
|
||||
"MAN: \"ssdp:discover\"",
|
||||
"MX: 1",
|
||||
"ST: ssdp:all",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
.join("\r\n");
|
||||
let target = "239.255.255.250:1900";
|
||||
let _ = socket.send_to(request.as_bytes(), target);
|
||||
|
||||
let mut services = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if let Ok(payload) = std::str::from_utf8(&buf[..len]) {
|
||||
if let Some(entry) = parse_ssdp_response(payload, from) {
|
||||
services.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SsdpReport {
|
||||
duration_ms: options.duration_ms,
|
||||
services,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ssdp_response(payload: &str, from: SocketAddr) -> Option<SsdpService> {
|
||||
let mut st = None;
|
||||
let mut usn = None;
|
||||
let mut location = None;
|
||||
let mut server = None;
|
||||
|
||||
for line in payload.lines() {
|
||||
let line = line.trim();
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = value.trim().to_string();
|
||||
match key.as_str() {
|
||||
"st" => st = Some(value),
|
||||
"usn" => usn = Some(value),
|
||||
"location" => location = Some(value),
|
||||
"server" => server = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if st.is_none() && usn.is_none() && location.is_none() && server.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SsdpService {
|
||||
from: from.to_string(),
|
||||
st,
|
||||
usn,
|
||||
location,
|
||||
server,
|
||||
})
|
||||
}
|
||||
11
crates/wtfnet-http/Cargo.toml
Normal file
11
crates/wtfnet-http/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "wtfnet-http"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["net", "time"] }
|
||||
url = "2"
|
||||
182
crates/wtfnet-http/src/lib.rs
Normal file
182
crates/wtfnet-http/src/lib.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::lookup_host;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HttpError {
|
||||
#[error("invalid url: {0}")]
|
||||
Url(String),
|
||||
#[error("request error: {0}")]
|
||||
Request(String),
|
||||
#[error("response error: {0}")]
|
||||
Response(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpTiming {
|
||||
pub total_ms: u128,
|
||||
pub dns_ms: Option<u128>,
|
||||
pub connect_ms: Option<u128>,
|
||||
pub tls_ms: Option<u128>,
|
||||
pub ttfb_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpReport {
|
||||
pub url: String,
|
||||
pub final_url: Option<String>,
|
||||
pub method: String,
|
||||
pub status: Option<u16>,
|
||||
pub http_version: Option<String>,
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
pub timing: HttpTiming,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum HttpMethod {
|
||||
Head,
|
||||
Get,
|
||||
}
|
||||
|
||||
impl HttpMethod {
|
||||
fn to_reqwest(self) -> Method {
|
||||
match self {
|
||||
HttpMethod::Head => Method::HEAD,
|
||||
HttpMethod::Get => Method::GET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpRequestOptions {
|
||||
pub method: HttpMethod,
|
||||
pub timeout_ms: u64,
|
||||
pub follow_redirects: Option<u32>,
|
||||
pub max_body_bytes: usize,
|
||||
pub show_headers: bool,
|
||||
pub show_body: bool,
|
||||
pub http1_only: bool,
|
||||
pub http2_only: bool,
|
||||
}
|
||||
|
||||
pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport, HttpError> {
|
||||
let parsed = Url::parse(url).map_err(|err| HttpError::Url(err.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 mut resolved_ips = Vec::new();
|
||||
let dns_start = Instant::now();
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
resolved_ips.push(ip.to_string());
|
||||
} else {
|
||||
let addrs = lookup_host((host, port))
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
for addr in addrs {
|
||||
resolved_ips.push(addr.ip().to_string());
|
||||
}
|
||||
resolved_ips.sort();
|
||||
resolved_ips.dedup();
|
||||
if resolved_ips.is_empty() {
|
||||
return Err(HttpError::Request("no addresses resolved".to_string()));
|
||||
}
|
||||
}
|
||||
let dns_ms = dns_start.elapsed().as_millis();
|
||||
|
||||
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))
|
||||
} else {
|
||||
builder.redirect(reqwest::redirect::Policy::none())
|
||||
};
|
||||
|
||||
if opts.http1_only {
|
||||
builder = builder.http1_only();
|
||||
}
|
||||
if opts.http2_only {
|
||||
builder = builder.http2_prior_knowledge();
|
||||
}
|
||||
|
||||
if let Some(first) = resolved_ips.first() {
|
||||
if let Ok(ip) = first.parse::<IpAddr>() {
|
||||
let addr = SocketAddr::new(ip, port);
|
||||
builder = builder.resolve(host, addr);
|
||||
}
|
||||
}
|
||||
|
||||
let client = builder.build().map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let start = Instant::now();
|
||||
let response = client
|
||||
.request(opts.method.to_reqwest(), parsed.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let ttfb_ms = start.elapsed().as_millis();
|
||||
|
||||
let status = response.status();
|
||||
let final_url = response.url().to_string();
|
||||
let version = response.version();
|
||||
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 bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| HttpError::Response(err.to_string()))?;
|
||||
let sliced = if bytes.len() > opts.max_body_bytes {
|
||||
&bytes[..opts.max_body_bytes]
|
||||
} else {
|
||||
&bytes
|
||||
};
|
||||
Some(String::from_utf8_lossy(sliced).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let total_ms = start.elapsed().as_millis();
|
||||
|
||||
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: status_code(status),
|
||||
http_version: Some(format!("{version:?}")),
|
||||
resolved_ips,
|
||||
headers,
|
||||
body,
|
||||
timing: HttpTiming {
|
||||
total_ms,
|
||||
dns_ms: Some(dns_ms),
|
||||
connect_ms: None,
|
||||
tls_ms: None,
|
||||
ttfb_ms: Some(ttfb_ms),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn status_code(status: StatusCode) -> Option<u16> {
|
||||
Some(status.as_u16())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use std::os::unix::io::AsRawFd;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
#[cfg(unix)]
|
||||
use std::mem::size_of_val;
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
|
||||
13
crates/wtfnet-tls/Cargo.toml
Normal file
13
crates/wtfnet-tls/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "wtfnet-tls"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rustls = { version = "0.21", features = ["dangerous_configuration"] }
|
||||
rustls-native-certs = "0.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["net", "time"] }
|
||||
tokio-rustls = "0.24"
|
||||
x509-parser = "0.16"
|
||||
335
crates/wtfnet-tls/src/lib.rs
Normal file
335
crates/wtfnet-tls/src/lib.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use rustls::{Certificate, ClientConfig, RootCertStore, ServerName};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TlsError {
|
||||
#[error("invalid target: {0}")]
|
||||
InvalidTarget(String),
|
||||
#[error("invalid sni: {0}")]
|
||||
InvalidSni(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
#[error("tls error: {0}")]
|
||||
Tls(String),
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("timeout")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsCertSummary {
|
||||
pub subject: String,
|
||||
pub issuer: String,
|
||||
pub not_before: String,
|
||||
pub not_after: String,
|
||||
pub san: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsHandshakeReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub alpn_offered: Vec<String>,
|
||||
pub alpn_negotiated: Option<String>,
|
||||
pub tls_version: Option<String>,
|
||||
pub cipher: Option<String>,
|
||||
pub cert_chain: Vec<TlsCertSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsVerifyReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub alpn_offered: Vec<String>,
|
||||
pub alpn_negotiated: Option<String>,
|
||||
pub tls_version: Option<String>,
|
||||
pub cipher: Option<String>,
|
||||
pub verified: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsCertReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub cert_chain: Vec<TlsCertSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsAlpnReport {
|
||||
pub target: String,
|
||||
pub sni: Option<String>,
|
||||
pub alpn_offered: Vec<String>,
|
||||
pub alpn_negotiated: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TlsOptions {
|
||||
pub sni: Option<String>,
|
||||
pub alpn: Vec<String>,
|
||||
pub timeout_ms: u64,
|
||||
pub insecure: bool,
|
||||
}
|
||||
|
||||
pub async fn handshake(target: &str, options: TlsOptions) -> Result<TlsHandshakeReport, TlsError> {
|
||||
let (addr, server_name) = parse_target(target, options.sni.as_deref())?;
|
||||
let connector = build_connector(options.insecure, &options.alpn)?;
|
||||
let stream = connect(addr, connector, server_name, options.timeout_ms).await?;
|
||||
let (_, session) = stream.get_ref();
|
||||
|
||||
Ok(TlsHandshakeReport {
|
||||
target: target.to_string(),
|
||||
sni: options.sni,
|
||||
alpn_offered: options.alpn.clone(),
|
||||
alpn_negotiated: session
|
||||
.alpn_protocol()
|
||||
.map(|value| String::from_utf8_lossy(value).to_string()),
|
||||
tls_version: session.protocol_version().map(|v| format!("{v:?}")),
|
||||
cipher: session
|
||||
.negotiated_cipher_suite()
|
||||
.map(|suite| format!("{suite:?}")),
|
||||
cert_chain: extract_cert_chain(session.peer_certificates())?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn verify(target: &str, options: TlsOptions) -> Result<TlsVerifyReport, TlsError> {
|
||||
let (addr, server_name) = parse_target(target, options.sni.as_deref())?;
|
||||
let connector = build_connector(false, &options.alpn)?;
|
||||
match connect(addr, connector, server_name, options.timeout_ms).await {
|
||||
Ok(stream) => {
|
||||
let (_, session) = stream.get_ref();
|
||||
Ok(TlsVerifyReport {
|
||||
target: target.to_string(),
|
||||
sni: options.sni,
|
||||
alpn_offered: options.alpn.clone(),
|
||||
alpn_negotiated: session
|
||||
.alpn_protocol()
|
||||
.map(|value| String::from_utf8_lossy(value).to_string()),
|
||||
tls_version: session.protocol_version().map(|v| format!("{v:?}")),
|
||||
cipher: session
|
||||
.negotiated_cipher_suite()
|
||||
.map(|suite| format!("{suite:?}")),
|
||||
verified: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(err) => Ok(TlsVerifyReport {
|
||||
target: target.to_string(),
|
||||
sni: options.sni,
|
||||
alpn_offered: options.alpn.clone(),
|
||||
alpn_negotiated: None,
|
||||
tls_version: None,
|
||||
cipher: None,
|
||||
verified: false,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn certs(target: &str, options: TlsOptions) -> Result<TlsCertReport, TlsError> {
|
||||
let (addr, server_name) = parse_target(target, options.sni.as_deref())?;
|
||||
let connector = build_connector(options.insecure, &options.alpn)?;
|
||||
let stream = connect(addr, connector, server_name, options.timeout_ms).await?;
|
||||
let (_, session) = stream.get_ref();
|
||||
Ok(TlsCertReport {
|
||||
target: target.to_string(),
|
||||
sni: options.sni,
|
||||
cert_chain: extract_cert_chain(session.peer_certificates())?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn alpn(target: &str, options: TlsOptions) -> Result<TlsAlpnReport, TlsError> {
|
||||
let (addr, server_name) = parse_target(target, options.sni.as_deref())?;
|
||||
let connector = build_connector(options.insecure, &options.alpn)?;
|
||||
let stream = connect(addr, connector, server_name, options.timeout_ms).await?;
|
||||
let (_, session) = stream.get_ref();
|
||||
Ok(TlsAlpnReport {
|
||||
target: target.to_string(),
|
||||
sni: options.sni,
|
||||
alpn_offered: options.alpn.clone(),
|
||||
alpn_negotiated: session
|
||||
.alpn_protocol()
|
||||
.map(|value| String::from_utf8_lossy(value).to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_target(target: &str, sni: Option<&str>) -> Result<(SocketAddr, ServerName), TlsError> {
|
||||
let (host, port) = split_host_port(target)?;
|
||||
let addr = resolve_addr(&host, port)?;
|
||||
let server_name = if let Some(sni) = sni {
|
||||
ServerName::try_from(sni).map_err(|_| TlsError::InvalidSni(sni.to_string()))?
|
||||
} else if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
ServerName::IpAddress(ip)
|
||||
} else {
|
||||
ServerName::try_from(host.as_str())
|
||||
.map_err(|_| TlsError::InvalidSni(host.to_string()))?
|
||||
};
|
||||
Ok((addr, server_name))
|
||||
}
|
||||
|
||||
fn split_host_port(value: &str) -> Result<(String, u16), TlsError> {
|
||||
if let Some(stripped) = value.strip_prefix('[') {
|
||||
if let Some(end) = stripped.find(']') {
|
||||
let host = &stripped[..end];
|
||||
let rest = &stripped[end + 1..];
|
||||
let port = rest
|
||||
.strip_prefix(':')
|
||||
.ok_or_else(|| TlsError::InvalidTarget(value.to_string()))?;
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| TlsError::InvalidTarget(value.to_string()))?;
|
||||
return Ok((host.to_string(), port));
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = value.rsplitn(2, ':');
|
||||
let port = parts
|
||||
.next()
|
||||
.ok_or_else(|| TlsError::InvalidTarget(value.to_string()))?;
|
||||
let host = parts
|
||||
.next()
|
||||
.ok_or_else(|| TlsError::InvalidTarget(value.to_string()))?;
|
||||
if host.contains(':') {
|
||||
return Err(TlsError::InvalidTarget(value.to_string()));
|
||||
}
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| TlsError::InvalidTarget(value.to_string()))?;
|
||||
Ok((host.to_string(), port))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_host_port_ipv4() {
|
||||
let (host, port) = split_host_port("example.com:443").unwrap();
|
||||
assert_eq!(host, "example.com");
|
||||
assert_eq!(port, 443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_host_port_ipv6() {
|
||||
let (host, port) = split_host_port("[2001:db8::1]:443").unwrap();
|
||||
assert_eq!(host, "2001:db8::1");
|
||||
assert_eq!(port, 443);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr, TlsError> {
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return Ok(SocketAddr::new(ip, port));
|
||||
}
|
||||
let addr = std::net::ToSocketAddrs::to_socket_addrs(&(host, port))
|
||||
.map_err(|err| TlsError::Io(err.to_string()))?
|
||||
.next()
|
||||
.ok_or_else(|| TlsError::InvalidTarget(host.to_string()))?;
|
||||
Ok(addr)
|
||||
}
|
||||
|
||||
fn build_connector(insecure: bool, alpn: &[String]) -> Result<TlsConnector, TlsError> {
|
||||
let mut config = if insecure {
|
||||
ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_custom_certificate_verifier(Arc::new(NoVerifier))
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
let mut roots = RootCertStore::empty();
|
||||
let store = rustls_native_certs::load_native_certs()
|
||||
.map_err(|err| TlsError::Io(err.to_string()))?;
|
||||
for cert in store {
|
||||
roots
|
||||
.add(&Certificate(cert.0))
|
||||
.map_err(|err| TlsError::Tls(err.to_string()))?;
|
||||
}
|
||||
ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth()
|
||||
};
|
||||
|
||||
if !alpn.is_empty() {
|
||||
config.alpn_protocols = alpn.iter().map(|p| p.as_bytes().to_vec()).collect();
|
||||
}
|
||||
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
addr: SocketAddr,
|
||||
connector: TlsConnector,
|
||||
server_name: ServerName,
|
||||
timeout_ms: u64,
|
||||
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, TlsError> {
|
||||
let tcp = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| TlsError::Timeout)?
|
||||
.map_err(|err| TlsError::Io(err.to_string()))?;
|
||||
let stream = timeout(
|
||||
Duration::from_millis(timeout_ms),
|
||||
connector.connect(server_name, tcp),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| TlsError::Timeout)?
|
||||
.map_err(|err| TlsError::Tls(err.to_string()))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn extract_cert_chain(certs: Option<&[Certificate]>) -> Result<Vec<TlsCertSummary>, TlsError> {
|
||||
let mut results = Vec::new();
|
||||
if let Some(certs) = certs {
|
||||
for cert in certs {
|
||||
let summary = parse_cert(&cert.0)?;
|
||||
results.push(summary);
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn parse_cert(der: &[u8]) -> Result<TlsCertSummary, TlsError> {
|
||||
let (_, cert) =
|
||||
X509Certificate::from_der(der).map_err(|err| TlsError::Parse(err.to_string()))?;
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_san(cert: &X509Certificate<'_>) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
if let Ok(Some(ext)) = cert.subject_alternative_name() {
|
||||
for name in ext.value.general_names.iter() {
|
||||
result.push(name.to_string());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct NoVerifier;
|
||||
|
||||
impl rustls::client::ServerCertVerifier for NoVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &Certificate,
|
||||
_intermediates: &[Certificate],
|
||||
_server_name: &ServerName,
|
||||
_scts: &mut dyn Iterator<Item = &[u8]>,
|
||||
_ocsp: &[u8],
|
||||
_now: SystemTime,
|
||||
) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::ServerCertVerified::assertion())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user