refactor: remove dead stealth_sni config field across the whole stack

stealth_sni was never actually consumed to construct any wire bytes —
verified dead in bridge.rs (only stored, never read). It implied
TLS/HTTP SNI mimicry that this project deliberately does not do
(zapret-like: packet-level DPI obfuscation only, no protocol
mimicry). Removed from the runtime schema (config.rs, bridge.rs),
both CLI/GUI local config shapes and their JSON templates, the
Flutter profile model/UI/share-link logic, and README feature docs.
migrate.rs now drops the field from legacy configs with a note
instead of carrying it forward.
This commit is contained in:
ospab 2026-07-10 01:04:56 +03:00
parent 1b3390a3cf
commit 1d1a1ea5af
10 changed files with 43 additions and 81 deletions

View File

@ -44,7 +44,7 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
| **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel that completely hides traffic. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
@ -121,7 +121,7 @@ graph TD
"server": "YOUR_SERVER_IP:50000",
"access_key": "YOUR_SECRET_KEY",
"socks5_bind": "127.0.0.1:1088",
"transport": { "mode": "udp", "stealth_sni": "vk.com" },
"transport": { "mode": "udp" },
"tun": { "enable": false, "dns": "1.1.1.1" }
}
```

View File

@ -24,7 +24,7 @@
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
| **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, который полностью скрывает трафик. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
@ -116,8 +116,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
"debug": false,
// Настройки транспорта (udp или uot)
"transport": {
"mode": "udp",
"stealth_sni": "vk.com"
"mode": "udp"
},
// TUN-режим (полносистемный VPN)
"tun": {

View File

@ -65,7 +65,6 @@ pub struct Bridge {
pub mux_sessions: usize,
pub transport_mode: String,
pub stealth_sni: String,
pub tcp_fragmentation: bool,
pub frag_chunk: usize,
pub frag_sleep: u64,
@ -102,7 +101,6 @@ impl Bridge {
mux_sessions: config.multiplex.sessions.max(1),
transport_mode: config.transport.mode.clone(),
stealth_sni: config.transport.stealth_sni.clone(),
tcp_fragmentation: config.transport.tcp_fragmentation,
frag_chunk: config.transport.frag_chunk,
frag_sleep: config.transport.frag_sleep,
@ -1033,7 +1031,6 @@ impl Bridge {
self.mux_enabled = cfg.multiplex.enabled;
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.frag_chunk = cfg.transport.frag_chunk.max(1);
self.frag_sleep = cfg.transport.frag_sleep;

View File

@ -70,15 +70,13 @@ pub struct LocalProxyConfig {
}
/// Transport layer configuration.
/// `mode` = "udp" (default) or "uot" (UDP over TCP с xHTTP-транспортом).
/// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry —
/// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportConfig {
/// "udp" or "uot"
#[serde(default = "default_transport_mode")]
pub mode: String,
/// 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.
pub tcp_fragmentation: bool,
@ -106,7 +104,6 @@ impl Default for TransportConfig {
fn default() -> Self {
Self {
mode: default_transport_mode(),
stealth_sni: String::new(),
tcp_fragmentation: false,
frag_chunk: default_frag_chunk(),
frag_sleep: default_frag_sleep(),
@ -192,7 +189,6 @@ struct RawUnifiedConfig {
#[derive(Debug, Deserialize)]
struct RawTransportSection {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
frag_chunk: Option<usize>,
frag_sleep: Option<u64>,
@ -270,7 +266,6 @@ 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),
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk),
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
@ -495,7 +490,6 @@ pub struct ClientFileConfig {
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TransportConfigRaw {
pub mode: Option<String>,
pub stealth_sni: Option<String>,
pub tcp_fragmentation: Option<bool>,
}

View File

@ -71,6 +71,11 @@ pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
/// never user-meaningful data) and `transport.wss` (the WSS framing
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
/// explicit note; everything else maps 1:1, nothing to convert.
/// - **configs carrying a leftover `transport.stealth_sni`**: dropped with a
/// note, same reasoning as `wss` — it never fed into anything on the wire
/// (no TLS/HTTP mimicry exists in this project), so there is no successor
/// field. Not tied to a specific version: it lingered in the schema well
/// past when the mimicry work it was meant for got removed.
/// - **current flat schema**: no-op, `changed = false`.
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
let mut report = MigrationReport::default();
@ -106,6 +111,14 @@ pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
.to_string(),
);
}
if transport.remove("stealth_sni").is_some() {
report.note(
"Dropped transport.stealth_sni — never actually used to construct any wire \
bytes (no TLS/HTTP mimicry exists in this project same zapret-like \
reasoning as transport.wss), so it was unused config plumbing with no effect."
.to_string(),
);
}
}
(out, report)
@ -199,12 +212,14 @@ fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Val
.and_then(|v| v.as_str())
.unwrap_or("udp")
.to_string();
let stealth_sni = primary
.get("transport")
.and_then(|t| t.get("stealth_sni"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
if !sni.is_empty() {
report.note(format!(
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
any wire bytes; unused config plumbing with no successor field."
));
}
}
let tcp_fragmentation = primary
.get("transport")
.and_then(|t| t.get("tcp_fragmentation"))
@ -287,7 +302,6 @@ fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Val
},
"transport": {
"mode": transport_type,
"stealth_sni": stealth_sni,
"tcp_fragmentation": tcp_fragmentation,
},
});
@ -388,13 +402,15 @@ mod tests {
assert_eq!(new["debug"], true);
assert_eq!(new["tun"]["enable"], true);
assert_eq!(new["transport"]["mode"], "uot");
assert_eq!(new["transport"]["stealth_sni"], "vk.com");
assert_eq!(new["transport"]["tcp_fragmentation"], true);
assert_eq!(new["mux"]["enabled"], true);
assert_eq!(new["mux"]["sessions"], 4);
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
// stealth_sni never fed into any wire bytes — dropped, not carried forward.
assert!(new["transport"].get("stealth_sni").is_none());
assert!(report.notes.iter().any(|n| n.contains("stealth_sni") && n.contains("vk.com")));
}
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
@ -423,9 +439,9 @@ mod tests {
}
/// Pre-0.3.1 flat config carrying fields that no longer exist
/// (tun.wintun_path, tun.ipv4_address, transport.wss) — those get
/// dropped with a note; every field that's still meaningful passes
/// through untouched, byte for byte.
/// (tun.wintun_path, tun.ipv4_address, transport.wss, transport.stealth_sni)
/// — those get dropped with a note; every field that's still meaningful
/// passes through untouched, byte for byte.
#[test]
fn flat_legacy_drops_only_dead_fields() {
let old = json!({
@ -455,15 +471,16 @@ mod tests {
assert_eq!(new["tun"]["dns"], "1.1.1.1");
assert_eq!(new["tun"]["kill_switch"], true);
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
assert_eq!(new["transport"]["stealth_sni"], "bing.com");
// Dead fields are gone...
assert!(new["tun"].get("wintun_path").is_none());
assert!(new["tun"].get("ipv4_address").is_none());
assert!(new["transport"].get("wss").is_none());
assert!(new["transport"].get("stealth_sni").is_none());
// ...and their removal was reported, not silent.
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
assert!(report.notes.iter().any(|n| n.contains("wss")));
assert!(report.notes.iter().any(|n| n.contains("stealth_sni")));
}
/// A config already in the current shape must be a true no-op: report
@ -476,7 +493,7 @@ mod tests {
"tun": { "enable": false, "dns": null, "kill_switch": false },
"exclude": { "domains": [], "ips": [], "processes": [] },
"mux": { "enabled": false, "sessions": 1 },
"transport": { "mode": "udp", "stealth_sni": "", "tcp_fragmentation": false }
"transport": { "mode": "udp", "tcp_fragmentation": false }
});
let (new, report) = migrate_client_json(current.clone());
assert!(!report.changed);

View File

@ -11,7 +11,6 @@ class OstpProfile {
String serverAddr;
String accessKey;
String transportMode; // 'udp' | 'uot'
String stealthSni;
bool active;
// Junk packets + TCP fragmentation per-profile, exactly like ostp-gui's
@ -31,7 +30,6 @@ class OstpProfile {
required this.serverAddr,
required this.accessKey,
this.transportMode = 'udp',
this.stealthSni = '',
this.active = false,
this.tcpFragmentation = false,
this.fragChunk = 2,
@ -49,7 +47,6 @@ class OstpProfile {
'serverAddr': serverAddr,
'accessKey': accessKey,
'transportMode': transportMode,
'stealthSni': stealthSni,
'active': active,
'tcpFragmentation': tcpFragmentation,
'fragChunk': fragChunk,
@ -68,7 +65,6 @@ class OstpProfile {
serverAddr: json['serverAddr'] as String? ?? '',
accessKey: json['accessKey'] as String? ?? '',
transportMode: json['transportMode'] as String? ?? 'udp',
stealthSni: json['stealthSni'] as String? ?? '',
active: json['active'] as bool? ?? false,
tcpFragmentation: json['tcpFragmentation'] as bool? ?? false,
fragChunk: json['fragChunk'] as int? ?? 2,

View File

@ -115,7 +115,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// shape as the desktop GUI's profile object — not global toggles.
"transport": {
"mode": p?.transportMode ?? 'udp',
"stealth_sni": (p?.stealthSni.isNotEmpty ?? false) ? p!.stealthSni : 'vk.com',
"tcp_fragmentation": p?.tcpFragmentation ?? false,
"frag_chunk": p?.fragChunk ?? 2,
"frag_sleep": p?.fragSleep ?? 2,

View File

@ -115,7 +115,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final type = uri.queryParameters['type'];
final transportMode = (type == 'tcp' || type == 'http') ? 'uot' : 'udp';
final name = uri.queryParameters['name'] ?? host;
final stealthSni = uri.queryParameters['sni'] ?? '';
final wasEmpty = _profiles.isEmpty;
setState(() {
@ -125,7 +124,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
serverAddr: host,
accessKey: key,
transportMode: transportMode,
stealthSni: stealthSni,
active: wasEmpty,
));
_saveProfiles();
@ -207,15 +205,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
static const List<String> _stealthDomains = [
'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me',
'top-fwz1.mail.ru', 'sso.passport.yandex.ru',
'sberbank.ru', 'ad.mail.ru', 'ads.vk.com',
'login.vk.com', 'api.sberbank.ru', 'ok.ru',
'rostelecom.ru', 'rt.ru', 'tinkoff.ru',
'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com',
];
void _showEditProfileDialog(OstpProfile? profile) {
final isNew = profile == null;
final nameCtrl = TextEditingController(text: profile?.name ?? '');
@ -229,7 +218,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final junkPsMaxCtrl = TextEditingController(text: (profile?.junkPsMax ?? 1000).toString());
String transportMode = profile?.transportMode ?? 'udp';
bool tcpFragmentation = profile?.tcpFragmentation ?? false;
String stealthSni = (profile?.stealthSni.isNotEmpty ?? false) ? profile!.stealthSni : 'vk.com';
bool obscureKey = true;
showDialog(
@ -265,27 +253,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
decoration: const InputDecoration(labelText: 'Transport'),
items: const [
DropdownMenuItem(value: 'udp', child: Text('UDP')),
DropdownMenuItem(value: 'uot', child: Text('TCP (UoT) — xHTTP stealth')),
DropdownMenuItem(value: 'uot', child: Text('TCP (UoT)')),
],
onChanged: (v) {
if (v != null) setDialogState(() => transportMode = v);
},
),
if (transportMode == 'uot') ...[
const SizedBox(height: 12),
Builder(builder: (context) {
final domains = [..._stealthDomains];
if (!domains.contains(stealthSni)) domains.add(stealthSni);
return DropdownButtonFormField<String>(
value: stealthSni,
decoration: const InputDecoration(labelText: 'Stealth SNI domain'),
items: domains.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
onChanged: (v) {
if (v != null) setDialogState(() => stealthSni = v);
},
);
}),
],
const Divider(height: 32),
// Junk packets + TCP fragmentation moved into their own
// modals (tap to configure) this dialog was carrying too
@ -358,7 +331,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
serverAddr: server,
accessKey: key,
transportMode: transportMode,
stealthSni: stealthSni,
active: wasEmpty,
tcpFragmentation: tcpFragmentation,
fragChunk: int.tryParse(fragChunkCtrl.text) ?? 2,
@ -373,7 +345,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
profile.serverAddr = server;
profile.accessKey = key;
profile.transportMode = transportMode;
profile.stealthSni = stealthSni;
profile.tcpFragmentation = tcpFragmentation;
profile.fragChunk = int.tryParse(fragChunkCtrl.text) ?? 2;
profile.fragSleep = int.tryParse(fragSleepCtrl.text) ?? 2;
@ -503,7 +474,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
final key = Uri.encodeComponent(p.accessKey);
if (p.serverAddr.isEmpty || p.accessKey.isEmpty) return;
final queryParams = <String>[];
if (p.stealthSni.isNotEmpty) queryParams.add('sni=${Uri.encodeComponent(p.stealthSni)}');
if (p.transportMode != 'udp') queryParams.add('type=${p.transportMode}');
final queryString = queryParams.isEmpty ? '' : '?${queryParams.join('&')}';
final url = 'ostp://$key@${p.serverAddr}$queryString';

View File

@ -56,7 +56,6 @@ struct TunConfig {
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
frag_chunk: Option<usize>,
frag_sleep: Option<u64>,
@ -167,7 +166,6 @@ 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),
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or(2),
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2),

View File

@ -169,14 +169,12 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
let host = parsed.host_str().ok_or_else(|| anyhow!("Missing host in share link"))?;
let port = parsed.port().ok_or_else(|| anyhow!("Missing port in share link"))?;
let server = format!("{host}:{port}");
let mut sni = String::new();
let mut transport_mode = String::from("udp");
let mut tun_enabled = false;
let mut tun_dns = None;
for (k, v) in parsed.query_pairs() {
match &*k {
"sni" => sni = v.into_owned(),
"type" => transport_mode = v.into_owned(),
"tun" => tun_enabled = v == "true",
"dns" => tun_dns = Some(v.into_owned()),
@ -190,7 +188,6 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
mtu: None,
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()),
@ -503,15 +500,14 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
// Try import from link first
let use_link = wizard_yn("Do you have a share link (ostp://...)?", false);
let (server, access_key, sni, transport_mode) = if use_link {
let (server, access_key, transport_mode) = if use_link {
let link_str = wizard_prompt("Paste link", "");
let parsed = url::Url::parse(&link_str).unwrap();
let mut p = parsed.query_pairs();
let sni = p.find(|(k, _)| k == "sni").map(|(_, v)| v.to_string()).unwrap_or_default();
let tm = p.find(|(k, _)| k == "type").map(|(_, v)| v.to_string()).unwrap_or("udp".to_string());
(parsed.host_str().unwrap().to_string() + ":" + &parsed.port().unwrap_or(50000).to_string(), parsed.username().to_string(), sni, tm)
(parsed.host_str().unwrap().to_string() + ":" + &parsed.port().unwrap_or(50000).to_string(), parsed.username().to_string(), tm)
} else {
("127.0.0.1:50000".to_string(), "".to_string(), "".to_string(), "udp".to_string())
("127.0.0.1:50000".to_string(), "".to_string(), "udp".to_string())
};
wizard_step(2, TOTAL, "Local proxy");
@ -564,7 +560,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
// Build and save config
let key_for_gen = generate_secure_key("hex");
let _ = key_for_gen;
let _ = &sni;
let client_json = serde_json::json!({
"mode": "client",
@ -585,8 +580,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
"processes": []
},
"transport": {
"mode": transport_mode,
"stealth_sni": "www.microsoft.com"
"mode": transport_mode
},
"mux": {
"enabled": mux_enable,
@ -1222,10 +1216,9 @@ async fn run_app() -> Result<()> {
"processes": []
}},
// Transport Mode: "udp" (default WebRTC masquerade) or "uot" (TCP UoT)
// Transport Mode: "udp" (default) or "uot" (UDP over TCP, no mimicry)
"transport": {{
"mode": "udp",
"stealth_sni": "www.microsoft.com"
"mode": "udp"
}},
"mux": {{
@ -1679,7 +1672,6 @@ 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),
frag_chunk: 2,
frag_sleep: 2,