Add multiple features

This commit is contained in:
DaZuo0122
2026-01-16 23:16:58 +08:00
parent c367ca29e4
commit cb022127c0
18 changed files with 1883 additions and 4 deletions

View File

@@ -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");
}
}

View File

@@ -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" }

View File

@@ -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;

View 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"

View 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)
}

View 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"] }

View 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,
})
}

View 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"

View 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())
}

View File

@@ -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;

View 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"

View 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())
}
}