mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-09-22 22:37:59 +03:00
fix(probe): multiple connections and TLS message (#91)
This commit is contained in:
Generated
+3
-2
@@ -2577,7 +2577,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "probe"
|
name = "probe"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2585,6 +2585,7 @@ dependencies = [
|
|||||||
"etherparse",
|
"etherparse",
|
||||||
"futures",
|
"futures",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"polling",
|
"polling",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
@@ -4652,7 +4653,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "website"
|
name = "website"
|
||||||
version = "1.3.1"
|
version = "1.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "probe"
|
name = "probe"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license-file = "../LICENSE"
|
license-file = "../LICENSE"
|
||||||
description = "Dynamic network probe daemon for Cheburcheck"
|
description = "Dynamic network probe daemon for Cheburcheck"
|
||||||
@@ -50,3 +50,6 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["loggin
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
socket2 = { version = "0.6", features = ["all"] }
|
socket2 = { version = "0.6", features = ["all"] }
|
||||||
hickory-resolver = { version = "0.26.0-beta.3", features = ["tokio", "webpki-roots", "https-ring"] }
|
hickory-resolver = { version = "0.26.0-beta.3", features = ["tokio", "webpki-roots", "https-ring"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|||||||
+133
-53
@@ -2,7 +2,6 @@ use etherparse::{
|
|||||||
Icmpv4Type, Icmpv6Slice, Icmpv6Type, IpNumber, LaxNetSlice, LaxSlicedPacket, TransportSlice,
|
Icmpv4Type, Icmpv6Slice, Icmpv6Type, IpNumber, LaxNetSlice, LaxSlicedPacket, TransportSlice,
|
||||||
icmpv4, icmpv6,
|
icmpv4, icmpv6,
|
||||||
};
|
};
|
||||||
use rand::RngCore;
|
|
||||||
use rustls::pki_types::ServerName;
|
use rustls::pki_types::ServerName;
|
||||||
use rustls::{ClientConfig, ClientConnection, RootCertStore};
|
use rustls::{ClientConfig, ClientConnection, RootCertStore};
|
||||||
use socket2::{Domain, Protocol, SockRef, Socket, Type};
|
use socket2::{Domain, Protocol, SockRef, Socket, Type};
|
||||||
@@ -12,7 +11,10 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, SocketAddrV6, TcpStre
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const PROBE_BYTES: usize = 256;
|
// TLS 1.3 compatibility-mode ChangeCipherSpec. RFC 8446 requires receivers
|
||||||
|
// to silently discard this record during the handshake, making it a harmless
|
||||||
|
// post-ClientHello TCP payload for TTL measurement.
|
||||||
|
const TLS_COMPATIBILITY_CHANGE_CIPHER_SPEC: &[u8] = &[20, 3, 3, 0, 1, 1];
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DpiHopProbeConfig {
|
pub struct DpiHopProbeConfig {
|
||||||
@@ -44,6 +46,13 @@ pub enum DpiHopProbeHopOutcome {
|
|||||||
IcmpTimeExceeded,
|
IcmpTimeExceeded,
|
||||||
Timeout,
|
Timeout,
|
||||||
TcpClosed,
|
TcpClosed,
|
||||||
|
TcpAcknowledged,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DpiHopProbeHopOutcome {
|
||||||
|
pub const fn invalidates_measurement(self) -> bool {
|
||||||
|
matches!(self, Self::TcpClosed | Self::TcpAcknowledged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn detect_dpi_hop(config: DpiHopProbeConfig) -> io::Result<DpiHopProbeResult> {
|
pub async fn detect_dpi_hop(config: DpiHopProbeConfig) -> io::Result<DpiHopProbeResult> {
|
||||||
@@ -60,7 +69,6 @@ pub fn detect_dpi_hop_blocking(config: DpiHopProbeConfig) -> io::Result<DpiHopPr
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let client_hello = make_client_hello(&config.control_sni)?;
|
|
||||||
let (domain, protocol) = match config.target {
|
let (domain, protocol) = match config.target {
|
||||||
SocketAddr::V4(_) => (Domain::IPV4, Protocol::ICMPV4),
|
SocketAddr::V4(_) => (Domain::IPV4, Protocol::ICMPV4),
|
||||||
SocketAddr::V6(_) => (Domain::IPV6, Protocol::ICMPV6),
|
SocketAddr::V6(_) => (Domain::IPV6, Protocol::ICMPV6),
|
||||||
@@ -68,33 +76,51 @@ pub fn detect_dpi_hop_blocking(config: DpiHopProbeConfig) -> io::Result<DpiHopPr
|
|||||||
let icmp = Socket::new(domain, Type::RAW, Some(protocol))?;
|
let icmp = Socket::new(domain, Type::RAW, Some(protocol))?;
|
||||||
icmp.set_read_timeout(Some(config.hop_timeout))?;
|
icmp.set_read_timeout(Some(config.hop_timeout))?;
|
||||||
|
|
||||||
let mut tcp = TcpStream::connect_timeout(&config.target, config.connect_timeout)?;
|
let client_hello = make_client_hello(&config.control_sni)?;
|
||||||
tcp.set_nodelay(true)?;
|
|
||||||
let local_addr = tcp.local_addr()?;
|
|
||||||
if !same_ip_family(local_addr, config.target) {
|
|
||||||
return Err(io::Error::other(
|
|
||||||
"DPI hop probe local and target address families differ",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Winsock requires a raw socket to be bound before `recvfrom`; otherwise
|
|
||||||
// the first drain/read fails with WSAEINVAL (10022). Binding to the address
|
|
||||||
// selected for the TCP connection also limits replies to the right local
|
|
||||||
// interface. Raw sockets do not use a transport port, so bind with port 0.
|
|
||||||
let mut icmp_addr = local_addr;
|
|
||||||
icmp_addr.set_port(0);
|
|
||||||
icmp.bind(&icmp_addr.into())?;
|
|
||||||
|
|
||||||
tcp.write_all(&client_hello)?;
|
|
||||||
let mut hops = Vec::with_capacity(config.max_ttl as usize);
|
let mut hops = Vec::with_capacity(config.max_ttl as usize);
|
||||||
let mut max_icmp_time_exceeded_ttl = None;
|
let mut max_icmp_time_exceeded_ttl = None;
|
||||||
|
let mut result_local_addr: Option<SocketAddr> = None;
|
||||||
|
let mut client_hello_bytes = None;
|
||||||
|
|
||||||
for ttl in 1..=config.max_ttl {
|
for ttl in 1..=config.max_ttl {
|
||||||
let mut payload = [0u8; PROBE_BYTES];
|
// TCP is a byte stream: once a low-TTL segment is lost, later writes on
|
||||||
rand::thread_rng().fill_bytes(&mut payload);
|
// that stream can remain queued behind it and retransmissions can use a
|
||||||
|
// subsequently changed socket TTL. Use an independent connection for
|
||||||
|
// every TTL so each hop corresponds to an actual packet and cannot
|
||||||
|
// affect later hops.
|
||||||
|
let mut tcp = TcpStream::connect_timeout(&config.target, config.connect_timeout)?;
|
||||||
|
tcp.set_nodelay(true)?;
|
||||||
|
let local_addr = tcp.local_addr()?;
|
||||||
|
if !same_ip_family(local_addr, config.target) {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"DPI hop probe local and target address families differ",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(result_local_addr) = result_local_addr {
|
||||||
|
if result_local_addr.ip() != local_addr.ip() {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"DPI hop probe changed local address between TTL attempts",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Winsock requires a raw socket to be bound before `recvfrom`.
|
||||||
|
// Binding after route selection also limits replies to the right
|
||||||
|
// local interface. Raw sockets do not use a transport port.
|
||||||
|
let mut icmp_addr = local_addr;
|
||||||
|
icmp_addr.set_port(0);
|
||||||
|
icmp.bind(&icmp_addr.into())?;
|
||||||
|
result_local_addr = Some(local_addr);
|
||||||
|
client_hello_bytes = Some(client_hello.len());
|
||||||
|
}
|
||||||
|
tcp.write_all(&client_hello)?;
|
||||||
drain_socket(&icmp)?;
|
drain_socket(&icmp)?;
|
||||||
|
|
||||||
if !send_with_ttl(&mut tcp, config.target, &payload, ttl)? {
|
if !send_with_ttl(
|
||||||
|
&mut tcp,
|
||||||
|
config.target,
|
||||||
|
TLS_COMPATIBILITY_CHANGE_CIPHER_SPEC,
|
||||||
|
ttl,
|
||||||
|
)? {
|
||||||
hops.push(DpiHopProbeHop {
|
hops.push(DpiHopProbeHop {
|
||||||
ttl,
|
ttl,
|
||||||
router: None,
|
router: None,
|
||||||
@@ -105,28 +131,26 @@ pub fn detect_dpi_hop_blocking(config: DpiHopProbeConfig) -> io::Result<DpiHopPr
|
|||||||
|
|
||||||
let router =
|
let router =
|
||||||
listen_for_time_exceeded(&icmp, local_addr, config.target, config.hop_timeout)?;
|
listen_for_time_exceeded(&icmp, local_addr, config.target, config.hop_timeout)?;
|
||||||
let outcome = if router.is_some() {
|
let outcome = classify_hop(router, peer_closed(&tcp)?, tcp_payload_acknowledged(&tcp)?);
|
||||||
|
if outcome == DpiHopProbeHopOutcome::IcmpTimeExceeded {
|
||||||
max_icmp_time_exceeded_ttl = Some(ttl);
|
max_icmp_time_exceeded_ttl = Some(ttl);
|
||||||
DpiHopProbeHopOutcome::IcmpTimeExceeded
|
}
|
||||||
} else if peer_closed(&tcp)? {
|
|
||||||
DpiHopProbeHopOutcome::TcpClosed
|
|
||||||
} else {
|
|
||||||
DpiHopProbeHopOutcome::Timeout
|
|
||||||
};
|
|
||||||
hops.push(DpiHopProbeHop {
|
hops.push(DpiHopProbeHop {
|
||||||
ttl,
|
ttl,
|
||||||
router,
|
router,
|
||||||
outcome,
|
outcome,
|
||||||
});
|
});
|
||||||
if outcome == DpiHopProbeHopOutcome::TcpClosed {
|
if outcome.invalidates_measurement() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(DpiHopProbeResult {
|
Ok(DpiHopProbeResult {
|
||||||
target: config.target,
|
target: config.target,
|
||||||
local_addr,
|
local_addr: result_local_addr
|
||||||
client_hello_bytes: client_hello.len(),
|
.ok_or_else(|| io::Error::other("DPI hop probe made no TTL attempts"))?,
|
||||||
|
client_hello_bytes: client_hello_bytes
|
||||||
|
.ok_or_else(|| io::Error::other("DPI hop probe produced no ClientHello"))?,
|
||||||
max_icmp_time_exceeded_ttl,
|
max_icmp_time_exceeded_ttl,
|
||||||
hops,
|
hops,
|
||||||
})
|
})
|
||||||
@@ -167,34 +191,20 @@ fn send_with_ttl(
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
ttl: u8,
|
ttl: u8,
|
||||||
) -> io::Result<bool> {
|
) -> io::Result<bool> {
|
||||||
let previous_ttl = {
|
|
||||||
let socket = SockRef::from(&*tcp);
|
|
||||||
match target {
|
|
||||||
SocketAddr::V4(_) => socket.ttl_v4()?,
|
|
||||||
SocketAddr::V6(_) => socket.unicast_hops_v6()?,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
set_ttl(tcp, target, ttl as u32)?;
|
set_ttl(tcp, target, ttl as u32)?;
|
||||||
let write_result = tcp.write_all(payload);
|
// Keep this TTL until the per-hop connection is dropped. Retransmissions
|
||||||
let restore_result = set_ttl(tcp, target, previous_ttl);
|
// must expire at the same hop instead of escaping with the default TTL.
|
||||||
match write_result {
|
match tcp.write_all(payload) {
|
||||||
Ok(()) => {
|
Ok(()) => Ok(true),
|
||||||
restore_result?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Err(error)
|
Err(error)
|
||||||
if matches!(
|
if matches!(
|
||||||
error.kind(),
|
error.kind(),
|
||||||
io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
|
io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
|
||||||
) =>
|
) =>
|
||||||
{
|
{
|
||||||
let _ = restore_result;
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => Err(error),
|
||||||
let _ = restore_result;
|
|
||||||
Err(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +270,54 @@ fn peer_closed(tcp: &TcpStream) -> io::Result<bool> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn classify_hop(
|
||||||
|
router: Option<IpAddr>,
|
||||||
|
peer_closed: bool,
|
||||||
|
payload_acknowledged: bool,
|
||||||
|
) -> DpiHopProbeHopOutcome {
|
||||||
|
if router.is_some() {
|
||||||
|
DpiHopProbeHopOutcome::IcmpTimeExceeded
|
||||||
|
} else if peer_closed {
|
||||||
|
DpiHopProbeHopOutcome::TcpClosed
|
||||||
|
} else if payload_acknowledged {
|
||||||
|
DpiHopProbeHopOutcome::TcpAcknowledged
|
||||||
|
} else {
|
||||||
|
DpiHopProbeHopOutcome::Timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn tcp_payload_acknowledged(tcp: &TcpStream) -> io::Result<bool> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let mut info = std::mem::MaybeUninit::<libc::tcp_info>::zeroed();
|
||||||
|
let mut length = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
|
||||||
|
// SAFETY: `info` points to writable storage of `length` bytes, and both
|
||||||
|
// pointers remain valid for the duration of `getsockopt`.
|
||||||
|
let result = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
tcp.as_raw_fd(),
|
||||||
|
libc::IPPROTO_TCP,
|
||||||
|
libc::TCP_INFO,
|
||||||
|
info.as_mut_ptr().cast(),
|
||||||
|
&mut length,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result == -1 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// Linux initialized the returned prefix, which includes `tcpi_unacked`.
|
||||||
|
let info = unsafe { info.assume_init() };
|
||||||
|
Ok(info.tcpi_unacked == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn tcp_payload_acknowledged(_tcp: &TcpStream) -> io::Result<bool> {
|
||||||
|
// TCP acknowledgment state is not exposed portably. Other platforms keep
|
||||||
|
// the previous conservative behavior and never infer direct delivery.
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn drain_socket(socket: &Socket) -> io::Result<usize> {
|
fn drain_socket(socket: &Socket) -> io::Result<usize> {
|
||||||
let previous_timeout = socket.read_timeout()?;
|
let previous_timeout = socket.read_timeout()?;
|
||||||
socket.set_nonblocking(true)?;
|
socket.set_nonblocking(true)?;
|
||||||
@@ -408,6 +466,28 @@ fn matching_quoted_tcp_tuple(packet: &[u8], local_addr: SocketAddr, target: Sock
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ttl_probe_is_tls_compatibility_change_cipher_spec() {
|
||||||
|
assert_eq!(TLS_COMPATIBILITY_CHANGE_CIPHER_SPEC, [20, 3, 3, 0, 1, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acknowledged_payload_marks_direct_tcp_delivery() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_hop(None, false, true),
|
||||||
|
DpiHopProbeHopOutcome::TcpAcknowledged
|
||||||
|
);
|
||||||
|
assert!(DpiHopProbeHopOutcome::TcpAcknowledged.invalidates_measurement());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icmp_response_takes_precedence_over_tcp_state() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_hop(Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), true, true),
|
||||||
|
DpiHopProbeHopOutcome::IcmpTimeExceeded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn matches_icmp_time_exceeded_quote_by_flow_tuple() {
|
fn matches_icmp_time_exceeded_quote_by_flow_tuple() {
|
||||||
let local = SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 10), 45_000);
|
let local = SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 10), 45_000);
|
||||||
|
|||||||
+22
-17
@@ -416,14 +416,14 @@ fn dpi_hop_from_result(result: &dpi_hop::DpiHopProbeResult) -> Option<u8> {
|
|||||||
result.target, hop.ttl, hop.router, hop.outcome
|
result.target, hop.ttl, hop.router, hop.outcome
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(closed_hop) = result
|
if let Some(invalid_hop) = result
|
||||||
.hops
|
.hops
|
||||||
.iter()
|
.iter()
|
||||||
.find(|hop| hop.outcome == dpi_hop::DpiHopProbeHopOutcome::TcpClosed)
|
.find(|hop| hop.outcome.invalidates_measurement())
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
"DPI hop measurement for {} is invalid: TCP connection closed at TTL {}",
|
"DPI hop measurement for {} is invalid: {:?} at TTL {}",
|
||||||
result.target, closed_hop.ttl
|
result.target, invalid_hop.outcome, invalid_hop.ttl
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -641,19 +641,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tcp_closed_invalidates_only_its_measurement() {
|
fn direct_tcp_delivery_invalidates_only_its_measurement() {
|
||||||
let result = dpi_hop::DpiHopProbeResult {
|
for outcome in [
|
||||||
target: "[2001:db8::10]:443".parse().unwrap(),
|
dpi_hop::DpiHopProbeHopOutcome::TcpClosed,
|
||||||
local_addr: "[2001:db8::1]:45000".parse().unwrap(),
|
dpi_hop::DpiHopProbeHopOutcome::TcpAcknowledged,
|
||||||
client_hello_bytes: 256,
|
] {
|
||||||
max_icmp_time_exceeded_ttl: Some(4),
|
let result = dpi_hop::DpiHopProbeResult {
|
||||||
hops: vec![dpi_hop::DpiHopProbeHop {
|
target: "[2001:db8::10]:443".parse().unwrap(),
|
||||||
ttl: 5,
|
local_addr: "[2001:db8::1]:45000".parse().unwrap(),
|
||||||
router: None,
|
client_hello_bytes: 256,
|
||||||
outcome: dpi_hop::DpiHopProbeHopOutcome::TcpClosed,
|
max_icmp_time_exceeded_ttl: Some(4),
|
||||||
}],
|
hops: vec![dpi_hop::DpiHopProbeHop {
|
||||||
};
|
ttl: 5,
|
||||||
|
router: None,
|
||||||
|
outcome,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
assert_eq!(dpi_hop_from_result(&result), None);
|
assert_eq!(dpi_hop_from_result(&result), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "website"
|
name = "website"
|
||||||
version = "1.3.1"
|
version = "1.3.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user