§E (base): port junk packets + TCP fragmentation (stream-only)

Anti-DPI obfuscation the project wants to keep, ported from 0.3.x with the
harmful UDP behaviour designed out from the start.

- Junk: before the handshake on a UoT/TCP connection, send 2-5 random
  length-prefixed frames (100-1000 B). The server reads each as a frame,
  fails to authenticate it, drops it and keeps reading (drop-and-continue),
  so junk perturbs DPI flow analysis without breaking the connection. Junk
  is NEVER sent over UDP — there each junk would be a lone datagram
  indistinguishable from a port scan (probe-flood / wasted CPU / the very
  "self-ban" risk the plan calls out). Verified the server has no
  probe-based ban, and the unauthorized-probe log is already rate-limited
  (§B), so junk-over-UoT produces one debug line, not a flood.
- TCP fragmentation: new `transport.tcp_fragmentation` flag (default off).
  When set, the writer splits the first real frame (the handshake) — length
  header byte-by-byte then payload in 2-byte chunks with short gaps — so DPI
  can't classify the handshake from a single read.
- Ranges are hardcoded for now; §E fine-tuning (configurable Jc/Jmin/Jmax,
  S1/S2, H1..H4) is deferred.

Verified by loopback E2E: a UoT client with tcp_fragmentation=true connects
(junk logged as one rate-limited probe, then real handshake accepted) and
curl via SOCKS5 tunnels HTTPS successfully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ospab 2026-06-27 17:11:12 +03:00
parent 89a5eea20d
commit db65e3367f
4 changed files with 75 additions and 7 deletions

View File

@ -66,6 +66,7 @@ pub struct Bridge {
pub transport_mode: String,
pub stealth_sni: String,
pub tcp_fragmentation: bool,
pub mtu: usize,
pub kill_switch: bool,
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
@ -98,6 +99,7 @@ impl Bridge {
transport_mode: config.transport.mode.clone(),
stealth_sni: config.transport.stealth_sni.clone(),
tcp_fragmentation: config.transport.tcp_fragmentation,
mtu: config.ostp.mtu,
kill_switch: config.kill_switch,
reload_tx: None,
@ -1024,6 +1026,7 @@ impl Bridge {
self.mux_sessions = cfg.multiplex.sessions.max(1);
self.transport_mode = cfg.transport.mode.clone();
self.stealth_sni = cfg.transport.stealth_sni.clone();
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
self.mtu = cfg.ostp.mtu;
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
self.kill_switch = cfg.kill_switch;
@ -1040,18 +1043,71 @@ impl Bridge {
let _ = stream.set_nodelay(true);
let (mut read_half, mut write_half) = stream.into_split();
let tcp_fragmentation = self.tcp_fragmentation;
// Amnezia-style junk to perturb DPI heuristics — ONLY over stream
// transports, where each junk frame rides inside the connection. The
// server reads it as a length-prefixed frame, fails to authenticate
// it, drops it, and keeps reading (drop-and-continue), so junk does
// not break the connection. Over plain UDP each junk would be a lone
// datagram indistinguishable from a port scan (probe-flood / wasted
// CPU), so junk is NEVER sent over UDP. Ranges are hardcoded for now;
// §E will make Jc/Jmin/Jmax configurable. (Ported from 0.3.x.)
{
use tokio::io::AsyncWriteExt;
// Build all junk frames up front so ThreadRng isn't held across an
// await point (keeps this future Send).
let junk_frames: Vec<Vec<u8>> = {
use rand::Rng;
let mut rng = rand::thread_rng();
let num_junk = rng.gen_range(2..=5);
(0..num_junk)
.map(|_| {
let junk_len = rng.gen_range(100..=1000usize);
let mut frame = Vec::with_capacity(2 + junk_len);
frame.extend_from_slice(&(junk_len as u16).to_be_bytes());
let start = frame.len();
frame.resize(start + junk_len, 0);
rng.fill(&mut frame[start..]);
frame
})
.collect()
};
for frame in junk_frames {
if write_half.write_all(&frame).await.is_err() { break; }
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
// Task to write from rx_out to tcp stream
// Writer: length-prefix each frame. With tcp_fragmentation on, split
// the FIRST real frame (the handshake — junk above was written
// directly, so it doesn't count) into tiny TCP segments with short
// gaps so DPI can't reassemble/classify the handshake from one read.
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut first_packet = true;
while let Some(data) = rx_out.recv().await {
let mut len_buf = [0u8; 2];
len_buf.copy_from_slice(&(data.len() as u16).to_be_bytes());
let len_buf = (data.len() as u16).to_be_bytes();
if first_packet && tcp_fragmentation {
first_packet = false;
if write_half.write_all(&len_buf[0..1]).await.is_err() { break; }
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
if write_half.write_all(&len_buf[1..2]).await.is_err() { break; }
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let mut broke = false;
for chunk in data.chunks(2) {
if write_half.write_all(chunk).await.is_err() { broke = true; break; }
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
if broke { break; }
} else {
if write_half.write_all(&len_buf).await.is_err() { break; }
if write_half.write_all(&data).await.is_err() { break; }
}
}
});
// Task to read from tcp stream to tx_in

View File

@ -79,6 +79,10 @@ pub struct TransportConfig {
/// TLS SNI and HTTP Host for xHTTP routing
#[serde(default)]
pub stealth_sni: String,
/// Split the first UoT/TCP packet (handshake) into tiny TCP segments to
/// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
#[serde(default)]
pub tcp_fragmentation: bool,
}
fn default_transport_mode() -> String { "udp".to_string() }
@ -88,6 +92,7 @@ impl Default for TransportConfig {
Self {
mode: default_transport_mode(),
stealth_sni: String::new(),
tcp_fragmentation: false,
}
}
}
@ -169,6 +174,7 @@ struct RawUnifiedConfig {
struct RawTransportSection {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize)]
@ -242,6 +248,7 @@ impl ClientConfig {
transport: TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
},
exclusions: ExclusionConfig {
domains: exclusions.domains.unwrap_or_default(),

View File

@ -57,6 +57,7 @@ struct TunConfig {
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -163,6 +164,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
transport: ostp_client::config::TransportConfig {
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
},
exclusions: ostp_client::config::ExclusionConfig {
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),

View File

@ -103,6 +103,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
transport: Some(TransportConfigRaw {
mode: Some(transport_mode),
stealth_sni: Some(sni.clone()),
tcp_fragmentation: None,
}),
socks5_bind: Some("127.0.0.1:1088".to_string()),
tun: Some(TunConfig {
@ -317,6 +318,7 @@ struct ClientConfig {
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -1627,6 +1629,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
transport: ostp_client::config::TransportConfig {
mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
stealth_sni: client_cfg.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
},
dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()),
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),