refactor: drop the dead TLS probe, turn Nagle off on every dial

`probe::tls` (196 lines) had no caller — test 2 grew its own L7 path — and the
blockpage signatures it checked are already kept per region in
`RegionProfile::blockpage_signatures`, so the module only duplicated knowledge and
kept nine names alive in the public surface. Deleted with its two re-exports.

The remaining raw dials (DoH, DoT, SOCKS5, the netinfo GET and the three Telegram
paths) now go through one `net::tcp::set_no_delay`, so the rule that `net/tcp.rs`
states — Nagle off is a requirement of the exchange, not a tuning knob — holds for
every dial instead of four of ten.
This commit is contained in:
Kirill Minovsky 2026-09-14 00:58:32 +03:00
parent 21bb1e80ed
commit 1aebf22ed2
9 changed files with 49 additions and 214 deletions

View file

@ -16,6 +16,7 @@ use super::resolve::resolve_host;
use super::types::{DnsError, DnsRecord};
use super::wire::{build_dns_query, parse_dns_response, QTYPE_A};
use crate::config::AppConfig;
use crate::net::tcp::set_no_delay;
use crate::net::tls::create_verifying_doh_tls_config;
pub enum DohSender {
@ -77,7 +78,7 @@ pub async fn doh_connect(endpoint_url: &str, timeout_dur: Duration) -> Result<(D
stage: "tcp_connect",
detail: e.to_string(),
})?;
let _ = tcp.set_nodelay(true);
set_no_delay(&tcp);
let tls_config = create_verifying_doh_tls_config();
let connector = TlsConnector::from(tls_config);

View file

@ -14,6 +14,7 @@ use tokio_rustls::TlsConnector;
use super::resolve::resolve_host;
use super::types::DnsError;
use super::wire::{build_dns_query, parse_dns_response, QTYPE_A};
use crate::net::tcp::set_no_delay;
use crate::net::tls::create_verifying_tls_config;
/// Splits a DoT endpoint `'host[:port]'` (default 853). Supports IPv6 literals.
@ -98,7 +99,7 @@ impl DotSession {
stage: "tcp_connect",
detail: e.to_string(),
})?;
let _ = tcp.set_nodelay(true);
set_no_delay(&tcp);
let connector = TlsConnector::from(create_verifying_tls_config());
let server_name = ServerName::try_from(host.to_string())
.or_else(|_| {

View file

@ -4,6 +4,7 @@ use tokio::net::TcpStream;
use url::Url;
use super::types::DnsError;
use crate::net::tcp::set_no_delay;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SocksProxyConfig {
@ -110,6 +111,7 @@ pub async fn associate_socks5_udp(
let mut tcp = TcpStream::connect(&addr)
.await
.map_err(|e| DnsError::Socks5(format!("TCP connect to {} failed: {}", addr, e)))?;
set_no_delay(&tcp);
// 1. Handshake
if proxy.username.is_some() && proxy.password.is_some() {

View file

@ -19,6 +19,7 @@ use tokio_rustls::TlsConnector;
use url::Url;
use crate::dns::query_doh_txt;
use crate::net::tcp::set_no_delay;
use crate::net::tls::create_verifying_tls_config;
#[derive(Debug, Clone, Default)]
@ -149,6 +150,7 @@ async fn http_get_once(url: &Url, timeout_dur: Duration) -> Result<(u16, Option<
let tcp = TcpStream::connect(&addr)
.await
.map_err(|e| format!("Connect to {} failed: {}", addr, e))?;
set_no_delay(&tcp);
let req = Request::builder()
.method(Method::GET)

View file

@ -29,6 +29,12 @@ pub async fn dial_tcp(addr: &SocketAddr, timeout_dur: Duration) -> Result<TcpStr
Ok(Err(e)) => return Err(DialError::Io(e)),
Err(_) => return Err(DialError::Timeout),
};
let _ = stream.set_nodelay(true);
set_no_delay(&stream);
Ok(stream)
}
/// Turns Nagle off on a dialed stream. See [`dial_tcp`] for why every probe wants
/// this — it is a requirement of the exchange, not a tuning knob.
pub(crate) fn set_no_delay(stream: &TcpStream) {
let _ = stream.set_nodelay(true);
}

View file

@ -1,6 +1,5 @@
pub mod connector;
pub mod tcp16;
pub mod tls;
pub mod domains;
pub mod burst;
pub mod telegram;
@ -9,6 +8,5 @@ pub use connector::{DpiTlsConnector, RustlsConnector};
pub mod http;
pub use burst::{burst_targets, BurstAttempt, BurstProfileReport, BurstReport, BurstSettings, BurstTarget};
pub use tcp16::{check_tcp_16_20, probe_tcp16};
pub use tls::{is_suspicious_redirect, probe_tls_domain, probe_tls_domain_default};
pub use telegram::{probe_telegram_all_dcs, probe_telegram_dc, run_download, run_telegram_full, run_telegram_test, run_upload, TelegramDcResult, TelegramFullReport, TelegramReport, TransferStats};
pub use whitelist::{run_whitelist_sni, AsRow, AsVerdict, WhitelistReport};

View file

@ -23,6 +23,7 @@ use tokio_rustls::TlsConnector;
use crate::classify::DET_SYN_TIMEOUT_SHORT;
use crate::config::AppConfig;
use crate::dns::resolve_host;
use crate::net::tcp::set_no_delay;
use crate::net::tls::create_insecure_dpi_tls_config;
use crate::PhaseProgress;
@ -69,7 +70,8 @@ pub async fn probe_telegram_dc(dc: &TelegramDc, timeout_dur: Duration) -> Telegr
match addr_str.parse::<SocketAddr>() {
Ok(sock_addr) => {
match timeout(timeout_dur, TcpStream::connect(sock_addr)).await {
Ok(Ok(_stream)) => {
Ok(Ok(stream)) => {
set_no_delay(&stream);
let latency = start.elapsed().as_millis() as u64;
TelegramDcResult {
name: dc.name.to_string(),
@ -189,6 +191,7 @@ fn split_url(url: &str) -> Option<(String, String)> {
async fn tls_get(host: &str, path: &str, user_agent: &str) -> Option<(impl Body<Data = Bytes, Error = hyper::Error> + Unpin, impl FnOnce() + Send)> {
let addr = resolve_host(host, 443, Duration::from_secs(10)).await.ok()?.into_iter().next()?;
let tcp = TcpStream::connect(addr).await.ok()?;
set_no_delay(&tcp);
let connector = TlsConnector::from(create_insecure_dpi_tls_config());
let server_name = ServerName::try_from(host.to_string()).ok()?;
let tls = connector.connect(server_name, tcp).await.ok()?;
@ -371,7 +374,10 @@ pub async fn run_upload(cfg: &AppConfig) -> TransferStats {
cfg.telegram_upload_port,
);
let tcp = match timeout(Duration::from_secs_f64(8.0), TcpStream::connect(&addr)).await {
Ok(Ok(s)) => s,
Ok(Ok(s)) => {
set_no_delay(&s);
s
}
_ => {
return TransferStats { status: "blocked".into(), duration: t0.elapsed().as_secs_f64(), ..Default::default() };
}

View file

@ -1,196 +0,0 @@
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant};
use http_body_util::BodyExt;
use hyper::Method;
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use tokio::net::TcpStream;
use tokio::time::timeout;
use super::connector::{DpiTlsConnector, RustlsConnector};
use super::http::{negotiated_h2, HttpRequest, HttpSender};
use crate::classify::{
classify_connect_error, classify_tls_error, ConnectionStage, DpiProbeStream, DpiProbeTracker,
DpiStatus, ProbeMetrics,
};
const DEFAULT_USER_AGENT: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
/// Checks whether an HTTP redirect location points to a known DPI/ISP blockpage.
pub fn is_suspicious_redirect(location: &str) -> bool {
let loc = location.to_ascii_lowercase();
const BLOCKPAGE_SIGNATURES: &[&str] = &[
"warning.rt.ru",
"blocked.rt.ru",
"block.mts.ru",
"eais.rkn.gov.ru",
"blackhole",
"zapret",
"blocked",
"blockpage",
"megafon.ru/blocked",
"beeline.ru/blocked",
"domru.ru/blocked",
];
BLOCKPAGE_SIGNATURES.iter().any(|sig| loc.contains(sig))
}
/// Probes a domain over TLS / HTTPS and classifies the DPI state.
pub async fn probe_tls_domain<C: DpiTlsConnector>(
domain: &str,
target_ip: IpAddr,
port: u16,
timeout_dur: Duration,
connector: &C,
) -> ProbeMetrics {
let start = Instant::now();
let tracker = DpiProbeTracker::new();
let addr = SocketAddr::new(target_ip, port);
let conn = connector;
let probe_future = async {
tracker.set_stage(ConnectionStage::TcpConnecting);
let tcp = match TcpStream::connect(&addr).await {
Ok(stream) => {
tracker.set_stage(ConnectionStage::TcpConnected);
stream
}
Err(err) => {
let (status, detail) = classify_connect_error(Some(&err), false);
tracker.record_error(status, detail);
return Err(err.to_string());
}
};
let probe_stream = DpiProbeStream::new(tcp, tracker.clone());
let server_name = match ServerName::try_from(domain.to_string()) {
Ok(name) => name,
Err(err) => {
let msg = format!("invalid domain for TLS SNI: {}", err);
tracker.record_error(DpiStatus::Unknown, &msg);
return Err(msg);
}
};
let tls_stream = match conn.connect(server_name, probe_stream).await {
Ok(stream) => {
tracker.set_stage(ConnectionStage::TlsHandshakeDone);
stream
}
Err(err) => {
let err_str = err.to_string();
let lock = tracker.state.lock();
let (status, detail) = classify_tls_error(
lock.stage,
lock.bytes_sent,
lock.bytes_recv,
&err_str,
false,
);
drop(lock);
tracker.record_error(status, detail);
return Err(err_str);
}
};
// Send minimal HTTP GET /
tracker.set_stage(ConnectionStage::HttpPayload);
let alpn_h2 = negotiated_h2(&tls_stream);
let io = TokioIo::new(tls_stream);
let mut sender = match HttpSender::handshake(io, alpn_h2).await {
Ok(sender) => sender,
Err(err) => {
tracker.record_error(DpiStatus::Unknown, format!("HTTP handshake failed: {}", err));
return Err(err.to_string());
}
};
let req = HttpRequest {
method: Method::GET,
host: domain,
path: "/",
user_agent: DEFAULT_USER_AGENT,
headers: Vec::new(),
};
let resp = match sender.send(req).await {
Ok(r) => r,
Err(err) => {
tracker.record_error(DpiStatus::Unknown, format!("HTTP request failed: {}", err));
return Err(err.to_string());
}
};
// Check for suspicious redirect location headers
if let Some(loc) = resp.headers().get("location") {
if let Ok(loc_str) = loc.to_str() {
if is_suspicious_redirect(loc_str) {
tracker.record_error(
DpiStatus::HttpBlocked,
format!("ISP blockpage redirect detected: {}", loc_str),
);
return Ok(());
}
}
}
let _ = resp.into_body().collect().await;
Ok(())
};
match timeout(timeout_dur, probe_future).await {
Ok(Ok(())) => {
let elapsed = start.elapsed().as_millis() as u64;
let mut metrics = tracker.get_metrics(elapsed);
if metrics.status == DpiStatus::Unknown {
metrics.status = DpiStatus::Ok;
}
metrics
}
Ok(Err(_)) => {
let elapsed = start.elapsed().as_millis() as u64;
tracker.get_metrics(elapsed)
}
Err(_) => {
let elapsed = start.elapsed().as_millis() as u64;
let lock = tracker.state.lock();
let (status, detail) = classify_tls_error(
lock.stage,
lock.bytes_sent,
lock.bytes_recv,
"timeout",
true,
);
drop(lock);
tracker.record_error(status, detail);
tracker.get_metrics(elapsed)
}
}
}
/// Probes a domain with the default insecure DPI TLS connector.
pub async fn probe_tls_domain_default(
domain: &str,
target_ip: IpAddr,
port: u16,
timeout_dur: Duration,
) -> ProbeMetrics {
let connector = RustlsConnector::new_insecure();
probe_tls_domain(domain, target_ip, port, timeout_dur, &connector).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_suspicious_redirect() {
assert!(is_suspicious_redirect("http://warning.rt.ru/blocked"));
assert!(is_suspicious_redirect("https://eais.rkn.gov.ru/"));
assert!(is_suspicious_redirect("http://block.mts.ru/index.html"));
assert!(!is_suspicious_redirect("https://example.com/login"));
assert!(!is_suspicious_redirect("/relative/path"));
}
}

View file

@ -83,9 +83,12 @@ config, profile, i18n — сбоку; бинарник — только пред
Аудит показал ~63 `pub`-элемента без единой ссылки вне своего файла. У библиотечного
крейта такие вещи не ловятся компилятором, поэтому чистка + защита от повторения:
* Удалить (или достроить и позвать из теста 2) `probe/tls.rs` — 196 строк,
`probe_tls_domain`, `probe_tls_domain_default`, `is_suspicious_redirect`;
сейчас живут только в реэкспорте `probe/mod.rs:12`.
* **Решено: удалить** `probe/tls.rs` — 196 строк, `probe_tls_domain`,
`probe_tls_domain_default`, `is_suspicious_redirect`; сейчас живут только в
реэкспорте `probe/mod.rs:12`. Знание не теряется: те же сигнатуры блок-страниц,
но разложенные по регионам, уже есть в `RegionProfile::blockpage_signatures`
(`profile/mod.rs:117-149`) — это и остаётся единственным домом для них, а плоский
регионо-независимый список из `probe/tls.rs:23-33` только дублировал его.
* Понизить до `pub(crate)` либо удалить: `net/version.rs:154` `version_badge`
(зовётся только из своего теста), 27 неиспользуемых реэкспортов `dns/mod.rs:10-17`
(живой через реэкспорт — один, `parse_socks_proxy`),
@ -167,8 +170,9 @@ config, profile, i18n — сбоку; бинарник — только пред
* Разрывает `net → dns` (`net/netinfo.rs:21`) и делает граф ацикличным.
* Заодно `TCP_NODELAY`: он есть в `net/tcp.rs:32`, `dns/doh.rs:79`, `dns/dot.rs:102`
и отсутствует в шести голых дозвонах — `netinfo.rs:149`, `telegram.rs:71,191,369`,
`probe/tls.rs:55`, `dns/socks.rs:110`. Либо ставить везде, либо перестать называть
его требованием в `net/tcp.rs:22`.
`probe/tls.rs:55`, `dns/socks.rs:110`. **Решено: ставить везде** — одним хелпером
(`net::tcp::set_no_delay`), чтобы правило из `net/tcp.rs:22` («это требование, а не
оптимизация») было верным для всех проб, а не для четырёх из десяти.
### P4.5 — Свернуть TLS-фабрики
@ -179,12 +183,23 @@ config, profile, i18n — сбоку; бинарник — только пред
`RustlsConnector::from(profile)`. Обязательные тесты: `grease_version_leads_supported_versions`,
`bundle_versions_match_their_ja3`, `bundle_versions_match_their_ja4`.
### P5 — `i18n` из ядра в бинарник (ждёт решения)
### P5 — `i18n` из ядра в бинарник (решено: выносим)
Плюс: ядро перестаёт возить тексты интерфейса, слоистость становится строгой.
Минус: разрывается связка «константы `DET_*` в ядре ↔ их переводы» (`i18n/details.rs`
импортирует `classify::*`), правятся правила проекта и README.
**Рекомендация:** делать после P2.5 и только если появится второй потребитель ядра.
Решение принято. Порядок обязателен: после **P1** (иначе ядру всё равно нужен `i18n`
для `net/version.rs`) и после **P2.5/P2.6** (чтобы прозу деталей не пришлось переносить
дважды): сначала `detail` становится типом с `code()`, потом тексты уезжают в
`dpi-detector/src/i18n/`.
* Что переезжает: `Messages` (4 блока), `legend_sections*()`, `details.rs`,
`fingerprint_label`, `fmt_speed`/`fmt_size`, `Messages::{config_warning, phase_text}`.
* Что остаётся в ядре: `PhaseId`/`ProgressBlock` (языконезависимые идентификаторы) и
константы деталей; `i18n` в бинарнике импортирует их — направление зависимости
становится односторонним.
* Вместе с переносом правятся правила проекта (§1 этого документа и README): точка
входа для новой строки — крейт бинарника, а не ядро.
* Проверка: тесты паритета четырёх блоков (`test_messages_coverage`,
`test_finglish_is_latin_only`, `i18n::details::tests`) переезжают вместе с модулем;
живой прогон меню и `--legend` на двух языках (`--lang en`, `--lang fa`).
### P6 — CI-гейт
@ -207,7 +222,7 @@ config, profile, i18n — сбоку; бинарник — только пред
кортеж компилируется молча. Ввести `struct TestKind`/битовый набор, потребителей —
`menu.rs`, `runner.rs`.
### P9 — Типизированный конфиг (опционально, продуктовое решение)
### P9 — Типизированный конфиг (отложено до конца рефакторинга)
1092 строки `config.rs` поверх `serde_yaml`: `sanitize_mapping` (`:426+`), `yaml_str`,
приведение типов, `ConfigWarning`. Слой существует, чтобы принимать «грязные»