Finish verion 0.1.0
This commit is contained in:
1709
Cargo.lock
generated
1709
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,11 @@ resolver = "3"
|
|||||||
members = [
|
members = [
|
||||||
"crates/wtfnet-core",
|
"crates/wtfnet-core",
|
||||||
"crates/wtfnet-cli",
|
"crates/wtfnet-cli",
|
||||||
|
"crates/wtfnet-calc",
|
||||||
"crates/wtfnet-platform",
|
"crates/wtfnet-platform",
|
||||||
"crates/wtfnet-platform-windows",
|
"crates/wtfnet-platform-windows",
|
||||||
"crates/wtfnet-platform-linux",
|
"crates/wtfnet-platform-linux",
|
||||||
|
"crates/wtfnet-geoip",
|
||||||
|
"crates/wtfnet-probe",
|
||||||
|
"crates/wtfnet-dns",
|
||||||
]
|
]
|
||||||
|
|||||||
9
crates/wtfnet-calc/Cargo.toml
Normal file
9
crates/wtfnet-calc/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-calc"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ipnet = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
thiserror = "2"
|
||||||
202
crates/wtfnet-calc/src/lib.rs
Normal file
202
crates/wtfnet-calc/src/lib.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CalcError {
|
||||||
|
#[error("invalid input: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
#[error("parse error: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SubnetInfo {
|
||||||
|
pub input: String,
|
||||||
|
pub version: String,
|
||||||
|
pub cidr: String,
|
||||||
|
pub network: String,
|
||||||
|
pub broadcast: Option<String>,
|
||||||
|
pub netmask: String,
|
||||||
|
pub hostmask: String,
|
||||||
|
pub prefix_len: u8,
|
||||||
|
pub total_addresses: String,
|
||||||
|
pub usable_addresses: String,
|
||||||
|
pub first_host: Option<String>,
|
||||||
|
pub last_host: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subnet_info(input: &str) -> Result<SubnetInfo, CalcError> {
|
||||||
|
let net = parse_net(input)?;
|
||||||
|
match net {
|
||||||
|
IpNet::V4(v4) => Ok(subnet_info_v4(input, v4)),
|
||||||
|
IpNet::V6(v6) => Ok(subnet_info_v6(input, v6)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(a: &str, b: &str) -> Result<bool, CalcError> {
|
||||||
|
let net_a = parse_net(a)?;
|
||||||
|
let net_b = parse_net(b)?;
|
||||||
|
Ok(net_a.contains(&net_b))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn overlap(a: &str, b: &str) -> Result<bool, CalcError> {
|
||||||
|
let net_a = parse_net(a)?;
|
||||||
|
let net_b = parse_net(b)?;
|
||||||
|
match (net_a, net_b) {
|
||||||
|
(IpNet::V4(a), IpNet::V4(b)) => Ok(overlap_v4(a, b)),
|
||||||
|
(IpNet::V6(a), IpNet::V6(b)) => Ok(overlap_v6(a, b)),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize(inputs: &[String]) -> Result<Vec<IpNet>, CalcError> {
|
||||||
|
if inputs.is_empty() {
|
||||||
|
return Err(CalcError::InvalidInput(
|
||||||
|
"at least one CIDR required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut nets = Vec::with_capacity(inputs.len());
|
||||||
|
for value in inputs {
|
||||||
|
nets.push(parse_net(value)?);
|
||||||
|
}
|
||||||
|
Ok(IpNet::aggregate(&nets))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subnet_info_v4(input: &str, net: Ipv4Net) -> SubnetInfo {
|
||||||
|
let total = total_addresses_v4(net.prefix_len());
|
||||||
|
let usable = usable_addresses_v4(net.prefix_len());
|
||||||
|
let (first, last) = first_last_v4(net);
|
||||||
|
SubnetInfo {
|
||||||
|
input: input.to_string(),
|
||||||
|
version: "ipv4".to_string(),
|
||||||
|
cidr: net.to_string(),
|
||||||
|
network: net.network().to_string(),
|
||||||
|
broadcast: Some(net.broadcast().to_string()),
|
||||||
|
netmask: net.netmask().to_string(),
|
||||||
|
hostmask: net.hostmask().to_string(),
|
||||||
|
prefix_len: net.prefix_len(),
|
||||||
|
total_addresses: total,
|
||||||
|
usable_addresses: usable,
|
||||||
|
first_host: first,
|
||||||
|
last_host: last,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subnet_info_v6(input: &str, net: Ipv6Net) -> SubnetInfo {
|
||||||
|
let total = total_addresses_v6(net.prefix_len());
|
||||||
|
let (first, last) = first_last_v6(net);
|
||||||
|
SubnetInfo {
|
||||||
|
input: input.to_string(),
|
||||||
|
version: "ipv6".to_string(),
|
||||||
|
cidr: net.to_string(),
|
||||||
|
network: net.network().to_string(),
|
||||||
|
broadcast: None,
|
||||||
|
netmask: net.netmask().to_string(),
|
||||||
|
hostmask: net.hostmask().to_string(),
|
||||||
|
prefix_len: net.prefix_len(),
|
||||||
|
total_addresses: total.clone(),
|
||||||
|
usable_addresses: total,
|
||||||
|
first_host: first,
|
||||||
|
last_host: last,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_net(value: &str) -> Result<IpNet, CalcError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(CalcError::InvalidInput("empty input".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = trimmed.split_whitespace();
|
||||||
|
let first = parts.next().unwrap();
|
||||||
|
if let Some(mask) = parts.next() {
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err(CalcError::InvalidInput(
|
||||||
|
"expected: <ip> <mask>".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return parse_ip_mask(first, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((ip, mask)) = trimmed.split_once('/') {
|
||||||
|
if mask.contains('.') || mask.contains(':') {
|
||||||
|
return parse_ip_mask(ip, mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed
|
||||||
|
.parse::<IpNet>()
|
||||||
|
.map_err(|err| CalcError::Parse(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ip_mask(ip: &str, mask: &str) -> Result<IpNet, CalcError> {
|
||||||
|
let ip: IpAddr = ip
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CalcError::Parse(format!("invalid ip: {ip}")))?;
|
||||||
|
let mask: IpAddr = mask
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CalcError::Parse(format!("invalid mask: {mask}")))?;
|
||||||
|
IpNet::with_netmask(ip, mask).map_err(|err| CalcError::Parse(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_addresses_v4(prefix: u8) -> String {
|
||||||
|
let bits = 32u32.saturating_sub(prefix as u32);
|
||||||
|
(1u128 << bits).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usable_addresses_v4(prefix: u8) -> String {
|
||||||
|
let total = 1u128 << (32u32.saturating_sub(prefix as u32));
|
||||||
|
let usable = if prefix <= 30 {
|
||||||
|
total.saturating_sub(2)
|
||||||
|
} else {
|
||||||
|
total
|
||||||
|
};
|
||||||
|
usable.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_addresses_v6(prefix: u8) -> String {
|
||||||
|
let bits = 128u32.saturating_sub(prefix as u32);
|
||||||
|
if bits == 128 {
|
||||||
|
return "340282366920938463463374607431768211456".to_string();
|
||||||
|
}
|
||||||
|
(1u128 << bits).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_last_v4(net: Ipv4Net) -> (Option<String>, Option<String>) {
|
||||||
|
let network = net.network();
|
||||||
|
let broadcast = net.broadcast();
|
||||||
|
let (first, last) = if net.prefix_len() <= 30 {
|
||||||
|
(
|
||||||
|
Some(Ipv4Addr::from(u32::from(network).saturating_add(1)).to_string()),
|
||||||
|
Some(Ipv4Addr::from(u32::from(broadcast).saturating_sub(1)).to_string()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Some(network.to_string()), Some(broadcast.to_string()))
|
||||||
|
};
|
||||||
|
(first, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_last_v6(net: Ipv6Net) -> (Option<String>, Option<String>) {
|
||||||
|
(
|
||||||
|
Some(net.network().to_string()),
|
||||||
|
Some(net.broadcast().to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlap_v4(a: Ipv4Net, b: Ipv4Net) -> bool {
|
||||||
|
let a_start = u32::from(a.network());
|
||||||
|
let a_end = u32::from(a.broadcast());
|
||||||
|
let b_start = u32::from(b.network());
|
||||||
|
let b_end = u32::from(b.broadcast());
|
||||||
|
a_start <= b_end && b_start <= a_end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlap_v6(a: Ipv6Net, b: Ipv6Net) -> bool {
|
||||||
|
let a_start = u128::from(a.network());
|
||||||
|
let a_end = u128::from(a.broadcast());
|
||||||
|
let b_start = u128::from(b.network());
|
||||||
|
let b_end = u128::from(b.broadcast());
|
||||||
|
a_start <= b_end && b_start <= a_end
|
||||||
|
}
|
||||||
@@ -13,7 +13,11 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
wtfnet-core = { path = "../wtfnet-core" }
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
|
wtfnet-calc = { path = "../wtfnet-calc" }
|
||||||
|
wtfnet-geoip = { path = "../wtfnet-geoip" }
|
||||||
wtfnet-platform = { path = "../wtfnet-platform" }
|
wtfnet-platform = { path = "../wtfnet-platform" }
|
||||||
|
wtfnet-probe = { path = "../wtfnet-probe" }
|
||||||
|
wtfnet-dns = { path = "../wtfnet-dns", features = ["pcap"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
wtfnet-platform-windows = { path = "../wtfnet-platform-windows" }
|
wtfnet-platform-windows = { path = "../wtfnet-platform-windows" }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
16
crates/wtfnet-dns/Cargo.toml
Normal file
16
crates/wtfnet-dns/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-dns"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hickory-resolver = { version = "0.24", features = ["dns-over-tls", "dns-over-https", "dns-over-https-rustls", "dns-over-rustls", "native-certs"] }
|
||||||
|
hickory-proto = "0.24"
|
||||||
|
reqwest = { version = "0.11", features = ["rustls-tls", "socks"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
pnet = { version = "0.34", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
pcap = ["dep:pnet"]
|
||||||
736
crates/wtfnet-dns/src/lib.rs
Normal file
736
crates/wtfnet-dns/src/lib.rs
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
use hickory_resolver::config::{
|
||||||
|
NameServerConfig, NameServerConfigGroup, Protocol, ResolverConfig, ResolverOpts,
|
||||||
|
};
|
||||||
|
use hickory_resolver::error::ResolveErrorKind;
|
||||||
|
use hickory_resolver::proto::rr::{RData, RecordType};
|
||||||
|
use hickory_resolver::TokioAsyncResolver;
|
||||||
|
use hickory_resolver::system_conf::read_system_conf;
|
||||||
|
use hickory_proto::op::{Message, MessageType, Query};
|
||||||
|
use hickory_proto::rr::Name;
|
||||||
|
use reqwest::Proxy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::datalink::{self, Channel, Config as DatalinkConfig, NetworkInterface};
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::packet::ip::IpNextHeaderProtocols;
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::packet::ipv4::Ipv4Packet;
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::packet::ipv6::Ipv6Packet;
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::packet::udp::UdpPacket;
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
use pnet::packet::Packet;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DnsError {
|
||||||
|
#[error("invalid record type: {0}")]
|
||||||
|
RecordType(String),
|
||||||
|
#[error("resolver error: {0}")]
|
||||||
|
Resolver(String),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("missing tls server name for {0}")]
|
||||||
|
MissingTlsName(String),
|
||||||
|
#[error("missing server for transport {0}")]
|
||||||
|
MissingServer(String),
|
||||||
|
#[error("proxy only supported for {0}")]
|
||||||
|
ProxyUnsupported(String),
|
||||||
|
#[error("proxy error: {0}")]
|
||||||
|
Proxy(String),
|
||||||
|
#[error("not supported: {0}")]
|
||||||
|
NotSupported(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DnsTransport {
|
||||||
|
Udp,
|
||||||
|
Tcp,
|
||||||
|
Dot,
|
||||||
|
Doh,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DnsTransport {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let value = match self {
|
||||||
|
DnsTransport::Udp => "udp",
|
||||||
|
DnsTransport::Tcp => "tcp",
|
||||||
|
DnsTransport::Dot => "dot",
|
||||||
|
DnsTransport::Doh => "doh",
|
||||||
|
};
|
||||||
|
f.write_str(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DnsTransport {
|
||||||
|
type Err = DnsError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"udp" => Ok(DnsTransport::Udp),
|
||||||
|
"tcp" => Ok(DnsTransport::Tcp),
|
||||||
|
"dot" | "tls" => Ok(DnsTransport::Dot),
|
||||||
|
"doh" | "https" => Ok(DnsTransport::Doh),
|
||||||
|
_ => Err(DnsError::Resolver(format!(
|
||||||
|
"invalid transport: {value}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsServerTarget {
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsAnswer {
|
||||||
|
pub name: String,
|
||||||
|
pub record_type: String,
|
||||||
|
pub ttl: u32,
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsQueryReport {
|
||||||
|
pub domain: String,
|
||||||
|
pub record_type: String,
|
||||||
|
pub transport: String,
|
||||||
|
pub server: Option<String>,
|
||||||
|
pub server_name: Option<String>,
|
||||||
|
pub proxy: Option<String>,
|
||||||
|
pub rcode: String,
|
||||||
|
pub answers: Vec<DnsAnswer>,
|
||||||
|
pub duration_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsDetectEvidence {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsDetectResult {
|
||||||
|
pub verdict: String,
|
||||||
|
pub evidence: Vec<DnsDetectEvidence>,
|
||||||
|
pub results: Vec<DnsQueryReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsWatchOptions {
|
||||||
|
pub iface: Option<String>,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsWatchEvent {
|
||||||
|
pub timestamp_ms: u128,
|
||||||
|
pub src: String,
|
||||||
|
pub dst: String,
|
||||||
|
pub query_name: String,
|
||||||
|
pub query_type: String,
|
||||||
|
pub rcode: String,
|
||||||
|
pub answers: Vec<String>,
|
||||||
|
pub is_response: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DnsWatchReport {
|
||||||
|
pub iface: Option<String>,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub filter: Option<String>,
|
||||||
|
pub events: Vec<DnsWatchEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn query(
|
||||||
|
domain: &str,
|
||||||
|
record_type: &str,
|
||||||
|
server: Option<DnsServerTarget>,
|
||||||
|
transport: DnsTransport,
|
||||||
|
proxy: Option<String>,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<DnsQueryReport, DnsError> {
|
||||||
|
let record_type = parse_record_type(record_type)?;
|
||||||
|
if let Some(proxy) = proxy {
|
||||||
|
if transport != DnsTransport::Doh {
|
||||||
|
return Err(DnsError::ProxyUnsupported(transport.to_string()));
|
||||||
|
}
|
||||||
|
let server = server.ok_or_else(|| DnsError::MissingServer(transport.to_string()))?;
|
||||||
|
return doh_query_via_proxy(domain, record_type, server, timeout_ms, proxy).await;
|
||||||
|
}
|
||||||
|
let resolver = build_resolver(server.clone(), transport, timeout_ms)?;
|
||||||
|
let start = Instant::now();
|
||||||
|
let response = resolver.lookup(domain, record_type).await;
|
||||||
|
let duration_ms = start.elapsed().as_millis();
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(lookup) => {
|
||||||
|
let mut answers = Vec::new();
|
||||||
|
for record in lookup.record_iter() {
|
||||||
|
let ttl = record.ttl();
|
||||||
|
let name = record.name().to_string();
|
||||||
|
let record_type = record.record_type().to_string();
|
||||||
|
if let Some(data) = record.data() {
|
||||||
|
if let Some(data) = format_rdata(data) {
|
||||||
|
answers.push(DnsAnswer {
|
||||||
|
name,
|
||||||
|
record_type,
|
||||||
|
ttl,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DnsQueryReport {
|
||||||
|
domain: domain.to_string(),
|
||||||
|
record_type: record_type.to_string(),
|
||||||
|
transport: transport.to_string(),
|
||||||
|
server: server.as_ref().map(|value| value.addr.to_string()),
|
||||||
|
server_name: server.as_ref().and_then(|value| value.name.clone()),
|
||||||
|
proxy: None,
|
||||||
|
rcode: "NOERROR".to_string(),
|
||||||
|
answers,
|
||||||
|
duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let ResolveErrorKind::NoRecordsFound { response_code, .. } = err.kind() {
|
||||||
|
Ok(DnsQueryReport {
|
||||||
|
domain: domain.to_string(),
|
||||||
|
record_type: record_type.to_string(),
|
||||||
|
transport: transport.to_string(),
|
||||||
|
server: server.as_ref().map(|value| value.addr.to_string()),
|
||||||
|
server_name: server.as_ref().and_then(|value| value.name.clone()),
|
||||||
|
proxy: None,
|
||||||
|
rcode: response_code.to_string(),
|
||||||
|
answers: Vec::new(),
|
||||||
|
duration_ms,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(DnsError::Resolver(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detect(
|
||||||
|
domain: &str,
|
||||||
|
servers: &[DnsServerTarget],
|
||||||
|
transport: DnsTransport,
|
||||||
|
proxy: Option<String>,
|
||||||
|
repeat: u32,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<DnsDetectResult, DnsError> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for server in servers {
|
||||||
|
for _ in 0..repeat.max(1) {
|
||||||
|
let report = query(
|
||||||
|
domain,
|
||||||
|
"A",
|
||||||
|
Some(server.clone()),
|
||||||
|
transport,
|
||||||
|
proxy.clone(),
|
||||||
|
timeout_ms,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
results.push(report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut evidence = Vec::new();
|
||||||
|
let verdict = evaluate_detect(domain, &results, &mut evidence);
|
||||||
|
|
||||||
|
Ok(DnsDetectResult {
|
||||||
|
verdict,
|
||||||
|
evidence,
|
||||||
|
results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_detect_servers(transport: DnsTransport) -> Vec<DnsServerTarget> {
|
||||||
|
let (port, names) = match transport {
|
||||||
|
DnsTransport::Udp | DnsTransport::Tcp => (53, [None, None, None]),
|
||||||
|
DnsTransport::Dot => (
|
||||||
|
853,
|
||||||
|
[
|
||||||
|
Some("cloudflare-dns.com"),
|
||||||
|
Some("dns.google"),
|
||||||
|
Some("dns.quad9.net"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
DnsTransport::Doh => (
|
||||||
|
443,
|
||||||
|
[
|
||||||
|
Some("cloudflare-dns.com"),
|
||||||
|
Some("dns.google"),
|
||||||
|
Some("dns.quad9.net"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ips = ["1.1.1.1", "8.8.8.8", "9.9.9.9"];
|
||||||
|
ips.iter()
|
||||||
|
.zip(names.iter())
|
||||||
|
.map(|(ip, name)| DnsServerTarget {
|
||||||
|
addr: SocketAddr::new(ip.parse().unwrap(), port),
|
||||||
|
name: name.map(|value| value.to_string()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pcap"))]
|
||||||
|
pub async fn watch(_options: DnsWatchOptions) -> Result<DnsWatchReport, DnsError> {
|
||||||
|
Err(DnsError::NotSupported(
|
||||||
|
"dns watch requires pcap feature".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
pub async fn watch(options: DnsWatchOptions) -> Result<DnsWatchReport, DnsError> {
|
||||||
|
let iface = match select_interface(options.iface.as_deref()) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
return Err(DnsError::Resolver(
|
||||||
|
"no suitable interface found".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = DatalinkConfig::default();
|
||||||
|
config.read_timeout = Some(Duration::from_millis(500));
|
||||||
|
let (_, mut rx) = match datalink::channel(&iface, config) {
|
||||||
|
Ok(Channel::Ethernet(tx, rx)) => (tx, rx),
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(DnsError::Resolver(
|
||||||
|
"unsupported datalink channel".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(err) => return Err(DnsError::Resolver(err.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let deadline = start + Duration::from_millis(options.duration_ms);
|
||||||
|
let filter = options.filter.as_ref().map(|value| value.to_ascii_lowercase());
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match rx.next() {
|
||||||
|
Ok(frame) => {
|
||||||
|
if let Some(event) = parse_dns_frame(frame, start, &filter) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DnsWatchReport {
|
||||||
|
iface: Some(iface.name),
|
||||||
|
duration_ms: options.duration_ms,
|
||||||
|
filter: options.filter,
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resolver(
|
||||||
|
server: Option<DnsServerTarget>,
|
||||||
|
transport: DnsTransport,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<TokioAsyncResolver, DnsError> {
|
||||||
|
let mut opts = ResolverOpts::default();
|
||||||
|
opts.timeout = Duration::from_millis(timeout_ms);
|
||||||
|
if let Some(server) = server {
|
||||||
|
let protocol = match transport {
|
||||||
|
DnsTransport::Udp => Protocol::Udp,
|
||||||
|
DnsTransport::Tcp => Protocol::Tcp,
|
||||||
|
DnsTransport::Dot => Protocol::Tls,
|
||||||
|
DnsTransport::Doh => Protocol::Https,
|
||||||
|
};
|
||||||
|
let tls_dns_name = match transport {
|
||||||
|
DnsTransport::Dot | DnsTransport::Doh => server
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| DnsError::MissingTlsName(transport.to_string()))?,
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let mut group = NameServerConfigGroup::new();
|
||||||
|
group.push(NameServerConfig {
|
||||||
|
socket_addr: server.addr,
|
||||||
|
protocol,
|
||||||
|
tls_dns_name: match transport {
|
||||||
|
DnsTransport::Dot | DnsTransport::Doh => Some(tls_dns_name),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
trust_negative_responses: true,
|
||||||
|
tls_config: None,
|
||||||
|
bind_addr: None,
|
||||||
|
});
|
||||||
|
let config = ResolverConfig::from_parts(None, vec![], group);
|
||||||
|
Ok(TokioAsyncResolver::tokio(config, opts))
|
||||||
|
} else {
|
||||||
|
match transport {
|
||||||
|
DnsTransport::Udp => {
|
||||||
|
let (config, mut sys_opts) =
|
||||||
|
read_system_conf().map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
sys_opts.timeout = opts.timeout;
|
||||||
|
Ok(TokioAsyncResolver::tokio(config, sys_opts))
|
||||||
|
}
|
||||||
|
DnsTransport::Tcp => {
|
||||||
|
let (config, mut sys_opts) =
|
||||||
|
read_system_conf().map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
sys_opts.timeout = opts.timeout;
|
||||||
|
let mut group = NameServerConfigGroup::new();
|
||||||
|
for entry in config.name_servers() {
|
||||||
|
group.push(NameServerConfig {
|
||||||
|
socket_addr: entry.socket_addr,
|
||||||
|
protocol: Protocol::Tcp,
|
||||||
|
tls_dns_name: None,
|
||||||
|
trust_negative_responses: entry.trust_negative_responses,
|
||||||
|
tls_config: None,
|
||||||
|
bind_addr: entry.bind_addr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let config = ResolverConfig::from_parts(
|
||||||
|
config.domain().cloned(),
|
||||||
|
config.search().to_vec(),
|
||||||
|
group,
|
||||||
|
);
|
||||||
|
Ok(TokioAsyncResolver::tokio(config, sys_opts))
|
||||||
|
}
|
||||||
|
DnsTransport::Dot | DnsTransport::Doh => {
|
||||||
|
Err(DnsError::MissingServer(transport.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doh_query_via_proxy(
|
||||||
|
domain: &str,
|
||||||
|
record_type: RecordType,
|
||||||
|
server: DnsServerTarget,
|
||||||
|
timeout_ms: u64,
|
||||||
|
proxy: String,
|
||||||
|
) -> Result<DnsQueryReport, DnsError> {
|
||||||
|
let tls_name = server
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| DnsError::MissingTlsName("doh".to_string()))?;
|
||||||
|
|
||||||
|
let name = Name::from_ascii(domain)
|
||||||
|
.map_err(|err| DnsError::Resolver(format!("invalid domain: {err}")))?;
|
||||||
|
|
||||||
|
let mut message = Message::new();
|
||||||
|
message
|
||||||
|
.set_id(0)
|
||||||
|
.set_message_type(MessageType::Query)
|
||||||
|
.set_recursion_desired(true)
|
||||||
|
.add_query(Query::query(name, record_type));
|
||||||
|
|
||||||
|
let body = message
|
||||||
|
.to_vec()
|
||||||
|
.map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
|
||||||
|
let url = format!("https://{tls_name}/dns-query");
|
||||||
|
let proxy_value = proxy.clone();
|
||||||
|
let proxy = Proxy::all(&proxy).map_err(|err| DnsError::Proxy(err.to_string()))?;
|
||||||
|
|
||||||
|
let mut builder = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(timeout_ms))
|
||||||
|
.proxy(proxy);
|
||||||
|
|
||||||
|
let server_addr = SocketAddr::new(server.addr.ip(), server.addr.port());
|
||||||
|
builder = builder.resolve(&tls_name, server_addr);
|
||||||
|
let client = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.header("content-type", "application/dns-message")
|
||||||
|
.header("accept", "application/dns-message")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(DnsError::Resolver(format!(
|
||||||
|
"doh status: {}",
|
||||||
|
status.as_u16()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
let response = Message::from_vec(&bytes).map_err(|err| DnsError::Resolver(err.to_string()))?;
|
||||||
|
let duration_ms = start.elapsed().as_millis();
|
||||||
|
|
||||||
|
let mut answers = Vec::new();
|
||||||
|
for record in response.answers() {
|
||||||
|
let ttl = record.ttl();
|
||||||
|
let name = record.name().to_string();
|
||||||
|
let record_type = record.record_type().to_string();
|
||||||
|
if let Some(data) = record.data() {
|
||||||
|
if let Some(data) = format_rdata(data) {
|
||||||
|
answers.push(DnsAnswer {
|
||||||
|
name,
|
||||||
|
record_type,
|
||||||
|
ttl,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DnsQueryReport {
|
||||||
|
domain: domain.to_string(),
|
||||||
|
record_type: record_type.to_string(),
|
||||||
|
transport: DnsTransport::Doh.to_string(),
|
||||||
|
server: Some(server.addr.to_string()),
|
||||||
|
server_name: Some(tls_name),
|
||||||
|
proxy: Some(proxy_value),
|
||||||
|
rcode: response.response_code().to_string(),
|
||||||
|
answers,
|
||||||
|
duration_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
fn select_interface(name: Option<&str>) -> Option<NetworkInterface> {
|
||||||
|
let interfaces = datalink::interfaces();
|
||||||
|
if let Some(name) = name {
|
||||||
|
return interfaces.into_iter().find(|iface| iface.name == name);
|
||||||
|
}
|
||||||
|
interfaces
|
||||||
|
.into_iter()
|
||||||
|
.find(|iface| iface.is_up() && !iface.is_loopback())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
fn parse_dns_frame(
|
||||||
|
frame: &[u8],
|
||||||
|
start: Instant,
|
||||||
|
filter: &Option<String>,
|
||||||
|
) -> Option<DnsWatchEvent> {
|
||||||
|
let ethernet = EthernetPacket::new(frame)?;
|
||||||
|
match ethernet.get_ethertype() {
|
||||||
|
EtherTypes::Ipv4 => parse_ipv4(ethernet.payload(), start, filter),
|
||||||
|
EtherTypes::Ipv6 => parse_ipv6(ethernet.payload(), start, filter),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
fn parse_ipv4(
|
||||||
|
payload: &[u8],
|
||||||
|
start: Instant,
|
||||||
|
filter: &Option<String>,
|
||||||
|
) -> Option<DnsWatchEvent> {
|
||||||
|
let ipv4 = Ipv4Packet::new(payload)?;
|
||||||
|
if ipv4.get_next_level_protocol() != IpNextHeaderProtocols::Udp {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let udp = UdpPacket::new(ipv4.payload())?;
|
||||||
|
parse_dns_packet(
|
||||||
|
ipv4.get_source().into(),
|
||||||
|
ipv4.get_destination().into(),
|
||||||
|
&udp,
|
||||||
|
start,
|
||||||
|
filter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
fn parse_ipv6(
|
||||||
|
payload: &[u8],
|
||||||
|
start: Instant,
|
||||||
|
filter: &Option<String>,
|
||||||
|
) -> Option<DnsWatchEvent> {
|
||||||
|
let ipv6 = Ipv6Packet::new(payload)?;
|
||||||
|
if ipv6.get_next_header() != IpNextHeaderProtocols::Udp {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let udp = UdpPacket::new(ipv6.payload())?;
|
||||||
|
parse_dns_packet(
|
||||||
|
ipv6.get_source().into(),
|
||||||
|
ipv6.get_destination().into(),
|
||||||
|
&udp,
|
||||||
|
start,
|
||||||
|
filter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pcap")]
|
||||||
|
fn parse_dns_packet(
|
||||||
|
src: IpAddr,
|
||||||
|
dst: IpAddr,
|
||||||
|
udp: &UdpPacket<'_>,
|
||||||
|
start: Instant,
|
||||||
|
filter: &Option<String>,
|
||||||
|
) -> Option<DnsWatchEvent> {
|
||||||
|
let src_port = udp.get_source();
|
||||||
|
let dst_port = udp.get_destination();
|
||||||
|
if src_port != 53 && dst_port != 53 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = Message::from_vec(udp.payload()).ok()?;
|
||||||
|
let query = message.queries().first()?;
|
||||||
|
let query_name = query.name().to_utf8();
|
||||||
|
if let Some(filter) = filter.as_ref() {
|
||||||
|
if !query_name.to_ascii_lowercase().contains(filter) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let query_type = query.query_type().to_string();
|
||||||
|
let rcode = message.response_code().to_string();
|
||||||
|
let answers = message
|
||||||
|
.answers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|record| record.data().and_then(format_rdata))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Some(DnsWatchEvent {
|
||||||
|
timestamp_ms: start.elapsed().as_millis(),
|
||||||
|
src: src.to_string(),
|
||||||
|
dst: dst.to_string(),
|
||||||
|
query_name,
|
||||||
|
query_type,
|
||||||
|
rcode,
|
||||||
|
answers,
|
||||||
|
is_response: message.message_type() == MessageType::Response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_record_type(value: &str) -> Result<RecordType, DnsError> {
|
||||||
|
value
|
||||||
|
.parse::<RecordType>()
|
||||||
|
.map_err(|_| DnsError::RecordType(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_rdata(data: &RData) -> Option<String> {
|
||||||
|
match data {
|
||||||
|
RData::A(addr) => Some(addr.to_string()),
|
||||||
|
RData::AAAA(addr) => Some(addr.to_string()),
|
||||||
|
RData::CNAME(name) => Some(name.to_string()),
|
||||||
|
RData::NS(name) => Some(name.to_string()),
|
||||||
|
RData::MX(mx) => Some(format!("{} {}", mx.preference(), mx.exchange())),
|
||||||
|
RData::TXT(txt) => Some(
|
||||||
|
txt.txt_data()
|
||||||
|
.iter()
|
||||||
|
.map(|part| String::from_utf8_lossy(part).to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_detect(
|
||||||
|
domain: &str,
|
||||||
|
results: &[DnsQueryReport],
|
||||||
|
evidence: &mut Vec<DnsDetectEvidence>,
|
||||||
|
) -> String {
|
||||||
|
if results.is_empty() {
|
||||||
|
evidence.push(DnsDetectEvidence {
|
||||||
|
code: "NO_RESULTS".to_string(),
|
||||||
|
message: "no dns results returned".to_string(),
|
||||||
|
});
|
||||||
|
return "inconclusive".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rcodes = BTreeSet::new();
|
||||||
|
let mut answer_sets = BTreeSet::new();
|
||||||
|
let mut ttl_values = Vec::new();
|
||||||
|
let mut private_hits = Vec::new();
|
||||||
|
|
||||||
|
for report in results {
|
||||||
|
rcodes.insert(report.rcode.clone());
|
||||||
|
let mut answers = BTreeSet::new();
|
||||||
|
for answer in &report.answers {
|
||||||
|
answers.insert(answer.data.clone());
|
||||||
|
if let Ok(ip) = answer.data.parse::<IpAddr>() {
|
||||||
|
if is_private_or_reserved(ip) {
|
||||||
|
private_hits.push(ip.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ttl_values.push(answer.ttl);
|
||||||
|
}
|
||||||
|
answer_sets.insert(answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if rcodes.len() > 1 {
|
||||||
|
evidence.push(DnsDetectEvidence {
|
||||||
|
code: "RCODE_DIVERGENCE".to_string(),
|
||||||
|
message: format!("rcodes differed for {}", domain),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if answer_sets.len() > 1 {
|
||||||
|
evidence.push(DnsDetectEvidence {
|
||||||
|
code: "ANSWER_DIVERGENCE".to_string(),
|
||||||
|
message: format!("answers diverged for {}", domain),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !private_hits.is_empty() {
|
||||||
|
evidence.push(DnsDetectEvidence {
|
||||||
|
code: "PRIVATE_RESULT".to_string(),
|
||||||
|
message: format!("private/reserved answers: {}", private_hits.join(", ")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ttl_span = ttl_span(&ttl_values);
|
||||||
|
if ttl_span > 3600 {
|
||||||
|
evidence.push(DnsDetectEvidence {
|
||||||
|
code: "TTL_VARIANCE".to_string(),
|
||||||
|
message: format!("ttl variance high: {ttl_span}s"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if evidence.is_empty() {
|
||||||
|
"clean".to_string()
|
||||||
|
} else if evidence.len() >= 2 {
|
||||||
|
"suspicious".to_string()
|
||||||
|
} else {
|
||||||
|
"inconclusive".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ttl_span(values: &[u32]) -> u32 {
|
||||||
|
let min = values.iter().min().copied().unwrap_or(0);
|
||||||
|
let max = values.iter().max().copied().unwrap_or(0);
|
||||||
|
max.saturating_sub(min)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_private_or_reserved(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
v4.is_private()
|
||||||
|
|| v4.is_loopback()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_broadcast()
|
||||||
|
|| v4.is_documentation()
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
v6.is_loopback() || v6.is_unique_local() || v6.is_unspecified()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/wtfnet-geoip/Cargo.toml
Normal file
9
crates/wtfnet-geoip/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-geoip"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
maxminddb = "0.24"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
wtfnet-core = { path = "../wtfnet-core" }
|
||||||
98
crates/wtfnet-geoip/src/lib.rs
Normal file
98
crates/wtfnet-geoip/src/lib.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use maxminddb::geoip2;
|
||||||
|
use maxminddb::Reader;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeoIpRecord {
|
||||||
|
pub ip: String,
|
||||||
|
pub country: Option<CountryInfo>,
|
||||||
|
pub asn: Option<AsnInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CountryInfo {
|
||||||
|
pub iso_code: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AsnInfo {
|
||||||
|
pub number: Option<u32>,
|
||||||
|
pub organization: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeoIpStatus {
|
||||||
|
pub country_db: Option<String>,
|
||||||
|
pub asn_db: Option<String>,
|
||||||
|
pub country_loaded: bool,
|
||||||
|
pub asn_loaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GeoIpService {
|
||||||
|
country_db: Option<(Reader<Vec<u8>>, PathBuf)>,
|
||||||
|
asn_db: Option<(Reader<Vec<u8>>, PathBuf)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeoIpService {
|
||||||
|
pub fn new(country_path: Option<PathBuf>, asn_path: Option<PathBuf>) -> Self {
|
||||||
|
let country_db = country_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| load_db(path).map(|db| (db, path.clone())));
|
||||||
|
let asn_db = asn_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| load_db(path).map(|db| (db, path.clone())));
|
||||||
|
Self { country_db, asn_db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> GeoIpStatus {
|
||||||
|
GeoIpStatus {
|
||||||
|
country_db: self.country_db.as_ref().map(|(_, path)| path.display().to_string()),
|
||||||
|
asn_db: self.asn_db.as_ref().map(|(_, path)| path.display().to_string()),
|
||||||
|
country_loaded: self.country_db.is_some(),
|
||||||
|
asn_loaded: self.asn_db.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup(&self, ip: IpAddr) -> GeoIpRecord {
|
||||||
|
let country = self
|
||||||
|
.country_db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(db, _)| lookup_country(db, ip));
|
||||||
|
let asn = self.asn_db.as_ref().and_then(|(db, _)| lookup_asn(db, ip));
|
||||||
|
GeoIpRecord {
|
||||||
|
ip: ip.to_string(),
|
||||||
|
country,
|
||||||
|
asn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_db(path: &Path) -> Option<Reader<Vec<u8>>> {
|
||||||
|
let bytes = std::fs::read(path).ok()?;
|
||||||
|
Reader::from_source(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_country(db: &Reader<Vec<u8>>, ip: IpAddr) -> Option<CountryInfo> {
|
||||||
|
let data: geoip2::Country = db.lookup(ip).ok()?;
|
||||||
|
let country = data.country?;
|
||||||
|
Some(CountryInfo {
|
||||||
|
iso_code: country.iso_code.map(|value| value.to_string()),
|
||||||
|
name: country
|
||||||
|
.names
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|names| names.get("en").map(|value| value.to_string())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_asn(db: &Reader<Vec<u8>>, ip: IpAddr) -> Option<AsnInfo> {
|
||||||
|
let data: geoip2::Asn = db.lookup(ip).ok()?;
|
||||||
|
Some(AsnInfo {
|
||||||
|
number: data.autonomous_system_number.map(|value| value as u32),
|
||||||
|
organization: data
|
||||||
|
.autonomous_system_organization
|
||||||
|
.map(|value| value.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wtfnet_core::ErrorCode;
|
use wtfnet_core::ErrorCode;
|
||||||
use wtfnet_platform::{
|
use wtfnet_platform::{
|
||||||
@@ -189,7 +190,11 @@ fn parse_ipv6_hex(value: &str) -> Option<std::net::Ipv6Addr> {
|
|||||||
Some(std::net::Ipv6Addr::from(bytes))
|
Some(std::net::Ipv6Addr::from(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_linux_tcp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, PlatformError> {
|
fn parse_linux_tcp_with_inode_map(
|
||||||
|
path: &str,
|
||||||
|
is_v6: bool,
|
||||||
|
inode_map: &HashMap<String, ProcInfo>,
|
||||||
|
) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
let contents = std::fs::read_to_string(path)
|
let contents = std::fs::read_to_string(path)
|
||||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
let mut sockets = Vec::new();
|
let mut sockets = Vec::new();
|
||||||
@@ -206,15 +211,28 @@ fn parse_linux_tcp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, Platfor
|
|||||||
if state != "0A" {
|
if state != "0A" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let inode = parts.get(9).copied();
|
||||||
if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) {
|
if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) {
|
||||||
|
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(ListenSocket {
|
sockets.push(ListenSocket {
|
||||||
proto: "tcp".to_string(),
|
proto: "tcp".to_string(),
|
||||||
local_addr,
|
local_addr,
|
||||||
state: Some("LISTEN".to_string()),
|
state: Some("LISTEN".to_string()),
|
||||||
pid: None,
|
pid,
|
||||||
ppid: None,
|
ppid,
|
||||||
process_name: None,
|
process_name,
|
||||||
process_path: None,
|
process_path,
|
||||||
owner: None,
|
owner: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -222,7 +240,11 @@ fn parse_linux_tcp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, Platfor
|
|||||||
Ok(sockets)
|
Ok(sockets)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_linux_udp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, PlatformError> {
|
fn parse_linux_udp_with_inode_map(
|
||||||
|
path: &str,
|
||||||
|
is_v6: bool,
|
||||||
|
inode_map: &HashMap<String, ProcInfo>,
|
||||||
|
) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
let contents = std::fs::read_to_string(path)
|
let contents = std::fs::read_to_string(path)
|
||||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
let mut sockets = Vec::new();
|
let mut sockets = Vec::new();
|
||||||
@@ -235,15 +257,28 @@ fn parse_linux_udp(path: &str, is_v6: bool) -> Result<Vec<ListenSocket>, Platfor
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let local = parts[1];
|
let local = parts[1];
|
||||||
|
let inode = parts.get(9).copied();
|
||||||
if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) {
|
if let Some(local_addr) = parse_proc_socket_addr(local, is_v6) {
|
||||||
|
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(ListenSocket {
|
sockets.push(ListenSocket {
|
||||||
proto: "udp".to_string(),
|
proto: "udp".to_string(),
|
||||||
local_addr,
|
local_addr,
|
||||||
state: None,
|
state: None,
|
||||||
pid: None,
|
pid,
|
||||||
ppid: None,
|
ppid,
|
||||||
process_name: None,
|
process_name,
|
||||||
process_path: None,
|
process_path,
|
||||||
owner: None,
|
owner: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -298,6 +333,80 @@ fn extract_port(value: &str) -> Option<u16> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ProcInfo {
|
||||||
|
pid: u32,
|
||||||
|
ppid: Option<u32>,
|
||||||
|
name: Option<String>,
|
||||||
|
path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_inode_map() -> HashMap<String, ProcInfo> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let entries = match std::fs::read_dir("/proc") {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return map,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = match file_name.to_str() {
|
||||||
|
Some(name) => name,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let pid = match name.parse::<u32>() {
|
||||||
|
Ok(pid) => pid,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let comm = std::fs::read_to_string(format!("/proc/{}/comm", pid))
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string());
|
||||||
|
let path = std::fs::read_link(format!("/proc/{}/exe", pid))
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.to_str().map(|s| s.to_string()));
|
||||||
|
let ppid = read_ppid(pid);
|
||||||
|
|
||||||
|
let info = ProcInfo {
|
||||||
|
pid,
|
||||||
|
ppid,
|
||||||
|
name: comm,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fd_dir = match std::fs::read_dir(format!("/proc/{}/fd", pid)) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
for fd in fd_dir.flatten() {
|
||||||
|
if let Ok(target) = std::fs::read_link(fd.path()) {
|
||||||
|
if let Some(target) = target.to_str() {
|
||||||
|
if let Some(inode) = parse_socket_inode(target) {
|
||||||
|
map.entry(inode).or_insert_with(|| info.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_socket_inode(value: &str) -> Option<String> {
|
||||||
|
let value = value.strip_prefix("socket:[")?;
|
||||||
|
let value = value.strip_suffix(']')?;
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ppid(pid: u32) -> Option<u32> {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
|
||||||
|
let end = stat.rfind(')')?;
|
||||||
|
let rest = stat.get(end + 2..)?;
|
||||||
|
let mut parts = rest.split_whitespace();
|
||||||
|
let _state = parts.next()?;
|
||||||
|
let ppid = parts.next()?.parse::<u32>().ok()?;
|
||||||
|
Some(ppid)
|
||||||
|
}
|
||||||
|
|
||||||
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
||||||
let certs = rustls_native_certs::load_native_certs()
|
let certs = rustls_native_certs::load_native_certs()
|
||||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
@@ -377,11 +486,28 @@ fn format_fingerprint(bytes: &[u8]) -> String {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PortsProvider for LinuxPortsProvider {
|
impl PortsProvider for LinuxPortsProvider {
|
||||||
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError> {
|
async fn listening(&self) -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let inode_map = build_inode_map();
|
||||||
let mut sockets = Vec::new();
|
let mut sockets = Vec::new();
|
||||||
sockets.extend(parse_linux_tcp("/proc/net/tcp", false)?);
|
sockets.extend(parse_linux_tcp_with_inode_map(
|
||||||
sockets.extend(parse_linux_tcp("/proc/net/tcp6", true)?);
|
"/proc/net/tcp",
|
||||||
sockets.extend(parse_linux_udp("/proc/net/udp", false)?);
|
false,
|
||||||
sockets.extend(parse_linux_udp("/proc/net/udp6", true)?);
|
&inode_map,
|
||||||
|
)?);
|
||||||
|
sockets.extend(parse_linux_tcp_with_inode_map(
|
||||||
|
"/proc/net/tcp6",
|
||||||
|
true,
|
||||||
|
&inode_map,
|
||||||
|
)?);
|
||||||
|
sockets.extend(parse_linux_udp_with_inode_map(
|
||||||
|
"/proc/net/udp",
|
||||||
|
false,
|
||||||
|
&inode_map,
|
||||||
|
)?);
|
||||||
|
sockets.extend(parse_linux_udp_with_inode_map(
|
||||||
|
"/proc/net/udp6",
|
||||||
|
true,
|
||||||
|
&inode_map,
|
||||||
|
)?);
|
||||||
Ok(sockets)
|
Ok(sockets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
use network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
use x509_parser::oid_registry::{
|
use x509_parser::oid_registry::{
|
||||||
OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256,
|
OID_KEY_TYPE_DSA, OID_KEY_TYPE_EC_PUBLIC_KEY, OID_KEY_TYPE_GOST_R3410_2012_256,
|
||||||
@@ -302,6 +303,7 @@ fn parse_ipconfig_dns(text: &str) -> DnsConfigSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_windows_listeners() -> Result<Vec<ListenSocket>, PlatformError> {
|
fn parse_windows_listeners() -> Result<Vec<ListenSocket>, PlatformError> {
|
||||||
|
let proc_map = load_windows_process_map();
|
||||||
let output = std::process::Command::new("netstat")
|
let output = std::process::Command::new("netstat")
|
||||||
.arg("-ano")
|
.arg("-ano")
|
||||||
.output()
|
.output()
|
||||||
@@ -316,11 +318,13 @@ fn parse_windows_listeners() -> Result<Vec<ListenSocket>, PlatformError> {
|
|||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.starts_with("TCP") {
|
if trimmed.starts_with("TCP") {
|
||||||
if let Some(socket) = parse_netstat_tcp_line(trimmed) {
|
if let Some(mut socket) = parse_netstat_tcp_line(trimmed) {
|
||||||
|
enrich_socket(&mut socket, &proc_map);
|
||||||
sockets.push(socket);
|
sockets.push(socket);
|
||||||
}
|
}
|
||||||
} else if trimmed.starts_with("UDP") {
|
} else if trimmed.starts_with("UDP") {
|
||||||
if let Some(socket) = parse_netstat_udp_line(trimmed) {
|
if let Some(mut socket) = parse_netstat_udp_line(trimmed) {
|
||||||
|
enrich_socket(&mut socket, &proc_map);
|
||||||
sockets.push(socket);
|
sockets.push(socket);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,6 +418,103 @@ fn extract_port(value: &str) -> Option<u16> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enrich_socket(socket: &mut ListenSocket, 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>,
|
||||||
|
path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_windows_process_map() -> HashMap<u32, ProcInfo> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let mut name_map = HashMap::new();
|
||||||
|
let tasklist = std::process::Command::new("tasklist")
|
||||||
|
.args(["/fo", "csv", "/nh"])
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = tasklist {
|
||||||
|
if output.status.success() {
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in text.lines() {
|
||||||
|
let parts = parse_csv_line(line);
|
||||||
|
if parts.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(pid) = parts[1].parse::<u32>() {
|
||||||
|
name_map.insert(pid, parts[0].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wmic = std::process::Command::new("wmic")
|
||||||
|
.args(["process", "get", "ProcessId,ExecutablePath", "/FORMAT:CSV"])
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = wmic {
|
||||||
|
if output.status.success() {
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
for line in text.lines() {
|
||||||
|
let parts = parse_csv_line(line);
|
||||||
|
if parts.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = parts[1].trim();
|
||||||
|
let pid = parts[2].trim().parse::<u32>().ok();
|
||||||
|
if let Some(pid) = pid {
|
||||||
|
let name = name_map.get(&pid).cloned();
|
||||||
|
let path = if path.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(path.to_string())
|
||||||
|
};
|
||||||
|
map.insert(pid, ProcInfo { name, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (pid, name) in name_map {
|
||||||
|
map.entry(pid)
|
||||||
|
.or_insert_with(|| ProcInfo {
|
||||||
|
name: Some(name),
|
||||||
|
path: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_csv_line(line: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut in_quotes = false;
|
||||||
|
for ch in line.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => {
|
||||||
|
in_quotes = !in_quotes;
|
||||||
|
}
|
||||||
|
',' if !in_quotes => {
|
||||||
|
out.push(current.trim_matches('"').to_string());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
_ => current.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
out.push(current.trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
fn load_native_roots(store: &str) -> Result<Vec<RootCert>, PlatformError> {
|
||||||
let certs = rustls_native_certs::load_native_certs()
|
let certs = rustls_native_certs::load_native_certs()
|
||||||
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
.map_err(|err| PlatformError::new(ErrorCode::IoError, err.to_string()))?;
|
||||||
|
|||||||
13
crates/wtfnet-probe/Cargo.toml
Normal file
13
crates/wtfnet-probe/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "wtfnet-probe"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
pnet = "0.34"
|
||||||
|
socket2 = "0.6"
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["net", "time"] }
|
||||||
|
surge-ping = "0.8"
|
||||||
|
wtfnet-geoip = { path = "../wtfnet-geoip" }
|
||||||
520
crates/wtfnet-probe/src/lib.rs
Normal file
520
crates/wtfnet-probe/src/lib.rs
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
#[cfg(unix)]
|
||||||
|
use pnet::packet::icmp::{IcmpPacket, IcmpTypes};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use pnet::packet::icmpv6::{Icmpv6Packet, Icmpv6Types};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use pnet::packet::ip::IpNextHeaderProtocols;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use pnet::transport::{
|
||||||
|
icmp_packet_iter, icmpv6_packet_iter, transport_channel, TransportChannelType,
|
||||||
|
TransportProtocol,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::net::{lookup_host, TcpStream};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use wtfnet_geoip::GeoIpRecord;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ProbeError {
|
||||||
|
#[error("resolution failed: {0}")]
|
||||||
|
Resolve(String),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("timeout")]
|
||||||
|
Timeout,
|
||||||
|
#[error("ping error: {0}")]
|
||||||
|
Ping(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PingResult {
|
||||||
|
pub seq: u16,
|
||||||
|
pub rtt_ms: Option<u128>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PingSummary {
|
||||||
|
pub sent: u32,
|
||||||
|
pub received: u32,
|
||||||
|
pub loss_pct: f64,
|
||||||
|
pub min_ms: Option<u128>,
|
||||||
|
pub avg_ms: Option<f64>,
|
||||||
|
pub max_ms: Option<u128>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PingReport {
|
||||||
|
pub target: String,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub geoip: Option<GeoIpRecord>,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub count: u32,
|
||||||
|
pub results: Vec<PingResult>,
|
||||||
|
pub summary: PingSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TcpPingResult {
|
||||||
|
pub seq: u16,
|
||||||
|
pub rtt_ms: Option<u128>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TcpPingReport {
|
||||||
|
pub target: String,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub geoip: Option<GeoIpRecord>,
|
||||||
|
pub port: u16,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub count: u32,
|
||||||
|
pub results: Vec<TcpPingResult>,
|
||||||
|
pub summary: PingSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TraceHop {
|
||||||
|
pub ttl: u8,
|
||||||
|
pub addr: Option<String>,
|
||||||
|
pub rtt_ms: Option<u128>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub geoip: Option<GeoIpRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TraceReport {
|
||||||
|
pub target: String,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub geoip: Option<GeoIpRecord>,
|
||||||
|
pub port: u16,
|
||||||
|
pub max_hops: u8,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
pub protocol: String,
|
||||||
|
pub hops: Vec<TraceHop>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(
|
||||||
|
target: &str,
|
||||||
|
count: u32,
|
||||||
|
timeout_ms: u64,
|
||||||
|
interval_ms: u64,
|
||||||
|
) -> Result<PingReport, ProbeError> {
|
||||||
|
let addr = resolve_one(target).await?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut received = 0u32;
|
||||||
|
let mut min = None;
|
||||||
|
let mut max = None;
|
||||||
|
let mut sum = 0u128;
|
||||||
|
|
||||||
|
let config = match addr {
|
||||||
|
IpAddr::V4(_) => surge_ping::Config::default(),
|
||||||
|
IpAddr::V6(_) => surge_ping::Config::builder()
|
||||||
|
.kind(surge_ping::ICMP::V6)
|
||||||
|
.build(),
|
||||||
|
};
|
||||||
|
let client = surge_ping::Client::new(&config)
|
||||||
|
.map_err(|err| ProbeError::Ping(err.to_string()))?;
|
||||||
|
let mut pinger = client
|
||||||
|
.pinger(addr, surge_ping::PingIdentifier(0))
|
||||||
|
.await;
|
||||||
|
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||||
|
|
||||||
|
for seq in 0..count {
|
||||||
|
let seq = seq as u16;
|
||||||
|
let start = Instant::now();
|
||||||
|
let response =
|
||||||
|
timeout(timeout_dur, pinger.ping(surge_ping::PingSequence(seq), &[0; 8])).await;
|
||||||
|
match response {
|
||||||
|
Ok(Ok((_packet, _))) => {
|
||||||
|
let rtt = start.elapsed().as_millis();
|
||||||
|
received += 1;
|
||||||
|
min = Some(min.map_or(rtt, |value: u128| value.min(rtt)));
|
||||||
|
max = Some(max.map_or(rtt, |value: u128| value.max(rtt)));
|
||||||
|
sum += rtt;
|
||||||
|
results.push(PingResult {
|
||||||
|
seq,
|
||||||
|
rtt_ms: Some(rtt),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
results.push(PingResult {
|
||||||
|
seq,
|
||||||
|
rtt_ms: None,
|
||||||
|
error: Some(err.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
results.push(PingResult {
|
||||||
|
seq,
|
||||||
|
rtt_ms: None,
|
||||||
|
error: Some("timeout".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interval_ms > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = build_summary(count, received, min, max, sum);
|
||||||
|
Ok(PingReport {
|
||||||
|
target: target.to_string(),
|
||||||
|
ip: Some(addr.to_string()),
|
||||||
|
geoip: None,
|
||||||
|
timeout_ms,
|
||||||
|
count,
|
||||||
|
results,
|
||||||
|
summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tcp_ping(
|
||||||
|
target: &str,
|
||||||
|
port: u16,
|
||||||
|
count: u32,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<TcpPingReport, ProbeError> {
|
||||||
|
let addr = resolve_one(target).await?;
|
||||||
|
let socket_addr = SocketAddr::new(addr, port);
|
||||||
|
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut received = 0u32;
|
||||||
|
let mut min = None;
|
||||||
|
let mut max = None;
|
||||||
|
let mut sum = 0u128;
|
||||||
|
|
||||||
|
for seq in 0..count {
|
||||||
|
let seq = seq as u16;
|
||||||
|
let start = Instant::now();
|
||||||
|
let attempt = timeout(timeout_dur, TcpStream::connect(socket_addr)).await;
|
||||||
|
match attempt {
|
||||||
|
Ok(Ok(_stream)) => {
|
||||||
|
let rtt = start.elapsed().as_millis();
|
||||||
|
received += 1;
|
||||||
|
min = Some(min.map_or(rtt, |value: u128| value.min(rtt)));
|
||||||
|
max = Some(max.map_or(rtt, |value: u128| value.max(rtt)));
|
||||||
|
sum += rtt;
|
||||||
|
results.push(TcpPingResult {
|
||||||
|
seq,
|
||||||
|
rtt_ms: Some(rtt),
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
results.push(TcpPingResult {
|
||||||
|
seq,
|
||||||
|
rtt_ms: None,
|
||||||
|
error: Some(err.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
results.push(TcpPingResult {
|
||||||
|
seq,
|
||||||
|
rtt_ms: None,
|
||||||
|
error: Some("timeout".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = build_summary(count, received, min, max, sum);
|
||||||
|
Ok(TcpPingReport {
|
||||||
|
target: target.to_string(),
|
||||||
|
ip: Some(addr.to_string()),
|
||||||
|
geoip: None,
|
||||||
|
port,
|
||||||
|
timeout_ms,
|
||||||
|
count,
|
||||||
|
results,
|
||||||
|
summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tcp_trace(
|
||||||
|
target: &str,
|
||||||
|
port: u16,
|
||||||
|
max_hops: u8,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<TraceReport, ProbeError> {
|
||||||
|
let addr = resolve_one(target).await?;
|
||||||
|
let socket_addr = SocketAddr::new(addr, port);
|
||||||
|
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||||
|
let mut hops = Vec::new();
|
||||||
|
|
||||||
|
for ttl in 1..=max_hops {
|
||||||
|
let addr = socket_addr;
|
||||||
|
let start = Instant::now();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
tcp_connect_with_ttl(addr, ttl, timeout_dur)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
let rtt = start.elapsed().as_millis();
|
||||||
|
hops.push(TraceHop {
|
||||||
|
ttl,
|
||||||
|
addr: Some(socket_addr.ip().to_string()),
|
||||||
|
rtt_ms: Some(rtt),
|
||||||
|
note: None,
|
||||||
|
geoip: None,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let rtt = start.elapsed().as_millis();
|
||||||
|
hops.push(TraceHop {
|
||||||
|
ttl,
|
||||||
|
addr: None,
|
||||||
|
rtt_ms: Some(rtt),
|
||||||
|
note: Some(err.to_string()),
|
||||||
|
geoip: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TraceReport {
|
||||||
|
target: target.to_string(),
|
||||||
|
ip: Some(addr.to_string()),
|
||||||
|
geoip: None,
|
||||||
|
port,
|
||||||
|
max_hops,
|
||||||
|
timeout_ms,
|
||||||
|
protocol: "tcp".to_string(),
|
||||||
|
hops,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn udp_trace(
|
||||||
|
target: &str,
|
||||||
|
port: u16,
|
||||||
|
max_hops: u8,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<TraceReport, ProbeError> {
|
||||||
|
let addr = resolve_one(target).await?;
|
||||||
|
|
||||||
|
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||||
|
let mut hops = Vec::new();
|
||||||
|
|
||||||
|
for ttl in 1..=max_hops {
|
||||||
|
let addr = SocketAddr::new(addr, port);
|
||||||
|
let start = Instant::now();
|
||||||
|
let result = tokio::task::spawn_blocking(move || udp_trace_hop(addr, ttl, timeout_dur))
|
||||||
|
.await
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((hop_addr, reached)) => {
|
||||||
|
let rtt = start.elapsed().as_millis();
|
||||||
|
hops.push(TraceHop {
|
||||||
|
ttl,
|
||||||
|
addr: hop_addr.map(|ip| ip.to_string()),
|
||||||
|
rtt_ms: Some(rtt),
|
||||||
|
note: None,
|
||||||
|
geoip: None,
|
||||||
|
});
|
||||||
|
if reached {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
hops.push(TraceHop {
|
||||||
|
ttl,
|
||||||
|
addr: None,
|
||||||
|
rtt_ms: None,
|
||||||
|
note: Some(err.to_string()),
|
||||||
|
geoip: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TraceReport {
|
||||||
|
target: target.to_string(),
|
||||||
|
ip: Some(addr.to_string()),
|
||||||
|
geoip: None,
|
||||||
|
port,
|
||||||
|
max_hops,
|
||||||
|
timeout_ms,
|
||||||
|
protocol: "udp".to_string(),
|
||||||
|
hops,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_summary(
|
||||||
|
sent: u32,
|
||||||
|
received: u32,
|
||||||
|
min: Option<u128>,
|
||||||
|
max: Option<u128>,
|
||||||
|
sum: u128,
|
||||||
|
) -> PingSummary {
|
||||||
|
let loss_pct = if sent == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
((sent - received) as f64 / sent as f64) * 100.0
|
||||||
|
};
|
||||||
|
let avg_ms = if received == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(sum as f64 / received as f64)
|
||||||
|
};
|
||||||
|
PingSummary {
|
||||||
|
sent,
|
||||||
|
received,
|
||||||
|
loss_pct,
|
||||||
|
min_ms: min,
|
||||||
|
avg_ms,
|
||||||
|
max_ms: max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_one(target: &str) -> Result<IpAddr, ProbeError> {
|
||||||
|
let mut iter = lookup_host((target, 0))
|
||||||
|
.await
|
||||||
|
.map_err(|err| ProbeError::Resolve(err.to_string()))?;
|
||||||
|
iter.next()
|
||||||
|
.map(|addr| addr.ip())
|
||||||
|
.ok_or_else(|| ProbeError::Resolve("no address found".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tcp_connect_with_ttl(
|
||||||
|
addr: SocketAddr,
|
||||||
|
ttl: u8,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(), ProbeError> {
|
||||||
|
let domain = match addr.ip() {
|
||||||
|
IpAddr::V4(_) => Domain::IPV4,
|
||||||
|
IpAddr::V6(_) => Domain::IPV6,
|
||||||
|
};
|
||||||
|
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
match addr.ip() {
|
||||||
|
IpAddr::V4(_) => socket
|
||||||
|
.set_ttl_v4(u32::from(ttl))
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?,
|
||||||
|
IpAddr::V6(_) => socket
|
||||||
|
.set_unicast_hops_v6(u32::from(ttl))
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?,
|
||||||
|
}
|
||||||
|
socket
|
||||||
|
.connect_timeout(&addr.into(), timeout)
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn udp_trace_hop(
|
||||||
|
addr: SocketAddr,
|
||||||
|
ttl: u8,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(Option<IpAddr>, bool), ProbeError> {
|
||||||
|
match addr.ip() {
|
||||||
|
IpAddr::V4(_) => udp_trace_hop_v4(addr, ttl, timeout),
|
||||||
|
IpAddr::V6(_) => udp_trace_hop_v6(addr, ttl, timeout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn udp_trace_hop(
|
||||||
|
_addr: SocketAddr,
|
||||||
|
_ttl: u8,
|
||||||
|
_timeout: Duration,
|
||||||
|
) -> Result<(Option<IpAddr>, bool), ProbeError> {
|
||||||
|
Err(ProbeError::Io(
|
||||||
|
"udp trace not supported on this platform".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn udp_trace_hop_v4(
|
||||||
|
addr: SocketAddr,
|
||||||
|
ttl: u8,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(Option<IpAddr>, bool), ProbeError> {
|
||||||
|
let protocol =
|
||||||
|
TransportChannelType::Layer4(TransportProtocol::Ipv4(IpNextHeaderProtocols::Icmp));
|
||||||
|
let (_tx, mut rx) = transport_channel(4096, protocol)
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
|
||||||
|
let socket = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
socket
|
||||||
|
.set_ttl(u32::from(ttl))
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
let _ = socket.send_to(&[0u8; 4], addr);
|
||||||
|
|
||||||
|
let mut iter = icmp_packet_iter(&mut rx);
|
||||||
|
match iter.next_with_timeout(timeout) {
|
||||||
|
Ok(Some((packet, addr))) => {
|
||||||
|
if let Some(result) = interpret_icmp_v4(&packet) {
|
||||||
|
return Ok((Some(addr), result));
|
||||||
|
}
|
||||||
|
Ok((Some(addr), false))
|
||||||
|
}
|
||||||
|
Ok(None) => Err(ProbeError::Timeout),
|
||||||
|
Err(err) => Err(ProbeError::Io(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn udp_trace_hop_v6(
|
||||||
|
addr: SocketAddr,
|
||||||
|
ttl: u8,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(Option<IpAddr>, bool), ProbeError> {
|
||||||
|
let protocol =
|
||||||
|
TransportChannelType::Layer4(TransportProtocol::Ipv6(IpNextHeaderProtocols::Icmpv6));
|
||||||
|
let (_tx, mut rx) = transport_channel(4096, protocol)
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
|
||||||
|
let socket = std::net::UdpSocket::bind("[::]:0")
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
socket
|
||||||
|
.set_unicast_hops_v6(u32::from(ttl))
|
||||||
|
.map_err(|err| ProbeError::Io(err.to_string()))?;
|
||||||
|
let _ = socket.send_to(&[0u8; 4], addr);
|
||||||
|
|
||||||
|
let mut iter = icmpv6_packet_iter(&mut rx);
|
||||||
|
match iter.next_with_timeout(timeout) {
|
||||||
|
Ok(Some((packet, addr))) => {
|
||||||
|
if let Some(result) = interpret_icmp_v6(&packet) {
|
||||||
|
return Ok((Some(addr), result));
|
||||||
|
}
|
||||||
|
Ok((Some(addr), false))
|
||||||
|
}
|
||||||
|
Ok(None) => Err(ProbeError::Timeout),
|
||||||
|
Err(err) => Err(ProbeError::Io(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn interpret_icmp_v4(packet: &IcmpPacket) -> Option<bool> {
|
||||||
|
let icmp_type = packet.get_icmp_type();
|
||||||
|
if icmp_type == IcmpTypes::TimeExceeded {
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
if icmp_type == IcmpTypes::DestinationUnreachable {
|
||||||
|
return Some(true);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn interpret_icmp_v6(packet: &Icmpv6Packet) -> Option<bool> {
|
||||||
|
let icmp_type = packet.get_icmpv6_type();
|
||||||
|
if icmp_type == Icmpv6Types::TimeExceeded {
|
||||||
|
return Some(false);
|
||||||
|
}
|
||||||
|
if icmp_type == Icmpv6Types::DestinationUnreachable {
|
||||||
|
return Some(true);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
84
docs/dns_poisoning_design.md
Normal file
84
docs/dns_poisoning_design.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# DNS Poisoning Detection Design
|
||||||
|
|
||||||
|
This document summarizes the current implementation approach for detecting DNS poisoning in active probing, and the planned design for passive methods.
|
||||||
|
|
||||||
|
## Active probing (current implementation)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
- Active probing compares answers from multiple resolvers for the same domain and record type.
|
||||||
|
- The current CLI command is `dns detect <domain>`.
|
||||||
|
- The current implementation focuses on deterministic, best-effort heuristics and avoids OS-specific parsing.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
- Domain name.
|
||||||
|
- Resolver list: either user-provided via `--servers` or default public resolvers.
|
||||||
|
- Transport: UDP/TCP/DoT/DoH.
|
||||||
|
- Optional SOCKS5 proxy for DoH queries (`--socks5`).
|
||||||
|
- Repeat count: `--repeat` (>= 1).
|
||||||
|
- Timeout: `--timeout-ms`.
|
||||||
|
|
||||||
|
### Query flow
|
||||||
|
1. For each resolver and each repeat, issue a DNS A query using `hickory-resolver`.
|
||||||
|
2. Collect a `DnsQueryReport` that includes:
|
||||||
|
- `domain`, `record_type`, `transport`, `server`, `server_name`, `rcode`, `answers`, `duration_ms`.
|
||||||
|
3. Enrich results in the CLI with GeoIP:
|
||||||
|
- `server_geoip` based on the resolver IP.
|
||||||
|
- Per-answer GeoIP when answer data is an IP (A/AAAA).
|
||||||
|
|
||||||
|
### Current heuristics
|
||||||
|
The detect verdict is derived from the following checks across all results:
|
||||||
|
- **RCODE divergence**: mismatch in response code across resolvers.
|
||||||
|
- **Answer divergence**: different answer sets across resolvers.
|
||||||
|
- **Private/reserved answers**: any A/AAAA in private/reserved space.
|
||||||
|
- **TTL variance**: wide TTL span (currently > 3600s).
|
||||||
|
|
||||||
|
### Verdict mapping
|
||||||
|
- `clean`: no evidence found.
|
||||||
|
- `inconclusive`: only one evidence signal or no results.
|
||||||
|
- `suspicious`: two or more evidence signals.
|
||||||
|
|
||||||
|
### Output
|
||||||
|
- JSON output returns a list of per-resolver reports plus evidence.
|
||||||
|
- Human output shows verdict, evidence, and per-resolver summaries with GeoIP.
|
||||||
|
- Reports also include transport, server name (for DoT/DoH), and proxy (if used).
|
||||||
|
|
||||||
|
### Rationale and limitations
|
||||||
|
- This approach is deterministic and does not rely on parsing OS tools.
|
||||||
|
- False positives may occur due to legitimate geo-load balancing or CDN behavior.
|
||||||
|
- DNSSEC validation is not currently used in detection logic.
|
||||||
|
|
||||||
|
## Passive methods (planned design)
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Observe DNS responses and correlate with active results.
|
||||||
|
- Identify anomalies without injecting traffic.
|
||||||
|
|
||||||
|
### Passive data sources (feature gated)
|
||||||
|
- Packet capture via `pcap` or `pnet` (root/admin privileges needed).
|
||||||
|
- Optional system resolver logs if available (platform-specific; best-effort).
|
||||||
|
|
||||||
|
### Planned pipeline
|
||||||
|
1. Capture DNS responses (UDP/TCP, port 53; optionally DoH/DoT if visible).
|
||||||
|
2. Parse responses into normalized records:
|
||||||
|
- `domain`, `record_type`, `rcode`, `answers`, `ttl`, `server_ip`.
|
||||||
|
3. Maintain short-term rolling windows (time-bounded) to:
|
||||||
|
- detect sudden shifts in answers
|
||||||
|
- detect private/reserved answers for public domains
|
||||||
|
- detect TTL anomalies compared to historical baseline
|
||||||
|
|
||||||
|
### Planned heuristics
|
||||||
|
- **Answer churn**: frequent changes in answer sets beyond normal CDN variance.
|
||||||
|
- **Resolver mismatch**: passive answers conflict with known public resolver responses.
|
||||||
|
- **Suspicious IP ranges**: private/reserved or local ISP blocks where not expected.
|
||||||
|
- **Low TTL bursts**: sudden TTL drops that persist for short windows.
|
||||||
|
|
||||||
|
### Output (planned)
|
||||||
|
- Passive summaries include:
|
||||||
|
- top domains observed
|
||||||
|
- divergence counts
|
||||||
|
- suspicious answer summaries
|
||||||
|
- optional GeoIP enrichment for answer IPs and resolver IPs
|
||||||
|
|
||||||
|
### Privacy and safety notes
|
||||||
|
- Passive capture should be explicit and opt-in.
|
||||||
|
- Store minimal metadata and avoid payload logging beyond DNS fields.
|
||||||
@@ -767,6 +767,11 @@ GeoIP:
|
|||||||
* `NETTOOL_GEOIP_COUNTRY_DB`
|
* `NETTOOL_GEOIP_COUNTRY_DB`
|
||||||
* `NETTOOL_GEOIP_ASN_DB`
|
* `NETTOOL_GEOIP_ASN_DB`
|
||||||
|
|
||||||
|
Lookup order:
|
||||||
|
1) Environment variable path
|
||||||
|
2) `data/` next to the CLI binary
|
||||||
|
3) `data/` in the current working directory
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
|
|
||||||
* `NETTOOL_LOG_LEVEL`
|
* `NETTOOL_LOG_LEVEL`
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ This document tracks the planned roadmap alongside the current implementation st
|
|||||||
- Platform `neigh list` best-effort parsing (Linux `/proc/net/arp`, Windows `arp -a`).
|
- Platform `neigh list` best-effort parsing (Linux `/proc/net/arp`, Windows `arp -a`).
|
||||||
- Platform `cert roots` implementation via native trust store parsing.
|
- Platform `cert roots` implementation via native trust store parsing.
|
||||||
- CLI commands for `ports listen/who`, `neigh list`, and `cert roots`.
|
- CLI commands for `ports listen/who`, `neigh list`, and `cert roots`.
|
||||||
|
- Process name/path enrichment for `ports listen/who` (Linux procfs, Windows tasklist/wmic).
|
||||||
|
- `wtfnet-geoip` crate with local mmdb lookup and CLI commands (`geoip`, `geoip status`).
|
||||||
|
- `wtfnet-probe` crate with ping/tcping and best-effort TCP trace, plus CLI commands.
|
||||||
|
- ICMP/UDP traceroute support (IPv4) via pnet.
|
||||||
|
- Probe outputs now include GeoIP by default with `--no-geoip` disable flags.
|
||||||
|
- UDP traceroute now supports IPv6 on Unix and includes per-hop RTT.
|
||||||
|
- `wtfnet-dns` crate with query/detect support wired to CLI.
|
||||||
|
- DNS query/detect output includes GeoIP enrichment for server and answer IPs.
|
||||||
|
- DNS query/detect supports DoT and DoH transports.
|
||||||
|
- DNS query/detect supports SOCKS5 proxying for DoH.
|
||||||
|
- DNS watch (passive, best-effort) implemented.
|
||||||
|
- Calc subcrate with subnet/contains/overlap/summarize wired to CLI.
|
||||||
|
|
||||||
### In progress
|
### In progress
|
||||||
- None.
|
- v0.2 features: http, tls, discover, diag.
|
||||||
|
|
||||||
### Next
|
### Next
|
||||||
- Start additional platform/feature crates per dependency map.
|
- Complete remaining v0.2 crates/commands (http/tls/discover/diag/dns watch).
|
||||||
|
- Add v0.2 tests (dns detect, calc, basic http/tls smoke).
|
||||||
|
|||||||
Reference in New Issue
Block a user