mirror of
https://github.com/telemt/telemt.git
synced 2026-06-24 03:41:10 +03:00
Format
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
use super::*;
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::stats::Stats;
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::error::ProxyError;
|
||||
use crate::ip_tracker::UserIpTracker;
|
||||
use crate::stats::Stats;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Priority 3: Massive Concurrency Stress (OWASP ASVS 5.1.6)
|
||||
@@ -15,13 +15,16 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
async fn client_stress_10k_connections_limit_strict() {
|
||||
let user = "stress-user";
|
||||
let limit = 512;
|
||||
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), limit);
|
||||
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), limit);
|
||||
|
||||
let iterations = 1000;
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
@@ -30,20 +33,18 @@ async fn client_stress_10k_connections_limit_strict() {
|
||||
let ip_tracker = Arc::clone(&ip_tracker);
|
||||
let config = config.clone();
|
||||
let user_str = user.to_string();
|
||||
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let peer = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(127, 0, 0, (i % 254 + 1) as u8)),
|
||||
10000 + (i % 1000) as u16,
|
||||
);
|
||||
|
||||
|
||||
match RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
&user_str,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
).await {
|
||||
&user_str, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => Ok(res),
|
||||
Err(ProxyError::ConnectionLimitExceeded { .. }) => Err(()),
|
||||
Err(e) => panic!("Unexpected error: {:?}", e),
|
||||
@@ -67,15 +68,27 @@ async fn client_stress_10k_connections_limit_strict() {
|
||||
}
|
||||
|
||||
assert_eq!(successes, limit, "Should allow exactly 'limit' connections");
|
||||
assert_eq!(failures, iterations - limit, "Should fail the rest with LimitExceeded");
|
||||
assert_eq!(
|
||||
failures,
|
||||
iterations - limit,
|
||||
"Should fail the rest with LimitExceeded"
|
||||
);
|
||||
assert_eq!(stats.get_user_curr_connects(user), limit as u64);
|
||||
|
||||
drop(reservations);
|
||||
|
||||
|
||||
ip_tracker.drain_cleanup_queue().await;
|
||||
|
||||
assert_eq!(stats.get_user_curr_connects(user), 0, "Stats must converge to 0 after all drops");
|
||||
assert_eq!(ip_tracker.get_active_ip_count(user).await, 0, "IP tracker must converge to 0");
|
||||
|
||||
assert_eq!(
|
||||
stats.get_user_curr_connects(user),
|
||||
0,
|
||||
"Stats must converge to 0 after all drops"
|
||||
);
|
||||
assert_eq!(
|
||||
ip_tracker.get_active_ip_count(user).await,
|
||||
0,
|
||||
"IP tracker must converge to 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -87,14 +100,14 @@ async fn client_ip_tracker_race_condition_stress() {
|
||||
let user = "race-user";
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
ip_tracker.set_user_limit(user, 100).await;
|
||||
|
||||
|
||||
let iterations = 1000;
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for i in 0..iterations {
|
||||
let ip_tracker = Arc::clone(&ip_tracker);
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, (i % 254 + 1) as u8));
|
||||
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
for _ in 0..10 {
|
||||
if let Ok(()) = ip_tracker.check_and_add("race-user", ip).await {
|
||||
@@ -105,8 +118,12 @@ async fn client_ip_tracker_race_condition_stress() {
|
||||
}
|
||||
|
||||
futures::future::join_all(tasks).await;
|
||||
|
||||
assert_eq!(ip_tracker.get_active_ip_count(user).await, 0, "IP count must be zero after balanced add/remove burst");
|
||||
|
||||
assert_eq!(
|
||||
ip_tracker.get_active_ip_count(user).await,
|
||||
0,
|
||||
"IP count must be zero after balanced add/remove burst"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -119,7 +136,10 @@ async fn client_limit_burst_peak_never_exceeds_cap() {
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), limit);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), limit);
|
||||
|
||||
let peak = Arc::new(AtomicU64::new(0));
|
||||
let mut tasks = Vec::with_capacity(attempts);
|
||||
@@ -207,10 +227,10 @@ async fn client_expiration_rejection_never_mutates_live_counters() {
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_expirations
|
||||
.insert(user.to_string(), chrono::Utc::now() - chrono::Duration::seconds(1));
|
||||
config.access.user_expirations.insert(
|
||||
user.to_string(),
|
||||
chrono::Utc::now() - chrono::Duration::seconds(1),
|
||||
);
|
||||
|
||||
let peer: SocketAddr = "198.51.100.202:31112".parse().unwrap();
|
||||
let res = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
@@ -235,7 +255,10 @@ async fn client_ip_limit_failure_rolls_back_counter_exactly() {
|
||||
ip_tracker.set_user_limit(user, 1).await;
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 16);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 16);
|
||||
|
||||
let first_peer: SocketAddr = "198.51.100.203:31113".parse().unwrap();
|
||||
let first = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
@@ -258,7 +281,10 @@ async fn client_ip_limit_failure_rolls_back_counter_exactly() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(second, Err(ProxyError::ConnectionLimitExceeded { .. })));
|
||||
assert!(matches!(
|
||||
second,
|
||||
Err(ProxyError::ConnectionLimitExceeded { .. })
|
||||
));
|
||||
assert_eq!(stats.get_user_curr_connects(user), 1);
|
||||
|
||||
drop(first);
|
||||
@@ -276,7 +302,10 @@ async fn client_parallel_limit_checks_success_path_leaves_no_residue() {
|
||||
ip_tracker.set_user_limit(user, 128).await;
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 128);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 128);
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..128u16 {
|
||||
@@ -310,7 +339,10 @@ async fn client_parallel_limit_checks_failure_path_leaves_no_residue() {
|
||||
ip_tracker.set_user_limit(user, 0).await;
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 512);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 512);
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..64u16 {
|
||||
@@ -319,7 +351,10 @@ async fn client_parallel_limit_checks_failure_path_leaves_no_residue() {
|
||||
let config = config.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(172, 16, 0, (i % 250 + 1) as u8)), 33000 + i);
|
||||
let peer = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(172, 16, 0, (i % 250 + 1) as u8)),
|
||||
33000 + i,
|
||||
);
|
||||
RunningClientHandler::check_user_limits_static(user, &config, &stats, peer, &ip_tracker)
|
||||
.await
|
||||
}));
|
||||
@@ -360,11 +395,7 @@ async fn client_churn_mixed_success_failure_converges_to_zero_state() {
|
||||
34000 + (i % 32),
|
||||
);
|
||||
let maybe_res = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -401,11 +432,7 @@ async fn client_same_ip_parallel_attempts_allow_at_most_one_when_limit_is_one()
|
||||
let config = config.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
@@ -424,7 +451,10 @@ async fn client_same_ip_parallel_attempts_allow_at_most_one_when_limit_is_one()
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(granted, 1, "only one reservation may be granted for same IP with limit=1");
|
||||
assert_eq!(
|
||||
granted, 1,
|
||||
"only one reservation may be granted for same IP with limit=1"
|
||||
);
|
||||
drop(reservations);
|
||||
ip_tracker.drain_cleanup_queue().await;
|
||||
assert_eq!(stats.get_user_curr_connects(user), 0);
|
||||
@@ -439,7 +469,10 @@ async fn client_repeat_acquire_release_cycles_never_accumulate_state() {
|
||||
ip_tracker.set_user_limit(user, 32).await;
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 32);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 32);
|
||||
|
||||
for i in 0..500u16 {
|
||||
let peer = SocketAddr::new(
|
||||
@@ -484,11 +517,7 @@ async fn client_multi_user_isolation_under_parallel_limit_exhaustion() {
|
||||
37000 + i,
|
||||
);
|
||||
RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
@@ -497,7 +526,11 @@ async fn client_multi_user_isolation_under_parallel_limit_exhaustion() {
|
||||
let mut u1_success = 0usize;
|
||||
let mut u2_success = 0usize;
|
||||
let mut reservations = Vec::new();
|
||||
for (idx, result) in futures::future::join_all(tasks).await.into_iter().enumerate() {
|
||||
for (idx, result) in futures::future::join_all(tasks)
|
||||
.await
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let user = if idx % 2 == 0 { "u1" } else { "u2" };
|
||||
match result.unwrap() {
|
||||
Ok(reservation) => {
|
||||
@@ -556,7 +589,10 @@ async fn client_limit_recovery_after_full_rejection_wave() {
|
||||
ip_tracker.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(denied, Err(ProxyError::ConnectionLimitExceeded { .. })));
|
||||
assert!(matches!(
|
||||
denied,
|
||||
Err(ProxyError::ConnectionLimitExceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
drop(reservation);
|
||||
@@ -572,7 +608,10 @@ async fn client_limit_recovery_after_full_rejection_wave() {
|
||||
ip_tracker.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(recovered.is_ok(), "capacity must recover after prior holder drops");
|
||||
assert!(
|
||||
recovered.is_ok(),
|
||||
"capacity must recover after prior holder drops"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -619,7 +658,10 @@ async fn client_dual_limit_cross_product_never_leaks_on_reject() {
|
||||
ip_tracker.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(denied, Err(ProxyError::ConnectionLimitExceeded { .. })));
|
||||
assert!(matches!(
|
||||
denied,
|
||||
Err(ProxyError::ConnectionLimitExceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
assert_eq!(stats.get_user_curr_connects(user), 2);
|
||||
@@ -637,7 +679,10 @@ async fn client_check_user_limits_concurrent_churn_no_counter_drift() {
|
||||
ip_tracker.set_user_limit(user, 64).await;
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 64);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 64);
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..512u16 {
|
||||
|
||||
@@ -2,17 +2,14 @@ use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::crypto::sha256_hmac;
|
||||
use crate::protocol::constants::{
|
||||
HANDSHAKE_LEN,
|
||||
MAX_TLS_PLAINTEXT_SIZE,
|
||||
MIN_TLS_CLIENT_HELLO_SIZE,
|
||||
TLS_RECORD_APPLICATION,
|
||||
HANDSHAKE_LEN, MAX_TLS_PLAINTEXT_SIZE, MIN_TLS_CLIENT_HELLO_SIZE, TLS_RECORD_APPLICATION,
|
||||
TLS_VERSION,
|
||||
};
|
||||
use crate::protocol::tls;
|
||||
use std::collections::HashSet;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
@@ -79,7 +76,10 @@ fn build_mask_harness(secret_hex: &str, mask_port: u16) -> CampaignHarness {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![fill; total_len];
|
||||
@@ -171,7 +171,10 @@ async fn run_tls_success_mtproto_fail_capture(
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
|
||||
|
||||
@@ -427,7 +430,10 @@ async fn blackhat_campaign_06_replayed_tls_hello_is_masked_without_serverhello()
|
||||
client_side.read_exact(&mut head).await.unwrap();
|
||||
assert_eq!(head[0], 0x16);
|
||||
read_and_discard_tls_record_body(&mut client_side, head).await;
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&first_tail).await.unwrap();
|
||||
} else {
|
||||
let mut one = [0u8; 1];
|
||||
@@ -697,13 +703,15 @@ async fn blackhat_campaign_12_parallel_tls_success_mtproto_fail_sessions_keep_is
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..sessions {
|
||||
let mut harness = build_mask_harness("abababababababababababababababab", backend_addr.port());
|
||||
let mut harness =
|
||||
build_mask_harness("abababababababababababababababab", backend_addr.port());
|
||||
let mut cfg = (*harness.config).clone();
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
harness.config = Arc::new(cfg);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let secret = [0xABu8; 16];
|
||||
let hello = make_valid_tls_client_hello(&secret, 100 + i as u32, 600, 0x40 + (i as u8 % 10));
|
||||
let hello =
|
||||
make_valid_tls_client_hello(&secret, 100 + i as u32, 600, 0x40 + (i as u8 % 10));
|
||||
let bad = wrap_tls_application_data(&vec![0u8; HANDSHAKE_LEN]);
|
||||
let tail = wrap_tls_application_data(&vec![i as u8; 8 + i]);
|
||||
let (server_side, mut client_side) = duplex(131072);
|
||||
@@ -843,12 +851,12 @@ async fn blackhat_campaign_15_light_fuzz_tls_lengths_and_fragmentation() {
|
||||
tls_len = MAX_TLS_PLAINTEXT_SIZE + 1 + (tls_len % 1024);
|
||||
}
|
||||
|
||||
let body_to_send = if (MIN_TLS_CLIENT_HELLO_SIZE..=MAX_TLS_PLAINTEXT_SIZE).contains(&tls_len)
|
||||
{
|
||||
(seed as usize % 29).min(tls_len.saturating_sub(1))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let body_to_send =
|
||||
if (MIN_TLS_CLIENT_HELLO_SIZE..=MAX_TLS_PLAINTEXT_SIZE).contains(&tls_len) {
|
||||
(seed as usize % 29).min(tls_len.saturating_sub(1))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut probe = vec![0u8; 5 + body_to_send];
|
||||
probe[0] = 0x16;
|
||||
@@ -856,7 +864,9 @@ async fn blackhat_campaign_15_light_fuzz_tls_lengths_and_fragmentation() {
|
||||
probe[2] = 0x01;
|
||||
probe[3..5].copy_from_slice(&(tls_len as u16).to_be_bytes());
|
||||
for b in &mut probe[5..] {
|
||||
seed = seed.wrapping_mul(2862933555777941757).wrapping_add(3037000493);
|
||||
seed = seed
|
||||
.wrapping_mul(2862933555777941757)
|
||||
.wrapping_add(3037000493);
|
||||
*b = (seed >> 24) as u8;
|
||||
}
|
||||
|
||||
@@ -879,7 +889,8 @@ async fn blackhat_campaign_16_mixed_probe_burst_stress_finishes_without_panics()
|
||||
probe[2] = 0x01;
|
||||
probe[3..5].copy_from_slice(&600u16.to_be_bytes());
|
||||
probe[5..].fill((0x90 + i as u8) ^ 0x5A);
|
||||
run_invalid_tls_capture(Arc::new(ProxyConfig::default()), probe.clone(), probe).await;
|
||||
run_invalid_tls_capture(Arc::new(ProxyConfig::default()), probe.clone(), probe)
|
||||
.await;
|
||||
} else {
|
||||
let hdr = vec![0x16, 0x03, 0x01, 0xFF, i as u8];
|
||||
run_invalid_tls_capture(Arc::new(ProxyConfig::default()), hdr.clone(), hdr).await;
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::crypto::sha256_hmac;
|
||||
use crate::protocol::constants::{HANDSHAKE_LEN, TLS_VERSION};
|
||||
use crate::protocol::tls;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
@@ -55,7 +55,10 @@ fn build_harness(config: ProxyConfig) -> PipelineHarness {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![fill; total_len];
|
||||
@@ -150,7 +153,10 @@ async fn masking_runs_outside_handshake_timeout_budget_with_high_reject_delay()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_ok(), "bad-client fallback must not be canceled by handshake timeout");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"bad-client fallback must not be canceled by handshake timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.get_handshake_timeouts(),
|
||||
0,
|
||||
@@ -175,10 +181,10 @@ async fn tls_mtproto_bad_client_does_not_reinject_clienthello_into_mask_backend(
|
||||
config.censorship.mask_port = backend_addr.port();
|
||||
config.censorship.mask_proxy_protocol = 0;
|
||||
config.access.ignore_time_skew = true;
|
||||
config
|
||||
.access
|
||||
.users
|
||||
.insert("user".to_string(), "d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0".to_string());
|
||||
config.access.users.insert(
|
||||
"user".to_string(),
|
||||
"d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0".to_string(),
|
||||
);
|
||||
|
||||
let harness = build_harness(config);
|
||||
|
||||
@@ -194,8 +200,7 @@ async fn tls_mtproto_bad_client_does_not_reinject_clienthello_into_mask_backend(
|
||||
let mut got = vec![0u8; expected_trailing.len()];
|
||||
stream.read_exact(&mut got).await.unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
expected_trailing,
|
||||
got, expected_trailing,
|
||||
"mask backend must receive only post-handshake trailing TLS records"
|
||||
);
|
||||
});
|
||||
@@ -223,11 +228,17 @@ async fn tls_mtproto_bad_client_does_not_reinject_clienthello_into_mask_backend(
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
@@ -163,21 +163,36 @@ async fn diagnostic_timing_profiles_are_within_realistic_guardrails() {
|
||||
);
|
||||
|
||||
assert!(p50 >= 650, "p50 too low for delayed reject class={}", class);
|
||||
assert!(p95 <= 1200, "p95 too high for delayed reject class={}", class);
|
||||
assert!(max <= 1500, "max too high for delayed reject class={}", class);
|
||||
assert!(
|
||||
p95 <= 1200,
|
||||
"p95 too high for delayed reject class={}",
|
||||
class
|
||||
);
|
||||
assert!(
|
||||
max <= 1500,
|
||||
"max too high for delayed reject class={}",
|
||||
class
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostic_forwarded_size_profiles_by_probe_class() {
|
||||
let classes = [0usize, 1usize, 7usize, 17usize, 63usize, 511usize, 1023usize, 2047usize];
|
||||
let classes = [
|
||||
0usize, 1usize, 7usize, 17usize, 63usize, 511usize, 1023usize, 2047usize,
|
||||
];
|
||||
let mut observed = Vec::new();
|
||||
|
||||
for class in classes {
|
||||
let len = capture_forwarded_len(class).await;
|
||||
println!("diagnostic_shape class={} forwarded_len={}", class, len);
|
||||
observed.push(len as u128);
|
||||
assert_eq!(len, 5 + class, "unexpected forwarded len for class={}", class);
|
||||
assert_eq!(
|
||||
len,
|
||||
5 + class,
|
||||
"unexpected forwarded len for class={}",
|
||||
class
|
||||
);
|
||||
}
|
||||
|
||||
let p50 = percentile_ms(observed.clone(), 50, 100);
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::crypto::sha256_hmac;
|
||||
use crate::protocol::constants::{HANDSHAKE_LEN, TLS_RECORD_APPLICATION, TLS_VERSION};
|
||||
use crate::protocol::tls;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
@@ -70,7 +70,10 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> Harness {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![fill; total_len];
|
||||
@@ -158,11 +161,17 @@ async fn run_tls_success_mtproto_fail_capture(
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
read_tls_record_body(&mut client_side, tls_response_head).await;
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
for record in trailing_records {
|
||||
client_side.write_all(&record).await.unwrap();
|
||||
}
|
||||
@@ -330,7 +339,10 @@ async fn replayed_tls_hello_gets_no_serverhello_and_is_masked() {
|
||||
client_side.read_exact(&mut head).await.unwrap();
|
||||
assert_eq!(head[0], 0x16);
|
||||
read_tls_record_body(&mut client_side, head).await;
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&first_tail).await.unwrap();
|
||||
} else {
|
||||
let mut one = [0u8; 1];
|
||||
@@ -402,7 +414,10 @@ async fn connects_bad_increments_once_per_invalid_mtproto() {
|
||||
let mut head = [0u8; 5];
|
||||
client_side.read_exact(&mut head).await.unwrap();
|
||||
read_tls_record_body(&mut client_side, head).await;
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&tail).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -625,7 +640,8 @@ async fn concurrent_tls_mtproto_fail_sessions_are_isolated() {
|
||||
for idx in 0..sessions {
|
||||
let secret_hex = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
|
||||
let harness = build_harness(secret_hex, backend_addr.port());
|
||||
let hello = make_valid_tls_client_hello(&[0xC4; 16], 20 + idx as u32, 600, 0x40 + idx as u8);
|
||||
let hello =
|
||||
make_valid_tls_client_hello(&[0xC4; 16], 20 + idx as u32, 600, 0x40 + idx as u8);
|
||||
let invalid_mtproto = wrap_tls_application_data(&vec![0u8; HANDSHAKE_LEN]);
|
||||
let trailing = wrap_tls_application_data(&vec![idx as u8; 32 + idx]);
|
||||
let peer: SocketAddr = format!("198.51.100.217:{}", 56100 + idx as u16)
|
||||
@@ -685,17 +701,67 @@ macro_rules! tail_length_case {
|
||||
*b = (i as u8).wrapping_mul(17).wrapping_add(5);
|
||||
}
|
||||
let record = wrap_tls_application_data(&payload);
|
||||
let got = run_tls_success_mtproto_fail_capture($hex, $secret, $ts, vec![record.clone()]).await;
|
||||
let got =
|
||||
run_tls_success_mtproto_fail_capture($hex, $secret, $ts, vec![record.clone()])
|
||||
.await;
|
||||
assert_eq!(got, record);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
tail_length_case!(tail_len_1_preserved, "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1", [0xD1; 16], 30, 1);
|
||||
tail_length_case!(tail_len_2_preserved, "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", [0xD2; 16], 31, 2);
|
||||
tail_length_case!(tail_len_3_preserved, "d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3", [0xD3; 16], 32, 3);
|
||||
tail_length_case!(tail_len_7_preserved, "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", [0xD4; 16], 33, 7);
|
||||
tail_length_case!(tail_len_31_preserved, "d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5", [0xD5; 16], 34, 31);
|
||||
tail_length_case!(tail_len_127_preserved, "d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6", [0xD6; 16], 35, 127);
|
||||
tail_length_case!(tail_len_511_preserved, "d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7", [0xD7; 16], 36, 511);
|
||||
tail_length_case!(tail_len_1023_preserved, "d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8", [0xD8; 16], 37, 1023);
|
||||
tail_length_case!(
|
||||
tail_len_1_preserved,
|
||||
"d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1",
|
||||
[0xD1; 16],
|
||||
30,
|
||||
1
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_2_preserved,
|
||||
"d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2",
|
||||
[0xD2; 16],
|
||||
31,
|
||||
2
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_3_preserved,
|
||||
"d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3",
|
||||
[0xD3; 16],
|
||||
32,
|
||||
3
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_7_preserved,
|
||||
"d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4",
|
||||
[0xD4; 16],
|
||||
33,
|
||||
7
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_31_preserved,
|
||||
"d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5",
|
||||
[0xD5; 16],
|
||||
34,
|
||||
31
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_127_preserved,
|
||||
"d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6",
|
||||
[0xD6; 16],
|
||||
35,
|
||||
127
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_511_preserved,
|
||||
"d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7d7",
|
||||
[0xD7; 16],
|
||||
36,
|
||||
511
|
||||
);
|
||||
tail_length_case!(
|
||||
tail_len_1023_preserved,
|
||||
"d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8",
|
||||
[0xD8; 16],
|
||||
37,
|
||||
1023
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ use rand::{Rng, SeedableRng};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const REPLY_404: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
|
||||
@@ -92,10 +92,13 @@ async fn run_generic_probe_and_capture_prefix(payload: Vec<u8>, expected_prefix:
|
||||
client_side.shutdown().await.unwrap();
|
||||
|
||||
let mut observed = vec![0u8; REPLY_404.len()];
|
||||
tokio::time::timeout(Duration::from_secs(2), client_side.read_exact(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client_side.read_exact(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed, REPLY_404);
|
||||
|
||||
let got = tokio::time::timeout(Duration::from_secs(2), accept_task)
|
||||
@@ -264,7 +267,8 @@ async fn stress_parallel_probe_mix_masks_all_sessions_without_cross_leakage() {
|
||||
|
||||
let mut expected = std::collections::HashSet::new();
|
||||
for idx in 0..session_count {
|
||||
let probe = format!("GET /stress-{idx} HTTP/1.1\r\nHost: s{idx}.example\r\n\r\n").into_bytes();
|
||||
let probe =
|
||||
format!("GET /stress-{idx} HTTP/1.1\r\nHost: s{idx}.example\r\n\r\n").into_bytes();
|
||||
expected.insert(probe);
|
||||
}
|
||||
|
||||
@@ -274,9 +278,15 @@ async fn stress_parallel_probe_mix_masks_all_sessions_without_cross_leakage() {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let head = read_http_probe_header(&mut stream).await;
|
||||
stream.write_all(REPLY_404).await.unwrap();
|
||||
assert!(remaining.remove(&head), "backend received unexpected or duplicated probe prefix");
|
||||
assert!(
|
||||
remaining.remove(&head),
|
||||
"backend received unexpected or duplicated probe prefix"
|
||||
);
|
||||
}
|
||||
assert!(remaining.is_empty(), "all session prefixes must be observed exactly once");
|
||||
assert!(
|
||||
remaining.is_empty(),
|
||||
"all session prefixes must be observed exactly once"
|
||||
);
|
||||
});
|
||||
|
||||
let mut tasks = Vec::with_capacity(session_count);
|
||||
@@ -291,7 +301,8 @@ async fn stress_parallel_probe_mix_masks_all_sessions_without_cross_leakage() {
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
let beobachten = Arc::new(BeobachtenStore::new());
|
||||
|
||||
let probe = format!("GET /stress-{idx} HTTP/1.1\r\nHost: s{idx}.example\r\n\r\n").into_bytes();
|
||||
let probe =
|
||||
format!("GET /stress-{idx} HTTP/1.1\r\nHost: s{idx}.example\r\n\r\n").into_bytes();
|
||||
let peer: SocketAddr = format!("203.0.113.{}:{}", 30 + idx, 56000 + idx)
|
||||
.parse()
|
||||
.unwrap();
|
||||
@@ -319,10 +330,13 @@ async fn stress_parallel_probe_mix_masks_all_sessions_without_cross_leakage() {
|
||||
client_side.shutdown().await.unwrap();
|
||||
|
||||
let mut observed = vec![0u8; REPLY_404.len()];
|
||||
tokio::time::timeout(Duration::from_secs(2), client_side.read_exact(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client_side.read_exact(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed, REPLY_404);
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(2), handler)
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::crypto::sha256_hmac;
|
||||
use crate::protocol::constants::{HANDSHAKE_LEN, TLS_VERSION};
|
||||
use crate::protocol::tls;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
@@ -67,7 +67,10 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> RedTeamHarness {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![fill; total_len];
|
||||
@@ -148,8 +151,14 @@ async fn run_tls_success_mtproto_fail_session(
|
||||
let mut body = vec![0u8; body_len];
|
||||
client_side.read_exact(&mut body).await.unwrap();
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side.write_all(&wrap_tls_application_data(&tail)).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side
|
||||
.write_all(&wrap_tls_application_data(&tail))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let forwarded = tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
.await
|
||||
@@ -175,7 +184,10 @@ async fn redteam_01_backend_receives_no_data_after_mtproto_fail() {
|
||||
b"probe-a".to_vec(),
|
||||
)
|
||||
.await;
|
||||
assert!(forwarded.is_empty(), "backend unexpectedly received fallback bytes");
|
||||
assert!(
|
||||
forwarded.is_empty(),
|
||||
"backend unexpectedly received fallback bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -188,7 +200,10 @@ async fn redteam_02_backend_must_never_receive_tls_records_after_mtproto_fail()
|
||||
b"probe-b".to_vec(),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(forwarded[0], 0x17, "received TLS application record despite strict policy");
|
||||
assert_ne!(
|
||||
forwarded[0], 0x17,
|
||||
"received TLS application record despite strict policy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -200,9 +215,10 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
|
||||
cfg.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
cfg.censorship.mask_port = 1;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "acacacacacacacacacacacacacacacac".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"acacacacacacacacacacacacacacacac".to_string(),
|
||||
);
|
||||
|
||||
let harness = RedTeamHarness {
|
||||
config: Arc::new(cfg),
|
||||
@@ -261,7 +277,10 @@ async fn redteam_03_masking_duration_must_be_less_than_1ms_when_backend_down() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(started.elapsed() < Duration::from_millis(1), "fallback path took longer than 1ms");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(1),
|
||||
"fallback path took longer than 1ms"
|
||||
);
|
||||
}
|
||||
|
||||
macro_rules! redteam_tail_must_not_forward_case {
|
||||
@@ -283,18 +302,90 @@ macro_rules! redteam_tail_must_not_forward_case {
|
||||
};
|
||||
}
|
||||
|
||||
redteam_tail_must_not_forward_case!(redteam_04_tail_len_1_not_forwarded, "adadadadadadadadadadadadadadadad", [0xAD; 16], 4, 1);
|
||||
redteam_tail_must_not_forward_case!(redteam_05_tail_len_2_not_forwarded, "aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae", [0xAE; 16], 5, 2);
|
||||
redteam_tail_must_not_forward_case!(redteam_06_tail_len_3_not_forwarded, "afafafafafafafafafafafafafafafaf", [0xAF; 16], 6, 3);
|
||||
redteam_tail_must_not_forward_case!(redteam_07_tail_len_7_not_forwarded, "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", [0xB0; 16], 7, 7);
|
||||
redteam_tail_must_not_forward_case!(redteam_08_tail_len_15_not_forwarded, "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", [0xB1; 16], 8, 15);
|
||||
redteam_tail_must_not_forward_case!(redteam_09_tail_len_63_not_forwarded, "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", [0xB2; 16], 9, 63);
|
||||
redteam_tail_must_not_forward_case!(redteam_10_tail_len_127_not_forwarded, "b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3", [0xB3; 16], 10, 127);
|
||||
redteam_tail_must_not_forward_case!(redteam_11_tail_len_255_not_forwarded, "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4", [0xB4; 16], 11, 255);
|
||||
redteam_tail_must_not_forward_case!(redteam_12_tail_len_511_not_forwarded, "b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5", [0xB5; 16], 12, 511);
|
||||
redteam_tail_must_not_forward_case!(redteam_13_tail_len_1023_not_forwarded, "b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6", [0xB6; 16], 13, 1023);
|
||||
redteam_tail_must_not_forward_case!(redteam_14_tail_len_2047_not_forwarded, "b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7", [0xB7; 16], 14, 2047);
|
||||
redteam_tail_must_not_forward_case!(redteam_15_tail_len_4095_not_forwarded, "b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8", [0xB8; 16], 15, 4095);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_04_tail_len_1_not_forwarded,
|
||||
"adadadadadadadadadadadadadadadad",
|
||||
[0xAD; 16],
|
||||
4,
|
||||
1
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_05_tail_len_2_not_forwarded,
|
||||
"aeaeaeaeaeaeaeaeaeaeaeaeaeaeaeae",
|
||||
[0xAE; 16],
|
||||
5,
|
||||
2
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_06_tail_len_3_not_forwarded,
|
||||
"afafafafafafafafafafafafafafafaf",
|
||||
[0xAF; 16],
|
||||
6,
|
||||
3
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_07_tail_len_7_not_forwarded,
|
||||
"b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
|
||||
[0xB0; 16],
|
||||
7,
|
||||
7
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_08_tail_len_15_not_forwarded,
|
||||
"b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1",
|
||||
[0xB1; 16],
|
||||
8,
|
||||
15
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_09_tail_len_63_not_forwarded,
|
||||
"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
|
||||
[0xB2; 16],
|
||||
9,
|
||||
63
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_10_tail_len_127_not_forwarded,
|
||||
"b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3",
|
||||
[0xB3; 16],
|
||||
10,
|
||||
127
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_11_tail_len_255_not_forwarded,
|
||||
"b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4",
|
||||
[0xB4; 16],
|
||||
11,
|
||||
255
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_12_tail_len_511_not_forwarded,
|
||||
"b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5",
|
||||
[0xB5; 16],
|
||||
12,
|
||||
511
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_13_tail_len_1023_not_forwarded,
|
||||
"b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6",
|
||||
[0xB6; 16],
|
||||
13,
|
||||
1023
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_14_tail_len_2047_not_forwarded,
|
||||
"b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7",
|
||||
[0xB7; 16],
|
||||
14,
|
||||
2047
|
||||
);
|
||||
redteam_tail_must_not_forward_case!(
|
||||
redteam_15_tail_len_4095_not_forwarded,
|
||||
"b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8",
|
||||
[0xB8; 16],
|
||||
15,
|
||||
4095
|
||||
);
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team expected-fail: impossible indistinguishability envelope"]
|
||||
@@ -349,14 +440,13 @@ async fn redteam_16_timing_delta_between_paths_must_be_sub_1ms_under_concurrency
|
||||
|
||||
let min = durations.iter().copied().min().unwrap();
|
||||
let max = durations.iter().copied().max().unwrap();
|
||||
assert!(max - min <= Duration::from_millis(1), "timing spread too wide for strict anti-probing envelope");
|
||||
assert!(
|
||||
max - min <= Duration::from_millis(1),
|
||||
"timing spread too wide for strict anti-probing envelope"
|
||||
);
|
||||
}
|
||||
|
||||
async fn measure_invalid_probe_duration_ms(
|
||||
delay_ms: u64,
|
||||
tls_len: u16,
|
||||
body_sent: usize,
|
||||
) -> u128 {
|
||||
async fn measure_invalid_probe_duration_ms(delay_ms: u64, tls_len: u16, body_sent: usize) -> u128 {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.general.beobachten = false;
|
||||
cfg.censorship.mask = true;
|
||||
@@ -501,7 +591,8 @@ macro_rules! redteam_timing_envelope_case {
|
||||
#[tokio::test]
|
||||
#[ignore = "red-team expected-fail: unrealistically tight reject timing envelope"]
|
||||
async fn $name() {
|
||||
let elapsed_ms = measure_invalid_probe_duration_ms($delay_ms, $tls_len, $body_sent).await;
|
||||
let elapsed_ms =
|
||||
measure_invalid_probe_duration_ms($delay_ms, $tls_len, $body_sent).await;
|
||||
assert!(
|
||||
elapsed_ms <= $max_ms,
|
||||
"timing envelope violated: elapsed={}ms, max={}ms",
|
||||
@@ -519,11 +610,9 @@ macro_rules! redteam_constant_shape_case {
|
||||
async fn $name() {
|
||||
let got = capture_forwarded_probe_len($tls_len, $body_sent).await;
|
||||
assert_eq!(
|
||||
got,
|
||||
$expected_len,
|
||||
got, $expected_len,
|
||||
"fingerprint shape mismatch: got={} expected={} (strict constant-shape model)",
|
||||
got,
|
||||
$expected_len
|
||||
got, $expected_len
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
@@ -172,7 +172,10 @@ async fn redteam_fuzz_01_hardened_output_length_correlation_should_be_below_0_2(
|
||||
let y_hard: Vec<f64> = hardened.iter().map(|v| *v as f64).collect();
|
||||
|
||||
let corr_hard = pearson_corr(&x, &y_hard).abs();
|
||||
println!("redteam_fuzz corr_hardened={corr_hard:.4} samples={}", sizes.len());
|
||||
println!(
|
||||
"redteam_fuzz corr_hardened={corr_hard:.4} samples={}",
|
||||
sizes.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
corr_hard < 0.2,
|
||||
@@ -234,9 +237,7 @@ async fn redteam_fuzz_03_hardened_signal_must_be_10x_lower_than_plain() {
|
||||
let corr_plain = pearson_corr(&x, &y_plain).abs();
|
||||
let corr_hard = pearson_corr(&x, &y_hard).abs();
|
||||
|
||||
println!(
|
||||
"redteam_fuzz corr_plain={corr_plain:.4} corr_hardened={corr_hard:.4}"
|
||||
);
|
||||
println!("redteam_fuzz corr_plain={corr_plain:.4} corr_hardened={corr_hard:.4}");
|
||||
|
||||
assert!(
|
||||
corr_hard <= corr_plain * 0.1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
@@ -164,10 +164,7 @@ async fn redteam_shape_02_padding_tail_must_be_non_deterministic() {
|
||||
let cap = 4096usize;
|
||||
let got = run_probe_capture(17, 600, true, floor, cap).await;
|
||||
|
||||
assert!(
|
||||
got.len() > 22,
|
||||
"test requires padding tail to exist"
|
||||
);
|
||||
assert!(got.len() > 22, "test requires padding tail to exist");
|
||||
|
||||
let tail = &got[22..];
|
||||
assert!(
|
||||
@@ -194,7 +191,9 @@ async fn redteam_shape_03_exact_floor_input_should_not_be_fixed_point() {
|
||||
async fn redteam_shape_04_all_sub_cap_sizes_should_collapse_to_single_size() {
|
||||
let floor = 512usize;
|
||||
let cap = 4096usize;
|
||||
let classes = [17usize, 63usize, 255usize, 511usize, 1023usize, 2047usize, 3071usize];
|
||||
let classes = [
|
||||
17usize, 63usize, 255usize, 511usize, 1023usize, 2047usize, 3071usize,
|
||||
];
|
||||
|
||||
let mut observed = Vec::new();
|
||||
for body in classes {
|
||||
@@ -203,7 +202,10 @@ async fn redteam_shape_04_all_sub_cap_sizes_should_collapse_to_single_size() {
|
||||
|
||||
let first = observed[0];
|
||||
for v in observed {
|
||||
assert_eq!(v, first, "strict model expects one collapsed class across all sub-cap probes");
|
||||
assert_eq!(
|
||||
v, first,
|
||||
"strict model expects one collapsed class across all sub-cap probes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::crypto::sha256_hmac;
|
||||
use crate::protocol::constants::{HANDSHAKE_LEN, TLS_RECORD_APPLICATION, TLS_VERSION};
|
||||
use crate::protocol::tls;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
@@ -70,7 +70,10 @@ fn build_harness(mask_port: u16, secret_hex: &str) -> StressHarness {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![fill; total_len];
|
||||
@@ -150,12 +153,8 @@ async fn run_parallel_tail_fallback_case(
|
||||
|
||||
for idx in 0..sessions {
|
||||
let harness = build_harness(backend_addr.port(), "e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0");
|
||||
let hello = make_valid_tls_client_hello(
|
||||
&[0xE0; 16],
|
||||
ts_base + idx as u32,
|
||||
600,
|
||||
0x40 + (idx as u8),
|
||||
);
|
||||
let hello =
|
||||
make_valid_tls_client_hello(&[0xE0; 16], ts_base + idx as u32, 600, 0x40 + (idx as u8));
|
||||
|
||||
let invalid_mtproto = wrap_tls_application_data(&vec![0u8; HANDSHAKE_LEN]);
|
||||
let payload = vec![((idx * 37) & 0xff) as u8; payload_len + idx % 3];
|
||||
@@ -170,8 +169,8 @@ async fn run_parallel_tail_fallback_case(
|
||||
peer_ip_fourth,
|
||||
peer_port_base + idx as u16
|
||||
)
|
||||
.parse()
|
||||
.unwrap();
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let (server_side, mut client_side) = duplex(262144);
|
||||
@@ -194,7 +193,10 @@ async fn run_parallel_tail_fallback_case(
|
||||
|
||||
client_side.write_all(&hello).await.unwrap();
|
||||
let mut server_hello_head = [0u8; 5];
|
||||
client_side.read_exact(&mut server_hello_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut server_hello_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(server_hello_head[0], 0x16);
|
||||
read_tls_record_body(&mut client_side, server_hello_head).await;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::handshake::HandshakeSuccess;
|
||||
use crate::stream::{CryptoReader, CryptoWriter};
|
||||
use crate::transport::proxy_protocol::ProxyProtocolV1Builder;
|
||||
use std::net::Ipv4Addr;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
#[test]
|
||||
@@ -49,25 +49,33 @@ async fn user_connection_reservation_drop_enqueues_cleanup_synchronously() {
|
||||
let stats = Arc::new(crate::stats::Stats::new());
|
||||
let user = "sync-drop-user".to_string();
|
||||
let ip: std::net::IpAddr = "192.168.1.1".parse().unwrap();
|
||||
|
||||
|
||||
ip_tracker.set_user_limit(&user, 1).await;
|
||||
ip_tracker.check_and_add(&user, ip).await.unwrap();
|
||||
stats.increment_user_curr_connects(&user);
|
||||
|
||||
|
||||
assert_eq!(ip_tracker.get_active_ip_count(&user).await, 1);
|
||||
assert_eq!(stats.get_user_curr_connects(&user), 1);
|
||||
|
||||
let reservation = UserConnectionReservation::new(stats.clone(), ip_tracker.clone(), user.clone(), ip);
|
||||
|
||||
|
||||
let reservation =
|
||||
UserConnectionReservation::new(stats.clone(), ip_tracker.clone(), user.clone(), ip);
|
||||
|
||||
// Drop the reservation synchronously without any tokio::spawn/await yielding!
|
||||
drop(reservation);
|
||||
|
||||
|
||||
// The IP is now inside the cleanup_queue, check that the queue has length 1
|
||||
let queue_len = ip_tracker.cleanup_queue.lock().unwrap().len();
|
||||
assert_eq!(queue_len, 1, "Reservation drop must push directly to synchronized IP queue");
|
||||
|
||||
assert_eq!(stats.get_user_curr_connects(&user), 0, "Stats must decrement immediately");
|
||||
|
||||
assert_eq!(
|
||||
queue_len, 1,
|
||||
"Reservation drop must push directly to synchronized IP queue"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
stats.get_user_curr_connects(&user),
|
||||
0,
|
||||
"Stats must decrement immediately"
|
||||
);
|
||||
|
||||
ip_tracker.drain_cleanup_queue().await;
|
||||
assert_eq!(ip_tracker.get_active_ip_count(&user).await, 0);
|
||||
}
|
||||
@@ -286,7 +294,10 @@ async fn relay_cutover_releases_user_gate_and_ip_reservation() {
|
||||
.await
|
||||
.expect("relay must terminate after cutover")
|
||||
.expect("relay task must not panic");
|
||||
assert!(relay_result.is_err(), "cutover must terminate direct relay session");
|
||||
assert!(
|
||||
relay_result.is_err(),
|
||||
"cutover must terminate direct relay session"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
stats.get_user_curr_connects(user),
|
||||
@@ -447,7 +458,12 @@ async fn stress_drop_without_release_converges_to_zero_user_and_ip_state() {
|
||||
let mut reservations = Vec::new();
|
||||
for idx in 0..512u16 {
|
||||
let peer = std::net::SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::new(198, 51, (idx >> 8) as u8, (idx & 0xff) as u8)),
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::new(
|
||||
198,
|
||||
51,
|
||||
(idx >> 8) as u8,
|
||||
(idx & 0xff) as u8,
|
||||
)),
|
||||
30_000 + idx,
|
||||
);
|
||||
let reservation = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
@@ -510,10 +526,15 @@ async fn proxy_protocol_header_is_rejected_when_trust_list_is_empty() {
|
||||
false,
|
||||
stats.clone(),
|
||||
));
|
||||
let replay_checker = std::sync::Arc::new(crate::stats::ReplayChecker::new(128, std::time::Duration::from_secs(60)));
|
||||
let replay_checker = std::sync::Arc::new(crate::stats::ReplayChecker::new(
|
||||
128,
|
||||
std::time::Duration::from_secs(60),
|
||||
));
|
||||
let buffer_pool = std::sync::Arc::new(crate::stream::BufferPool::new());
|
||||
let rng = std::sync::Arc::new(crate::crypto::SecureRandom::new());
|
||||
let route_runtime = std::sync::Arc::new(crate::proxy::route_mode::RouteRuntimeController::new(crate::proxy::route_mode::RelayRouteMode::Direct));
|
||||
let route_runtime = std::sync::Arc::new(crate::proxy::route_mode::RouteRuntimeController::new(
|
||||
crate::proxy::route_mode::RelayRouteMode::Direct,
|
||||
));
|
||||
let ip_tracker = std::sync::Arc::new(crate::ip_tracker::UserIpTracker::new());
|
||||
let beobachten = std::sync::Arc::new(crate::stats::beobachten::BeobachtenStore::new());
|
||||
|
||||
@@ -581,10 +602,16 @@ async fn proxy_protocol_header_from_untrusted_peer_range_is_rejected_under_load(
|
||||
false,
|
||||
stats.clone(),
|
||||
));
|
||||
let replay_checker = std::sync::Arc::new(crate::stats::ReplayChecker::new(64, std::time::Duration::from_secs(60)));
|
||||
let replay_checker = std::sync::Arc::new(crate::stats::ReplayChecker::new(
|
||||
64,
|
||||
std::time::Duration::from_secs(60),
|
||||
));
|
||||
let buffer_pool = std::sync::Arc::new(crate::stream::BufferPool::new());
|
||||
let rng = std::sync::Arc::new(crate::crypto::SecureRandom::new());
|
||||
let route_runtime = std::sync::Arc::new(crate::proxy::route_mode::RouteRuntimeController::new(crate::proxy::route_mode::RelayRouteMode::Direct));
|
||||
let route_runtime =
|
||||
std::sync::Arc::new(crate::proxy::route_mode::RouteRuntimeController::new(
|
||||
crate::proxy::route_mode::RelayRouteMode::Direct,
|
||||
));
|
||||
let ip_tracker = std::sync::Arc::new(crate::ip_tracker::UserIpTracker::new());
|
||||
let beobachten = std::sync::Arc::new(crate::stats::beobachten::BeobachtenStore::new());
|
||||
|
||||
@@ -669,8 +696,16 @@ async fn reservation_limit_failure_does_not_leak_curr_connects_counter() {
|
||||
matches!(second, Err(crate::error::ProxyError::ConnectionLimitExceeded { user: denied }) if denied == user),
|
||||
"second reservation must be rejected at the configured tcp-conns limit"
|
||||
);
|
||||
assert_eq!(stats.get_user_curr_connects(user), 1, "failed acquisition must not leak a counter increment");
|
||||
assert_eq!(ip_tracker.get_active_ip_count(user).await, 1, "failed acquisition must not mutate IP tracker state");
|
||||
assert_eq!(
|
||||
stats.get_user_curr_connects(user),
|
||||
1,
|
||||
"failed acquisition must not leak a counter increment"
|
||||
);
|
||||
assert_eq!(
|
||||
ip_tracker.get_active_ip_count(user).await,
|
||||
1,
|
||||
"failed acquisition must not mutate IP tracker state"
|
||||
);
|
||||
|
||||
first.release().await;
|
||||
ip_tracker.drain_cleanup_queue().await;
|
||||
@@ -1119,7 +1154,10 @@ async fn partial_tls_header_stall_triggers_handshake_timeout() {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello_with_len(secret: &[u8], timestamp: u32, tls_len: usize) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![0x42u8; total_len];
|
||||
@@ -1140,7 +1178,8 @@ fn make_valid_tls_client_hello_with_len(secret: &[u8], timestamp: u32, tls_len:
|
||||
digest[28 + i] ^= ts[i];
|
||||
}
|
||||
|
||||
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].copy_from_slice(&digest);
|
||||
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||
.copy_from_slice(&digest);
|
||||
handshake
|
||||
}
|
||||
|
||||
@@ -1203,8 +1242,7 @@ fn make_valid_tls_client_hello_with_alpn(
|
||||
digest[28 + i] ^= ts[i];
|
||||
}
|
||||
|
||||
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||
.copy_from_slice(&digest);
|
||||
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].copy_from_slice(&digest);
|
||||
record
|
||||
}
|
||||
|
||||
@@ -1233,9 +1271,10 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "11111111111111111111111111111111".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"11111111111111111111111111111111".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -1307,8 +1346,7 @@ async fn valid_tls_path_does_not_fall_back_to_mask_backend() {
|
||||
|
||||
let bad_after = stats_for_assert.get_connects_bad();
|
||||
assert_eq!(
|
||||
bad_before,
|
||||
bad_after,
|
||||
bad_before, bad_after,
|
||||
"Authenticated TLS path must not increment connects_bad"
|
||||
);
|
||||
}
|
||||
@@ -1341,9 +1379,10 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "33333333333333333333333333333333".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"33333333333333333333333333333333".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -1394,7 +1433,10 @@ async fn valid_tls_with_invalid_mtproto_falls_back_to_mask_backend() {
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&tls_app_record).await.unwrap();
|
||||
@@ -1443,9 +1485,10 @@ async fn client_handler_tls_bad_mtproto_is_forwarded_to_mask_backend() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "44444444444444444444444444444444".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"44444444444444444444444444444444".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -1563,9 +1606,10 @@ async fn alpn_mismatch_tls_probe_is_masked_through_client_pipeline() {
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.censorship.alpn_enforce = true;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "66666666666666666666666666666666".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"66666666666666666666666666666666".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -1654,9 +1698,10 @@ async fn invalid_hmac_tls_probe_is_masked_through_client_pipeline() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "77777777777777777777777777777777".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"77777777777777777777777777777777".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -1751,9 +1796,10 @@ async fn burst_invalid_tls_probes_are_masked_verbatim() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "88888888888888888888888888888888".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"88888888888888888888888888888888".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -1981,10 +2027,7 @@ async fn zero_tcp_limit_rejects_without_ip_or_counter_side_effects() {
|
||||
async fn check_user_limits_static_success_does_not_leak_counter_or_ip_reservation() {
|
||||
let user = "check-helper-user";
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 1);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 1);
|
||||
|
||||
let stats = Stats::new();
|
||||
let ip_tracker = UserIpTracker::new();
|
||||
@@ -1998,7 +2041,10 @@ async fn check_user_limits_static_success_does_not_leak_counter_or_ip_reservatio
|
||||
&ip_tracker,
|
||||
)
|
||||
.await;
|
||||
assert!(first.is_ok(), "first check-only limit validation must succeed");
|
||||
assert!(
|
||||
first.is_ok(),
|
||||
"first check-only limit validation must succeed"
|
||||
);
|
||||
|
||||
let second = RunningClientHandler::check_user_limits_static(
|
||||
user,
|
||||
@@ -2008,7 +2054,10 @@ async fn check_user_limits_static_success_does_not_leak_counter_or_ip_reservatio
|
||||
&ip_tracker,
|
||||
)
|
||||
.await;
|
||||
assert!(second.is_ok(), "second check-only validation must not fail from leaked state");
|
||||
assert!(
|
||||
second.is_ok(),
|
||||
"second check-only validation must not fail from leaked state"
|
||||
);
|
||||
assert_eq!(stats.get_user_curr_connects(user), 0);
|
||||
assert_eq!(ip_tracker.get_active_ip_count(user).await, 0);
|
||||
}
|
||||
@@ -2017,10 +2066,7 @@ async fn check_user_limits_static_success_does_not_leak_counter_or_ip_reservatio
|
||||
async fn stress_check_user_limits_static_success_never_leaks_state() {
|
||||
let user = "check-helper-stress-user";
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 1);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 1);
|
||||
|
||||
let stats = Stats::new();
|
||||
let ip_tracker = UserIpTracker::new();
|
||||
@@ -2039,7 +2085,10 @@ async fn stress_check_user_limits_static_success_never_leaks_state() {
|
||||
&ip_tracker,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "check-only helper must remain leak-free under stress");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"check-only helper must remain leak-free under stress"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
@@ -2090,11 +2139,7 @@ async fn concurrent_distinct_ip_rejections_rollback_user_counter_without_leak()
|
||||
41000 + i as u16,
|
||||
);
|
||||
let result = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
@@ -2130,10 +2175,7 @@ async fn explicit_reservation_release_cleans_user_and_ip_immediately() {
|
||||
let peer_addr: SocketAddr = "198.51.100.240:50002".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 4);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 4);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2171,10 +2213,7 @@ async fn explicit_reservation_release_does_not_double_decrement_on_drop() {
|
||||
let peer_addr: SocketAddr = "198.51.100.241:50003".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 4);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 4);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2204,10 +2243,7 @@ async fn drop_fallback_eventually_cleans_user_and_ip_reservation() {
|
||||
let peer_addr: SocketAddr = "198.51.100.242:50004".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 4);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 4);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2248,10 +2284,7 @@ async fn explicit_release_allows_immediate_cross_ip_reacquire_under_limit() {
|
||||
let peer2: SocketAddr = "198.51.100.244:50006".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 4);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 4);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2473,8 +2506,14 @@ async fn parallel_users_abort_release_isolation_preserves_independent_cleanup()
|
||||
let user_b = "abort-isolation-b";
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_max_tcp_conns.insert(user_a.to_string(), 64);
|
||||
config.access.user_max_tcp_conns.insert(user_b.to_string(), 64);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user_a.to_string(), 64);
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user_b.to_string(), 64);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2595,10 +2634,7 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 1);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 1);
|
||||
config
|
||||
.dc_overrides
|
||||
.insert("2".to_string(), vec![format!("127.0.0.1:{dead_port}")]);
|
||||
@@ -2661,7 +2697,10 @@ async fn relay_connect_error_releases_user_and_ip_before_return() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "relay must fail when upstream DC is unreachable");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"relay must fail when upstream DC is unreachable"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.get_user_curr_connects(user),
|
||||
0,
|
||||
@@ -2680,10 +2719,7 @@ async fn mixed_release_and_drop_same_ip_preserves_counter_correctness() {
|
||||
let peer_addr: SocketAddr = "198.51.100.246:50008".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 8);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 8);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2743,10 +2779,7 @@ async fn drop_one_of_two_same_ip_reservations_keeps_ip_active() {
|
||||
let peer_addr: SocketAddr = "198.51.100.247:50009".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 8);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 8);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2802,7 +2835,10 @@ async fn drop_one_of_two_same_ip_reservations_keeps_ip_active() {
|
||||
#[tokio::test]
|
||||
async fn quota_rejection_does_not_reserve_ip_or_trigger_rollback() {
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.user_data_quota.insert("user".to_string(), 1024);
|
||||
config
|
||||
.access
|
||||
.user_data_quota
|
||||
.insert("user".to_string(), 1024);
|
||||
|
||||
let stats = Stats::new();
|
||||
stats.add_user_octets_from("user", 1024);
|
||||
@@ -2838,10 +2874,10 @@ async fn quota_rejection_does_not_reserve_ip_or_trigger_rollback() {
|
||||
#[tokio::test]
|
||||
async fn expired_user_rejection_does_not_reserve_ip_or_increment_curr_connects() {
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_expirations
|
||||
.insert("user".to_string(), chrono::Utc::now() - chrono::Duration::seconds(1));
|
||||
config.access.user_expirations.insert(
|
||||
"user".to_string(),
|
||||
chrono::Utc::now() - chrono::Duration::seconds(1),
|
||||
);
|
||||
|
||||
let stats = Stats::new();
|
||||
let ip_tracker = UserIpTracker::new();
|
||||
@@ -2870,10 +2906,7 @@ async fn same_ip_second_reservation_succeeds_under_unique_ip_limit_one() {
|
||||
let peer_addr: SocketAddr = "198.51.100.248:50010".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 8);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 8);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2914,10 +2947,7 @@ async fn second_distinct_ip_is_rejected_under_unique_ip_limit_one() {
|
||||
let peer2: SocketAddr = "198.51.100.250:50012".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 8);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 8);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -2958,10 +2988,7 @@ async fn cross_thread_drop_uses_captured_runtime_for_ip_cleanup() {
|
||||
let peer_addr: SocketAddr = "198.51.100.251:50013".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 8);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 8);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -3005,10 +3032,7 @@ async fn immediate_reacquire_after_cross_thread_drop_succeeds() {
|
||||
let peer_addr: SocketAddr = "198.51.100.252:50014".parse().unwrap();
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 1);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 1);
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||
@@ -3043,11 +3067,7 @@ async fn immediate_reacquire_after_cross_thread_drop_succeeds() {
|
||||
.expect("cross-thread cleanup must settle before reacquire check");
|
||||
|
||||
let reacquire = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer_addr,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer_addr, ip_tracker,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -3113,10 +3133,7 @@ async fn concurrent_limit_rejections_from_mixed_ips_leave_no_ip_footprint() {
|
||||
.get_recent_ips_for_users(&["user".to_string()])
|
||||
.await;
|
||||
assert!(
|
||||
recent
|
||||
.get("user")
|
||||
.map(|ips| ips.is_empty())
|
||||
.unwrap_or(true),
|
||||
recent.get("user").map(|ips| ips.is_empty()).unwrap_or(true),
|
||||
"Concurrent rejected attempts must not leave recent IP footprint"
|
||||
);
|
||||
|
||||
@@ -3150,11 +3167,7 @@ async fn atomic_limit_gate_allows_only_one_concurrent_acquire() {
|
||||
30000 + i,
|
||||
);
|
||||
RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
"user",
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
"user", &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
@@ -3769,9 +3782,10 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "55555555555555555555555555555555".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"55555555555555555555555555555555".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -3824,7 +3838,10 @@ async fn tls_record_len_16384_is_accepted_in_generic_stream_pipeline() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut record_header = [0u8; 5];
|
||||
client_side.read_exact(&mut record_header).await.unwrap();
|
||||
assert_eq!(record_header[0], 0x16, "Valid max-length ClientHello must be accepted");
|
||||
assert_eq!(
|
||||
record_header[0], 0x16,
|
||||
"Valid max-length ClientHello must be accepted"
|
||||
);
|
||||
|
||||
drop(client_side);
|
||||
let handler_result = tokio::time::timeout(Duration::from_secs(3), handler)
|
||||
@@ -3865,9 +3882,10 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
|
||||
cfg.censorship.mask_port = backend_addr.port();
|
||||
cfg.censorship.mask_proxy_protocol = 0;
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), "66666666666666666666666666666666".to_string());
|
||||
cfg.access.users.insert(
|
||||
"user".to_string(),
|
||||
"66666666666666666666666666666666".to_string(),
|
||||
);
|
||||
|
||||
let config = Arc::new(cfg);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -3938,7 +3956,10 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
|
||||
|
||||
let mut record_header = [0u8; 5];
|
||||
client.read_exact(&mut record_header).await.unwrap();
|
||||
assert_eq!(record_header[0], 0x16, "Valid max-length ClientHello must be accepted");
|
||||
assert_eq!(
|
||||
record_header[0], 0x16,
|
||||
"Valid max-length ClientHello must be accepted"
|
||||
);
|
||||
|
||||
drop(client);
|
||||
|
||||
@@ -3947,7 +3968,8 @@ async fn tls_record_len_16384_is_accepted_in_client_handler_pipeline() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let no_mask_connect = tokio::time::timeout(Duration::from_millis(250), mask_listener.accept()).await;
|
||||
let no_mask_connect =
|
||||
tokio::time::timeout(Duration::from_millis(250), mask_listener.accept()).await;
|
||||
assert!(
|
||||
no_mask_connect.is_err(),
|
||||
"Valid max-length ClientHello must not trigger mask fallback in ClientHandler path"
|
||||
@@ -4004,11 +4026,7 @@ async fn burst_acquire_distinct_ips(
|
||||
55000 + i,
|
||||
);
|
||||
RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -4190,11 +4208,7 @@ async fn cross_thread_drop_storm_then_parallel_reacquire_wave_has_no_leak() {
|
||||
54000 + i,
|
||||
);
|
||||
RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
stats,
|
||||
peer,
|
||||
ip_tracker,
|
||||
user, &config, stats, peer, ip_tracker,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -4228,10 +4242,7 @@ async fn cross_thread_drop_storm_then_parallel_reacquire_wave_has_no_leak() {
|
||||
async fn scheduled_near_limit_and_burst_windows_preserve_admission_invariants() {
|
||||
let user: &'static str = "scheduled-attack-user";
|
||||
let mut config = ProxyConfig::default();
|
||||
config
|
||||
.access
|
||||
.user_max_tcp_conns
|
||||
.insert(user.to_string(), 6);
|
||||
config.access.user_max_tcp_conns.insert(user.to_string(), 6);
|
||||
|
||||
let config = Arc::new(config);
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -4240,7 +4251,10 @@ async fn scheduled_near_limit_and_burst_windows_preserve_admission_invariants()
|
||||
|
||||
let mut base = Vec::new();
|
||||
for i in 0..5u16 {
|
||||
let peer = SocketAddr::new(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 130, 1)), 56000 + i);
|
||||
let peer = SocketAddr::new(
|
||||
IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 130, 1)),
|
||||
56000 + i,
|
||||
);
|
||||
let reservation = RunningClientHandler::acquire_user_connection_reservation_static(
|
||||
user,
|
||||
&config,
|
||||
@@ -4288,15 +4302,8 @@ async fn scheduled_near_limit_and_burst_windows_preserve_admission_invariants()
|
||||
.await
|
||||
.expect("window cleanup must settle to expected occupancy");
|
||||
|
||||
let (wave2_success, wave2_fail) = burst_acquire_distinct_ips(
|
||||
user,
|
||||
config,
|
||||
stats.clone(),
|
||||
ip_tracker.clone(),
|
||||
132,
|
||||
32,
|
||||
)
|
||||
.await;
|
||||
let (wave2_success, wave2_fail) =
|
||||
burst_acquire_distinct_ips(user, config, stats.clone(), ip_tracker.clone(), 132, 32).await;
|
||||
assert_eq!(wave2_success.len(), 1);
|
||||
assert_eq!(wave2_fail, 31);
|
||||
assert_eq!(stats.get_user_curr_connects(user), 5);
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::protocol::constants::MIN_TLS_CLIENT_HELLO_SIZE;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const REPLY_404: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
|
||||
@@ -135,10 +135,13 @@ async fn run_generic_once(class: ProbeClass) -> u128 {
|
||||
client_side.shutdown().await.unwrap();
|
||||
|
||||
let mut observed = vec![0u8; REPLY_404.len()];
|
||||
tokio::time::timeout(Duration::from_secs(2), client_side.read_exact(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client_side.read_exact(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed, REPLY_404);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), accept_task)
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::protocol::constants::{MAX_TLS_PLAINTEXT_SIZE, MIN_TLS_CLIENT_HELLO_SIZE};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
fn test_probe_for_len(len: usize) -> [u8; 5] {
|
||||
@@ -100,7 +100,10 @@ async fn run_probe_and_assert_masking(len: usize, expect_bad_increment: bool) {
|
||||
client_side.write_all(&probe).await.unwrap();
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_side.read_exact(&mut observed).await.unwrap();
|
||||
assert_eq!(observed, backend_reply, "invalid TLS path must be masked as a real site");
|
||||
assert_eq!(
|
||||
observed, backend_reply,
|
||||
"invalid TLS path must be masked as a real site"
|
||||
);
|
||||
|
||||
drop(client_side);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), handler)
|
||||
@@ -109,7 +112,11 @@ async fn run_probe_and_assert_masking(len: usize, expect_bad_increment: bool) {
|
||||
.unwrap();
|
||||
accept_task.await.unwrap();
|
||||
|
||||
let expected_bad = if expect_bad_increment { bad_before + 1 } else { bad_before };
|
||||
let expected_bad = if expect_bad_increment {
|
||||
bad_before + 1
|
||||
} else {
|
||||
bad_before
|
||||
};
|
||||
assert_eq!(
|
||||
stats.get_connects_bad(),
|
||||
expected_bad,
|
||||
@@ -187,7 +194,9 @@ fn tls_client_hello_len_bounds_stress_many_evaluations() {
|
||||
for _ in 0..100_000 {
|
||||
assert!(tls_clienthello_len_in_bounds(MIN_TLS_CLIENT_HELLO_SIZE));
|
||||
assert!(tls_clienthello_len_in_bounds(MAX_TLS_PLAINTEXT_SIZE));
|
||||
assert!(!tls_clienthello_len_in_bounds(MIN_TLS_CLIENT_HELLO_SIZE - 1));
|
||||
assert!(!tls_clienthello_len_in_bounds(
|
||||
MIN_TLS_CLIENT_HELLO_SIZE - 1
|
||||
));
|
||||
assert!(!tls_clienthello_len_in_bounds(MAX_TLS_PLAINTEXT_SIZE + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::protocol::constants::MIN_TLS_CLIENT_HELLO_SIZE;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -48,7 +48,12 @@ fn truncated_in_range_record(actual_body_len: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
async fn write_fragmented<W: AsyncWriteExt + Unpin>(writer: &mut W, bytes: &[u8], chunks: &[usize], delay_ms: u64) {
|
||||
async fn write_fragmented<W: AsyncWriteExt + Unpin>(
|
||||
writer: &mut W,
|
||||
bytes: &[u8],
|
||||
chunks: &[usize],
|
||||
delay_ms: u64,
|
||||
) {
|
||||
let mut offset = 0usize;
|
||||
for &chunk in chunks {
|
||||
if offset >= bytes.len() {
|
||||
@@ -130,10 +135,13 @@ async fn run_blackhat_generic_fragmented_probe_should_mask(
|
||||
client_side.shutdown().await.unwrap();
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
tokio::time::timeout(Duration::from_secs(2), client_side.read_exact(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client_side.read_exact(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), mask_accept_task)
|
||||
@@ -311,10 +319,13 @@ async fn blackhat_truncated_in_range_clienthello_generic_stream_should_mask() {
|
||||
// Security expectation: even malformed in-range TLS should be masked.
|
||||
// This invariant must hold to avoid probe-distinguishable EOF/timeout behavior.
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
tokio::time::timeout(Duration::from_secs(2), client_side.read_exact(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client_side.read_exact(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), mask_accept_task)
|
||||
|
||||
@@ -2,16 +2,11 @@ use super::*;
|
||||
use crate::config::{UpstreamConfig, UpstreamType};
|
||||
use crate::crypto::sha256_hmac;
|
||||
use crate::protocol::constants::{
|
||||
HANDSHAKE_LEN,
|
||||
MAX_TLS_CIPHERTEXT_SIZE,
|
||||
TLS_RECORD_ALERT,
|
||||
TLS_RECORD_APPLICATION,
|
||||
TLS_RECORD_CHANGE_CIPHER,
|
||||
TLS_RECORD_HANDSHAKE,
|
||||
TLS_VERSION,
|
||||
HANDSHAKE_LEN, MAX_TLS_CIPHERTEXT_SIZE, TLS_RECORD_ALERT, TLS_RECORD_APPLICATION,
|
||||
TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_VERSION,
|
||||
};
|
||||
use crate::protocol::tls;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
struct PipelineHarness {
|
||||
@@ -74,7 +69,10 @@ fn build_harness(secret_hex: &str, mask_port: u16) -> PipelineHarness {
|
||||
}
|
||||
|
||||
fn make_valid_tls_client_hello(secret: &[u8], timestamp: u32, tls_len: usize, fill: u8) -> Vec<u8> {
|
||||
assert!(tls_len <= u16::MAX as usize, "TLS length must fit into record header");
|
||||
assert!(
|
||||
tls_len <= u16::MAX as usize,
|
||||
"TLS length must fit into record header"
|
||||
);
|
||||
|
||||
let total_len = 5 + tls_len;
|
||||
let mut handshake = vec![fill; total_len];
|
||||
@@ -181,11 +179,17 @@ async fn tls_bad_mtproto_fallback_preserves_wire_and_backend_response() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -246,10 +250,16 @@ async fn tls_bad_mtproto_fallback_keeps_connects_bad_accounting() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -264,7 +274,11 @@ async fn tls_bad_mtproto_fallback_keeps_connects_bad_accounting() {
|
||||
.unwrap();
|
||||
|
||||
let bad_after = stats_for_assert.get_connects_bad();
|
||||
assert_eq!(bad_after, bad_before + 1, "connects_bad must increase exactly once for invalid MTProto after valid TLS");
|
||||
assert_eq!(
|
||||
bad_after,
|
||||
bad_before + 1,
|
||||
"connects_bad must increase exactly once for invalid MTProto after valid TLS"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -310,10 +324,16 @@ async fn tls_bad_mtproto_fallback_forwards_zero_length_tls_record_verbatim() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -372,10 +392,16 @@ async fn tls_bad_mtproto_fallback_forwards_max_tls_record_verbatim() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -399,7 +425,8 @@ async fn tls_bad_mtproto_fallback_light_fuzz_tls_record_lengths_verbatim() {
|
||||
let backend_addr = listener.local_addr().unwrap();
|
||||
|
||||
let secret = [0x85u8; 16];
|
||||
let client_hello = make_valid_tls_client_hello(&secret, idx as u32 + 4, 600, 0x46 + idx as u8);
|
||||
let client_hello =
|
||||
make_valid_tls_client_hello(&secret, idx as u32 + 4, 600, 0x46 + idx as u8);
|
||||
let invalid_mtproto = vec![0u8; HANDSHAKE_LEN];
|
||||
let invalid_mtproto_record = wrap_tls_application_data(&invalid_mtproto);
|
||||
|
||||
@@ -443,10 +470,16 @@ async fn tls_bad_mtproto_fallback_light_fuzz_tls_record_lengths_verbatim() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -498,7 +531,10 @@ async fn tls_bad_mtproto_fallback_concurrent_sessions_are_isolated() {
|
||||
);
|
||||
}
|
||||
|
||||
assert!(remaining.is_empty(), "all expected client sessions must be matched exactly once");
|
||||
assert!(
|
||||
remaining.is_empty(),
|
||||
"all expected client sessions must be matched exactly once"
|
||||
);
|
||||
});
|
||||
|
||||
let mut client_tasks = Vec::with_capacity(sessions);
|
||||
@@ -506,7 +542,8 @@ async fn tls_bad_mtproto_fallback_concurrent_sessions_are_isolated() {
|
||||
for idx in 0..sessions {
|
||||
let harness = build_harness("86868686868686868686868686868686", backend_addr.port());
|
||||
let secret = [0x86u8; 16];
|
||||
let client_hello = make_valid_tls_client_hello(&secret, idx as u32 + 100, 600, 0x60 + idx as u8);
|
||||
let client_hello =
|
||||
make_valid_tls_client_hello(&secret, idx as u32 + 100, 600, 0x60 + idx as u8);
|
||||
let invalid_mtproto = vec![0u8; HANDSHAKE_LEN];
|
||||
let invalid_mtproto_record = wrap_tls_application_data(&invalid_mtproto);
|
||||
let trailing_payload = vec![idx as u8; 64 + idx];
|
||||
@@ -538,10 +575,16 @@ async fn tls_bad_mtproto_fallback_concurrent_sessions_are_isolated() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
drop(client_side);
|
||||
@@ -606,10 +649,16 @@ async fn tls_bad_mtproto_fallback_forwards_fragmented_client_writes_verbatim() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for chunk in trailing_record.chunks(3) {
|
||||
client_side.write_all(chunk).await.unwrap();
|
||||
@@ -669,10 +718,16 @@ async fn tls_bad_mtproto_fallback_header_fragmentation_bytewise_is_verbatim() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
for b in trailing_record.iter().copied() {
|
||||
client_side.write_all(&[b]).await.unwrap();
|
||||
}
|
||||
@@ -736,10 +791,16 @@ async fn tls_bad_mtproto_fallback_record_splitting_chaos_is_verbatim() {
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let chaos = [7usize, 1, 19, 3, 5, 31, 2, 11, 13, 17];
|
||||
let mut pos = 0usize;
|
||||
@@ -747,7 +808,10 @@ async fn tls_bad_mtproto_fallback_record_splitting_chaos_is_verbatim() {
|
||||
while pos < trailing_record.len() {
|
||||
let step = chaos[idx % chaos.len()];
|
||||
let end = (pos + step).min(trailing_record.len());
|
||||
client_side.write_all(&trailing_record[pos..end]).await.unwrap();
|
||||
client_side
|
||||
.write_all(&trailing_record[pos..end])
|
||||
.await
|
||||
.unwrap();
|
||||
pos = end;
|
||||
idx += 1;
|
||||
}
|
||||
@@ -809,10 +873,16 @@ async fn tls_bad_mtproto_fallback_multiple_tls_records_are_forwarded_in_order()
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&r1).await.unwrap();
|
||||
client_side.write_all(&r2).await.unwrap();
|
||||
client_side.write_all(&r3).await.unwrap();
|
||||
@@ -848,7 +918,10 @@ async fn tls_bad_mtproto_fallback_client_half_close_propagates_eof_to_backend()
|
||||
|
||||
let mut tail = [0u8; 1];
|
||||
let n = stream.read(&mut tail).await.unwrap();
|
||||
assert_eq!(n, 0, "backend must observe EOF after client write half-close");
|
||||
assert_eq!(
|
||||
n, 0,
|
||||
"backend must observe EOF after client write half-close"
|
||||
);
|
||||
});
|
||||
|
||||
let harness = build_harness("8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b", backend_addr.port());
|
||||
@@ -874,10 +947,16 @@ async fn tls_bad_mtproto_fallback_client_half_close_propagates_eof_to_backend()
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
client_side.shutdown().await.unwrap();
|
||||
|
||||
@@ -938,11 +1017,17 @@ async fn tls_bad_mtproto_fallback_backend_half_close_after_response_is_tolerated
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
read_and_discard_tls_record_body(&mut client_side, tls_response_head).await;
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(3), accept_task)
|
||||
@@ -994,10 +1079,16 @@ async fn tls_bad_mtproto_fallback_backend_reset_after_clienthello_is_handled() {
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
let write_res = client_side.write_all(&trailing_record).await;
|
||||
assert!(
|
||||
write_res.is_ok() || write_res.is_err(),
|
||||
@@ -1068,10 +1159,16 @@ async fn tls_bad_mtproto_fallback_backend_slow_reader_preserves_byte_identity()
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), accept_task)
|
||||
@@ -1152,7 +1249,10 @@ async fn tls_bad_mtproto_fallback_replay_pressure_masks_replay_without_serverhel
|
||||
let mut head = [0u8; 5];
|
||||
client_side.read_exact(&mut head).await.unwrap();
|
||||
assert_eq!(head[0], 0x16);
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&trailing_record).await.unwrap();
|
||||
} else {
|
||||
let mut one = [0u8; 1];
|
||||
@@ -1241,10 +1341,16 @@ async fn tls_bad_mtproto_fallback_large_multi_record_chaos_under_backpressure()
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let chaos = [5usize, 23, 11, 47, 3, 19, 29, 13, 7, 31];
|
||||
for record in [&a, &b, &c] {
|
||||
@@ -1316,10 +1422,16 @@ async fn tls_bad_mtproto_fallback_interleaved_control_and_application_records_ve
|
||||
|
||||
client_side.write_all(&client_hello).await.unwrap();
|
||||
let mut tls_response_head = [0u8; 5];
|
||||
client_side.read_exact(&mut tls_response_head).await.unwrap();
|
||||
client_side
|
||||
.read_exact(&mut tls_response_head)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(tls_response_head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
client_side.write_all(&ccs).await.unwrap();
|
||||
client_side.write_all(&app).await.unwrap();
|
||||
client_side.write_all(&alert).await.unwrap();
|
||||
@@ -1372,7 +1484,10 @@ async fn tls_bad_mtproto_fallback_many_short_sessions_with_chaos_no_cross_leak()
|
||||
);
|
||||
}
|
||||
|
||||
assert!(remaining.is_empty(), "all expected sessions must be consumed exactly once");
|
||||
assert!(
|
||||
remaining.is_empty(),
|
||||
"all expected sessions must be consumed exactly once"
|
||||
);
|
||||
});
|
||||
|
||||
let mut tasks = Vec::with_capacity(sessions);
|
||||
@@ -1413,7 +1528,10 @@ async fn tls_bad_mtproto_fallback_many_short_sessions_with_chaos_no_cross_leak()
|
||||
client_side.read_exact(&mut head).await.unwrap();
|
||||
assert_eq!(head[0], 0x16);
|
||||
|
||||
client_side.write_all(&invalid_mtproto_record).await.unwrap();
|
||||
client_side
|
||||
.write_all(&invalid_mtproto_record)
|
||||
.await
|
||||
.unwrap();
|
||||
for chunk in record.chunks((idx % 9) + 1) {
|
||||
client_side.write_all(chunk).await.unwrap();
|
||||
}
|
||||
@@ -2520,7 +2638,10 @@ async fn blackhat_coalesced_tail_parallel_32_sessions_no_cross_bleed() {
|
||||
"session mixup detected in parallel-32 blackhat test"
|
||||
);
|
||||
}
|
||||
assert!(remaining.is_empty(), "all expected sessions must be consumed");
|
||||
assert!(
|
||||
remaining.is_empty(),
|
||||
"all expected sessions must be consumed"
|
||||
);
|
||||
});
|
||||
|
||||
let mut tasks = Vec::with_capacity(sessions);
|
||||
|
||||
@@ -5,7 +5,10 @@ use std::net::SocketAddr;
|
||||
#[test]
|
||||
fn business_scope_hint_accepts_exact_boundary_length() {
|
||||
let value = format!("scope_{}", "a".repeat(MAX_SCOPE_HINT_LEN));
|
||||
assert_eq!(validated_scope_hint(&value), Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||
assert_eq!(
|
||||
validated_scope_hint(&value),
|
||||
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -24,7 +27,8 @@ fn business_known_dc_uses_ipv4_table_by_default() {
|
||||
#[test]
|
||||
fn business_negative_dc_maps_by_absolute_value() {
|
||||
let cfg = ProxyConfig::default();
|
||||
let resolved = get_dc_addr_static(-3, &cfg).expect("negative dc index must map by absolute value");
|
||||
let resolved =
|
||||
get_dc_addr_static(-3, &cfg).expect("negative dc index must map by absolute value");
|
||||
let expected = SocketAddr::new(TG_DATACENTERS_V4[2], TG_DATACENTER_PORT);
|
||||
assert_eq!(resolved, expected);
|
||||
}
|
||||
@@ -45,7 +49,8 @@ fn business_unknown_dc_uses_configured_default_dc_when_in_range() {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.default_dc = Some(4);
|
||||
|
||||
let resolved = get_dc_addr_static(29_999, &cfg).expect("unknown dc must resolve to configured default");
|
||||
let resolved =
|
||||
get_dc_addr_static(29_999, &cfg).expect("unknown dc must resolve to configured default");
|
||||
let expected = SocketAddr::new(TG_DATACENTERS_V4[3], TG_DATACENTER_PORT);
|
||||
assert_eq!(resolved, expected);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ fn common_invalid_override_entries_fallback_to_static_table() {
|
||||
vec!["bad-address".to_string(), "still-bad".to_string()],
|
||||
);
|
||||
|
||||
let resolved = get_dc_addr_static(2, &cfg).expect("fallback to static table must still resolve");
|
||||
let resolved =
|
||||
get_dc_addr_static(2, &cfg).expect("fallback to static table must still resolve");
|
||||
let expected = SocketAddr::new(TG_DATACENTERS_V4[1], TG_DATACENTER_PORT);
|
||||
assert_eq!(resolved, expected);
|
||||
}
|
||||
@@ -25,7 +26,8 @@ fn common_prefer_v6_with_only_ipv4_override_uses_override_instead_of_ignoring_it
|
||||
cfg.dc_overrides
|
||||
.insert("3".to_string(), vec!["203.0.113.203:443".to_string()]);
|
||||
|
||||
let resolved = get_dc_addr_static(3, &cfg).expect("ipv4 override must be used if no ipv6 override exists");
|
||||
let resolved =
|
||||
get_dc_addr_static(3, &cfg).expect("ipv4 override must be used if no ipv6 override exists");
|
||||
assert_eq!(resolved, "203.0.113.203:443".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::duplex;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{timeout, Duration as TokioDuration};
|
||||
use tokio::time::{Duration as TokioDuration, timeout};
|
||||
|
||||
fn make_crypto_reader<R>(reader: R) -> CryptoReader<R>
|
||||
where
|
||||
@@ -79,7 +79,9 @@ fn unknown_dc_log_respects_distinct_limit() {
|
||||
|
||||
#[test]
|
||||
fn unknown_dc_log_fails_closed_when_dedup_lock_is_poisoned() {
|
||||
let poisoned = Arc::new(std::sync::Mutex::new(std::collections::HashSet::<i16>::new()));
|
||||
let poisoned = Arc::new(std::sync::Mutex::new(
|
||||
std::collections::HashSet::<i16>::new(),
|
||||
));
|
||||
let poisoned_for_thread = poisoned.clone();
|
||||
|
||||
let _ = std::thread::spawn(move || {
|
||||
@@ -243,7 +245,10 @@ fn unknown_dc_log_path_sanitizer_accepts_safe_relative_path() {
|
||||
fs::create_dir_all(&base).expect("temp test directory must be creatable");
|
||||
|
||||
let candidate = base.join("unknown-dc.txt");
|
||||
let candidate_relative = format!("target/telemt-unknown-dc-log-{}/unknown-dc.txt", std::process::id());
|
||||
let candidate_relative = format!(
|
||||
"target/telemt-unknown-dc-log-{}/unknown-dc.txt",
|
||||
std::process::id()
|
||||
);
|
||||
|
||||
let sanitized = sanitize_unknown_dc_log_path(&candidate_relative)
|
||||
.expect("safe relative path with existing parent must be accepted");
|
||||
@@ -325,7 +330,10 @@ fn unknown_dc_log_path_sanitizer_accepts_symlinked_parent_inside_workspace() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-log-symlink-internal-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-log-symlink-internal-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let real_parent = base.join("real_parent");
|
||||
fs::create_dir_all(&real_parent).expect("real parent dir must be creatable");
|
||||
|
||||
@@ -354,7 +362,10 @@ fn unknown_dc_log_path_sanitizer_accepts_symlink_parent_escape_as_canonical_path
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-log-symlink-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-log-symlink-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("symlink test directory must be creatable");
|
||||
|
||||
let symlink_parent = base.join("escape_link");
|
||||
@@ -382,7 +393,10 @@ fn unknown_dc_log_path_revalidation_rejects_symlinked_target_escape() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-target-link-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-target-link-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("target-link base must be creatable");
|
||||
|
||||
let outside = std::env::temp_dir().join(format!("telemt-outside-{}", std::process::id()));
|
||||
@@ -445,7 +459,10 @@ fn unknown_dc_open_append_rejects_broken_symlink_target_with_nofollow() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-broken-link-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-broken-link-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("broken-link base must be creatable");
|
||||
|
||||
let linked_target = base.join("unknown-dc.log");
|
||||
@@ -470,7 +487,10 @@ fn adversarial_unknown_dc_open_append_symlink_flip_never_writes_outside_file() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-symlink-flip-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-symlink-flip-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("symlink-flip base must be creatable");
|
||||
|
||||
let outside = std::env::temp_dir().join(format!(
|
||||
@@ -530,7 +550,10 @@ fn stress_unknown_dc_open_append_regular_file_preserves_line_integrity() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-open-stress-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-open-stress-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("stress open base must be creatable");
|
||||
|
||||
let target = base.join("unknown-dc.log");
|
||||
@@ -556,7 +579,10 @@ fn unknown_dc_log_path_revalidation_accepts_regular_existing_target() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-safe-target-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-safe-target-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("safe target base must be creatable");
|
||||
|
||||
let target = base.join("unknown-dc.log");
|
||||
@@ -566,8 +592,8 @@ fn unknown_dc_log_path_revalidation_accepts_regular_existing_target() {
|
||||
"target/telemt-unknown-dc-safe-target-{}/unknown-dc.log",
|
||||
std::process::id()
|
||||
);
|
||||
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
||||
.expect("safe candidate must sanitize");
|
||||
let sanitized =
|
||||
sanitize_unknown_dc_log_path(&rel_candidate).expect("safe candidate must sanitize");
|
||||
assert!(
|
||||
unknown_dc_log_path_is_still_safe(&sanitized),
|
||||
"revalidation must allow safe existing regular files"
|
||||
@@ -579,7 +605,10 @@ fn unknown_dc_log_path_revalidation_rejects_deleted_parent_after_sanitize() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-vanish-parent-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-vanish-parent-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("vanish-parent base must be creatable");
|
||||
|
||||
let rel_candidate = format!(
|
||||
@@ -604,7 +633,10 @@ fn unknown_dc_log_path_revalidation_rejects_parent_swapped_to_symlink() {
|
||||
let parent = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-parent-swap-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-parent-swap-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&parent).expect("parent-swap test parent must be creatable");
|
||||
|
||||
let rel_candidate = format!(
|
||||
@@ -633,7 +665,10 @@ fn adversarial_check_then_symlink_flip_is_blocked_by_nofollow_open() {
|
||||
let parent = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-check-open-race-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-check-open-race-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&parent).expect("check-open-race parent must be creatable");
|
||||
|
||||
let target = parent.join("unknown-dc.log");
|
||||
@@ -642,8 +677,7 @@ fn adversarial_check_then_symlink_flip_is_blocked_by_nofollow_open() {
|
||||
"target/telemt-unknown-dc-check-open-race-{}/unknown-dc.log",
|
||||
std::process::id()
|
||||
);
|
||||
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
||||
.expect("candidate must sanitize");
|
||||
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate).expect("candidate must sanitize");
|
||||
|
||||
assert!(
|
||||
unknown_dc_log_path_is_still_safe(&sanitized),
|
||||
@@ -675,7 +709,10 @@ fn adversarial_parent_swap_after_check_is_blocked_by_anchored_open() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-parent-swap-openat-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-parent-swap-openat-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("parent-swap-openat base must be creatable");
|
||||
|
||||
let rel_candidate = format!(
|
||||
@@ -708,7 +745,10 @@ fn adversarial_parent_swap_after_check_is_blocked_by_anchored_open() {
|
||||
.expect_err("anchored open must fail when parent is swapped to symlink");
|
||||
let raw = err.raw_os_error();
|
||||
assert!(
|
||||
matches!(raw, Some(libc::ELOOP) | Some(libc::ENOTDIR) | Some(libc::ENOENT)),
|
||||
matches!(
|
||||
raw,
|
||||
Some(libc::ELOOP) | Some(libc::ENOTDIR) | Some(libc::ENOENT)
|
||||
),
|
||||
"anchored open must fail closed on parent swap race, got raw_os_error={raw:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -896,7 +936,10 @@ async fn unknown_dc_symlinked_target_escape_is_not_written_integration() {
|
||||
let base = std::env::current_dir()
|
||||
.expect("cwd must be available")
|
||||
.join("target")
|
||||
.join(format!("telemt-unknown-dc-no-write-link-{}", std::process::id()));
|
||||
.join(format!(
|
||||
"telemt-unknown-dc-no-write-link-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&base).expect("integration symlink base must be creatable");
|
||||
|
||||
let outside = std::env::temp_dir().join(format!(
|
||||
@@ -1024,11 +1067,17 @@ async fn direct_relay_abort_midflight_releases_route_gauge() {
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(started.is_ok(), "direct relay must increment route gauge before abort");
|
||||
assert!(
|
||||
started.is_ok(),
|
||||
"direct relay must increment route gauge before abort"
|
||||
);
|
||||
|
||||
relay_task.abort();
|
||||
let joined = relay_task.await;
|
||||
assert!(joined.is_err(), "aborted direct relay task must return join error");
|
||||
assert!(
|
||||
joined.is_err(),
|
||||
"aborted direct relay task must return join error"
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert_eq!(
|
||||
@@ -1313,15 +1362,22 @@ fn prefer_v6_override_matrix_prefers_matching_family_then_degrades_safely() {
|
||||
],
|
||||
);
|
||||
let a = get_dc_addr_static(dc_idx, &cfg_a).expect("v6+v4 override set must resolve");
|
||||
assert!(a.is_ipv6(), "prefer_v6 should choose v6 override when present");
|
||||
assert!(
|
||||
a.is_ipv6(),
|
||||
"prefer_v6 should choose v6 override when present"
|
||||
);
|
||||
|
||||
let mut cfg_b = ProxyConfig::default();
|
||||
cfg_b.network.prefer = 6;
|
||||
cfg_b.network.ipv6 = Some(true);
|
||||
cfg_b.dc_overrides
|
||||
cfg_b
|
||||
.dc_overrides
|
||||
.insert(dc_idx.to_string(), vec!["203.0.113.91:443".to_string()]);
|
||||
let b = get_dc_addr_static(dc_idx, &cfg_b).expect("v4-only override must still resolve");
|
||||
assert!(b.is_ipv4(), "when no v6 override exists, v4 override must be used");
|
||||
assert!(
|
||||
b.is_ipv4(),
|
||||
"when no v6 override exists, v4 override must be used"
|
||||
);
|
||||
|
||||
let mut cfg_c = ProxyConfig::default();
|
||||
cfg_c.network.prefer = 6;
|
||||
@@ -1350,7 +1406,8 @@ fn prefer_v6_override_matrix_ignores_invalid_entries_and_keeps_fail_closed_fallb
|
||||
],
|
||||
);
|
||||
|
||||
let addr = get_dc_addr_static(dc_idx, &cfg).expect("at least one valid override must keep resolution alive");
|
||||
let addr = get_dc_addr_static(dc_idx, &cfg)
|
||||
.expect("at least one valid override must keep resolution alive");
|
||||
assert_eq!(addr, "203.0.113.55:443".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
@@ -1370,7 +1427,10 @@ fn stress_prefer_v6_override_matrix_is_deterministic_under_mixed_inputs() {
|
||||
|
||||
let first = get_dc_addr_static(idx, &cfg).expect("first lookup must resolve");
|
||||
let second = get_dc_addr_static(idx, &cfg).expect("second lookup must resolve");
|
||||
assert_eq!(first, second, "override resolution must stay deterministic for dc {idx}");
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"override resolution must stay deterministic for dc {idx}"
|
||||
);
|
||||
assert!(first.is_ipv6(), "dc {idx}: v6 override should be preferred");
|
||||
}
|
||||
}
|
||||
@@ -1379,12 +1439,12 @@ fn stress_prefer_v6_override_matrix_is_deterministic_under_mixed_inputs() {
|
||||
async fn negative_direct_relay_dc_connection_refused_fails_fast() {
|
||||
let (client_reader_side, _client_writer_side) = duplex(1024);
|
||||
let (_client_reader_relay, client_writer_side) = duplex(1024);
|
||||
|
||||
|
||||
let key = [0u8; 32];
|
||||
let iv = 0u128;
|
||||
let client_reader = CryptoReader::new(client_reader_side, AesCtr::new(&key, iv));
|
||||
let client_writer = CryptoWriter::new(client_writer_side, AesCtr::new(&key, iv), 1024);
|
||||
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let buffer_pool = Arc::new(BufferPool::with_config(1024, 1));
|
||||
let rng = Arc::new(SecureRandom::new());
|
||||
@@ -1397,9 +1457,11 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
|
||||
drop(listener);
|
||||
|
||||
let mut config_with_override = ProxyConfig::default();
|
||||
config_with_override.dc_overrides.insert("1".to_string(), vec![dc_addr.to_string()]);
|
||||
config_with_override
|
||||
.dc_overrides
|
||||
.insert("1".to_string(), vec![dc_addr.to_string()]);
|
||||
let config = Arc::new(config_with_override);
|
||||
|
||||
|
||||
let upstream_manager = Arc::new(UpstreamManager::new(
|
||||
vec![UpstreamConfig {
|
||||
enabled: true,
|
||||
@@ -1418,7 +1480,7 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
|
||||
false,
|
||||
stats.clone(),
|
||||
));
|
||||
|
||||
|
||||
let success = HandshakeSuccess {
|
||||
user: "test-user".to_string(),
|
||||
peer: "127.0.0.1:12345".parse().unwrap(),
|
||||
@@ -1460,21 +1522,21 @@ async fn negative_direct_relay_dc_connection_refused_fails_fast() {
|
||||
async fn adversarial_direct_relay_cutover_integrity() {
|
||||
let (client_reader_side, _client_writer_side) = duplex(1024);
|
||||
let (_client_reader_relay, client_writer_side) = duplex(1024);
|
||||
|
||||
|
||||
let key = [0u8; 32];
|
||||
let iv = 0u128;
|
||||
let client_reader = CryptoReader::new(client_reader_side, AesCtr::new(&key, iv));
|
||||
let client_writer = CryptoWriter::new(client_writer_side, AesCtr::new(&key, iv), 1024);
|
||||
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let buffer_pool = Arc::new(BufferPool::with_config(1024, 1));
|
||||
let rng = Arc::new(SecureRandom::new());
|
||||
let route_runtime = RouteRuntimeController::new(RelayRouteMode::Direct);
|
||||
|
||||
|
||||
// Mock upstream server.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let dc_addr = listener.local_addr().unwrap();
|
||||
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
// Read handshake nonce.
|
||||
@@ -1485,9 +1547,11 @@ async fn adversarial_direct_relay_cutover_integrity() {
|
||||
});
|
||||
|
||||
let mut config_with_override = ProxyConfig::default();
|
||||
config_with_override.dc_overrides.insert("1".to_string(), vec![dc_addr.to_string()]);
|
||||
config_with_override
|
||||
.dc_overrides
|
||||
.insert("1".to_string(), vec![dc_addr.to_string()]);
|
||||
let config = Arc::new(config_with_override);
|
||||
|
||||
|
||||
let upstream_manager = Arc::new(UpstreamManager::new(
|
||||
vec![UpstreamConfig {
|
||||
enabled: true,
|
||||
@@ -1506,7 +1570,7 @@ async fn adversarial_direct_relay_cutover_integrity() {
|
||||
false,
|
||||
stats.clone(),
|
||||
));
|
||||
|
||||
|
||||
let success = HandshakeSuccess {
|
||||
user: "test-user".to_string(),
|
||||
peer: "127.0.0.1:12345".parse().unwrap(),
|
||||
@@ -1534,7 +1598,8 @@ async fn adversarial_direct_relay_cutover_integrity() {
|
||||
runtime_clone.subscribe(),
|
||||
runtime_clone.snapshot(),
|
||||
0xABCD_1234,
|
||||
).await
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
timeout(TokioDuration::from_secs(2), async {
|
||||
@@ -1547,10 +1612,10 @@ async fn adversarial_direct_relay_cutover_integrity() {
|
||||
})
|
||||
.await
|
||||
.expect("direct relay session must start before cutover");
|
||||
|
||||
|
||||
// Trigger cutover.
|
||||
route_runtime.set_mode(RelayRouteMode::Middle).unwrap();
|
||||
|
||||
|
||||
// The session should terminate after the staggered delay (1000-2000ms).
|
||||
let result = timeout(TokioDuration::from_secs(5), session_task)
|
||||
.await
|
||||
|
||||
@@ -40,9 +40,7 @@ fn subtle_light_fuzz_scope_hint_matches_oracle() {
|
||||
};
|
||||
!rest.is_empty()
|
||||
&& rest.len() <= MAX_SCOPE_HINT_LEN
|
||||
&& rest
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
&& rest.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
}
|
||||
|
||||
let mut state: u64 = 0xC0FF_EE11_D15C_AFE5;
|
||||
@@ -94,7 +92,10 @@ fn subtle_light_fuzz_dc_resolution_never_panics_and_preserves_port() {
|
||||
let dc_idx = (state as i16).wrapping_sub(16_384);
|
||||
let resolved = get_dc_addr_static(dc_idx, &cfg).expect("dc resolution must never fail");
|
||||
|
||||
assert_eq!(resolved.port(), crate::protocol::constants::TG_DATACENTER_PORT);
|
||||
assert_eq!(
|
||||
resolved.port(),
|
||||
crate::protocol::constants::TG_DATACENTER_PORT
|
||||
);
|
||||
let expect_v6 = cfg.network.prefer == 6 && cfg.network.ipv6.unwrap_or(true);
|
||||
assert_eq!(resolved.is_ipv6(), expect_v6);
|
||||
}
|
||||
@@ -166,7 +167,9 @@ async fn subtle_integration_parallel_unique_dcs_log_unique_lines() {
|
||||
cfg.general.unknown_dc_log_path = Some(rel_file);
|
||||
|
||||
let cfg = Arc::new(cfg);
|
||||
let dcs = [31_901_i16, 31_902, 31_903, 31_904, 31_905, 31_906, 31_907, 31_908];
|
||||
let dcs = [
|
||||
31_901_i16, 31_902, 31_903, 31_904, 31_905, 31_906, 31_907, 31_908,
|
||||
];
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for dc in dcs {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::time::{Duration, Instant};
|
||||
use crate::crypto::sha256;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn make_valid_mtproto_handshake(secret_hex: &str, proto_tag: ProtoTag, dc_idx: i16) -> [u8; HANDSHAKE_LEN] {
|
||||
fn make_valid_mtproto_handshake(
|
||||
secret_hex: &str,
|
||||
proto_tag: ProtoTag,
|
||||
dc_idx: i16,
|
||||
) -> [u8; HANDSHAKE_LEN] {
|
||||
let secret = hex::decode(secret_hex).expect("secret hex must decode");
|
||||
let mut handshake = [0x5Au8; HANDSHAKE_LEN];
|
||||
for (idx, b) in handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN]
|
||||
@@ -49,7 +53,9 @@ fn auth_probe_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.users.clear();
|
||||
cfg.access.users.insert("user".to_string(), secret_hex.to_string());
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), secret_hex.to_string());
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.general.modes.secure = true;
|
||||
cfg
|
||||
@@ -71,9 +77,19 @@ async fn mtproto_handshake_bit_flip_anywhere_rejected() {
|
||||
let peer: SocketAddr = "192.0.2.1:12345".parse().unwrap();
|
||||
|
||||
// Baseline check
|
||||
let res = handle_mtproto_handshake(&base, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, None).await;
|
||||
let res = handle_mtproto_handshake(
|
||||
&base,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
HandshakeResult::Success(_) => {},
|
||||
HandshakeResult::Success(_) => {}
|
||||
_ => panic!("Baseline failed: expected Success"),
|
||||
}
|
||||
|
||||
@@ -81,8 +97,21 @@ async fn mtproto_handshake_bit_flip_anywhere_rejected() {
|
||||
for byte_pos in SKIP_LEN..HANDSHAKE_LEN {
|
||||
let mut h = base;
|
||||
h[byte_pos] ^= 0x01; // Flip 1 bit
|
||||
let res = handle_mtproto_handshake(&h, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, None).await;
|
||||
assert!(matches!(res, HandshakeResult::BadClient { .. }), "Flip at byte {byte_pos} bit 0 must be rejected");
|
||||
let res = handle_mtproto_handshake(
|
||||
&h,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(res, HandshakeResult::BadClient { .. }),
|
||||
"Flip at byte {byte_pos} bit 0 must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,25 +128,51 @@ async fn mtproto_handshake_timing_neutrality_mocked() {
|
||||
let peer: SocketAddr = "192.0.2.2:54321".parse().unwrap();
|
||||
|
||||
const ITER: usize = 50;
|
||||
|
||||
|
||||
let mut start = Instant::now();
|
||||
for _ in 0..ITER {
|
||||
let _ = handle_mtproto_handshake(&base, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, None).await;
|
||||
let _ = handle_mtproto_handshake(
|
||||
&base,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let duration_success = start.elapsed();
|
||||
|
||||
start = Instant::now();
|
||||
for i in 0..ITER {
|
||||
let mut h = base;
|
||||
h[SKIP_LEN + (i % 48)] ^= 0xFF;
|
||||
let _ = handle_mtproto_handshake(&h, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, None).await;
|
||||
h[SKIP_LEN + (i % 48)] ^= 0xFF;
|
||||
let _ = handle_mtproto_handshake(
|
||||
&h,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let duration_fail = start.elapsed();
|
||||
|
||||
let avg_diff_ms = (duration_success.as_millis() as f64 - duration_fail.as_millis() as f64).abs() / ITER as f64;
|
||||
|
||||
let avg_diff_ms = (duration_success.as_millis() as f64 - duration_fail.as_millis() as f64)
|
||||
.abs()
|
||||
/ ITER as f64;
|
||||
|
||||
// Threshold (loose for CI)
|
||||
assert!(avg_diff_ms < 100.0, "Timing difference too large: {} ms/iter", avg_diff_ms);
|
||||
assert!(
|
||||
avg_diff_ms < 100.0,
|
||||
"Timing difference too large: {} ms/iter",
|
||||
avg_diff_ms
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -130,13 +185,13 @@ async fn auth_probe_throttle_saturation_stress() {
|
||||
clear_auth_probe_state_for_testing();
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
|
||||
// Record enough failures for one IP to trigger backoff
|
||||
let target_ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
|
||||
for _ in 0..AUTH_PROBE_BACKOFF_START_FAILS {
|
||||
auth_probe_record_failure(target_ip, now);
|
||||
}
|
||||
|
||||
|
||||
assert!(auth_probe_is_throttled(target_ip, now));
|
||||
|
||||
// Stress test with many unique IPs
|
||||
@@ -145,10 +200,7 @@ async fn auth_probe_throttle_saturation_stress() {
|
||||
auth_probe_record_failure(ip, now);
|
||||
}
|
||||
|
||||
let tracked = AUTH_PROBE_STATE
|
||||
.get()
|
||||
.map(|state| state.len())
|
||||
.unwrap_or(0);
|
||||
let tracked = AUTH_PROBE_STATE.get().map(|state| state.len()).unwrap_or(0);
|
||||
assert!(
|
||||
tracked <= AUTH_PROBE_TRACK_MAX_ENTRIES,
|
||||
"auth probe state grew past hard cap: {tracked} > {AUTH_PROBE_TRACK_MAX_ENTRIES}"
|
||||
@@ -166,7 +218,17 @@ async fn mtproto_handshake_abridged_prefix_rejected() {
|
||||
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
|
||||
let peer: SocketAddr = "192.0.2.3:12345".parse().unwrap();
|
||||
|
||||
let res = handle_mtproto_handshake(&handshake, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, None).await;
|
||||
let res = handle_mtproto_handshake(
|
||||
&handshake,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// MTProxy stops immediately on 0xef
|
||||
assert!(matches!(res, HandshakeResult::BadClient { .. }));
|
||||
}
|
||||
@@ -178,11 +240,17 @@ async fn mtproto_handshake_preferred_user_mismatch_continues() {
|
||||
|
||||
let secret1_hex = "11111111111111111111111111111111";
|
||||
let secret2_hex = "22222222222222222222222222222222";
|
||||
|
||||
|
||||
let base = make_valid_mtproto_handshake(secret2_hex, ProtoTag::Secure, 1);
|
||||
let mut config = ProxyConfig::default();
|
||||
config.access.users.insert("user1".to_string(), secret1_hex.to_string());
|
||||
config.access.users.insert("user2".to_string(), secret2_hex.to_string());
|
||||
config
|
||||
.access
|
||||
.users
|
||||
.insert("user1".to_string(), secret1_hex.to_string());
|
||||
config
|
||||
.access
|
||||
.users
|
||||
.insert("user2".to_string(), secret2_hex.to_string());
|
||||
config.access.ignore_time_skew = true;
|
||||
config.general.modes.secure = true;
|
||||
|
||||
@@ -190,7 +258,17 @@ async fn mtproto_handshake_preferred_user_mismatch_continues() {
|
||||
let peer: SocketAddr = "192.0.2.4:12345".parse().unwrap();
|
||||
|
||||
// Even if we prefer user1, if user2 matches, it should succeed.
|
||||
let res = handle_mtproto_handshake(&base, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, Some("user1")).await;
|
||||
let res = handle_mtproto_handshake(
|
||||
&base,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
Some("user1"),
|
||||
)
|
||||
.await;
|
||||
if let HandshakeResult::Success((_, _, success)) = res {
|
||||
assert_eq!(success.user, "user2");
|
||||
} else {
|
||||
@@ -209,20 +287,30 @@ async fn mtproto_handshake_concurrent_flood_stability() {
|
||||
config.access.ignore_time_skew = true;
|
||||
let replay_checker = Arc::new(ReplayChecker::new(1024, Duration::from_secs(60)));
|
||||
let config = Arc::new(config);
|
||||
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for i in 0..50 {
|
||||
let base = base;
|
||||
let config = Arc::clone(&config);
|
||||
let replay_checker = Arc::clone(&replay_checker);
|
||||
let peer: SocketAddr = format!("192.0.2.{}:12345", (i % 254) + 1).parse().unwrap();
|
||||
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let res = handle_mtproto_handshake(&base, tokio::io::empty(), tokio::io::sink(), peer, &config, &replay_checker, false, None).await;
|
||||
let res = handle_mtproto_handshake(
|
||||
&base,
|
||||
tokio::io::empty(),
|
||||
tokio::io::sink(),
|
||||
peer,
|
||||
&config,
|
||||
&replay_checker,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
matches!(res, HandshakeResult::Success(_))
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// We don't necessarily care if they all succeed (some might fail due to replay if they hit the same chunk),
|
||||
// but the system must not panic or hang.
|
||||
for task in tasks {
|
||||
@@ -306,7 +394,10 @@ async fn mtproto_blackhat_mutation_corpus_never_panics_and_stays_fail_closed() {
|
||||
.expect("fuzzed mutation must complete in bounded time");
|
||||
|
||||
assert!(
|
||||
matches!(res, HandshakeResult::BadClient { .. } | HandshakeResult::Success(_)),
|
||||
matches!(
|
||||
res,
|
||||
HandshakeResult::BadClient { .. } | HandshakeResult::Success(_)
|
||||
),
|
||||
"mutation corpus must stay within explicit handshake outcomes"
|
||||
);
|
||||
}
|
||||
@@ -345,7 +436,12 @@ async fn mtproto_invalid_storm_over_cap_keeps_probe_map_hard_bounded() {
|
||||
|
||||
for i in 0..(AUTH_PROBE_TRACK_MAX_ENTRIES + 512) {
|
||||
let peer: SocketAddr = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(10, (i / 65535) as u8, ((i / 255) % 255) as u8, (i % 255 + 1) as u8)),
|
||||
IpAddr::V4(Ipv4Addr::new(
|
||||
10,
|
||||
(i / 65535) as u8,
|
||||
((i / 255) % 255) as u8,
|
||||
(i % 255 + 1) as u8,
|
||||
)),
|
||||
43000 + (i % 20000) as u16,
|
||||
);
|
||||
let res = handle_mtproto_handshake(
|
||||
@@ -362,10 +458,7 @@ async fn mtproto_invalid_storm_over_cap_keeps_probe_map_hard_bounded() {
|
||||
assert!(matches!(res, HandshakeResult::BadClient { .. }));
|
||||
}
|
||||
|
||||
let tracked = AUTH_PROBE_STATE
|
||||
.get()
|
||||
.map(|state| state.len())
|
||||
.unwrap_or(0);
|
||||
let tracked = AUTH_PROBE_STATE.get().map(|state| state.len()).unwrap_or(0);
|
||||
assert!(
|
||||
tracked <= AUTH_PROBE_TRACK_MAX_ENTRIES,
|
||||
"probe map must remain bounded under invalid storm: {tracked}"
|
||||
@@ -415,7 +508,10 @@ async fn mtproto_property_style_multi_bit_mutations_fail_closed_or_auth_only() {
|
||||
.expect("mutation iteration must complete in bounded time");
|
||||
|
||||
assert!(
|
||||
matches!(outcome, HandshakeResult::BadClient { .. } | HandshakeResult::Success(_)),
|
||||
matches!(
|
||||
outcome,
|
||||
HandshakeResult::BadClient { .. } | HandshakeResult::Success(_)
|
||||
),
|
||||
"mutations must remain fail-closed/auth-only"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::protocol::constants::ProtoTag;
|
||||
use crate::stats::ReplayChecker;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::MutexGuard;
|
||||
use tokio::time::{timeout, Duration as TokioDuration};
|
||||
use tokio::time::{Duration as TokioDuration, timeout};
|
||||
|
||||
fn make_mtproto_handshake_with_proto_bytes(
|
||||
secret_hex: &str,
|
||||
@@ -48,14 +48,20 @@ fn make_mtproto_handshake_with_proto_bytes(
|
||||
handshake
|
||||
}
|
||||
|
||||
fn make_valid_mtproto_handshake(secret_hex: &str, proto_tag: ProtoTag, dc_idx: i16) -> [u8; HANDSHAKE_LEN] {
|
||||
fn make_valid_mtproto_handshake(
|
||||
secret_hex: &str,
|
||||
proto_tag: ProtoTag,
|
||||
dc_idx: i16,
|
||||
) -> [u8; HANDSHAKE_LEN] {
|
||||
make_mtproto_handshake_with_proto_bytes(secret_hex, proto_tag.to_bytes(), dc_idx)
|
||||
}
|
||||
|
||||
fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
|
||||
let mut cfg = ProxyConfig::default();
|
||||
cfg.access.users.clear();
|
||||
cfg.access.users.insert("user".to_string(), secret_hex.to_string());
|
||||
cfg.access
|
||||
.users
|
||||
.insert("user".to_string(), secret_hex.to_string());
|
||||
cfg.access.ignore_time_skew = true;
|
||||
cfg.general.modes.secure = true;
|
||||
cfg
|
||||
@@ -140,7 +146,9 @@ async fn mtproto_handshake_fuzz_corpus_never_panics_and_stays_fail_closed() {
|
||||
for _ in 0..32 {
|
||||
let mut mutated = base;
|
||||
for _ in 0..4 {
|
||||
seed = seed.wrapping_mul(2862933555777941757).wrapping_add(3037000493);
|
||||
seed = seed
|
||||
.wrapping_mul(2862933555777941757)
|
||||
.wrapping_add(3037000493);
|
||||
let idx = SKIP_LEN + (seed as usize % (PREKEY_LEN + IV_LEN));
|
||||
mutated[idx] ^= ((seed >> 19) as u8).wrapping_add(1);
|
||||
}
|
||||
@@ -267,4 +275,4 @@ async fn mtproto_handshake_mixed_corpus_never_panics_and_exact_duplicates_are_re
|
||||
}
|
||||
|
||||
clear_auth_probe_state_for_testing();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::*;
|
||||
use crate::crypto::{sha256, sha256_hmac};
|
||||
use dashmap::DashMap;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -80,8 +80,7 @@ fn make_valid_tls_client_hello_with_alpn(
|
||||
for i in 0..4 {
|
||||
digest[28 + i] ^= ts[i];
|
||||
}
|
||||
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||
.copy_from_slice(&digest);
|
||||
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].copy_from_slice(&digest);
|
||||
|
||||
record
|
||||
}
|
||||
@@ -151,8 +150,7 @@ fn make_valid_tls_client_hello_with_sni_and_alpn(
|
||||
for i in 0..4 {
|
||||
digest[28 + i] ^= ts[i];
|
||||
}
|
||||
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||
.copy_from_slice(&digest);
|
||||
record[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].copy_from_slice(&digest);
|
||||
|
||||
record
|
||||
}
|
||||
@@ -167,7 +165,11 @@ fn test_config_with_secret_hex(secret_hex: &str) -> ProxyConfig {
|
||||
cfg
|
||||
}
|
||||
|
||||
fn make_valid_mtproto_handshake(secret_hex: &str, proto_tag: ProtoTag, dc_idx: i16) -> [u8; HANDSHAKE_LEN] {
|
||||
fn make_valid_mtproto_handshake(
|
||||
secret_hex: &str,
|
||||
proto_tag: ProtoTag,
|
||||
dc_idx: i16,
|
||||
) -> [u8; HANDSHAKE_LEN] {
|
||||
let secret = hex::decode(secret_hex).expect("secret hex must decode for mtproto test helper");
|
||||
|
||||
let mut handshake = [0x5Au8; HANDSHAKE_LEN];
|
||||
@@ -328,7 +330,10 @@ fn test_generate_tg_nonce_fast_mode_embeds_reversed_client_enc_material() {
|
||||
expected.extend_from_slice(&client_enc_iv.to_be_bytes());
|
||||
expected.reverse();
|
||||
|
||||
assert_eq!(&nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN], expected.as_slice());
|
||||
assert_eq!(
|
||||
&nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN],
|
||||
expected.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -445,7 +450,9 @@ async fn tls_replay_with_ignore_time_skew_and_small_boot_timestamp_is_still_bloc
|
||||
#[tokio::test]
|
||||
async fn tls_replay_concurrent_identical_handshake_allows_exactly_one_success() {
|
||||
let secret = [0x77u8; 16];
|
||||
let config = Arc::new(test_config_with_secret_hex("77777777777777777777777777777777"));
|
||||
let config = Arc::new(test_config_with_secret_hex(
|
||||
"77777777777777777777777777777777",
|
||||
));
|
||||
let replay_checker = Arc::new(ReplayChecker::new(4096, Duration::from_secs(60)));
|
||||
let rng = Arc::new(SecureRandom::new());
|
||||
let handshake = Arc::new(make_valid_tls_handshake(&secret, 0));
|
||||
@@ -785,10 +792,10 @@ async fn mixed_secret_lengths_keep_valid_user_authenticating() {
|
||||
.access
|
||||
.users
|
||||
.insert("broken_user".to_string(), "aa".to_string());
|
||||
config
|
||||
.access
|
||||
.users
|
||||
.insert("valid_user".to_string(), "22222222222222222222222222222222".to_string());
|
||||
config.access.users.insert(
|
||||
"valid_user".to_string(),
|
||||
"22222222222222222222222222222222".to_string(),
|
||||
);
|
||||
config.access.ignore_time_skew = true;
|
||||
|
||||
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
|
||||
@@ -829,12 +836,8 @@ async fn tls_sni_preferred_user_hint_selects_matching_identity_first() {
|
||||
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
|
||||
let rng = SecureRandom::new();
|
||||
let peer: SocketAddr = "198.51.100.188:44326".parse().unwrap();
|
||||
let handshake = make_valid_tls_client_hello_with_sni_and_alpn(
|
||||
&shared_secret,
|
||||
0,
|
||||
"user-b",
|
||||
&[b"h2"],
|
||||
);
|
||||
let handshake =
|
||||
make_valid_tls_client_hello_with_sni_and_alpn(&shared_secret, 0, "user-b", &[b"h2"]);
|
||||
|
||||
let result = handle_tls_handshake(
|
||||
&handshake,
|
||||
@@ -868,10 +871,10 @@ fn stress_decode_user_secrets_keeps_preferred_user_first_in_large_set() {
|
||||
let secret_hex = "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f".to_string();
|
||||
|
||||
for i in 0..4096usize {
|
||||
config.access.users.insert(
|
||||
format!("decoy-{i:04}.example"),
|
||||
secret_hex.clone(),
|
||||
);
|
||||
config
|
||||
.access
|
||||
.users
|
||||
.insert(format!("decoy-{i:04}.example"), secret_hex.clone());
|
||||
}
|
||||
config
|
||||
.access
|
||||
@@ -910,10 +913,10 @@ async fn stress_tls_sni_preferred_user_hint_scales_to_large_user_set() {
|
||||
let secret_hex = "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f".to_string();
|
||||
|
||||
for i in 0..4096usize {
|
||||
config.access.users.insert(
|
||||
format!("decoy-{i:04}.example"),
|
||||
secret_hex.clone(),
|
||||
);
|
||||
config
|
||||
.access
|
||||
.users
|
||||
.insert(format!("decoy-{i:04}.example"), secret_hex.clone());
|
||||
}
|
||||
config
|
||||
.access
|
||||
@@ -945,8 +948,7 @@ async fn stress_tls_sni_preferred_user_hint_scales_to_large_user_set() {
|
||||
match result {
|
||||
HandshakeResult::Success((_, _, user)) => {
|
||||
assert_eq!(
|
||||
user,
|
||||
preferred_user,
|
||||
user, preferred_user,
|
||||
"SNI preferred-user hint must remain stable under large user cardinality"
|
||||
);
|
||||
}
|
||||
@@ -1880,11 +1882,15 @@ fn auth_probe_ipv6_different_prefixes_use_distinct_buckets() {
|
||||
"different IPv6 /64 prefixes must not share throttle buckets"
|
||||
);
|
||||
assert_eq!(
|
||||
state.get(&normalize_auth_probe_ip(ip_a)).map(|entry| entry.fail_streak),
|
||||
state
|
||||
.get(&normalize_auth_probe_ip(ip_a))
|
||||
.map(|entry| entry.fail_streak),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
state.get(&normalize_auth_probe_ip(ip_b)).map(|entry| entry.fail_streak),
|
||||
state
|
||||
.get(&normalize_auth_probe_ip(ip_b))
|
||||
.map(|entry| entry.fail_streak),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
@@ -1944,7 +1950,6 @@ fn auth_probe_eviction_offset_changes_with_time_component() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn auth_probe_round_limited_overcap_eviction_marks_saturation_and_keeps_newcomer_trackable() {
|
||||
let _guard = auth_probe_test_lock()
|
||||
@@ -1986,7 +1991,10 @@ fn auth_probe_round_limited_overcap_eviction_marks_saturation_and_keeps_newcomer
|
||||
let newcomer = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 40));
|
||||
auth_probe_record_failure_with_state(&state, newcomer, now + Duration::from_millis(1));
|
||||
|
||||
assert!(state.get(&newcomer).is_some(), "newcomer must still be tracked under over-cap pressure");
|
||||
assert!(
|
||||
state.get(&newcomer).is_some(),
|
||||
"newcomer must still be tracked under over-cap pressure"
|
||||
);
|
||||
assert!(
|
||||
state.get(&sentinel).is_some(),
|
||||
"high fail-streak sentinel must survive round-limited eviction"
|
||||
@@ -2077,13 +2085,20 @@ fn stress_auth_probe_overcap_churn_does_not_starve_high_threat_sentinel_bucket()
|
||||
((step >> 8) & 0xff) as u8,
|
||||
(step & 0xff) as u8,
|
||||
));
|
||||
auth_probe_record_failure_with_state(&state, newcomer, base_now + Duration::from_millis(step as u64 + 1));
|
||||
auth_probe_record_failure_with_state(
|
||||
&state,
|
||||
newcomer,
|
||||
base_now + Duration::from_millis(step as u64 + 1),
|
||||
);
|
||||
|
||||
assert!(
|
||||
state.get(&sentinel).is_some(),
|
||||
"step {step}: high-threat sentinel must not be starved by newcomer churn"
|
||||
);
|
||||
assert!(state.get(&newcomer).is_some(), "step {step}: newcomer must be tracked");
|
||||
assert!(
|
||||
state.get(&newcomer).is_some(),
|
||||
"step {step}: newcomer must be tracked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2129,10 +2144,22 @@ fn light_fuzz_auth_probe_overcap_eviction_prefers_less_threatening_entries() {
|
||||
);
|
||||
}
|
||||
|
||||
let newcomer = IpAddr::V4(Ipv4Addr::new(203, 10, ((round >> 8) & 0xff) as u8, (round & 0xff) as u8));
|
||||
auth_probe_record_failure_with_state(&state, newcomer, now + Duration::from_millis(round as u64 + 1));
|
||||
let newcomer = IpAddr::V4(Ipv4Addr::new(
|
||||
203,
|
||||
10,
|
||||
((round >> 8) & 0xff) as u8,
|
||||
(round & 0xff) as u8,
|
||||
));
|
||||
auth_probe_record_failure_with_state(
|
||||
&state,
|
||||
newcomer,
|
||||
now + Duration::from_millis(round as u64 + 1),
|
||||
);
|
||||
|
||||
assert!(state.get(&newcomer).is_some(), "round {round}: newcomer should be tracked");
|
||||
assert!(
|
||||
state.get(&newcomer).is_some(),
|
||||
"round {round}: newcomer should be tracked"
|
||||
);
|
||||
assert!(
|
||||
state.get(&sentinel).is_some(),
|
||||
"round {round}: high fail-streak sentinel should survive mixed low-threat pool"
|
||||
@@ -2145,7 +2172,12 @@ fn light_fuzz_auth_probe_eviction_offset_is_deterministic_per_input_pair() {
|
||||
let base = Instant::now();
|
||||
|
||||
for _ in 0..4096usize {
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(rng.random(), rng.random(), rng.random(), rng.random()));
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(
|
||||
rng.random(),
|
||||
rng.random(),
|
||||
rng.random(),
|
||||
rng.random(),
|
||||
));
|
||||
let offset_ns = rng.random_range(0_u64..2_000_000);
|
||||
let when = base + Duration::from_nanos(offset_ns);
|
||||
|
||||
@@ -2244,8 +2276,7 @@ async fn auth_probe_concurrent_failures_do_not_lose_fail_streak_updates() {
|
||||
let streak = auth_probe_fail_streak_for_testing(peer_ip)
|
||||
.expect("tracked peer must exist after concurrent failure burst");
|
||||
assert_eq!(
|
||||
streak as usize,
|
||||
tasks,
|
||||
streak as usize, tasks,
|
||||
"concurrent failures for one source must account every attempt"
|
||||
);
|
||||
}
|
||||
@@ -2258,7 +2289,9 @@ async fn invalid_probe_noise_from_other_ips_does_not_break_valid_tls_handshake()
|
||||
clear_auth_probe_state_for_testing();
|
||||
|
||||
let secret = [0x31u8; 16];
|
||||
let config = Arc::new(test_config_with_secret_hex("31313131313131313131313131313131"));
|
||||
let config = Arc::new(test_config_with_secret_hex(
|
||||
"31313131313131313131313131313131",
|
||||
));
|
||||
let replay_checker = Arc::new(ReplayChecker::new(4096, Duration::from_secs(60)));
|
||||
let rng = Arc::new(SecureRandom::new());
|
||||
let victim_peer: SocketAddr = "198.51.100.91:44391".parse().unwrap();
|
||||
@@ -2845,7 +2878,10 @@ async fn saturation_grace_progression_tls_reaches_cap_then_stops_incrementing()
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, HandshakeResult::BadClient { .. }));
|
||||
assert_eq!(auth_probe_fail_streak_for_testing(peer.ip()), Some(expected));
|
||||
assert_eq!(
|
||||
auth_probe_fail_streak_for_testing(peer.ip()),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -2924,7 +2960,10 @@ async fn saturation_grace_progression_mtproto_reaches_cap_then_stops_incrementin
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, HandshakeResult::BadClient { .. }));
|
||||
assert_eq!(auth_probe_fail_streak_for_testing(peer.ip()), Some(expected));
|
||||
assert_eq!(
|
||||
auth_probe_fail_streak_for_testing(peer.ip()),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -3148,7 +3187,9 @@ async fn adversarial_same_peer_invalid_tls_storm_does_not_bypass_saturation_grac
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
clear_auth_probe_state_for_testing();
|
||||
|
||||
let config = Arc::new(test_config_with_secret_hex("75757575757575757575757575757575"));
|
||||
let config = Arc::new(test_config_with_secret_hex(
|
||||
"75757575757575757575757575757575",
|
||||
));
|
||||
let replay_checker = Arc::new(ReplayChecker::new(1024, Duration::from_secs(60)));
|
||||
let rng = Arc::new(SecureRandom::new());
|
||||
let peer: SocketAddr = "198.51.100.212:45212".parse().unwrap();
|
||||
@@ -3296,7 +3337,11 @@ async fn adversarial_saturation_burst_only_admits_valid_tls_and_mtproto_handshak
|
||||
}
|
||||
|
||||
let valid_tls = Arc::new(make_valid_tls_handshake(&secret, 0));
|
||||
let valid_mtproto = Arc::new(make_valid_mtproto_handshake(secret_hex, ProtoTag::Secure, 3));
|
||||
let valid_mtproto = Arc::new(make_valid_mtproto_handshake(
|
||||
secret_hex,
|
||||
ProtoTag::Secure,
|
||||
3,
|
||||
));
|
||||
let mut invalid_tls = vec![0x42u8; tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 + 32];
|
||||
invalid_tls[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] = 32;
|
||||
let invalid_tls = Arc::new(invalid_tls);
|
||||
@@ -3368,7 +3413,9 @@ async fn adversarial_saturation_burst_only_admits_valid_tls_and_mtproto_handshak
|
||||
match task.await.unwrap() {
|
||||
HandshakeResult::BadClient { .. } => bad_clients += 1,
|
||||
HandshakeResult::Success(_) => panic!("invalid TLS probe unexpectedly authenticated"),
|
||||
HandshakeResult::Error(err) => panic!("unexpected error in invalid TLS saturation burst test: {err}"),
|
||||
HandshakeResult::Error(err) => {
|
||||
panic!("unexpected error in invalid TLS saturation burst test: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3385,8 +3432,7 @@ async fn adversarial_saturation_burst_only_admits_valid_tls_and_mtproto_handshak
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bad_clients,
|
||||
48,
|
||||
bad_clients, 48,
|
||||
"all invalid TLS probes in mixed saturation burst must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,7 +277,10 @@ async fn integration_ab_harness_envelope_and_blur_improve_obfuscation_vs_baselin
|
||||
hardened_b.len()
|
||||
);
|
||||
|
||||
assert_eq!(baseline_overlap, 0, "baseline above-cap classes should be disjoint");
|
||||
assert_eq!(
|
||||
baseline_overlap, 0,
|
||||
"baseline above-cap classes should be disjoint"
|
||||
);
|
||||
assert!(
|
||||
hardened_overlap > baseline_overlap,
|
||||
"above-cap blur should increase cross-class overlap: baseline={} hardened={}",
|
||||
@@ -314,7 +317,10 @@ fn timing_classifier_helper_threshold_accuracy_drops_for_identical_sets() {
|
||||
let a = vec![10u128, 11, 12, 13, 14];
|
||||
let b = vec![10u128, 11, 12, 13, 14];
|
||||
let acc = best_threshold_accuracy_u128(&a, &b);
|
||||
assert!(acc <= 0.6, "identical sets should not be strongly separable");
|
||||
assert!(
|
||||
acc <= 0.6,
|
||||
"identical sets should not be strongly separable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -336,7 +342,10 @@ async fn timing_classifier_baseline_connect_fail_vs_slow_backend_is_highly_separ
|
||||
let slow = collect_timing_samples(PathClass::SlowBackend, false, 8).await;
|
||||
|
||||
let acc = best_threshold_accuracy_u128(&fail, &slow);
|
||||
assert!(acc >= 0.80, "baseline timing classes should be separable enough");
|
||||
assert!(
|
||||
acc >= 0.80,
|
||||
"baseline timing classes should be separable enough"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -408,7 +417,10 @@ async fn timing_classifier_normalized_mean_bucket_delta_connect_fail_vs_connect_
|
||||
let fail_mean = mean_ms(&fail);
|
||||
let success_mean = mean_ms(&success);
|
||||
let delta_bucket = ((fail_mean as i128 - success_mean as i128).abs()) / 20;
|
||||
assert!(delta_bucket <= 3, "mean bucket delta too large: {delta_bucket}");
|
||||
assert!(
|
||||
delta_bucket <= 3,
|
||||
"mean bucket delta too large: {delta_bucket}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -418,7 +430,10 @@ async fn timing_classifier_normalized_p95_bucket_delta_connect_success_vs_slow_i
|
||||
let p95_success = percentile_ms(success, 95, 100);
|
||||
let p95_slow = percentile_ms(slow, 95, 100);
|
||||
let delta_bucket = ((p95_success as i128 - p95_slow as i128).abs()) / 20;
|
||||
assert!(delta_bucket <= 4, "p95 bucket delta too large: {delta_bucket}");
|
||||
assert!(
|
||||
delta_bucket <= 4,
|
||||
"p95 bucket delta too large: {delta_bucket}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -434,7 +449,8 @@ async fn timing_classifier_normalized_spread_is_not_worse_than_baseline_for_conn
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_under_normalization() {
|
||||
async fn timing_classifier_light_fuzz_pairwise_bucketed_accuracy_stays_bounded_under_normalization()
|
||||
{
|
||||
let pairs = [
|
||||
(PathClass::ConnectFail, PathClass::ConnectSuccess),
|
||||
(PathClass::ConnectFail, PathClass::SlowBackend),
|
||||
@@ -504,7 +520,10 @@ async fn timing_classifier_stress_parallel_sampling_finishes_and_stays_bounded()
|
||||
_ => PathClass::SlowBackend,
|
||||
};
|
||||
let sample = measure_masking_duration_ms(class, true).await;
|
||||
assert!((100..=1600).contains(&sample), "stress sample out of bounds: {sample}");
|
||||
assert!(
|
||||
(100..=1600).contains(&sample),
|
||||
"stress sample out of bounds: {sample}"
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::duplex;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Instant, Duration};
|
||||
use crate::config::ProxyConfig;
|
||||
use crate::proxy::relay::relay_bidirectional;
|
||||
use crate::stats::Stats;
|
||||
use crate::stats::beobachten::BeobachtenStore;
|
||||
use crate::stream::BufferPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::duplex;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Probing Indistinguishability (OWASP ASVS 5.1.7)
|
||||
@@ -19,7 +19,7 @@ async fn masking_probes_indistinguishable_timing() {
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = 80; // Should timeout/refuse
|
||||
|
||||
|
||||
let peer: SocketAddr = "192.0.2.10:443".parse().unwrap();
|
||||
let local_addr: SocketAddr = "127.0.0.1:443".parse().unwrap();
|
||||
let beobachten = BeobachtenStore::new();
|
||||
@@ -28,14 +28,17 @@ async fn masking_probes_indistinguishable_timing() {
|
||||
let probes = vec![
|
||||
(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n".to_vec(), "HTTP"),
|
||||
(b"SSH-2.0-probe".to_vec(), "SSH"),
|
||||
(vec![0x16, 0x03, 0x03, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00], "TLS-scanner"),
|
||||
(
|
||||
vec![0x16, 0x03, 0x03, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00],
|
||||
"TLS-scanner",
|
||||
),
|
||||
(vec![0x42; 5], "port-scanner"),
|
||||
];
|
||||
|
||||
for (probe, type_name) in probes {
|
||||
let (client_reader, _client_writer) = duplex(256);
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(256);
|
||||
|
||||
|
||||
let start = Instant::now();
|
||||
handle_bad_client(
|
||||
client_reader,
|
||||
@@ -45,13 +48,17 @@ async fn masking_probes_indistinguishable_timing() {
|
||||
local_addr,
|
||||
&config,
|
||||
&beobachten,
|
||||
).await;
|
||||
|
||||
)
|
||||
.await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
|
||||
// We expect any outcome to take roughly MASK_TIMEOUT (50ms in tests)
|
||||
// to mask whether the backend was reachable or refused.
|
||||
assert!(elapsed >= Duration::from_millis(30), "Probe {type_name} finished too fast: {elapsed:?}");
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(30),
|
||||
"Probe {type_name} finished too fast: {elapsed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +83,7 @@ async fn masking_budget_stress_under_load() {
|
||||
let (_client_visible_reader, client_visible_writer) = duplex(256);
|
||||
let config = config.clone();
|
||||
let beobachten = Arc::clone(&beobachten);
|
||||
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let start = Instant::now();
|
||||
handle_bad_client(
|
||||
@@ -87,14 +94,18 @@ async fn masking_budget_stress_under_load() {
|
||||
local_addr,
|
||||
&config,
|
||||
&beobachten,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
start.elapsed()
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
let elapsed = task.await.unwrap();
|
||||
assert!(elapsed >= Duration::from_millis(30), "Stress probe finished too fast: {elapsed:?}");
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(30),
|
||||
"Stress probe finished too fast: {elapsed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +119,10 @@ fn test_detect_client_type_boundary_cases() {
|
||||
assert_eq!(detect_client_type(&[0x42; 9]), "port-scanner");
|
||||
// 10 bytes = unknown
|
||||
assert_eq!(detect_client_type(&[0x42; 10]), "unknown");
|
||||
|
||||
|
||||
// HTTP verbs without trailing space
|
||||
assert_eq!(detect_client_type(b"GET/"), "port-scanner"); // because len < 10
|
||||
assert_eq!(detect_client_type(b"GET /path"), "HTTP");
|
||||
assert_eq!(detect_client_type(b"GET /path"), "HTTP");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -133,7 +144,9 @@ async fn masking_slowloris_client_idle_timeout_rejected() {
|
||||
assert_eq!(observed, initial);
|
||||
|
||||
let mut drip = [0u8; 1];
|
||||
let drip_read = tokio::time::timeout(Duration::from_millis(220), stream.read_exact(&mut drip)).await;
|
||||
let drip_read =
|
||||
tokio::time::timeout(Duration::from_millis(220), stream.read_exact(&mut drip))
|
||||
.await;
|
||||
assert!(
|
||||
drip_read.is_err() || drip_read.unwrap().is_err(),
|
||||
"backend must not receive post-timeout slowloris drip bytes"
|
||||
@@ -183,18 +196,31 @@ async fn masking_fallback_down_mimics_timeout() {
|
||||
config.censorship.mask = true;
|
||||
config.censorship.mask_host = Some("127.0.0.1".to_string());
|
||||
config.censorship.mask_port = 1; // Unlikely port
|
||||
|
||||
|
||||
let (server_reader, server_writer) = duplex(1024);
|
||||
let beobachten = BeobachtenStore::new();
|
||||
let peer: SocketAddr = "192.0.2.12:12345".parse().unwrap();
|
||||
let local: SocketAddr = "192.0.2.1:443".parse().unwrap();
|
||||
|
||||
let start = Instant::now();
|
||||
handle_bad_client(server_reader, server_writer, b"GET / HTTP/1.1\r\n", peer, local, &config, &beobachten).await;
|
||||
|
||||
handle_bad_client(
|
||||
server_reader,
|
||||
server_writer,
|
||||
b"GET / HTTP/1.1\r\n",
|
||||
peer,
|
||||
local,
|
||||
&config,
|
||||
&beobachten,
|
||||
)
|
||||
.await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
// It should wait for MASK_TIMEOUT (50ms in tests) even if connection was refused immediately
|
||||
assert!(elapsed >= Duration::from_millis(40), "Must respect connect budget even on failure: {:?}", elapsed);
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(40),
|
||||
"Must respect connect budget even on failure: {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -205,7 +231,13 @@ async fn masking_fallback_down_mimics_timeout() {
|
||||
async fn masking_ssrf_resolve_internal_ranges_blocked() {
|
||||
use crate::network::dns_overrides::resolve_socket_addr;
|
||||
|
||||
let blocked_ips = ["127.0.0.1", "169.254.169.254", "10.0.0.1", "192.168.1.1", "0.0.0.0"];
|
||||
let blocked_ips = [
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"10.0.0.1",
|
||||
"192.168.1.1",
|
||||
"0.0.0.0",
|
||||
];
|
||||
|
||||
for ip in blocked_ips {
|
||||
assert!(
|
||||
@@ -270,7 +302,10 @@ async fn masking_zero_length_initial_data_does_not_hang_or_panic() {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "backend must observe clean EOF for empty initial payload");
|
||||
assert_eq!(
|
||||
n, 0,
|
||||
"backend must observe clean EOF for empty initial payload"
|
||||
);
|
||||
});
|
||||
|
||||
let mut config = ProxyConfig::default();
|
||||
@@ -312,7 +347,10 @@ async fn masking_oversized_initial_payload_is_forwarded_verbatim() {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut observed = vec![0u8; payload.len()];
|
||||
stream.read_exact(&mut observed).await.unwrap();
|
||||
assert_eq!(observed, payload, "large initial payload must stay byte-for-byte");
|
||||
assert_eq!(
|
||||
observed, payload,
|
||||
"large initial payload must stay byte-for-byte"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -491,7 +529,10 @@ async fn chaos_burst_reconnect_storm_for_masking_and_relay_concurrently() {
|
||||
});
|
||||
|
||||
let mut observed = vec![0u8; expected_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, expected_reply);
|
||||
|
||||
timeout(Duration::from_secs(2), handle)
|
||||
@@ -646,7 +687,10 @@ async fn chaos_burst_reconnect_storm_for_masking_and_relay_multiwave_soak() {
|
||||
});
|
||||
|
||||
let mut observed = vec![0u8; expected_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, expected_reply);
|
||||
|
||||
timeout(Duration::from_secs(3), handle)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::*;
|
||||
use crate::config::ProxyConfig;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{duplex, AsyncBufReadExt, BufReader};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixListener;
|
||||
use tokio::time::{Instant, sleep, timeout, Duration};
|
||||
use tokio::time::{Duration, Instant, sleep, timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_client_probe_is_forwarded_verbatim_to_mask_backend() {
|
||||
@@ -56,7 +56,10 @@ async fn bad_client_probe_is_forwarded_verbatim_to_mask_backend() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
accept_task.await.unwrap();
|
||||
}
|
||||
@@ -108,7 +111,10 @@ async fn tls_scanner_probe_keeps_http_like_fallback_surface() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
|
||||
let snapshot = beobachten.snapshot_text(Duration::from_secs(60));
|
||||
@@ -147,8 +153,8 @@ fn build_mask_proxy_header_v2_matches_builder_output() {
|
||||
let expected = ProxyProtocolV2Builder::new()
|
||||
.with_addrs(peer, local_addr)
|
||||
.build();
|
||||
let actual = build_mask_proxy_header(2, peer, local_addr)
|
||||
.expect("v2 mode must produce a header");
|
||||
let actual =
|
||||
build_mask_proxy_header(2, peer, local_addr).expect("v2 mode must produce a header");
|
||||
|
||||
assert_eq!(actual, expected, "v2 header bytes must be deterministic");
|
||||
}
|
||||
@@ -159,8 +165,8 @@ fn build_mask_proxy_header_v1_mixed_ip_family_uses_generic_unknown_form() {
|
||||
let local_addr: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
|
||||
|
||||
let expected = ProxyProtocolV1Builder::new().build();
|
||||
let actual = build_mask_proxy_header(1, peer, local_addr)
|
||||
.expect("v1 mode must produce a header");
|
||||
let actual =
|
||||
build_mask_proxy_header(1, peer, local_addr).expect("v1 mode must produce a header");
|
||||
|
||||
assert_eq!(actual, expected, "mixed-family v1 must use UNKNOWN form");
|
||||
}
|
||||
@@ -197,7 +203,10 @@ async fn beobachten_records_scanner_class_when_mask_is_disabled() {
|
||||
client_reader_side.write_all(b"noise").await.unwrap();
|
||||
drop(client_reader_side);
|
||||
|
||||
let beobachten = timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
let beobachten = timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot = beobachten.snapshot_text(Duration::from_secs(60));
|
||||
assert!(snapshot.contains("[SSH]"));
|
||||
assert!(snapshot.contains("203.0.113.99-1"));
|
||||
@@ -241,7 +250,10 @@ async fn backend_unavailable_falls_back_to_silent_consume() {
|
||||
client_reader_side.write_all(b"noise").await.unwrap();
|
||||
drop(client_reader_side);
|
||||
|
||||
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
let n = timeout(Duration::from_secs(1), client_visible_reader.read(&mut buf))
|
||||
@@ -393,9 +405,9 @@ async fn proxy_header_write_error_on_tcp_path_still_honors_coarse_outcome_budget
|
||||
.await;
|
||||
});
|
||||
|
||||
timeout(Duration::from_millis(35), task)
|
||||
.await
|
||||
.expect_err("proxy-header write error path should remain inside coarse masking budget window");
|
||||
timeout(Duration::from_millis(35), task).await.expect_err(
|
||||
"proxy-header write error path should remain inside coarse masking budget window",
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(35),
|
||||
"proxy-header write error path should avoid immediate-return timing signature"
|
||||
@@ -450,9 +462,9 @@ async fn proxy_header_write_error_on_unix_path_still_honors_coarse_outcome_budge
|
||||
.await;
|
||||
});
|
||||
|
||||
timeout(Duration::from_millis(35), task)
|
||||
.await
|
||||
.expect_err("unix proxy-header write error path should remain inside coarse masking budget window");
|
||||
timeout(Duration::from_millis(35), task).await.expect_err(
|
||||
"unix proxy-header write error path should remain inside coarse masking budget window",
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_millis(35),
|
||||
"unix proxy-header write error path should avoid immediate-return timing signature"
|
||||
@@ -486,8 +498,14 @@ async fn unix_socket_proxy_protocol_v1_header_is_sent_before_probe() {
|
||||
let mut header_line = Vec::new();
|
||||
reader.read_until(b'\n', &mut header_line).await.unwrap();
|
||||
let header_text = String::from_utf8(header_line).unwrap();
|
||||
assert!(header_text.starts_with("PROXY "), "must start with PROXY prefix");
|
||||
assert!(header_text.ends_with("\r\n"), "v1 header must end with CRLF");
|
||||
assert!(
|
||||
header_text.starts_with("PROXY "),
|
||||
"must start with PROXY prefix"
|
||||
);
|
||||
assert!(
|
||||
header_text.ends_with("\r\n"),
|
||||
"v1 header must end with CRLF"
|
||||
);
|
||||
|
||||
let mut received_probe = vec![0u8; probe.len()];
|
||||
reader.read_exact(&mut received_probe).await.unwrap();
|
||||
@@ -523,7 +541,10 @@ async fn unix_socket_proxy_protocol_v1_header_is_sent_before_probe() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
|
||||
accept_task.await.unwrap();
|
||||
@@ -552,7 +573,10 @@ async fn unix_socket_proxy_protocol_v2_header_is_sent_before_probe() {
|
||||
|
||||
let mut sig = [0u8; 12];
|
||||
stream.read_exact(&mut sig).await.unwrap();
|
||||
assert_eq!(&sig, b"\r\n\r\n\0\r\nQUIT\n", "v2 signature must match spec");
|
||||
assert_eq!(
|
||||
&sig, b"\r\n\r\n\0\r\nQUIT\n",
|
||||
"v2 signature must match spec"
|
||||
);
|
||||
|
||||
let mut fixed = [0u8; 4];
|
||||
stream.read_exact(&mut fixed).await.unwrap();
|
||||
@@ -593,7 +617,10 @@ async fn unix_socket_proxy_protocol_v2_header_is_sent_before_probe() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
|
||||
accept_task.await.unwrap();
|
||||
@@ -893,10 +920,16 @@ async fn mask_disabled_consumes_client_data_without_response() {
|
||||
.await;
|
||||
});
|
||||
|
||||
client_reader_side.write_all(b"untrusted payload").await.unwrap();
|
||||
client_reader_side
|
||||
.write_all(b"untrusted payload")
|
||||
.await
|
||||
.unwrap();
|
||||
drop(client_reader_side);
|
||||
|
||||
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
let n = timeout(Duration::from_secs(1), client_visible_reader.read(&mut buf))
|
||||
@@ -962,7 +995,10 @@ async fn proxy_protocol_v1_header_is_sent_before_probe() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
accept_task.await.unwrap();
|
||||
}
|
||||
@@ -1026,7 +1062,10 @@ async fn proxy_protocol_v2_header_is_sent_before_probe() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
accept_task.await.unwrap();
|
||||
}
|
||||
@@ -1086,7 +1125,10 @@ async fn proxy_protocol_v1_mixed_family_falls_back_to_unknown_header() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
accept_task.await.unwrap();
|
||||
}
|
||||
@@ -1094,7 +1136,11 @@ async fn proxy_protocol_v1_mixed_family_falls_back_to_unknown_header() {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unix_socket_mask_path_forwards_probe_and_response() {
|
||||
let sock_path = format!("/tmp/telemt-mask-test-{}-{}.sock", std::process::id(), rand::random::<u64>());
|
||||
let sock_path = format!(
|
||||
"/tmp/telemt-mask-test-{}-{}.sock",
|
||||
std::process::id(),
|
||||
rand::random::<u64>()
|
||||
);
|
||||
let _ = std::fs::remove_file(&sock_path);
|
||||
|
||||
let listener = UnixListener::bind(&sock_path).unwrap();
|
||||
@@ -1138,7 +1184,10 @@ async fn unix_socket_mask_path_forwards_probe_and_response() {
|
||||
.await;
|
||||
|
||||
let mut observed = vec![0u8; backend_reply.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, backend_reply);
|
||||
|
||||
accept_task.await.unwrap();
|
||||
@@ -1171,7 +1220,10 @@ async fn mask_disabled_slowloris_connection_is_closed_by_consume_timeout() {
|
||||
.await;
|
||||
});
|
||||
|
||||
timeout(Duration::from_secs(1), task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1329,14 +1381,20 @@ async fn relay_to_mask_keeps_backend_to_client_flow_when_client_to_backend_stall
|
||||
|
||||
// Allow relay tasks to start, then emulate mask backend response.
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
backend_feed_writer.write_all(b"HTTP/1.1 200 OK\r\n\r\n").await.unwrap();
|
||||
backend_feed_writer
|
||||
.write_all(b"HTTP/1.1 200 OK\r\n\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
backend_feed_writer.shutdown().await.unwrap();
|
||||
|
||||
let mut observed = vec![0u8; 19];
|
||||
timeout(Duration::from_secs(1), client_visible_reader.read_exact(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(
|
||||
Duration::from_secs(1),
|
||||
client_visible_reader.read_exact(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed, b"HTTP/1.1 200 OK\r\n\r\n");
|
||||
|
||||
relay.abort();
|
||||
@@ -1394,14 +1452,23 @@ async fn relay_to_mask_preserves_backend_response_after_client_half_close() {
|
||||
client_write.shutdown().await.unwrap();
|
||||
|
||||
let mut observed_resp = vec![0u8; response.len()];
|
||||
timeout(Duration::from_secs(1), client_visible_reader.read_exact(&mut observed_resp))
|
||||
timeout(
|
||||
Duration::from_secs(1),
|
||||
client_visible_reader.read_exact(&mut observed_resp),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed_resp, response);
|
||||
|
||||
timeout(Duration::from_secs(1), fallback_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(1), backend_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observed_resp, response);
|
||||
|
||||
timeout(Duration::from_secs(1), fallback_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(1), backend_task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1427,16 +1494,7 @@ async fn relay_to_mask_timeout_cancels_and_drops_all_io_endpoints() {
|
||||
let timed = timeout(
|
||||
Duration::from_millis(40),
|
||||
relay_to_mask(
|
||||
reader,
|
||||
writer,
|
||||
mask_read,
|
||||
mask_write,
|
||||
b"",
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
reader, writer, mask_read, mask_write, b"", false, 0, 0, false, 0,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -1574,9 +1632,11 @@ async fn timing_matrix_masking_classes_under_controlled_inputs() {
|
||||
(mean, min, p95, max)
|
||||
}
|
||||
|
||||
let (disabled_mean, disabled_min, disabled_p95, disabled_max) = summarize(&mut disabled_samples);
|
||||
let (disabled_mean, disabled_min, disabled_p95, disabled_max) =
|
||||
summarize(&mut disabled_samples);
|
||||
let (refused_mean, refused_min, refused_p95, refused_max) = summarize(&mut refused_samples);
|
||||
let (reachable_mean, reachable_min, reachable_p95, reachable_max) = summarize(&mut reachable_samples);
|
||||
let (reachable_mean, reachable_min, reachable_p95, reachable_max) =
|
||||
summarize(&mut reachable_samples);
|
||||
|
||||
println!(
|
||||
"TIMING_MATRIX masking class=disabled_eof mean_ms={:.2} min_ms={} p95_ms={} max_ms={} bucket_mean={}",
|
||||
@@ -1698,7 +1758,10 @@ async fn reachable_backend_one_response_then_silence_is_cut_by_idle_timeout() {
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
let mut observed = vec![0u8; response.len()];
|
||||
client_visible_reader.read_exact(&mut observed).await.unwrap();
|
||||
client_visible_reader
|
||||
.read_exact(&mut observed)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed, response);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(190),
|
||||
@@ -1763,6 +1826,9 @@ async fn adversarial_client_drip_feed_longer_than_idle_timeout_is_cut_off() {
|
||||
let _ = client_writer_side.write_all(b"X").await;
|
||||
drop(client_writer_side);
|
||||
|
||||
timeout(Duration::from_secs(1), relay_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(1), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
accept_task.await.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::Duration;
|
||||
|
||||
@@ -90,9 +90,7 @@ fn nearest_centroid_classifier_accuracy(
|
||||
samples_b: &[usize],
|
||||
samples_c: &[usize],
|
||||
) -> f64 {
|
||||
let mean = |xs: &[usize]| -> f64 {
|
||||
xs.iter().copied().sum::<usize>() as f64 / xs.len() as f64
|
||||
};
|
||||
let mean = |xs: &[usize]| -> f64 { xs.iter().copied().sum::<usize>() as f64 / xs.len() as f64 };
|
||||
|
||||
let ca = mean(samples_a);
|
||||
let cb = mean(samples_b);
|
||||
@@ -104,11 +102,7 @@ fn nearest_centroid_classifier_accuracy(
|
||||
for &x in samples_a {
|
||||
total += 1;
|
||||
let xf = x as f64;
|
||||
let d = [
|
||||
(xf - ca).abs(),
|
||||
(xf - cb).abs(),
|
||||
(xf - cc).abs(),
|
||||
];
|
||||
let d = [(xf - ca).abs(), (xf - cb).abs(), (xf - cc).abs()];
|
||||
if d[0] <= d[1] && d[0] <= d[2] {
|
||||
correct += 1;
|
||||
}
|
||||
@@ -117,11 +111,7 @@ fn nearest_centroid_classifier_accuracy(
|
||||
for &x in samples_b {
|
||||
total += 1;
|
||||
let xf = x as f64;
|
||||
let d = [
|
||||
(xf - ca).abs(),
|
||||
(xf - cb).abs(),
|
||||
(xf - cc).abs(),
|
||||
];
|
||||
let d = [(xf - ca).abs(), (xf - cb).abs(), (xf - cc).abs()];
|
||||
if d[1] <= d[0] && d[1] <= d[2] {
|
||||
correct += 1;
|
||||
}
|
||||
@@ -130,11 +120,7 @@ fn nearest_centroid_classifier_accuracy(
|
||||
for &x in samples_c {
|
||||
total += 1;
|
||||
let xf = x as f64;
|
||||
let d = [
|
||||
(xf - ca).abs(),
|
||||
(xf - cb).abs(),
|
||||
(xf - cc).abs(),
|
||||
];
|
||||
let d = [(xf - ca).abs(), (xf - cb).abs(), (xf - cc).abs()];
|
||||
if d[2] <= d[0] && d[2] <= d[1] {
|
||||
correct += 1;
|
||||
}
|
||||
@@ -166,7 +152,10 @@ async fn masking_shape_classifier_resistance_blur_reduces_threshold_attack_accur
|
||||
let hardened_acc = best_threshold_accuracy(&hardened_a, &hardened_b);
|
||||
|
||||
// Baseline classes are deterministic/non-overlapping -> near-perfect threshold attack.
|
||||
assert!(baseline_acc >= 0.99, "baseline separability unexpectedly low: {baseline_acc:.3}");
|
||||
assert!(
|
||||
baseline_acc >= 0.99,
|
||||
"baseline separability unexpectedly low: {baseline_acc:.3}"
|
||||
);
|
||||
// Blur must materially reduce the best one-dimensional length classifier.
|
||||
assert!(
|
||||
hardened_acc <= 0.90,
|
||||
@@ -247,7 +236,11 @@ async fn masking_shape_classifier_resistance_edge_max_extra_one_has_two_point_su
|
||||
seen.insert(observed);
|
||||
}
|
||||
|
||||
assert_eq!(seen.len(), 2, "both support points should appear under repeated sampling");
|
||||
assert_eq!(
|
||||
seen.len(),
|
||||
2,
|
||||
"both support points should appear under repeated sampling"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -262,13 +255,25 @@ async fn masking_shape_classifier_resistance_negative_blur_without_shape_hardeni
|
||||
bs_observed.insert(capture_forwarded_len(BODY_B, false, true, 96).await);
|
||||
}
|
||||
|
||||
assert_eq!(as_observed.len(), 1, "without shape hardening class A must stay deterministic");
|
||||
assert_eq!(bs_observed.len(), 1, "without shape hardening class B must stay deterministic");
|
||||
assert_ne!(as_observed, bs_observed, "distinct classes should remain separable without shaping");
|
||||
assert_eq!(
|
||||
as_observed.len(),
|
||||
1,
|
||||
"without shape hardening class A must stay deterministic"
|
||||
);
|
||||
assert_eq!(
|
||||
bs_observed.len(),
|
||||
1,
|
||||
"without shape hardening class B must stay deterministic"
|
||||
);
|
||||
assert_ne!(
|
||||
as_observed, bs_observed,
|
||||
"distinct classes should remain separable without shaping"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_adversarial_three_class_centroid_attack_degrades_with_blur() {
|
||||
async fn masking_shape_classifier_resistance_adversarial_three_class_centroid_attack_degrades_with_blur()
|
||||
{
|
||||
const SAMPLES: usize = 80;
|
||||
const MAX_EXTRA: usize = 96;
|
||||
const C1: usize = 5000;
|
||||
@@ -295,13 +300,23 @@ async fn masking_shape_classifier_resistance_adversarial_three_class_centroid_at
|
||||
let base_acc = nearest_centroid_classifier_accuracy(&base1, &base2, &base3);
|
||||
let hard_acc = nearest_centroid_classifier_accuracy(&hard1, &hard2, &hard3);
|
||||
|
||||
assert!(base_acc >= 0.99, "baseline centroid separability should be near-perfect");
|
||||
assert!(hard_acc <= 0.88, "blur should materially degrade 3-class centroid attack");
|
||||
assert!(hard_acc <= base_acc - 0.1, "accuracy drop should be meaningful");
|
||||
assert!(
|
||||
base_acc >= 0.99,
|
||||
"baseline centroid separability should be near-perfect"
|
||||
);
|
||||
assert!(
|
||||
hard_acc <= 0.88,
|
||||
"blur should materially degrade 3-class centroid attack"
|
||||
);
|
||||
assert!(
|
||||
hard_acc <= base_acc - 0.1,
|
||||
"accuracy drop should be meaningful"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn masking_shape_classifier_resistance_light_fuzz_bounds_hold_for_randomized_above_cap_campaign() {
|
||||
async fn masking_shape_classifier_resistance_light_fuzz_bounds_hold_for_randomized_above_cap_campaign()
|
||||
{
|
||||
let mut s: u64 = 0xDEAD_BEEF_CAFE_BABE;
|
||||
for _ in 0..96 {
|
||||
s ^= s << 7;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use tokio::io::{duplex, empty, sink, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex, empty, sink};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
|
||||
fn oracle_len(
|
||||
total_sent: usize,
|
||||
@@ -54,17 +54,23 @@ async fn run_relay_case(
|
||||
client_writer.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
timeout(Duration::from_secs(2), relay).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), relay)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
if !close_client {
|
||||
drop(client_writer);
|
||||
}
|
||||
|
||||
let mut observed = Vec::new();
|
||||
timeout(Duration::from_secs(2), mask_observer.read_to_end(&mut observed))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(
|
||||
Duration::from_secs(2),
|
||||
mask_observer.read_to_end(&mut observed),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
observed
|
||||
}
|
||||
|
||||
@@ -97,12 +103,29 @@ async fn masking_shape_guard_positive_clean_eof_path_shapes_and_preserves_prefix
|
||||
let extra = vec![0x55; 300];
|
||||
let total = initial.len() + extra.len();
|
||||
|
||||
let observed = run_relay_case(initial.clone(), extra.clone(), true, true, 512, 4096, false, 0).await;
|
||||
let observed = run_relay_case(
|
||||
initial.clone(),
|
||||
extra.clone(),
|
||||
true,
|
||||
true,
|
||||
512,
|
||||
4096,
|
||||
false,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected_len = oracle_len(total, true, true, initial.len(), 512, 4096);
|
||||
assert_eq!(observed.len(), expected_len, "clean EOF path must be bucket-shaped");
|
||||
assert_eq!(
|
||||
observed.len(),
|
||||
expected_len,
|
||||
"clean EOF path must be bucket-shaped"
|
||||
);
|
||||
assert_eq!(&observed[..initial.len()], initial.as_slice());
|
||||
assert_eq!(&observed[initial.len()..(initial.len() + extra.len())], extra.as_slice());
|
||||
assert_eq!(
|
||||
&observed[initial.len()..(initial.len() + extra.len())],
|
||||
extra.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -112,7 +135,11 @@ async fn masking_shape_guard_edge_empty_initial_remains_transparent_under_clean_
|
||||
|
||||
let observed = run_relay_case(initial, extra.clone(), true, true, 512, 4096, false, 0).await;
|
||||
|
||||
assert_eq!(observed.len(), extra.len(), "empty initial_data must never trigger shaping");
|
||||
assert_eq!(
|
||||
observed.len(),
|
||||
extra.len(),
|
||||
"empty initial_data must never trigger shaping"
|
||||
);
|
||||
assert_eq!(observed, extra);
|
||||
}
|
||||
|
||||
@@ -212,13 +239,19 @@ async fn masking_shape_guard_stress_parallel_mixed_sessions_keep_oracle_and_no_h
|
||||
assert_eq!(&observed[..initial_len], initial.as_slice());
|
||||
}
|
||||
if extra_len > 0 {
|
||||
assert_eq!(&observed[initial_len..(initial_len + extra_len)], extra.as_slice());
|
||||
assert_eq!(
|
||||
&observed[initial_len..(initial_len + extra_len)],
|
||||
extra.as_slice()
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +271,10 @@ async fn masking_shape_guard_integration_slow_drip_timeout_is_cut_without_tail_l
|
||||
|
||||
let mut one = [0u8; 1];
|
||||
let r = timeout(Duration::from_millis(220), stream.read_exact(&mut one)).await;
|
||||
assert!(r.is_err() || r.unwrap().is_err(), "no post-timeout drip/tail may reach backend");
|
||||
assert!(
|
||||
r.is_err() || r.unwrap().is_err(),
|
||||
"no post-timeout drip/tail may reach backend"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -274,8 +310,14 @@ async fn masking_shape_guard_integration_slow_drip_timeout_is_cut_without_tail_l
|
||||
sleep(Duration::from_millis(160)).await;
|
||||
let _ = client_writer.write_all(b"X").await;
|
||||
|
||||
timeout(Duration::from_secs(2), relay).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), relay)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -352,7 +394,10 @@ async fn masking_shape_guard_above_cap_blur_parallel_stress_keeps_bounds() {
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
timeout(Duration::from_secs(3), task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
#[tokio::test]
|
||||
async fn shape_guard_empty_initial_data_keeps_transparent_length_on_clean_eof() {
|
||||
@@ -15,7 +15,10 @@ async fn shape_guard_empty_initial_data_keeps_transparent_length_on_clean_eof()
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut got = Vec::new();
|
||||
stream.read_to_end(&mut got).await.unwrap();
|
||||
assert_eq!(got, expected, "empty initial_data path must not inject shape padding");
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"empty initial_data path must not inject shape padding"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,8 +54,14 @@ async fn shape_guard_empty_initial_data_keeps_transparent_length_on_clean_eof()
|
||||
client_writer.write_all(&client_payload).await.unwrap();
|
||||
client_writer.shutdown().await.unwrap();
|
||||
|
||||
timeout(Duration::from_secs(2), relay_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -105,7 +114,10 @@ async fn shape_guard_timeout_exit_does_not_append_padding_after_initial_probe()
|
||||
)
|
||||
.await;
|
||||
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -126,7 +138,11 @@ async fn shape_guard_clean_eof_with_nonempty_initial_still_applies_bucket_paddin
|
||||
let expected_prefix_len = initial.len() + extra.len();
|
||||
assert_eq!(&got[..initial.len()], initial.as_slice());
|
||||
assert_eq!(&got[initial.len()..expected_prefix_len], extra.as_slice());
|
||||
assert_eq!(got.len(), 512, "clean EOF path should still shape to floor bucket");
|
||||
assert_eq!(
|
||||
got.len(),
|
||||
512,
|
||||
"clean EOF path should still shape to floor bucket"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -162,6 +178,12 @@ async fn shape_guard_clean_eof_with_nonempty_initial_still_applies_bucket_paddin
|
||||
client_writer.write_all(&extra).await.unwrap();
|
||||
client_writer.shutdown().await.unwrap();
|
||||
|
||||
timeout(Duration::from_secs(2), relay_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task).await.unwrap().unwrap();
|
||||
timeout(Duration::from_secs(2), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(2), accept_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use tokio::io::{duplex, empty, sink, AsyncReadExt, AsyncWrite};
|
||||
use tokio::io::{AsyncReadExt, AsyncWrite, duplex, empty, sink};
|
||||
|
||||
struct CountingWriter {
|
||||
written: usize,
|
||||
@@ -46,7 +46,10 @@ fn shape_bucket_clamps_to_cap_when_next_power_of_two_exceeds_cap() {
|
||||
fn shape_bucket_never_drops_below_total_for_valid_ranges() {
|
||||
for total in [1usize, 32, 127, 512, 999, 1000, 1001, 1499, 1500, 1501] {
|
||||
let bucket = next_mask_shape_bucket(total, 1000, 1500);
|
||||
assert!(bucket >= total || total >= 1500, "bucket={bucket} total={total}");
|
||||
assert!(
|
||||
bucket >= total || total >= 1500,
|
||||
"bucket={bucket} total={total}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ async fn timing_normalization_does_not_sleep_if_path_already_exceeds_ceiling() {
|
||||
|
||||
let slow = measure_bad_client_duration_ms(MaskPath::SlowBackend, floor, ceiling).await;
|
||||
|
||||
assert!(slow >= 280, "slow backend path should remain slow (got {slow}ms)");
|
||||
assert!(slow <= 520, "slow backend path should remain bounded in tests (got {slow}ms)");
|
||||
assert!(
|
||||
slow >= 280,
|
||||
"slow backend path should remain slow (got {slow}ms)"
|
||||
);
|
||||
assert!(
|
||||
slow <= 520,
|
||||
"slow backend path should remain bounded in tests (got {slow}ms)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,11 @@ fn desync_all_full_bypass_keeps_existing_dedup_entries_unchanged() {
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(dedup.len(), 2, "bypass path must not mutate dedup cardinality");
|
||||
assert_eq!(
|
||||
dedup.len(),
|
||||
2,
|
||||
"bypass path must not mutate dedup cardinality"
|
||||
);
|
||||
assert_eq!(
|
||||
*dedup
|
||||
.get(&0xAAAABBBBCCCCDDDD)
|
||||
@@ -73,7 +77,11 @@ fn edge_all_full_burst_does_not_poison_later_false_path_tracking() {
|
||||
|
||||
let now = Instant::now();
|
||||
for i in 0..8192u64 {
|
||||
assert!(should_emit_full_desync(0xABCD_0000_0000_0000 ^ i, true, now));
|
||||
assert!(should_emit_full_desync(
|
||||
0xABCD_0000_0000_0000 ^ i,
|
||||
true,
|
||||
now
|
||||
));
|
||||
}
|
||||
|
||||
let tracked_key = 0xDEAD_BEEF_0000_0001u64;
|
||||
@@ -175,5 +183,9 @@ fn stress_parallel_all_full_storm_does_not_grow_or_mutate_cache() {
|
||||
}
|
||||
|
||||
assert_eq!(emits.load(Ordering::Relaxed), 16 * 4096);
|
||||
assert_eq!(dedup.len(), before_len, "parallel all_full storm must not mutate cache len");
|
||||
assert_eq!(
|
||||
dedup.len(),
|
||||
before_len,
|
||||
"parallel all_full storm must not mutate cache len"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::*;
|
||||
use crate::crypto::AesCtr;
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::{BufferPool, CryptoReader};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::duplex;
|
||||
use tokio::time::{Duration as TokioDuration, Instant as TokioInstant, timeout};
|
||||
@@ -93,7 +93,9 @@ async fn idle_policy_soft_mark_then_hard_close_increments_reason_counters() {
|
||||
.await
|
||||
.expect("idle test must complete");
|
||||
|
||||
assert!(matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut));
|
||||
assert!(
|
||||
matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut)
|
||||
);
|
||||
let err_text = match result {
|
||||
Err(ProxyError::Io(ref e)) => e.to_string(),
|
||||
_ => String::new(),
|
||||
@@ -143,7 +145,9 @@ async fn idle_policy_downstream_activity_grace_extends_hard_deadline() {
|
||||
.await
|
||||
.expect("grace test must complete");
|
||||
|
||||
assert!(matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut));
|
||||
assert!(
|
||||
matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut)
|
||||
);
|
||||
assert!(
|
||||
start.elapsed() >= TokioDuration::from_millis(100),
|
||||
"recent downstream activity must extend hard idle deadline"
|
||||
@@ -171,7 +175,9 @@ async fn relay_idle_policy_disabled_keeps_legacy_timeout_behavior() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut));
|
||||
assert!(
|
||||
matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut)
|
||||
);
|
||||
let err_text = match result {
|
||||
Err(ProxyError::Io(ref e)) => e.to_string(),
|
||||
_ => String::new(),
|
||||
@@ -225,8 +231,13 @@ async fn adversarial_partial_frame_trickle_cannot_bypass_hard_idle_close() {
|
||||
.await
|
||||
.expect("partial frame trickle test must complete");
|
||||
|
||||
assert!(matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut));
|
||||
assert_eq!(frame_counter, 0, "partial trickle must not count as a valid frame");
|
||||
assert!(
|
||||
matches!(result, Err(ProxyError::Io(ref e)) if e.kind() == std::io::ErrorKind::TimedOut)
|
||||
);
|
||||
assert_eq!(
|
||||
frame_counter, 0,
|
||||
"partial trickle must not count as a valid frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -291,7 +302,10 @@ async fn protocol_desync_small_frame_updates_reason_counter() {
|
||||
plaintext.extend_from_slice(&3u32.to_le_bytes());
|
||||
plaintext.extend_from_slice(&[1u8, 2, 3]);
|
||||
let encrypted = encrypt_for_reader(&plaintext);
|
||||
writer.write_all(&encrypted).await.expect("must write frame");
|
||||
writer
|
||||
.write_all(&encrypted)
|
||||
.await
|
||||
.expect("must write frame");
|
||||
|
||||
let result = read_client_payload(
|
||||
&mut crypto_reader,
|
||||
@@ -657,7 +671,8 @@ fn blackhat_pressure_seq_saturation_must_not_break_multiple_distinct_events() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn integration_race_single_pressure_event_allows_at_most_one_eviction_under_parallel_claims() {
|
||||
async fn integration_race_single_pressure_event_allows_at_most_one_eviction_under_parallel_claims()
|
||||
{
|
||||
let _guard = acquire_idle_pressure_test_lock();
|
||||
clear_relay_idle_pressure_state_for_testing();
|
||||
|
||||
@@ -680,7 +695,8 @@ async fn integration_race_single_pressure_event_allows_at_most_one_eviction_unde
|
||||
let conn_id = *conn_id;
|
||||
let stats = stats.clone();
|
||||
joins.push(tokio::spawn(async move {
|
||||
let evicted = maybe_evict_idle_candidate_on_pressure(conn_id, &mut seen, stats.as_ref());
|
||||
let evicted =
|
||||
maybe_evict_idle_candidate_on_pressure(conn_id, &mut seen, stats.as_ref());
|
||||
(idx, conn_id, seen, evicted)
|
||||
}));
|
||||
}
|
||||
@@ -753,7 +769,8 @@ async fn integration_race_burst_pressure_with_churn_preserves_empty_set_invalida
|
||||
let conn_id = *conn_id;
|
||||
let stats = stats.clone();
|
||||
joins.push(tokio::spawn(async move {
|
||||
let evicted = maybe_evict_idle_candidate_on_pressure(conn_id, &mut seen, stats.as_ref());
|
||||
let evicted =
|
||||
maybe_evict_idle_candidate_on_pressure(conn_id, &mut seen, stats.as_ref());
|
||||
(idx, conn_id, seen, evicted)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
use super::*;
|
||||
use crate::proxy::handshake::HandshakeSuccess;
|
||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||
use bytes::Bytes;
|
||||
use crate::config::{GeneralConfig, MeRouteNoWriterMode, MeSocksKdfPolicy, MeWriterPickMode};
|
||||
use crate::crypto::AesCtr;
|
||||
use crate::crypto::SecureRandom;
|
||||
use crate::config::{GeneralConfig, MeRouteNoWriterMode, MeSocksKdfPolicy, MeWriterPickMode};
|
||||
use crate::network::probe::NetworkDecision;
|
||||
use crate::proxy::handshake::HandshakeSuccess;
|
||||
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::{BufferPool, CryptoReader, CryptoWriter, PooledBuffer};
|
||||
use crate::transport::middle_proxy::MePool;
|
||||
use bytes::Bytes;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use tokio::sync::Barrier;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::duplex;
|
||||
use tokio::sync::Barrier;
|
||||
use tokio::time::{Duration as TokioDuration, timeout};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn make_pooled_payload(data: &[u8]) -> PooledBuffer {
|
||||
let pool = Arc::new(BufferPool::with_config(data.len().max(1), 4));
|
||||
@@ -46,8 +46,14 @@ fn quota_user_lock_test_lock() -> &'static Mutex<()> {
|
||||
#[test]
|
||||
fn should_yield_sender_only_on_budget_with_backlog() {
|
||||
assert!(!should_yield_c2me_sender(0, true));
|
||||
assert!(!should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET - 1, true));
|
||||
assert!(!should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET, false));
|
||||
assert!(!should_yield_c2me_sender(
|
||||
C2ME_SENDER_FAIRNESS_BUDGET - 1,
|
||||
true
|
||||
));
|
||||
assert!(!should_yield_c2me_sender(
|
||||
C2ME_SENDER_FAIRNESS_BUDGET,
|
||||
false
|
||||
));
|
||||
assert!(should_yield_c2me_sender(C2ME_SENDER_FAIRNESS_BUDGET, true));
|
||||
}
|
||||
|
||||
@@ -125,14 +131,7 @@ async fn enqueue_c2me_command_closed_channel_recycles_payload() {
|
||||
let (tx, rx) = mpsc::channel::<C2MeCommand>(1);
|
||||
drop(rx);
|
||||
|
||||
let result = enqueue_c2me_command(
|
||||
&tx,
|
||||
C2MeCommand::Data {
|
||||
payload,
|
||||
flags: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let result = enqueue_c2me_command(&tx, C2MeCommand::Data { payload, flags: 0 }).await;
|
||||
|
||||
assert!(result.is_err(), "closed queue must fail enqueue");
|
||||
drop(result);
|
||||
@@ -314,9 +313,7 @@ fn quota_user_lock_cache_saturation_returns_ephemeral_lock_without_growth() {
|
||||
return;
|
||||
}
|
||||
|
||||
panic!(
|
||||
"unable to observe stable saturated lock-cache precondition after bounded retries"
|
||||
);
|
||||
panic!("unable to observe stable saturated lock-cache precondition after bounded retries");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -390,14 +387,7 @@ async fn stress_quota_race_under_lock_cache_saturation_never_allows_double_succe
|
||||
12_000 + round,
|
||||
barrier.clone(),
|
||||
);
|
||||
let two = run_quota_race_attempt(
|
||||
&stats,
|
||||
&bytes_me2c,
|
||||
&user,
|
||||
0x72,
|
||||
13_000 + round,
|
||||
barrier,
|
||||
);
|
||||
let two = run_quota_race_attempt(&stats, &bytes_me2c, &user, 0x72, 13_000 + round, barrier);
|
||||
|
||||
let (r1, r2) = tokio::join!(one, two);
|
||||
assert!(
|
||||
@@ -823,7 +813,9 @@ fn full_cache_gate_lock_poison_is_fail_closed_without_panic() {
|
||||
// Poison the full-cache gate lock intentionally.
|
||||
let gate = DESYNC_FULL_CACHE_LAST_EMIT_AT.get_or_init(|| Mutex::new(None));
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _lock = gate.lock().expect("gate lock must be lockable before poison");
|
||||
let _lock = gate
|
||||
.lock()
|
||||
.expect("gate lock must be lockable before poison");
|
||||
panic!("intentional gate poison for fail-closed regression");
|
||||
});
|
||||
|
||||
@@ -1208,7 +1200,11 @@ async fn read_client_payload_large_intermediate_frame_is_exact() {
|
||||
|
||||
let (frame, quickack) = read;
|
||||
assert!(!quickack, "quickack flag must be unset");
|
||||
assert_eq!(frame.len(), payload_len, "payload size must match wire length");
|
||||
assert_eq!(
|
||||
frame.len(),
|
||||
payload_len,
|
||||
"payload size must match wire length"
|
||||
);
|
||||
for (idx, byte) in frame.iter().enumerate() {
|
||||
assert_eq!(*byte, (idx as u8).wrapping_mul(31));
|
||||
}
|
||||
@@ -1376,7 +1372,10 @@ async fn read_client_payload_abridged_extended_len_sets_quickack() {
|
||||
.expect("frame must be present");
|
||||
|
||||
let (frame, quickack) = read;
|
||||
assert!(quickack, "quickack bit must be propagated from abridged header");
|
||||
assert!(
|
||||
quickack,
|
||||
"quickack bit must be propagated from abridged header"
|
||||
);
|
||||
assert_eq!(frame.len(), payload_len);
|
||||
assert_eq!(frame_counter, 1, "one abridged frame must be counted");
|
||||
}
|
||||
@@ -1436,7 +1435,11 @@ async fn read_client_payload_keeps_pool_buffer_checked_out_until_frame_drop() {
|
||||
|
||||
let pool = Arc::new(BufferPool::with_config(64, 2));
|
||||
pool.preallocate(1);
|
||||
assert_eq!(pool.stats().pooled, 1, "one pooled buffer must be available");
|
||||
assert_eq!(
|
||||
pool.stats().pooled,
|
||||
1,
|
||||
"one pooled buffer must be available"
|
||||
);
|
||||
|
||||
let (reader, mut writer) = duplex(1024);
|
||||
let mut crypto_reader = make_crypto_reader(reader);
|
||||
@@ -1491,7 +1494,8 @@ async fn enqueue_c2me_close_unblocks_after_queue_drain() {
|
||||
.unwrap();
|
||||
|
||||
let tx2 = tx.clone();
|
||||
let close_task = tokio::spawn(async move { enqueue_c2me_command(&tx2, C2MeCommand::Close).await });
|
||||
let close_task =
|
||||
tokio::spawn(async move { enqueue_c2me_command(&tx2, C2MeCommand::Close).await });
|
||||
|
||||
tokio::time::sleep(TokioDuration::from_millis(10)).await;
|
||||
|
||||
@@ -1501,7 +1505,10 @@ async fn enqueue_c2me_close_unblocks_after_queue_drain() {
|
||||
.expect("first queued item must be present");
|
||||
assert!(matches!(first, C2MeCommand::Data { .. }));
|
||||
|
||||
close_task.await.unwrap().expect("close enqueue must succeed after drain");
|
||||
close_task
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("close enqueue must succeed after drain");
|
||||
|
||||
let second = timeout(TokioDuration::from_millis(100), rx.recv())
|
||||
.await
|
||||
@@ -1521,7 +1528,8 @@ async fn enqueue_c2me_close_full_then_receiver_drop_fails_cleanly() {
|
||||
.unwrap();
|
||||
|
||||
let tx2 = tx.clone();
|
||||
let close_task = tokio::spawn(async move { enqueue_c2me_command(&tx2, C2MeCommand::Close).await });
|
||||
let close_task =
|
||||
tokio::spawn(async move { enqueue_c2me_command(&tx2, C2MeCommand::Close).await });
|
||||
|
||||
tokio::time::sleep(TokioDuration::from_millis(10)).await;
|
||||
drop(rx);
|
||||
@@ -1756,7 +1764,8 @@ async fn process_me_writer_response_concurrent_same_user_quota_does_not_overshoo
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_me_writer_response_data_does_not_forward_partial_payload_when_remaining_quota_is_smaller_than_message() {
|
||||
async fn process_me_writer_response_data_does_not_forward_partial_payload_when_remaining_quota_is_smaller_than_message()
|
||||
{
|
||||
let (writer_side, mut reader_side) = duplex(1024);
|
||||
let mut writer = make_crypto_writer(writer_side);
|
||||
let rng = SecureRandom::new();
|
||||
@@ -1851,11 +1860,17 @@ async fn middle_relay_abort_midflight_releases_route_gauge() {
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(started.is_ok(), "middle relay must increment route gauge before abort");
|
||||
assert!(
|
||||
started.is_ok(),
|
||||
"middle relay must increment route gauge before abort"
|
||||
);
|
||||
|
||||
relay_task.abort();
|
||||
let joined = relay_task.await;
|
||||
assert!(joined.is_err(), "aborted middle relay task must return join error");
|
||||
assert!(
|
||||
joined.is_err(),
|
||||
"aborted middle relay task must return join error"
|
||||
);
|
||||
|
||||
tokio::time::sleep(TokioDuration::from_millis(20)).await;
|
||||
assert_eq!(
|
||||
@@ -2014,8 +2029,14 @@ async fn abridged_max_extended_length_fails_closed_without_panic_or_partial_read
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "oversized abridged length must fail closed");
|
||||
assert_eq!(frame_counter, 0, "oversized frame must not be counted as accepted");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"oversized abridged length must fail closed"
|
||||
);
|
||||
assert_eq!(
|
||||
frame_counter, 0,
|
||||
"oversized frame must not be counted as accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -2067,14 +2088,7 @@ async fn stress_quota_race_bursts_never_allow_double_success_per_round() {
|
||||
6000 + round,
|
||||
barrier.clone(),
|
||||
);
|
||||
let two = run_quota_race_attempt(
|
||||
&stats,
|
||||
&bytes_me2c,
|
||||
&user,
|
||||
0x44,
|
||||
7000 + round,
|
||||
barrier,
|
||||
);
|
||||
let two = run_quota_race_attempt(&stats, &bytes_me2c, &user, 0x44, 7000 + round, barrier);
|
||||
|
||||
let (r1, r2) = tokio::join!(one, two);
|
||||
assert!(
|
||||
@@ -2274,18 +2288,18 @@ async fn secure_padding_distribution_in_relay_writer() {
|
||||
async fn negative_middle_end_connection_lost_during_relay_exits_on_client_eof() {
|
||||
let (client_reader_side, client_writer_side) = duplex(1024);
|
||||
let (_relay_reader_side, relay_writer_side) = duplex(1024);
|
||||
|
||||
|
||||
let key = [0u8; 32];
|
||||
let iv = 0u128;
|
||||
let crypto_reader = CryptoReader::new(client_reader_side, AesCtr::new(&key, iv));
|
||||
let crypto_writer = CryptoWriter::new(relay_writer_side, AesCtr::new(&key, iv), 1024);
|
||||
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
let config = Arc::new(ProxyConfig::default());
|
||||
let buffer_pool = Arc::new(BufferPool::with_config(1024, 1));
|
||||
let rng = Arc::new(SecureRandom::new());
|
||||
let route_runtime = RouteRuntimeController::new(RelayRouteMode::Middle);
|
||||
|
||||
|
||||
// Create an ME pool.
|
||||
let me_pool = make_me_pool_for_abort_test(stats.clone()).await;
|
||||
|
||||
@@ -2296,7 +2310,7 @@ async fn negative_middle_end_connection_lost_during_relay_exits_on_client_eof()
|
||||
drop(probe_rx);
|
||||
me_pool.registry().unregister(probe_conn_id).await;
|
||||
let target_conn_id = probe_conn_id.wrapping_add(1);
|
||||
|
||||
|
||||
let success = HandshakeSuccess {
|
||||
user: "test-user".to_string(),
|
||||
peer: "127.0.0.1:12345".parse().unwrap(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::error::ProxyError;
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::BufferPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::time::{Duration, Instant, timeout};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -14,7 +14,7 @@ use tokio::time::{Duration, Instant, timeout};
|
||||
async fn relay_hol_blocking_prevention_regression() {
|
||||
let stats = Arc::new(Stats::new());
|
||||
let user = "hol-user";
|
||||
|
||||
|
||||
let (client_peer, relay_client) = duplex(65536);
|
||||
let (relay_server, server_peer) = duplex(65536);
|
||||
|
||||
@@ -42,7 +42,7 @@ async fn relay_hol_blocking_prevention_regression() {
|
||||
|
||||
let s2c_handle = tokio::spawn(async move {
|
||||
sp_writer.write_all(&s2c_payload).await.unwrap();
|
||||
|
||||
|
||||
let mut total_read = 0;
|
||||
let mut buf = [0u8; 10];
|
||||
while total_read < payload_size {
|
||||
@@ -54,12 +54,16 @@ async fn relay_hol_blocking_prevention_regression() {
|
||||
|
||||
let start = Instant::now();
|
||||
cp_writer.write_all(&c2s_payload).await.unwrap();
|
||||
|
||||
|
||||
let mut server_buf = vec![0u8; payload_size];
|
||||
sp_reader.read_exact(&mut server_buf).await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(elapsed < Duration::from_millis(1000), "C->S must not be blocked by slow S->C (HOL blocking): {:?}", elapsed);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1000),
|
||||
"C->S must not be blocked by slow S->C (HOL blocking): {:?}",
|
||||
elapsed
|
||||
);
|
||||
assert_eq!(server_buf, c2s_payload);
|
||||
|
||||
s2c_handle.abort();
|
||||
@@ -75,7 +79,7 @@ async fn relay_quota_mid_session_cutoff() {
|
||||
let stats = Arc::new(Stats::new());
|
||||
let user = "quota-mid-user";
|
||||
let quota = 5000;
|
||||
|
||||
|
||||
let (client_peer, relay_client) = duplex(8192);
|
||||
let (relay_server, server_peer) = duplex(8192);
|
||||
|
||||
@@ -106,9 +110,9 @@ async fn relay_quota_mid_session_cutoff() {
|
||||
// Send another 2000 bytes (Total 6000 > 5000)
|
||||
let buf2 = vec![0x42; 2000];
|
||||
let _ = cp_writer.write_all(&buf2).await;
|
||||
|
||||
|
||||
let relay_res = timeout(Duration::from_secs(1), relay_task).await.unwrap();
|
||||
|
||||
|
||||
match relay_res {
|
||||
Ok(Err(ProxyError::DataQuotaExceeded { .. })) => {
|
||||
// Expected
|
||||
@@ -155,7 +159,10 @@ async fn relay_chaos_half_close_crossfire_terminates_without_hang() {
|
||||
.await
|
||||
.expect("relay must terminate after bilateral half-close")
|
||||
.expect("relay task must not panic");
|
||||
assert!(done.is_ok(), "relay must terminate cleanly under half-close crossfire");
|
||||
assert!(
|
||||
done.is_ok(),
|
||||
"relay must terminate cleanly under half-close crossfire"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::stream::BufferPool;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::{timeout, Duration, Instant};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::time::{Duration, Instant, timeout};
|
||||
|
||||
async fn read_available<R: AsyncRead + Unpin>(reader: &mut R, budget: Duration) -> usize {
|
||||
let start = Instant::now();
|
||||
@@ -52,7 +52,10 @@ async fn integration_full_duplex_exact_budget_then_hard_cutoff() {
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
client_peer.write_all(&[0x10, 0x11, 0x12, 0x13]).await.unwrap();
|
||||
client_peer
|
||||
.write_all(&[0x10, 0x11, 0x12, 0x13])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut c2s = [0u8; 4];
|
||||
server_peer.read_exact(&mut c2s).await.unwrap();
|
||||
assert_eq!(c2s, [0x10, 0x11, 0x12, 0x13]);
|
||||
@@ -70,8 +73,16 @@ async fn integration_full_duplex_exact_budget_then_hard_cutoff() {
|
||||
|
||||
let mut probe_server = [0u8; 1];
|
||||
let mut probe_client = [0u8; 1];
|
||||
let leaked_to_server = timeout(Duration::from_millis(120), server_peer.read(&mut probe_server)).await;
|
||||
let leaked_to_client = timeout(Duration::from_millis(120), client_peer.read(&mut probe_client)).await;
|
||||
let leaked_to_server = timeout(
|
||||
Duration::from_millis(120),
|
||||
server_peer.read(&mut probe_server),
|
||||
)
|
||||
.await;
|
||||
let leaked_to_client = timeout(
|
||||
Duration::from_millis(120),
|
||||
client_peer.read(&mut probe_client),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!matches!(leaked_to_server, Ok(Ok(n)) if n > 0),
|
||||
@@ -126,14 +137,23 @@ async fn negative_preloaded_quota_blocks_both_directions_immediately() {
|
||||
let leaked_to_server = read_available(&mut server_peer, Duration::from_millis(120)).await;
|
||||
let leaked_to_client = read_available(&mut client_peer, Duration::from_millis(120)).await;
|
||||
|
||||
assert_eq!(leaked_to_server, 0, "preloaded limit must block C->S immediately");
|
||||
assert_eq!(leaked_to_client, 0, "preloaded limit must block S->C immediately");
|
||||
assert_eq!(
|
||||
leaked_to_server, 0,
|
||||
"preloaded limit must block C->S immediately"
|
||||
);
|
||||
assert_eq!(
|
||||
leaked_to_client, 0,
|
||||
"preloaded limit must block S->C immediately"
|
||||
);
|
||||
|
||||
let relay_result = timeout(Duration::from_secs(2), relay)
|
||||
.await
|
||||
.expect("relay must terminate under preloaded cutoff")
|
||||
.expect("relay task must not panic");
|
||||
assert!(matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })));
|
||||
assert!(matches!(
|
||||
relay_result,
|
||||
Err(ProxyError::DataQuotaExceeded { .. })
|
||||
));
|
||||
assert!(stats.get_user_total_octets(user) <= 5);
|
||||
}
|
||||
|
||||
@@ -160,19 +180,24 @@ async fn edge_quota_one_bidirectional_race_allows_at_most_one_forwarded_octet()
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
let _ = tokio::join!(client_peer.write_all(&[0xAA]), server_peer.write_all(&[0xBB]));
|
||||
let _ = tokio::join!(
|
||||
client_peer.write_all(&[0xAA]),
|
||||
server_peer.write_all(&[0xBB])
|
||||
);
|
||||
|
||||
let mut to_server = [0u8; 1];
|
||||
let mut to_client = [0u8; 1];
|
||||
|
||||
let delivered_server = match timeout(Duration::from_millis(120), server_peer.read(&mut to_server)).await {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
let delivered_client = match timeout(Duration::from_millis(120), client_peer.read(&mut to_client)).await {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
let delivered_server =
|
||||
match timeout(Duration::from_millis(120), server_peer.read(&mut to_server)).await {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
let delivered_client =
|
||||
match timeout(Duration::from_millis(120), client_peer.read(&mut to_client)).await {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
assert!(
|
||||
delivered_server + delivered_client <= 1,
|
||||
@@ -183,7 +208,10 @@ async fn edge_quota_one_bidirectional_race_allows_at_most_one_forwarded_octet()
|
||||
.await
|
||||
.expect("relay must terminate under quota=1")
|
||||
.expect("relay task must not panic");
|
||||
assert!(matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })));
|
||||
assert!(matches!(
|
||||
relay_result,
|
||||
Err(ProxyError::DataQuotaExceeded { .. })
|
||||
));
|
||||
assert!(stats.get_user_total_octets(user) <= 1);
|
||||
}
|
||||
|
||||
@@ -241,7 +269,10 @@ async fn adversarial_blackhat_alternating_fragmented_jitter_never_overshoots_glo
|
||||
.expect("relay must terminate under black-hat jitter attack")
|
||||
.expect("relay task must not panic");
|
||||
|
||||
assert!(matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })));
|
||||
assert!(matches!(
|
||||
relay_result,
|
||||
Err(ProxyError::DataQuotaExceeded { .. })
|
||||
));
|
||||
assert!(
|
||||
delivered_to_server + delivered_to_client <= quota as usize,
|
||||
"combined forwarded bytes must never exceed configured quota"
|
||||
@@ -291,13 +322,17 @@ async fn light_fuzz_randomized_schedule_preserves_quota_and_forwarded_byte_invar
|
||||
if rng.random::<bool>() {
|
||||
let _ = client_peer.write_all(&[rng.random::<u8>()]).await;
|
||||
let mut one = [0u8; 1];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_millis(3), server_peer.read(&mut one)).await {
|
||||
if let Ok(Ok(n)) =
|
||||
timeout(Duration::from_millis(3), server_peer.read(&mut one)).await
|
||||
{
|
||||
delivered_total = delivered_total.saturating_add(n);
|
||||
}
|
||||
} else {
|
||||
let _ = server_peer.write_all(&[rng.random::<u8>()]).await;
|
||||
let mut one = [0u8; 1];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_millis(3), client_peer.read(&mut one)).await {
|
||||
if let Ok(Ok(n)) =
|
||||
timeout(Duration::from_millis(3), client_peer.read(&mut one)).await
|
||||
{
|
||||
delivered_total = delivered_total.saturating_add(n);
|
||||
}
|
||||
}
|
||||
@@ -312,7 +347,8 @@ async fn light_fuzz_randomized_schedule_preserves_quota_and_forwarded_byte_invar
|
||||
.expect("fuzz relay task must not panic");
|
||||
|
||||
assert!(
|
||||
relay_result.is_ok() || matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
relay_result.is_ok()
|
||||
|| matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
"relay must either close cleanly or terminate via typed quota error"
|
||||
);
|
||||
assert!(
|
||||
@@ -371,18 +407,25 @@ async fn stress_multi_relay_same_user_mixed_direction_jitter_respects_global_quo
|
||||
if ((step as usize + worker_id as usize) & 1) == 0 {
|
||||
let _ = client_peer.write_all(&[step ^ 0x3C]).await;
|
||||
let mut one = [0u8; 1];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_millis(3), server_peer.read(&mut one)).await {
|
||||
if let Ok(Ok(n)) =
|
||||
timeout(Duration::from_millis(3), server_peer.read(&mut one)).await
|
||||
{
|
||||
delivered = delivered.saturating_add(n);
|
||||
}
|
||||
} else {
|
||||
let _ = server_peer.write_all(&[step ^ 0xC3]).await;
|
||||
let mut one = [0u8; 1];
|
||||
if let Ok(Ok(n)) = timeout(Duration::from_millis(3), client_peer.read(&mut one)).await {
|
||||
if let Ok(Ok(n)) =
|
||||
timeout(Duration::from_millis(3), client_peer.read(&mut one)).await
|
||||
{
|
||||
delivered = delivered.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis((((worker_id as u64) + (step as u64)) % 3) + 1)).await;
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
(((worker_id as u64) + (step as u64)) % 3) + 1,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
drop(client_peer);
|
||||
@@ -393,7 +436,8 @@ async fn stress_multi_relay_same_user_mixed_direction_jitter_respects_global_quo
|
||||
.expect("stress relay task must not panic");
|
||||
|
||||
assert!(
|
||||
relay_result.is_ok() || matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
relay_result.is_ok()
|
||||
|| matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
"stress relay must either close cleanly or terminate via typed quota error"
|
||||
);
|
||||
delivered
|
||||
@@ -402,7 +446,8 @@ async fn stress_multi_relay_same_user_mixed_direction_jitter_respects_global_quo
|
||||
|
||||
let mut delivered_sum = 0usize;
|
||||
for worker in workers {
|
||||
delivered_sum = delivered_sum.saturating_add(worker.await.expect("stress worker must not panic"));
|
||||
delivered_sum =
|
||||
delivered_sum.saturating_add(worker.await.expect("stress worker must not panic"));
|
||||
}
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -6,7 +6,7 @@ use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::sync::Barrier;
|
||||
use tokio::time::Instant;
|
||||
|
||||
@@ -62,7 +62,10 @@ fn quota_lock_unique_users_materialize_distinct_entries() {
|
||||
}
|
||||
|
||||
for user in &users {
|
||||
assert!(map.get(user).is_some(), "lock cache must contain entry for {user}");
|
||||
assert!(
|
||||
map.get(user).is_some(),
|
||||
"lock cache must contain entry for {user}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +163,10 @@ fn quota_lock_saturated_same_user_must_not_return_distinct_locks() {
|
||||
|
||||
let mut retained = Vec::with_capacity(QUOTA_USER_LOCKS_MAX);
|
||||
for idx in 0..QUOTA_USER_LOCKS_MAX {
|
||||
retained.push(quota_user_lock(&format!("quota-saturated-held-{}-{idx}", std::process::id())));
|
||||
retained.push(quota_user_lock(&format!(
|
||||
"quota-saturated-held-{}-{idx}",
|
||||
std::process::id()
|
||||
)));
|
||||
}
|
||||
|
||||
let overflow_user = format!("quota-saturated-same-user-{}", std::process::id());
|
||||
@@ -183,7 +189,10 @@ async fn quota_lock_saturation_concurrent_same_user_never_overshoots_quota() {
|
||||
|
||||
let mut retained = Vec::with_capacity(QUOTA_USER_LOCKS_MAX);
|
||||
for idx in 0..QUOTA_USER_LOCKS_MAX {
|
||||
retained.push(quota_user_lock(&format!("quota-saturated-race-held-{}-{idx}", std::process::id())));
|
||||
retained.push(quota_user_lock(&format!(
|
||||
"quota-saturated-race-held-{}-{idx}",
|
||||
std::process::id()
|
||||
)));
|
||||
}
|
||||
|
||||
let stats = Arc::new(Stats::new());
|
||||
@@ -234,7 +243,10 @@ async fn quota_lock_saturation_stress_same_user_never_overshoots_quota() {
|
||||
|
||||
let mut retained = Vec::with_capacity(QUOTA_USER_LOCKS_MAX);
|
||||
for idx in 0..QUOTA_USER_LOCKS_MAX {
|
||||
retained.push(quota_user_lock(&format!("quota-saturated-stress-held-{}-{idx}", std::process::id())));
|
||||
retained.push(quota_user_lock(&format!(
|
||||
"quota-saturated-stress-held-{}-{idx}",
|
||||
std::process::id()
|
||||
)));
|
||||
}
|
||||
|
||||
for round in 0..128u32 {
|
||||
@@ -355,7 +367,8 @@ async fn quota_lock_integration_zero_quota_cuts_off_without_forwarding() {
|
||||
.expect("client write must succeed");
|
||||
|
||||
let mut probe = [0u8; 1];
|
||||
let forwarded = tokio::time::timeout(Duration::from_millis(80), server_peer.read(&mut probe)).await;
|
||||
let forwarded =
|
||||
tokio::time::timeout(Duration::from_millis(80), server_peer.read(&mut probe)).await;
|
||||
if let Ok(Ok(n)) = forwarded {
|
||||
assert_eq!(n, 0, "zero quota path must not forward payload bytes");
|
||||
}
|
||||
@@ -392,14 +405,26 @@ async fn quota_lock_integration_no_quota_relays_both_directions_under_burst() {
|
||||
let c2s = vec![0xA5; 2048];
|
||||
let s2c = vec![0x5A; 1536];
|
||||
|
||||
client_peer.write_all(&c2s).await.expect("client burst write must succeed");
|
||||
client_peer
|
||||
.write_all(&c2s)
|
||||
.await
|
||||
.expect("client burst write must succeed");
|
||||
let mut got_c2s = vec![0u8; c2s.len()];
|
||||
server_peer.read_exact(&mut got_c2s).await.expect("server must receive c2s burst");
|
||||
server_peer
|
||||
.read_exact(&mut got_c2s)
|
||||
.await
|
||||
.expect("server must receive c2s burst");
|
||||
assert_eq!(got_c2s, c2s);
|
||||
|
||||
server_peer.write_all(&s2c).await.expect("server burst write must succeed");
|
||||
server_peer
|
||||
.write_all(&s2c)
|
||||
.await
|
||||
.expect("server burst write must succeed");
|
||||
let mut got_s2c = vec![0u8; s2c.len()];
|
||||
client_peer.read_exact(&mut got_s2c).await.expect("client must receive s2c burst");
|
||||
client_peer
|
||||
.read_exact(&mut got_s2c)
|
||||
.await
|
||||
.expect("client must receive s2c burst");
|
||||
assert_eq!(got_s2c, s2c);
|
||||
|
||||
drop(client_peer);
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::stream::BufferPool;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::sync::Barrier;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
fn assert_is_prefix(received: &[u8], sent: &[u8], direction: &str) {
|
||||
assert!(
|
||||
@@ -110,7 +110,8 @@ async fn model_fuzz_bidirectional_schedule_preserves_prefixes_and_quota_budget()
|
||||
.expect("fuzz relay task must not panic");
|
||||
|
||||
assert!(
|
||||
relay_result.is_ok() || matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
relay_result.is_ok()
|
||||
|| matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
"fuzz case {case}: relay must end cleanly or with typed quota error"
|
||||
);
|
||||
|
||||
@@ -172,11 +173,21 @@ async fn adversarial_dual_direction_cutoff_race_allows_at_most_one_forwarded_byt
|
||||
let mut got_at_server = [0u8; 1];
|
||||
let mut got_at_client = [0u8; 1];
|
||||
|
||||
let n_server = match timeout(Duration::from_millis(120), server_peer.read(&mut got_at_server)).await {
|
||||
let n_server = match timeout(
|
||||
Duration::from_millis(120),
|
||||
server_peer.read(&mut got_at_server),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
let n_client = match timeout(Duration::from_millis(120), client_peer.read(&mut got_at_client)).await {
|
||||
let n_client = match timeout(
|
||||
Duration::from_millis(120),
|
||||
client_peer.read(&mut got_at_client),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
@@ -194,7 +205,10 @@ async fn adversarial_dual_direction_cutoff_race_allows_at_most_one_forwarded_byt
|
||||
.expect("quota race relay must terminate")
|
||||
.expect("quota race relay task must not panic");
|
||||
|
||||
assert!(matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })));
|
||||
assert!(matches!(
|
||||
relay_result,
|
||||
Err(ProxyError::DataQuotaExceeded { .. })
|
||||
));
|
||||
assert!(stats.get_user_total_octets(user) <= 1);
|
||||
}
|
||||
|
||||
@@ -276,7 +290,8 @@ async fn stress_shared_user_multi_relay_global_quota_never_overshoots_under_mode
|
||||
.expect("stress relay task must not panic");
|
||||
|
||||
assert!(
|
||||
relay_result.is_ok() || matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
relay_result.is_ok()
|
||||
|| matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
"stress relay must end cleanly or with typed quota error"
|
||||
);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ use crate::error::ProxyError;
|
||||
use crate::stats::Stats;
|
||||
use crate::stream::BufferPool;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{duplex, AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, duplex};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
async fn read_available<R: AsyncRead + Unpin>(reader: &mut R, budget_ms: u64) -> usize {
|
||||
let mut total = 0usize;
|
||||
@@ -46,7 +46,10 @@ async fn regression_client_chunk_larger_than_remaining_quota_does_not_overshoot_
|
||||
));
|
||||
|
||||
// Single chunk attempts to cross remaining budget (4 > 1).
|
||||
client_peer.write_all(&[0x11, 0x22, 0x33, 0x44]).await.unwrap();
|
||||
client_peer
|
||||
.write_all(&[0x11, 0x22, 0x33, 0x44])
|
||||
.await
|
||||
.unwrap();
|
||||
client_peer.shutdown().await.unwrap();
|
||||
|
||||
let forwarded = read_available(&mut server_peer, 60).await;
|
||||
@@ -60,7 +63,10 @@ async fn regression_client_chunk_larger_than_remaining_quota_does_not_overshoot_
|
||||
forwarded, 0,
|
||||
"overflowing C->S chunk must not be forwarded when it exceeds remaining quota"
|
||||
);
|
||||
assert!(matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })));
|
||||
assert!(matches!(
|
||||
relay_result,
|
||||
Err(ProxyError::DataQuotaExceeded { .. })
|
||||
));
|
||||
assert!(
|
||||
stats.get_user_total_octets(user) <= 10,
|
||||
"accounted bytes must never exceed quota after overflowing chunk"
|
||||
@@ -94,7 +100,10 @@ async fn regression_client_exact_remaining_quota_forwards_once_then_hard_cuts_of
|
||||
));
|
||||
|
||||
// Exact boundary write should pass once.
|
||||
client_peer.write_all(&[0xAA, 0xBB, 0xCC, 0xDD]).await.unwrap();
|
||||
client_peer
|
||||
.write_all(&[0xAA, 0xBB, 0xCC, 0xDD])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut exact = [0u8; 4];
|
||||
timeout(Duration::from_secs(1), server_peer.read_exact(&mut exact))
|
||||
@@ -118,7 +127,10 @@ async fn regression_client_exact_remaining_quota_forwards_once_then_hard_cuts_of
|
||||
leaked_after, 0,
|
||||
"no bytes may pass after exact boundary is consumed"
|
||||
);
|
||||
assert!(matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })));
|
||||
assert!(matches!(
|
||||
relay_result,
|
||||
Err(ProxyError::DataQuotaExceeded { .. })
|
||||
));
|
||||
assert!(stats.get_user_total_octets(user) <= 10);
|
||||
}
|
||||
|
||||
@@ -171,7 +183,8 @@ async fn stress_parallel_relays_same_user_quota_overflow_never_exceeds_cap() {
|
||||
.expect("stress relay task must not panic");
|
||||
|
||||
assert!(
|
||||
relay_result.is_ok() || matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
relay_result.is_ok()
|
||||
|| matches!(relay_result, Err(ProxyError::DataQuotaExceeded { .. })),
|
||||
"stress relay must finish cleanly or with typed quota error"
|
||||
);
|
||||
forwarded
|
||||
|
||||
@@ -186,7 +186,10 @@ async fn integration_parallel_waiters_resume_after_single_release_event() {
|
||||
timeout(Duration::from_secs(1), async {
|
||||
for waiter in waiters {
|
||||
let outcome = waiter.await.expect("waiter must not panic");
|
||||
assert!(outcome.is_ok(), "waiter must resume and complete after release");
|
||||
assert!(
|
||||
outcome.is_ok(),
|
||||
"waiter must resume and complete after release"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -235,7 +238,10 @@ async fn light_fuzz_release_timing_matrix_preserves_liveness() {
|
||||
.await
|
||||
.expect("fuzz round writer must complete")
|
||||
.expect("fuzz writer task must not panic");
|
||||
assert!(done.is_ok(), "fuzz round writer must not stall after release");
|
||||
assert!(
|
||||
done.is_ok(),
|
||||
"fuzz round writer must not stall after release"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::task::{Context, Waker};
|
||||
use tokio::io::{ReadBuf, AsyncWriteExt};
|
||||
use tokio::io::{AsyncWriteExt, ReadBuf};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -83,7 +83,10 @@ async fn positive_contended_writer_emits_deferred_wake_for_liveness() {
|
||||
|
||||
drop(held_guard);
|
||||
let ready = Pin::new(&mut io).poll_write(&mut cx, &[0xA2]);
|
||||
assert!(ready.is_ready(), "writer must progress after contention release");
|
||||
assert!(
|
||||
ready.is_ready(),
|
||||
"writer must progress after contention release"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -117,7 +120,10 @@ async fn adversarial_blackhat_writer_contention_does_not_create_waker_storm() {
|
||||
|
||||
for _ in 0..512 {
|
||||
let poll = Pin::new(&mut io).poll_write(&mut cx, &[0xBE]);
|
||||
assert!(poll.is_pending(), "writer must stay pending while lock is held");
|
||||
assert!(
|
||||
poll.is_pending(),
|
||||
"writer must stay pending while lock is held"
|
||||
);
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ use std::future::poll_fn;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::task::{Context, Poll};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::task::Waker;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt, duplex};
|
||||
use tokio::time::{Duration, timeout};
|
||||
@@ -60,7 +60,10 @@ async fn quota_lock_contention_does_not_self_wake_pending_writer() {
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
|
||||
let poll = Pin::new(&mut io).poll_write(&mut cx, &[0x11]);
|
||||
assert!(poll.is_pending(), "writer must remain pending while lock is contended");
|
||||
assert!(
|
||||
poll.is_pending(),
|
||||
"writer must remain pending while lock is contended"
|
||||
);
|
||||
assert_eq!(
|
||||
wake_counter.wakes.load(Ordering::Relaxed),
|
||||
0,
|
||||
@@ -99,7 +102,10 @@ async fn quota_lock_contention_writer_schedules_single_deferred_wake_until_lock_
|
||||
let mut cx = Context::from_waker(&waker);
|
||||
|
||||
let first = Pin::new(&mut io).poll_write(&mut cx, &[0x11]);
|
||||
assert!(first.is_pending(), "writer must remain pending while lock is contended");
|
||||
assert!(
|
||||
first.is_pending(),
|
||||
"writer must remain pending while lock is contended"
|
||||
);
|
||||
assert_eq!(
|
||||
wake_counter.wakes.load(Ordering::Relaxed),
|
||||
0,
|
||||
@@ -123,7 +129,10 @@ async fn quota_lock_contention_writer_schedules_single_deferred_wake_until_lock_
|
||||
);
|
||||
|
||||
let second = Pin::new(&mut io).poll_write(&mut cx, &[0x22]);
|
||||
assert!(second.is_pending(), "writer remains pending while lock is still held");
|
||||
assert!(
|
||||
second.is_pending(),
|
||||
"writer remains pending while lock is still held"
|
||||
);
|
||||
|
||||
for _ in 0..8 {
|
||||
tokio::task::yield_now().await;
|
||||
@@ -136,7 +145,10 @@ async fn quota_lock_contention_writer_schedules_single_deferred_wake_until_lock_
|
||||
|
||||
drop(held_lock);
|
||||
let released = Pin::new(&mut io).poll_write(&mut cx, &[0x33]);
|
||||
assert!(released.is_ready(), "writer must make progress once quota lock is released");
|
||||
assert!(
|
||||
released.is_ready(),
|
||||
"writer must make progress once quota lock is released"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -172,7 +184,10 @@ async fn quota_lock_contention_read_path_schedules_deferred_wake_for_liveness()
|
||||
let mut buf = ReadBuf::new(&mut storage);
|
||||
|
||||
let first = Pin::new(&mut io).poll_read(&mut cx, &mut buf);
|
||||
assert!(first.is_pending(), "reader must remain pending while lock is contended");
|
||||
assert!(
|
||||
first.is_pending(),
|
||||
"reader must remain pending while lock is contended"
|
||||
);
|
||||
assert_eq!(
|
||||
wake_counter.wakes.load(Ordering::Relaxed),
|
||||
0,
|
||||
@@ -193,7 +208,10 @@ async fn quota_lock_contention_read_path_schedules_deferred_wake_for_liveness()
|
||||
drop(held_lock);
|
||||
let mut buf_after_release = ReadBuf::new(&mut storage);
|
||||
let released = Pin::new(&mut io).poll_read(&mut cx, &mut buf_after_release);
|
||||
assert!(released.is_ready(), "reader must make progress once quota lock is released");
|
||||
assert!(
|
||||
released.is_ready(),
|
||||
"reader must make progress once quota lock is released"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -297,7 +315,8 @@ async fn relay_bidirectional_does_not_forward_server_bytes_after_quota_is_exhaus
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_bidirectional_does_not_leak_partial_server_payload_when_remaining_quota_is_smaller_than_write() {
|
||||
async fn relay_bidirectional_does_not_leak_partial_server_payload_when_remaining_quota_is_smaller_than_write()
|
||||
{
|
||||
let stats = Arc::new(Stats::new());
|
||||
let quota_user = "partial-leak-user";
|
||||
stats.add_user_octets_from(quota_user, 3);
|
||||
@@ -569,7 +588,7 @@ async fn relay_bidirectional_terminates_on_activity_timeout() {
|
||||
|
||||
// Wait past the activity timeout threshold (1800 seconds) + buffer
|
||||
tokio::time::sleep(Duration::from_secs(1805)).await;
|
||||
|
||||
|
||||
// Resume time to process timeouts
|
||||
tokio::time::resume();
|
||||
|
||||
@@ -582,7 +601,7 @@ async fn relay_bidirectional_terminates_on_activity_timeout() {
|
||||
relay_result.is_ok(),
|
||||
"relay should complete successfully on scheduled inactivity timeout"
|
||||
);
|
||||
|
||||
|
||||
// Verify client/server sockets are closed
|
||||
drop(client_peer);
|
||||
drop(server_peer);
|
||||
@@ -634,12 +653,13 @@ async fn relay_bidirectional_watchdog_resists_premature_execution() {
|
||||
relay_result.is_err(),
|
||||
"Relay must not exit prematurely as long as activity was received before timeout"
|
||||
);
|
||||
|
||||
|
||||
// Explicitly drop sockets to cleanly shut down relay loop
|
||||
drop(client_peer);
|
||||
drop(server_peer);
|
||||
|
||||
let completion = timeout(Duration::from_secs(1), relay_task).await
|
||||
|
||||
let completion = timeout(Duration::from_secs(1), relay_task)
|
||||
.await
|
||||
.expect("relay task must complete securely after client disconnection")
|
||||
.expect("relay task must not panic");
|
||||
assert!(completion.is_ok(), "relay exits clean");
|
||||
@@ -654,16 +674,29 @@ async fn relay_bidirectional_half_closure_terminates_cleanly() {
|
||||
let (server_reader, server_writer) = tokio::io::split(relay_server);
|
||||
|
||||
let relay_task = tokio::spawn(relay_bidirectional(
|
||||
client_reader, client_writer, server_reader, server_writer, 1024, 1024, "half-close", stats, None, Arc::new(BufferPool::new()),
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
1024,
|
||||
1024,
|
||||
"half-close",
|
||||
stats,
|
||||
None,
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
|
||||
// Half closure: drop the client completely but leave the server active.
|
||||
drop(client_peer);
|
||||
|
||||
|
||||
// Check that we don't immediately crash. Bidirectional relay stays open for the server -> client flush.
|
||||
// Eventually dropping the server cleanly closes the task.
|
||||
drop(server_peer);
|
||||
timeout(Duration::from_secs(1), relay_task).await.unwrap().unwrap().unwrap();
|
||||
timeout(Duration::from_secs(1), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -675,7 +708,16 @@ async fn relay_bidirectional_zero_length_noise_fuzzing() {
|
||||
let (server_reader, server_writer) = tokio::io::split(relay_server);
|
||||
|
||||
let relay_task = tokio::spawn(relay_bidirectional(
|
||||
client_reader, client_writer, server_reader, server_writer, 1024, 1024, "fuzz", stats, None, Arc::new(BufferPool::new()),
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
1024,
|
||||
1024,
|
||||
"fuzz",
|
||||
stats,
|
||||
None,
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
// Flood with zero-length payloads (edge cases in stream framing logic sometimes loop)
|
||||
@@ -684,45 +726,62 @@ async fn relay_bidirectional_zero_length_noise_fuzzing() {
|
||||
}
|
||||
client_peer.write_all(&[1, 2, 3]).await.unwrap();
|
||||
client_peer.flush().await.unwrap();
|
||||
|
||||
|
||||
let mut buf = [0u8; 3];
|
||||
server_peer.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, &[1, 2, 3]);
|
||||
|
||||
|
||||
drop(client_peer);
|
||||
drop(server_peer);
|
||||
timeout(Duration::from_secs(1), relay_task).await.unwrap().unwrap().unwrap();
|
||||
timeout(Duration::from_secs(1), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_bidirectional_asymmetric_backpressure() {
|
||||
let stats = Arc::new(Stats::new());
|
||||
// Give the client stream an extremely narrow throughput limit explicitly
|
||||
let (client_peer, relay_client) = duplex(1024);
|
||||
let (client_peer, relay_client) = duplex(1024);
|
||||
let (relay_server, mut server_peer) = duplex(4096);
|
||||
let (client_reader, client_writer) = tokio::io::split(relay_client);
|
||||
let (server_reader, server_writer) = tokio::io::split(relay_server);
|
||||
|
||||
let relay_task = tokio::spawn(relay_bidirectional(
|
||||
client_reader, client_writer, server_reader, server_writer, 1024, 1024, "slowloris", stats, None, Arc::new(BufferPool::new()),
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
1024,
|
||||
1024,
|
||||
"slowloris",
|
||||
stats,
|
||||
None,
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
let payload = vec![0xba; 65536]; // 64k payload
|
||||
|
||||
|
||||
// Server attempts to shove 64KB into a relay whose client pipe only holds 1KB!
|
||||
let write_res = tokio::time::timeout(Duration::from_millis(50), server_peer.write_all(&payload)).await;
|
||||
|
||||
let write_res =
|
||||
tokio::time::timeout(Duration::from_millis(50), server_peer.write_all(&payload)).await;
|
||||
|
||||
assert!(
|
||||
write_res.is_err(),
|
||||
write_res.is_err(),
|
||||
"Relay backpressure MUST halt the server writer from unbounded buffering when client stream is full!"
|
||||
);
|
||||
|
||||
|
||||
drop(client_peer);
|
||||
drop(server_peer);
|
||||
|
||||
let completion = timeout(Duration::from_secs(1), relay_task).await.unwrap().unwrap();
|
||||
|
||||
let completion = timeout(Duration::from_secs(1), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
completion.is_ok() || completion.is_err(),
|
||||
completion.is_ok() || completion.is_err(),
|
||||
"Task must unwind reliably (either Ok or BrokenPipe Err) when dropped despite active backpressure locks"
|
||||
);
|
||||
}
|
||||
@@ -739,27 +798,43 @@ async fn relay_bidirectional_light_fuzzing_temporal_jitter() {
|
||||
let (server_reader, server_writer) = tokio::io::split(relay_server);
|
||||
|
||||
let mut relay_task = tokio::spawn(relay_bidirectional(
|
||||
client_reader, client_writer, server_reader, server_writer, 1024, 1024, "fuzz-user", stats, None, Arc::new(BufferPool::new()),
|
||||
client_reader,
|
||||
client_writer,
|
||||
server_reader,
|
||||
server_writer,
|
||||
1024,
|
||||
1024,
|
||||
"fuzz-user",
|
||||
stats,
|
||||
None,
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
let mut rng = StdRng::seed_from_u64(0xDEADBEEF);
|
||||
|
||||
|
||||
for _ in 0..10 {
|
||||
// Vary timing significantly up to 1600 seconds (limit is 1800s)
|
||||
let jitter = rng.random_range(100..1600);
|
||||
let jitter = rng.random_range(100..1600);
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
|
||||
|
||||
client_peer.write_all(&[0x11]).await.unwrap();
|
||||
client_peer.flush().await.unwrap();
|
||||
|
||||
|
||||
// Ensure task has not died
|
||||
let res = timeout(Duration::from_millis(10), &mut relay_task).await;
|
||||
assert!(res.is_err(), "Relay must remain open indefinitely under light temporal fuzzing with active jitter pulses");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Relay must remain open indefinitely under light temporal fuzzing with active jitter pulses"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
drop(client_peer);
|
||||
drop(server_peer);
|
||||
timeout(Duration::from_secs(1), relay_task).await.unwrap().unwrap().unwrap();
|
||||
timeout(Duration::from_secs(1), relay_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct FaultyReader {
|
||||
@@ -1038,11 +1113,14 @@ async fn stress_same_user_quota_parallel_relays_never_exceed_limit() {
|
||||
server_peer_b.write_all(&[0x04]),
|
||||
);
|
||||
|
||||
let _ = timeout(Duration::from_millis(50), poll_fn(|cx| {
|
||||
let mut one = [0u8; 1];
|
||||
let _ = Pin::new(&mut client_peer_a).poll_read(cx, &mut ReadBuf::new(&mut one));
|
||||
Poll::Ready(())
|
||||
}))
|
||||
let _ = timeout(
|
||||
Duration::from_millis(50),
|
||||
poll_fn(|cx| {
|
||||
let mut one = [0u8; 1];
|
||||
let _ = Pin::new(&mut client_peer_a).poll_read(cx, &mut ReadBuf::new(&mut one));
|
||||
Poll::Ready(())
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
drop(client_peer_a);
|
||||
@@ -1063,7 +1141,10 @@ async fn stress_same_user_quota_parallel_relays_never_exceed_limit() {
|
||||
impl FaultyReader {
|
||||
fn permission_denied_with_message(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error_once: Some(io::Error::new(io::ErrorKind::PermissionDenied, message.into())),
|
||||
error_once: Some(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
message.into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1179,14 +1260,20 @@ async fn relay_half_close_keeps_reverse_direction_progressing() {
|
||||
Arc::new(BufferPool::new()),
|
||||
));
|
||||
|
||||
sp_writer.write_all(&[0x10, 0x20, 0x30, 0x40]).await.unwrap();
|
||||
sp_writer
|
||||
.write_all(&[0x10, 0x20, 0x30, 0x40])
|
||||
.await
|
||||
.unwrap();
|
||||
sp_writer.shutdown().await.unwrap();
|
||||
|
||||
let mut inbound = [0u8; 4];
|
||||
cp_reader.read_exact(&mut inbound).await.unwrap();
|
||||
assert_eq!(inbound, [0x10, 0x20, 0x30, 0x40]);
|
||||
|
||||
cp_writer.write_all(&[0xaa, 0xbb, 0xcc, 0xdd]).await.unwrap();
|
||||
cp_writer
|
||||
.write_all(&[0xaa, 0xbb, 0xcc, 0xdd])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut outbound = [0u8; 4];
|
||||
sp_reader.read_exact(&mut outbound).await.unwrap();
|
||||
assert_eq!(outbound, [0xaa, 0xbb, 0xcc, 0xdd]);
|
||||
|
||||
@@ -44,7 +44,10 @@ fn light_fuzz_mixed_pairs_match_saturating_sub_contract() {
|
||||
|
||||
let expected = current.saturating_sub(previous);
|
||||
let actual = watchdog_delta(current, previous);
|
||||
assert_eq!(actual, expected, "delta mismatch for ({current}, {previous})");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"delta mismatch for ({current}, {previous})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ fn positive_direct_cutover_sets_timestamp_and_snapshot_coherently() {
|
||||
.expect("middle->direct must emit cutover");
|
||||
let observed = *rx.borrow();
|
||||
|
||||
assert_eq!(observed, emitted, "watch snapshot must match emitted cutover");
|
||||
assert_eq!(
|
||||
observed, emitted,
|
||||
"watch snapshot must match emitted cutover"
|
||||
);
|
||||
assert_eq!(observed.mode, RelayRouteMode::Direct);
|
||||
assert!(
|
||||
runtime.direct_since_epoch_secs().is_some(),
|
||||
@@ -64,7 +67,10 @@ fn edge_middle_cutover_clears_timestamp() {
|
||||
.expect("direct->middle must emit cutover");
|
||||
let observed = *rx.borrow();
|
||||
|
||||
assert_eq!(observed, emitted, "watch snapshot must match emitted cutover");
|
||||
assert_eq!(
|
||||
observed, emitted,
|
||||
"watch snapshot must match emitted cutover"
|
||||
);
|
||||
assert_eq!(observed.mode, RelayRouteMode::Middle);
|
||||
assert!(
|
||||
runtime.direct_since_epoch_secs().is_none(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{RngExt, SeedableRng};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -19,14 +19,7 @@ fn cutover_stagger_delay_stays_within_budget_bounds() {
|
||||
// Black-hat model: censors trigger many cutovers and correlate disconnect timing.
|
||||
// Keep delay inside a narrow coarse window to avoid long-tail spikes.
|
||||
for generation in [0u64, 1, 2, 3, 16, 128, u32::MAX as u64, u64::MAX] {
|
||||
for session_id in [
|
||||
0u64,
|
||||
1,
|
||||
2,
|
||||
0xdead_beef,
|
||||
0xfeed_face_cafe_babe,
|
||||
u64::MAX,
|
||||
] {
|
||||
for session_id in [0u64, 1, 2, 0xdead_beef, 0xfeed_face_cafe_babe, u64::MAX] {
|
||||
let delay = cutover_stagger_delay(session_id, generation);
|
||||
assert!(
|
||||
(1000..=1999).contains(&delay.as_millis()),
|
||||
@@ -216,7 +209,10 @@ fn light_fuzz_set_mode_generation_tracks_only_real_transitions() {
|
||||
let changed = runtime.set_mode(candidate);
|
||||
|
||||
if candidate == expected_mode {
|
||||
assert!(changed.is_none(), "idempotent set_mode must not emit cutover state");
|
||||
assert!(
|
||||
changed.is_none(),
|
||||
"idempotent set_mode must not emit cutover state"
|
||||
);
|
||||
} else {
|
||||
expected_mode = candidate;
|
||||
expected_generation = expected_generation.saturating_add(1);
|
||||
@@ -298,7 +294,9 @@ fn stress_concurrent_transition_count_matches_final_generation() {
|
||||
}
|
||||
|
||||
for worker in workers {
|
||||
worker.join().expect("route mode transition worker must not panic");
|
||||
worker
|
||||
.join()
|
||||
.expect("route mode transition worker must not panic");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -391,8 +389,8 @@ fn stress_cutover_stagger_delay_distribution_remains_stable_across_generations()
|
||||
for generation in [0u64, 1, 7, 31, 255, 1024, u32::MAX as u64, u64::MAX - 1] {
|
||||
let mut buckets = [0usize; 1000];
|
||||
for session_id in 0..100_000u64 {
|
||||
let delay_ms = cutover_stagger_delay(session_id ^ 0x9E37_79B9, generation)
|
||||
.as_millis() as usize;
|
||||
let delay_ms =
|
||||
cutover_stagger_delay(session_id ^ 0x9E37_79B9, generation).as_millis() as usize;
|
||||
buckets[delay_ms - 1000] += 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user