Merge pull request #871 from xaer981/fix/client-mss-bulk

fix(server): client_mss_bulk — fragment only ServerHello, keep bulk MSS from start
This commit is contained in:
Alexey
2026-07-18 14:02:03 +03:00
committed by GitHub
8 changed files with 460 additions and 98 deletions
+58 -59
View File
@@ -109,7 +109,8 @@ use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_pro
use crate::proxy::direct_relay::handle_via_direct_with_shared;
use crate::proxy::handshake::{
HandshakeSuccess, handle_mtproto_handshake_with_shared, handle_tls_handshake_with_shared,
HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared,
handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options,
};
#[cfg(test)]
use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake};
@@ -989,6 +990,7 @@ pub struct RunningClientHandler {
#[cfg(unix)]
raw_fd: std::os::unix::io::RawFd,
rst_on_close: crate::config::RstOnCloseMode,
tls_response_fragment_size: Option<u16>,
}
impl ClientHandler {
@@ -1036,6 +1038,7 @@ impl ClientHandler {
#[cfg(unix)]
raw_fd,
crate::config::RstOnCloseMode::Off,
None,
)
}
@@ -1060,6 +1063,7 @@ impl ClientHandler {
real_peer_report: Arc<std::sync::Mutex<Option<SocketAddr>>>,
#[cfg(unix)] raw_fd: std::os::unix::io::RawFd,
rst_on_close: crate::config::RstOnCloseMode,
tls_response_fragment_size: Option<u16>,
) -> RunningClientHandler {
let normalized_peer = normalize_ip(peer);
RunningClientHandler {
@@ -1084,6 +1088,7 @@ impl ClientHandler {
#[cfg(unix)]
raw_fd,
rst_on_close,
tls_response_fragment_size,
}
}
}
@@ -1105,12 +1110,6 @@ impl RunningClientHandler {
#[cfg(unix)]
let raw_fd = self.raw_fd;
let rst_on_close = self.rst_on_close;
// MSS for the bulk data phase: once the handshake (incl. ServerHello) is
// sent, restore a normal MSS so only the handshake stays fragmented by the
// low listener `client_mss`. Cuts pps ~10x (anti-DDoS abuse on pps-policing
// hosts like FastVPS). None = keep handshake MSS for the whole connection.
#[cfg(unix)]
let bulk_mss: Option<u16> = self.config.server.client_mss_bulk_value().ok().flatten();
let outcome = match self.do_handshake().await? {
Some(outcome) => outcome,
@@ -1124,14 +1123,6 @@ impl RunningClientHandler {
if matches!(rst_on_close, crate::config::RstOnCloseMode::Errors) {
let _ = crate::transport::socket::clear_linger_fd(raw_fd);
}
// Handshake (ServerHello) done — raise MSS for bulk transfer.
#[cfg(unix)]
if let Some(mss) = bulk_mss {
if let Err(e) = crate::transport::socket::set_tcp_mss_fd(raw_fd, u32::from(mss))
{
debug!(error = %e, "Failed to raise bulk MSS; keeping handshake MSS");
}
}
fut.await
}
HandshakeOutcome::NeedsMasking(fut) => fut.await,
@@ -1412,50 +1403,58 @@ impl RunningClientHandler {
let (read_half, write_half) = self.stream.into_split();
let (mut tls_reader, tls_writer, tls_user) = match handle_tls_handshake_with_shared(
&handshake,
read_half,
write_half,
peer,
&config,
&replay_checker,
&self.rng,
self.tls_cache.clone(),
self.shared.as_ref(),
)
.await
{
HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
return Ok(masking_outcome(
reader,
writer,
handshake.clone(),
peer,
local_addr,
config.clone(),
self.beobachten.clone(),
self.shared.clone(),
));
}
HandshakeResult::Error(e) => {
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
return Err(e);
}
};
#[cfg(target_os = "linux")]
let response_write_options =
TlsResponseWriteOptions::tcp(self.raw_fd, self.tls_response_fragment_size);
#[cfg(not(target_os = "linux"))]
let response_write_options = TlsResponseWriteOptions::default();
let (mut tls_reader, tls_writer, tls_user) =
match handle_tls_handshake_with_shared_and_options(
&handshake,
read_half,
write_half,
peer,
&config,
&replay_checker,
&self.rng,
self.tls_cache.clone(),
self.shared.as_ref(),
response_write_options,
)
.await
{
HandshakeResult::Success(result) => result,
HandshakeResult::BadClient { reader, writer } => {
stats.increment_connects_bad_with_class("tls_handshake_bad_client");
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
return Ok(masking_outcome(
reader,
writer,
handshake.clone(),
peer,
local_addr,
config.clone(),
self.beobachten.clone(),
self.shared.clone(),
));
}
HandshakeResult::Error(e) => {
record_tls_fingerprint_bad_or_probe(
stats.as_ref(),
&config,
peer.ip(),
tls_fingerprint.as_ref(),
);
increment_bad_on_unknown_tls_sni(stats.as_ref(), &e);
return Err(e);
}
};
record_tls_fingerprint_auth_success(
stats.as_ref(),
&config,
+76 -6
View File
@@ -59,6 +59,26 @@ const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8;
const EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD: usize = 64;
const RECENT_USER_RING_SCAN_LIMIT: usize = 32;
/// Controls how the authenticated FakeTLS response is written to a client.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct TlsResponseWriteOptions {
#[cfg(target_os = "linux")]
socket_fd: Option<std::os::unix::io::RawFd>,
#[cfg(target_os = "linux")]
fragment_size: Option<u16>,
}
impl TlsResponseWriteOptions {
/// Creates Linux TCP response fragmentation options for an accepted socket.
#[cfg(target_os = "linux")]
pub(crate) fn tcp(fd: std::os::unix::io::RawFd, fragment_size: Option<u16>) -> Self {
Self {
socket_fd: fragment_size.map(|_| fd),
fragment_size,
}
}
}
#[cfg(test)]
const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1;
#[cfg(not(test))]
@@ -925,6 +945,7 @@ where
rng,
tls_cache,
shared.as_ref(),
TlsResponseWriteOptions::default(),
)
.await
}
@@ -954,6 +975,39 @@ where
rng,
tls_cache,
shared,
TlsResponseWriteOptions::default(),
)
.await
}
/// Handles FakeTLS with optional initial-response fragmentation on a TCP socket.
pub(crate) async fn handle_tls_handshake_with_shared_and_options<R, W>(
handshake: &[u8],
reader: R,
writer: W,
peer: SocketAddr,
config: &ProxyConfig,
replay_checker: &ReplayChecker,
rng: &SecureRandom,
tls_cache: Option<Arc<TlsFrontCache>>,
shared: &ProxySharedState,
response_write_options: TlsResponseWriteOptions,
) -> HandshakeResult<(FakeTlsReader<R>, FakeTlsWriter<W>, String), R, W>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
handle_tls_handshake_impl(
handshake,
reader,
writer,
peer,
config,
replay_checker,
rng,
tls_cache,
shared,
response_write_options,
)
.await
}
@@ -968,6 +1022,7 @@ async fn handle_tls_handshake_impl<R, W>(
rng: &SecureRandom,
tls_cache: Option<Arc<TlsFrontCache>>,
shared: &ProxySharedState,
response_write_options: TlsResponseWriteOptions,
) -> HandshakeResult<(FakeTlsReader<R>, FakeTlsWriter<W>, String), R, W>
where
R: AsyncRead + Unpin,
@@ -1468,16 +1523,11 @@ where
debug!(peer = %peer, response_len = response.len(), "Sending TLS ServerHello");
if let Err(e) = writer.write_all(&response).await {
if let Err(e) = write_tls_response(&mut writer, &response, response_write_options).await {
warn!(peer = %peer, error = %e, "Failed to write TLS ServerHello");
return HandshakeResult::Error(ProxyError::Io(e));
}
if let Err(e) = writer.flush().await {
warn!(peer = %peer, error = %e, "Failed to flush TLS ServerHello");
return HandshakeResult::Error(ProxyError::Io(e));
}
debug!(
peer = %peer,
user = %validated_user,
@@ -1498,6 +1548,26 @@ where
))
}
async fn write_tls_response<W: AsyncWrite + Unpin>(
writer: &mut W,
response: &[u8],
options: TlsResponseWriteOptions,
) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
if let (Some(fd), Some(fragment_size)) = (options.socket_fd, options.fragment_size) {
return crate::transport::socket::send_tcp_fragmented_fd(
fd,
response,
usize::from(fragment_size),
)
.await;
}
let _ = options;
writer.write_all(response).await?;
writer.flush().await
}
/// Handle MTProto obfuscation handshake
#[cfg(test)]
pub async fn handle_mtproto_handshake<R, W>(
+92
View File
@@ -1940,6 +1940,98 @@ async fn fragmented_tls_mtproto_with_interleaved_ccs_is_accepted() {
assert_eq!(success.dc_idx, 2);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn chunked_tls_response_preserves_complete_records_and_configured_mss() {
use crate::proxy::handshake::{
TlsResponseWriteOptions, handle_tls_handshake_with_shared_and_options,
};
use crate::proxy::shared_state::ProxySharedState;
use crate::transport::socket::{ListenOptions, create_listener};
use std::os::fd::{AsRawFd, BorrowedFd};
let options = ListenOptions {
reuse_port: false,
client_mss: Some(1400),
..Default::default()
};
let socket = create_listener("127.0.0.1:0".parse().unwrap(), &options).unwrap();
let listener = TcpListener::from_std(socket.into()).unwrap();
let listener_addr = listener.local_addr().unwrap();
let mut client = TcpStream::connect(listener_addr).await.unwrap();
let (server, peer) = listener.accept().await.unwrap();
let secret_hex = "56565656565656565656565656565656";
let secret = [0x56u8; 16];
let client_hello = make_valid_tls_client_hello(&secret, 0);
let mut config = ProxyConfig::default();
config.general.beobachten = false;
config.access.ignore_time_skew = true;
config
.access
.users
.insert("user".to_string(), secret_hex.to_string());
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
let rng = SecureRandom::new();
let shared = ProxySharedState::new();
let raw_fd = server.as_raw_fd();
let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap();
let (read_half, write_half) = server.into_split();
let (tls_reader, tls_writer, tls_user) = match handle_tls_handshake_with_shared_and_options(
&client_hello,
read_half,
write_half,
peer,
&config,
&replay_checker,
&rng,
None,
&shared,
TlsResponseWriteOptions::tcp(raw_fd, Some(92)),
)
.await
{
HandshakeResult::Success(result) => result,
_ => panic!("expected successful TLS handshake"),
};
// SAFETY: both split halves still own the accepted socket while it is borrowed.
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(raw_fd) };
let mss_after = socket2::SockRef::from(&borrowed_fd).tcp_mss().unwrap();
assert_eq!(
mss_after, mss_before,
"chunked response writes must not change the configured socket MSS"
);
assert_eq!(tls_user, "user");
drop(tls_reader);
let mut writer = tls_writer.into_inner();
writer.shutdown().await.unwrap();
let mut response = Vec::new();
client.read_to_end(&mut response).await.unwrap();
assert!(!response.is_empty());
let mut offset = 0;
while offset < response.len() {
assert!(response.len() - offset >= 5, "truncated TLS record header");
let payload_len = u16::from_be_bytes([response[offset + 3], response[offset + 4]]) as usize;
offset += 5;
assert!(
response.len() - offset >= payload_len,
"truncated TLS record payload"
);
offset += payload_len;
}
assert_eq!(
offset,
response.len(),
"chunked response writes must preserve complete TLS records"
);
}
#[tokio::test]
async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();