40ddb0a0f6
Сервер получает флаги --require-auth/--backend-url (по умолчанию выключено, поведение уже развёрнутых нод не меняется). Auth-кадр хендшейка расширен с "session_id:leg_id" до "session_id:leg_id:token"; при включённом --require-auth сервер валидирует токен через новый AuthValidator (BackendClient к netrunner-backend, с кешем на 60с). Muxer теперь ведёт монотонный учёт трафика сессии (переживает реконнект ног — раньше total_bytes мог занижаться после переподключения) и раз в ~30с отчитывается бэкенду; при превышении лимита сессия разрывается. Токен клиента прокидывается через EngineConfig/TunnelConfig до Tauri-плагина (desktop.rs + Android Kotlin-плагин). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
577 lines
29 KiB
Rust
577 lines
29 KiB
Rust
//! Движок одной ноги туннеля: жизненный цикл TCP-соединения и его reader/writer.
|
|
//!
|
|
//! [`TunnelEngine`] владеет одним физическим TCP+TLS-соединением и крутит его в
|
|
//! [`run`](TunnelEngine::run), пока нога жива. Внутри одной итерации соединение
|
|
//! расщепляется на две параллельные задачи tokio:
|
|
//!
|
|
//! - **Reader** — читает байты из сокета, прогоняет через [`RxCodec`]
|
|
//! (расшифровка + сборка кадров), PONG'и инлайн обновляют RTT, остальные кадры
|
|
//! уходят в [`StreamHandler`].
|
|
//! - **Writer** — `biased`-`select!` по приоритету: heartbeat → control → data.
|
|
//! Данные режутся на interleave-чанки (адаптивно под RTT) и шифруются
|
|
//! [`TxCodec`] в [`handle_outbound`](TunnelEngine::handle_outbound); несколько
|
|
//! кадров коалесятся в один `write_all` (экономия syscalls).
|
|
//!
|
|
//! При обрыве (EOF/ошибка) задачи останавливаются, их состояние (кодеки,
|
|
//! приёмники, буфер) возвращается в `self`, и — если это клиент — нога идёт на
|
|
//! переподключение с экспоненциальным backoff+jitter. Сервер (`remote_addr`
|
|
//! пуст) при обрыве просто завершает задачу: реконнект инициирует клиент.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use bytes::{Bytes, BytesMut};
|
|
use netrunner_logger::{
|
|
error, info, AppError, ERR_INFRA_TIMEOUT, ERR_NET_TLS_TAMPER, ERR_SYS_PANIC,
|
|
};
|
|
use tokio::{
|
|
io::{AsyncReadExt, AsyncWriteExt},
|
|
net::tcp::{OwnedReadHalf, OwnedWriteHalf},
|
|
sync::mpsc::Receiver,
|
|
};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::instrument;
|
|
|
|
use crate::{
|
|
net::{
|
|
connection::{handler::StreamHandler, muxer::MuxMessage},
|
|
FALLBACK_CONNECT_TIMEOUT, HEALTH_CHECK_INTERVAL, MAX_INTERNAL_RECONNECT_ATTEMPTS,
|
|
MAX_RECONNECT_BACKOFF_MS, RECONNECT_BACKOFF_BASE, RECONNECT_BACKOFF_JITTER_MS,
|
|
TUNNEL_INTERLEAVE_CHUNK, TUNNEL_MAX_BUFFER_SIZE, TUNNEL_READ_RESERVE,
|
|
},
|
|
nrxp::{ErrorAction, FrameType, RxCodec, TxCodec, MAX_FRAME_PAYLOAD},
|
|
};
|
|
|
|
/// Состояние ноги: работает или в процессе переподключения.
|
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
|
pub enum LegStatus {
|
|
Active,
|
|
Reconnecting,
|
|
}
|
|
|
|
/// Состояние и ресурсы одной ноги туннеля.
|
|
///
|
|
/// Половинки сокета, кодеки, приёмники каналов и буфер чтения хранятся в
|
|
/// [`Option`], потому что на время работы reader/writer они «выдаются» в задачи
|
|
/// через `take()`, а по завершении итерации возвращаются обратно — это позволяет
|
|
/// переиспользовать кодеки (с их счётчиками nonce) между итерациями без Arc/Mutex.
|
|
pub(crate) struct TunnelEngine {
|
|
/// Читающая половина TCP-сокета (выдаётся reader-задаче).
|
|
pub inbound: Option<OwnedReadHalf>,
|
|
/// Пишущая половина TCP-сокета (выдаётся writer-задаче).
|
|
pub outbound: Option<OwnedWriteHalf>,
|
|
/// Адрес удалённой стороны; **пустой у сервера** (сервер не реконнектит).
|
|
pub remote_addr: String,
|
|
/// Идентификатор сессии (для логов и хендшейка реконнекта).
|
|
pub session_id: String,
|
|
/// Текущий статус ноги.
|
|
pub leg_status: LegStatus,
|
|
/// Кодек расшифровки входящего потока.
|
|
pub rx_codec: Option<RxCodec>,
|
|
/// Кодек шифрования исходящего потока.
|
|
pub tx_codec: Option<TxCodec>,
|
|
/// Накопительный буфер чтения из сокета.
|
|
pub read_buf: BytesMut,
|
|
/// Приёмник управляющих сообщений от muxer (Close/Heartbeat).
|
|
pub control_rx: Option<Receiver<MuxMessage>>,
|
|
/// Приёмник сообщений данных от muxer.
|
|
pub data_rx: Option<Receiver<MuxMessage>>,
|
|
/// Обработчик входящих кадров.
|
|
pub handler: Arc<StreamHandler>,
|
|
/// Идентификатор этой ноги.
|
|
pub leg_id: u32,
|
|
/// Общий мультиплексор туннеля.
|
|
pub muxer: Arc<crate::net::connection::muxer::Muxer>,
|
|
/// SNI поддельного `ClientHello` (атрибут, задаётся снаружи —
|
|
/// [`ClientHandler::connect`](crate::net::connection::ClientHandler::connect));
|
|
/// нужен для внутреннего реконнекта в [`attempt_reconnect`](Self::attempt_reconnect).
|
|
pub decoy_sni: Arc<str>,
|
|
/// Bearer-токен клиента (пусто — авторизация выключена/не залогинен),
|
|
/// нужен для того же внутреннего реконнекта, что и `decoy_sni` выше.
|
|
pub auth_token: Arc<str>,
|
|
}
|
|
|
|
impl TunnelEngine {
|
|
/// Переподключает ногу: заново резолвит хост (подхватывает смену IP/DNS),
|
|
/// создаёт TCP-сокет с тюнингом буферов и проводит хендшейк заново. Возвращает
|
|
/// свежие половинки сокета и кодеки.
|
|
///
|
|
/// Профиль браузера выбирается через [`BrowserProfile::for_session`] по
|
|
/// `self.session_id` — тот же стабильный отпечаток, что и при первичном
|
|
/// установлении ноги в
|
|
/// [`ClientHandler::establish_leg`](crate::net::connection::ClientHandler::establish_leg),
|
|
/// а не новый на каждую попытку реконнекта (см. doc на `for_session`).
|
|
pub async fn attempt_reconnect(
|
|
&mut self,
|
|
) -> Result<(OwnedReadHalf, OwnedWriteHalf, RxCodec, TxCodec), AppError> {
|
|
info!("🔄 Attempting reconnect to {}", self.remote_addr);
|
|
|
|
// Re-resolve the hostname each time so a server IP change or DNS
|
|
// failover is picked up automatically.
|
|
let mut addrs = tokio::net::lookup_host(&self.remote_addr)
|
|
.await
|
|
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "DNS при реконнекте", e.to_string()))?;
|
|
let addr = addrs.next().ok_or_else(|| {
|
|
AppError::new(ERR_INFRA_TIMEOUT, "Нет IP", "No IPs for reconnect addr")
|
|
})?;
|
|
|
|
let socket = (if addr.is_ipv4() {
|
|
tokio::net::TcpSocket::new_v4()
|
|
} else {
|
|
tokio::net::TcpSocket::new_v6()
|
|
})
|
|
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сокет", e.to_string()))?;
|
|
let _ = socket.set_send_buffer_size(crate::net::TUNNEL_SOCKET_SNDBUF);
|
|
let _ = socket.set_recv_buffer_size(crate::net::TUNNEL_SOCKET_RCVBUF);
|
|
|
|
let stream = tokio::time::timeout(FALLBACK_CONNECT_TIMEOUT, socket.connect(addr))
|
|
.await
|
|
.map_err(|_| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", "Reconnect timeout"))?
|
|
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?;
|
|
|
|
let profile = crate::tlseng::BrowserProfile::for_session(&self.session_id);
|
|
crate::net::ClientHandler::perform_handshake(
|
|
stream,
|
|
&self.session_id,
|
|
self.leg_id,
|
|
profile,
|
|
&self.decoy_sni,
|
|
&self.auth_token,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Главный цикл ноги: переподключение (при нужде) → запуск reader/writer →
|
|
/// ожидание завершения одной из задач → сбор состояния обратно → повтор.
|
|
///
|
|
/// Возвращает `Ok(())` при штатном завершении (например, сервер словил EOF);
|
|
/// `Err` — когда исчерпан внутренний лимит реконнектов
|
|
/// ([`MAX_INTERNAL_RECONNECT_ATTEMPTS`]) и управление надо вернуть внешнему
|
|
/// циклу `establish_leg` (он перерезолвит DNS и сбросит счётчики).
|
|
#[instrument(skip_all, fields(leg_id = self.leg_id))]
|
|
pub async fn run(mut self) -> Result<(), AppError> {
|
|
// Tracks consecutive internal reconnect failures. Resets to 0 on
|
|
// success. When it reaches MAX_INTERNAL_RECONNECT_ATTEMPTS the engine
|
|
// returns Err so the outer establish_leg loop gets control: it re-runs
|
|
// DNS, resets its own counters, and emits proper diagnostic events.
|
|
let mut internal_attempt: u32 = 0;
|
|
|
|
loop {
|
|
// Проверяем наличие всех необходимых ресурсов
|
|
if self.inbound.is_none()
|
|
|| self.outbound.is_none()
|
|
|| self.rx_codec.is_none()
|
|
|| self.tx_codec.is_none()
|
|
{
|
|
// 💡 ИСПРАВЛЕНИЕ 2: Если это Сервер (remote_addr пуст), он НЕ должен делать реконнект.
|
|
// Мертвая лега должна просто завершиться и удалиться из памяти.
|
|
if self.remote_addr.is_empty() {
|
|
info!(
|
|
"Server leg {} dropped, shutting down engine task",
|
|
self.leg_id
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
self.leg_status = LegStatus::Reconnecting;
|
|
match self.attempt_reconnect().await {
|
|
Ok((new_in, new_out, new_rx, new_tx)) => {
|
|
internal_attempt = 0; // successful reconnect — reset counter
|
|
|
|
let cap = crate::net::NetworkConfig::global().channel_capacity;
|
|
let (control_tx, control_rx) =
|
|
tokio::sync::mpsc::channel::<MuxMessage>(cap);
|
|
let (data_tx, data_rx) = tokio::sync::mpsc::channel::<MuxMessage>(cap);
|
|
self.muxer.add_leg(self.leg_id, control_tx, data_tx);
|
|
self.control_rx = Some(control_rx);
|
|
self.data_rx = Some(data_rx);
|
|
|
|
self.inbound = Some(new_in);
|
|
self.outbound = Some(new_out);
|
|
self.rx_codec = Some(new_rx);
|
|
self.tx_codec = Some(new_tx);
|
|
self.leg_status = LegStatus::Active;
|
|
info!("✅ Leg {} reconnected successfully", self.leg_id);
|
|
}
|
|
Err(e) => {
|
|
internal_attempt += 1;
|
|
|
|
// Emit a diagnostic event so the snapshot system (and
|
|
// operator dashboards) can see we're stuck, even though
|
|
// the outer establish_leg loop hasn't returned yet.
|
|
crate::net::diagnostics::send_diag_event(
|
|
crate::net::diagnostics::DiagnosticsEvent::LegReconnecting {
|
|
leg_id: self.leg_id,
|
|
attempt: internal_attempt,
|
|
},
|
|
);
|
|
|
|
if internal_attempt >= MAX_INTERNAL_RECONNECT_ATTEMPTS {
|
|
// Give up so the outer loop re-runs DNS, resets
|
|
// its state, and records the failure in counters.
|
|
error!(
|
|
"Leg {} giving up after {} consecutive reconnect failures — handing off to outer loop",
|
|
self.leg_id, internal_attempt
|
|
);
|
|
return Err(e);
|
|
}
|
|
|
|
error!(
|
|
"Reconnect failed for leg {} (attempt {}/{}): {}",
|
|
self.leg_id, internal_attempt, MAX_INTERNAL_RECONNECT_ATTEMPTS, e
|
|
);
|
|
|
|
// Exponential back-off: 2 s, 4 s, 8 s, 16 s, 30 s (cap).
|
|
// The shift is capped at 4 to avoid overflow (2^4 = 16).
|
|
let exp_ms = RECONNECT_BACKOFF_BASE.as_millis() as u64
|
|
* (1u64 << internal_attempt.saturating_sub(1).min(4));
|
|
let jitter = rand::random::<u64>() % RECONNECT_BACKOFF_JITTER_MS;
|
|
let backoff_ms = (exp_ms + jitter).min(MAX_RECONNECT_BACKOFF_MS);
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(backoff_ms)).await;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
let inbound = self.inbound.take().unwrap();
|
|
let outbound = self.outbound.take().unwrap();
|
|
let read_buf = std::mem::take(&mut self.read_buf);
|
|
|
|
let mut rx_codec = self.rx_codec.take().unwrap();
|
|
let mut tx_codec = self.tx_codec.take().unwrap();
|
|
let mut control_rx = self.control_rx.take().expect("control_rx is missing");
|
|
let mut data_rx = self.data_rx.take().expect("data_rx is missing");
|
|
|
|
let handler = self.handler.clone();
|
|
let leg_id = self.leg_id;
|
|
let muxer = self.muxer.clone();
|
|
let muxer_pong = self.muxer.clone();
|
|
|
|
let token = CancellationToken::new();
|
|
let token_reader = token.clone();
|
|
let token_writer = token.clone();
|
|
|
|
// ЧИТАЮЩАЯ ЗАДАЧА (Остается без изменений)
|
|
let mut reader_handle = tokio::spawn(async move {
|
|
let mut read_buf = read_buf;
|
|
let mut inbound = inbound;
|
|
loop {
|
|
if read_buf.len() > TUNNEL_MAX_BUFFER_SIZE {
|
|
error!("CRITICAL: Read buffer exceeded 1MB (OOM Protection). Dropping connection!");
|
|
return Err(AppError::new(
|
|
ERR_INFRA_TIMEOUT,
|
|
"Переполнение буфера",
|
|
"OOM Protection",
|
|
));
|
|
}
|
|
|
|
if read_buf.is_empty() {
|
|
read_buf.clear();
|
|
}
|
|
read_buf.reserve(TUNNEL_READ_RESERVE);
|
|
|
|
tokio::select! {
|
|
_ = token_reader.cancelled() => {
|
|
info!("Reader Task: Shutdown signal received.");
|
|
break;
|
|
}
|
|
res = inbound.read_buf(&mut read_buf) => {
|
|
let n = res.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?;
|
|
if n == 0 {
|
|
info!("Connection closed by peer (Clean EOF)");
|
|
return Ok::<_, AppError>((true, read_buf, rx_codec));
|
|
}
|
|
|
|
muxer.record_leg_rx(leg_id, n as u64);
|
|
let mut frames = Vec::new();
|
|
|
|
loop {
|
|
match rx_codec.decode_inbound(&mut read_buf) {
|
|
Ok(Some(frame)) => frames.push(frame),
|
|
Ok(None) => break,
|
|
Err(e) => {
|
|
if e.action == ErrorAction::Wait { break; }
|
|
if e.action == ErrorAction::Drop {
|
|
return Err(AppError::new(ERR_NET_TLS_TAMPER, "Ошибка шифрования", "Crypto drop"));
|
|
}
|
|
return Err(AppError::new(ERR_NET_TLS_TAMPER, "Сбой кодека", format!("{:?}", e)));
|
|
}
|
|
}
|
|
}
|
|
|
|
for frame in frames {
|
|
if frame.header.frame_type == FrameType::Heartbeat {
|
|
// record_pong does no .await internally, so run it inline:
|
|
// a spawn+Arc-clone per PONG was pure scheduler churn.
|
|
muxer.record_pong(leg_id).await;
|
|
}
|
|
let _ = handler.handle(frame).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok::<_, AppError>((false, read_buf, rx_codec))
|
|
});
|
|
|
|
// ПИШУЩАЯ ЗАДАЧА
|
|
let mut writer_handle = tokio::spawn(async move {
|
|
let mut outbound = outbound;
|
|
let mut heartbeat = tokio::time::interval(HEALTH_CHECK_INTERVAL);
|
|
|
|
let mut pending_data: Option<MuxMessage> = None;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
biased;
|
|
|
|
_ = token_writer.cancelled() => break,
|
|
|
|
_ = heartbeat.tick() => {
|
|
muxer_pong.record_ping_sent(leg_id);
|
|
let msg = MuxMessage { stream_id: 0, frame_type: FrameType::Heartbeat, data: Bytes::new() };
|
|
if let Err(e) = Self::handle_outbound(&mut outbound, &mut tx_codec, msg).await {
|
|
crate::net::diagnostics::send_diag_event(
|
|
crate::net::diagnostics::DiagnosticsEvent::TunnelWriteStuck {
|
|
leg_id, stream_id: 0,
|
|
},
|
|
);
|
|
return Err((e, control_rx, data_rx, tx_codec));
|
|
}
|
|
}
|
|
|
|
msg_opt = control_rx.recv() => {
|
|
if let Some(msg) = msg_opt {
|
|
let sid = msg.stream_id;
|
|
if let Err(e) = Self::handle_outbound(&mut outbound, &mut tx_codec, msg).await {
|
|
crate::net::diagnostics::send_diag_event(
|
|
crate::net::diagnostics::DiagnosticsEvent::TunnelWriteStuck {
|
|
leg_id, stream_id: sid,
|
|
},
|
|
);
|
|
return Err((e, control_rx, data_rx, tx_codec));
|
|
}
|
|
} else { break; }
|
|
}
|
|
|
|
// 💡 ИСПРАВЛЕНИЕ 1: Мгновенно заходим в эту ветку, если есть данные
|
|
_ = std::future::ready(()), if pending_data.is_some() => {
|
|
let mut msg = pending_data.take().unwrap();
|
|
|
|
// #4 Adaptive batch: under high RTT take a bigger interleave
|
|
// chunk so more frames coalesce into one write in
|
|
// handle_outbound (#3); at low RTT stay small for fairness.
|
|
let interleave_chunk = crate::net::connection::muxer::adaptive_batch_chunk(
|
|
TUNNEL_INTERLEAVE_CHUNK,
|
|
);
|
|
let chunk_size = std::cmp::min(msg.data.len(), interleave_chunk);
|
|
let chunk_data = msg.data.split_to(chunk_size);
|
|
|
|
let chunk_msg = MuxMessage {
|
|
stream_id: msg.stream_id,
|
|
frame_type: msg.frame_type.clone(),
|
|
data: chunk_data,
|
|
};
|
|
let chunk_sid = chunk_msg.stream_id;
|
|
|
|
if let Err(e) = Self::handle_outbound(&mut outbound, &mut tx_codec, chunk_msg).await {
|
|
crate::net::diagnostics::send_diag_event(
|
|
crate::net::diagnostics::DiagnosticsEvent::TunnelWriteStuck {
|
|
leg_id, stream_id: chunk_sid,
|
|
},
|
|
);
|
|
return Err((e, control_rx, data_rx, tx_codec));
|
|
}
|
|
|
|
if !msg.data.is_empty() {
|
|
pending_data = Some(msg);
|
|
}
|
|
|
|
// 💡 ИСПРАВЛЕНИЕ 1.2: Вызываем yield ЗДЕСЬ. Это заставит планировщик
|
|
// проверить пинги и контрольные пакеты перед отправкой следующего куска.
|
|
tokio::task::yield_now().await;
|
|
}
|
|
|
|
msg_opt = data_rx.recv(), if pending_data.is_none() => {
|
|
if let Some(msg) = msg_opt {
|
|
pending_data = Some(msg);
|
|
} else { break; }
|
|
}
|
|
}
|
|
}
|
|
Ok::<
|
|
_,
|
|
(
|
|
AppError,
|
|
Receiver<MuxMessage>,
|
|
Receiver<MuxMessage>,
|
|
TxCodec,
|
|
),
|
|
>((control_rx, data_rx, tx_codec))
|
|
});
|
|
|
|
let res: Result<(), AppError> = tokio::select! {
|
|
res_reader = &mut reader_handle => {
|
|
match res_reader {
|
|
Ok(Ok((is_eof, r_buf, returned_rx_codec))) => {
|
|
self.read_buf = r_buf;
|
|
self.rx_codec = Some(returned_rx_codec);
|
|
if is_eof {
|
|
token.cancel();
|
|
let w_res = writer_handle.await.unwrap();
|
|
let (c_rx, d_rx, returned_tx_codec) = match w_res {
|
|
Ok((c, d, t)) => (c, d, t),
|
|
Err((_, c, d, t)) => (c, d, t),
|
|
};
|
|
self.control_rx = Some(c_rx);
|
|
self.data_rx = Some(d_rx);
|
|
self.tx_codec = Some(returned_tx_codec);
|
|
|
|
self.inbound = None;
|
|
self.outbound = None;
|
|
continue;
|
|
}
|
|
Ok(())
|
|
},
|
|
Ok(Err(e)) => Err(e),
|
|
Err(e) => Err(AppError::new(ERR_SYS_PANIC, "Сбой", format!("Reader panic: {}", e))),
|
|
}
|
|
},
|
|
res_writer = &mut writer_handle => {
|
|
match res_writer {
|
|
Ok(Ok((c_rx, d_rx, returned_tx_codec))) => {
|
|
self.control_rx = Some(c_rx);
|
|
self.data_rx = Some(d_rx);
|
|
self.tx_codec = Some(returned_tx_codec);
|
|
Ok(())
|
|
}
|
|
Ok(Err((e, c_rx, d_rx, returned_tx_codec))) => {
|
|
self.control_rx = Some(c_rx);
|
|
self.data_rx = Some(d_rx);
|
|
self.tx_codec = Some(returned_tx_codec);
|
|
Err(e)
|
|
}
|
|
Err(e) => Err(AppError::new(ERR_SYS_PANIC, "Сбой", format!("Writer panic: {}", e))),
|
|
}
|
|
}
|
|
};
|
|
|
|
token.cancel();
|
|
reader_handle.abort();
|
|
writer_handle.abort();
|
|
|
|
if let Err(e) = res {
|
|
error!("TunnelEngine critical failure: {}", e);
|
|
return Err(e);
|
|
}
|
|
|
|
// 💡 ИСПРАВЛЕНИЕ 2.2: И здесь тоже, если сервер словил EOF, он не должен идти на реконнект.
|
|
if self.remote_addr.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
info!("Tunnel iteration finished, preparing to reconnect...");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
/// Шифрует сообщение в один или несколько кадров и пишет их в сокет.
|
|
///
|
|
/// `Data` режется на кадры по [`MAX_FRAME_PAYLOAD`]; управляющие/UDP идут одним
|
|
/// кадром. Срабатывает адаптивный по RTT дедлайн записи
|
|
/// ([`adaptive_write_timeout`](super::muxer::adaptive_write_timeout)) — чтобы
|
|
/// медленная, но живая нога не убивалась по жёсткому тайм-ауту. Несколько
|
|
/// кадров коалесятся в один `write_all` (аналог sendmmsg для байт-потока:
|
|
/// меньше syscalls); одиночный кадр пишется напрямую без лишней копии.
|
|
async fn handle_outbound(
|
|
outbound: &mut OwnedWriteHalf,
|
|
tx_codec: &mut TxCodec,
|
|
msg: MuxMessage,
|
|
) -> Result<(), AppError> {
|
|
let mut data = msg.data;
|
|
let stream_id = msg.stream_id;
|
|
let frame_type = msg.frame_type;
|
|
let mut packets = Vec::new();
|
|
|
|
if frame_type == FrameType::Data {
|
|
while !data.is_empty() {
|
|
let chunk_size = std::cmp::min(data.len(), MAX_FRAME_PAYLOAD);
|
|
let chunk = data.split_to(chunk_size);
|
|
|
|
match tx_codec.encode_frame(stream_id, frame_type.clone(), chunk) {
|
|
Ok(pkt) => packets.push(pkt),
|
|
Err(e) => {
|
|
error!(stream_id, error = ?e, "Encryption failed for TCP chunk");
|
|
return Err(AppError::new(
|
|
ERR_NET_TLS_TAMPER,
|
|
"Ошибка шифрования пакета",
|
|
format!("Encryption error: {:?}", e),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
match tx_codec.encode_frame(stream_id, frame_type.clone(), data) {
|
|
Ok(pkt) => packets.push(pkt),
|
|
Err(e) => {
|
|
error!(stream_id, error = ?e, "Encryption failed for control/udp frame");
|
|
return Err(AppError::new(
|
|
ERR_NET_TLS_TAMPER,
|
|
"Ошибка шифрования пакета",
|
|
format!("Encryption error: {:?}", e),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Adaptive write deadline: floor of 20 s (BBR-friendly), but scales with
|
|
// the live RTT so a high-latency path (RTT > 2.5 s) doesn't trip a flat
|
|
// timeout on a leg that is slow rather than dead. Killing such a leg is
|
|
// what set off the leg-drop → stream-close cascade.
|
|
let write_timeout = crate::net::connection::muxer::adaptive_write_timeout(
|
|
std::time::Duration::from_secs(20),
|
|
);
|
|
// #3 Syscall batching (sendmmsg-analog for a TCP byte stream): when a Data
|
|
// message produced several MAX_FRAME_PAYLOAD frames, coalesce them into ONE
|
|
// contiguous buffer and issue a single write_all instead of N — fewer
|
|
// User→Kernel transitions under exactly the high-throughput conditions that
|
|
// were producing tunnel_write_stuck. The single-frame case (control/UDP and
|
|
// ≤16 KB payloads) keeps the zero-copy direct write with no extra copy.
|
|
let stuck = || -> AppError {
|
|
error!(stream_id, "🔥 Physical leg STUCK on write. Killing leg.");
|
|
// Increment counter; the call site in run() emits the full event
|
|
// with the correct leg_id since handle_outbound is a static fn.
|
|
crate::net::diagnostics::DIAG_COUNTERS
|
|
.tunnel_write_stalls
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
AppError::new(
|
|
ERR_INFRA_TIMEOUT,
|
|
"Таймаут отправки",
|
|
"Physical leg STUCK on write",
|
|
)
|
|
};
|
|
|
|
if packets.len() == 1 {
|
|
let write_future = outbound.write_all(&packets[0]);
|
|
if tokio::time::timeout(write_timeout, write_future)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Err(stuck());
|
|
}
|
|
} else if !packets.is_empty() {
|
|
let total: usize = packets.iter().map(|p| p.len()).sum();
|
|
let mut batch = BytesMut::with_capacity(total);
|
|
for pkt in &packets {
|
|
batch.extend_from_slice(pkt);
|
|
}
|
|
let write_future = outbound.write_all(&batch);
|
|
if tokio::time::timeout(write_timeout, write_future)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Err(stuck());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|