§A: remove WSS + Reality (TLS-mimicry); bump to 0.4.0 / AGPL-3.0

Clean-rebuild on the stably-working v0.2.98 base. The project's stealth
path is zapret-like (packet obfuscation / junk / fragmentation), NOT
TLS-mimicry, so WSS and Reality are dropped entirely.

- Delete dead orphan files: ostp-client/src/transport/xhttp.rs and
  ostp-core/src/crypto/reality.rs (never declared as modules → not even
  compiled), plus ostp-core/src/framing/wss.rs.
- Scrub the `wss` transport field from client config/bridge, the unified
  CLI (ostp/src/main.rs), the Tauri GUI backend, the GUI frontend
  (index.html/main.js), and the Flutter UI; also drop the Reality
  pbk/sid plumbing and XTLS auto-search modes from both frontends.
- Drop now-unused client deps (x25519-dalek, chacha20poly1305, hex).
- Bump workspace to version 0.4.0 and license AGPL-3.0; make ostp's
  ostp-core dep path-only so the version bump resolves.
- gitignore ostp-control/ (panel assets built separately; a dummy dist
  is created for the rust-embed build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ospab 2026-06-27 16:29:42 +03:00
parent 31d0020483
commit 92d6b06d75
18 changed files with 212 additions and 859 deletions

3
.gitignore vendored
View File

@ -36,3 +36,6 @@ turn-harvesting-idea.md
ostp-prober/
ostp-brain/
# Management panel built assets (built separately; dummy dist created for rust-embed build)
ostp-control/

15
Cargo.lock generated
View File

@ -1384,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.2.98"
version = "0.4.0"
dependencies = [
"anyhow",
"base64",
@ -1405,16 +1405,14 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.2.98"
version = "0.4.0"
dependencies = [
"anyhow",
"base64",
"bytes",
"chacha20poly1305",
"chrono",
"futures",
"futures-util",
"hex",
"hmac",
"json_comments",
"libc",
@ -1434,12 +1432,11 @@ dependencies = [
"tun",
"webpki-roots 0.26.11",
"winapi",
"x25519-dalek",
]
[[package]]
name = "ostp-core"
version = "0.2.98"
version = "0.4.0"
dependencies = [
"anyhow",
"bytes",
@ -1473,7 +1470,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.2.98"
version = "0.4.0"
dependencies = [
"anyhow",
"axum",
@ -1505,7 +1502,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.2.98"
version = "0.4.0"
dependencies = [
"anyhow",
"libc",
@ -1517,7 +1514,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.2.98"
version = "0.4.0"
dependencies = [
"anyhow",
"chrono",

View File

@ -11,8 +11,8 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "BSL 1.1"
version = "0.2.98"
license = "AGPL-3.0"
version = "0.4.0"
[workspace.dependencies]
anyhow = "1.0"

185
REBUILD_PLAN.md Normal file
View File

@ -0,0 +1,185 @@
# Чистая переборка на базе v0.2.98
База: `v0.2.98` (commit `31d0020`) — последняя версия, которая **стабильно работает**.
Ветка: `clean-rebuild`. Всё, что появилось после (0.3.1 … 0.3.21), переносим
**выборочно и с чистой головой**, а не копируем рефактор целиком.
Принцип: 0.3.1 принёс «модульный multi-server рефактор» + лавину фич — и вместе с
ними нестабильность. Берём только проверенное и нужное.
---
## Решения (зафиксировано пользователем)
- **Junk-пакеты + TCP-фрагментация — ОСТАВЛЯЕМ** (нравятся). НО починить вредную
часть: junk по UDP не должен выглядеть для сервера как `Unauthorized probe`
(rate-limit/гейт на сервере), иначе флуд лога и риск самобана клиента. Фича
остаётся — чиним поведение, а не выпиливаем. Тонкая настройка — §E.
- **Версия переборки — 0.4.0** (решено; 0.3.x сожжены в pre-release).
- **WSS и Reality (TLS-мимикрия) — ВЫКИНУТЬ.** Путь проекта — **zapret-like**:
обфускация/DPI-evasion на уровне пакетов (junk, фрагментация, обфускация), а НЕ
мимикрия под TLS. Reality с нуля тяжела и не вписывается.
- **Multi-server — НЕ НУЖЕН.** Режем до одного сервера → уходит urltest-группа и
половина сложности 0.3.1.
- **Конфиг — ПЛОСКИЙ по сути, но оформлен красиво/секционно как сейчас** (решено).
Сохраняем читаемую секционную структуру (server / transport / tun / dns / exclude
и т.п.), но **выпиливаем модульную машинерию**: массивы `inbounds[]`/`outbounds[]`,
`routing.rules[]` с тегами, `default_outbound`, urltest, мульти-сервер. Один сервер
на конфиг. Исключения = плоский список внутри секции `exclude`.
- **Профили — ОСТАВЛЯЕМ, single-select, в UI-слое** (решено). Профиль = сохранённый
конфиг одного сервера; активен ровно один (radio). Список/выбор/share живут во
фронте (prefs GUI / Flutter); **ядро о профилях не знает** — на «Подключить» из
выбранного профиля генерится плоский конфиг на один сервер. Никаких чекбоксов/
мульти-актив/urltest.
- **Derived-secrets — ОСТАВЛЯЕМ, но ОБЯЗАТЕЛЬНО проверить, что он РЕАЛЬНО работает:**
старый клиент НЕ должен подключаться к новому серверу. В прошлой реализации это
НЕ соблюдалось (старый клиент → новый сервер подключался) — значит сервер всё ещё
принимал старый формат handshake / obfuscation-key. Это **баг**, закрыть в первую
очередь: сервер обязан отвергать всё, что не прошло derived-secrets.
- **Лицензия — AGPLv3.**
- **Брендинг — ОСТАВЛЯЕМ**: тёмная тема + орёл на фоне (watermark/логотип).
- **Стелс-философия (north-star): zapret-like** — «нет узнаваемого заголовка +
манипуляции пакетами» (обфускация, junk, фрагментация, DNS/UoT-транспорты), а НЕ
«притворись известным протоколом» (Reality/WSS — выкинуты).
---
## 0. Корневая причина нестабильности 0.3.x
**Модульный multi-server рефактор (0.3.1)** — `580faf6`, `8ed66f9`, `67f9c06`.
Сменил формат конфига (inbounds/outbounds/routing/urltest), session-модель,
hot-reload. Источник большинства багов (мёртвые маршруты, фейк-коннект,
рассинхрон конфига). **НЕ копировать целиком.** Если multi-server реально нужен —
добавлять минимально и поверх рабочей одно-серверной модели 0.2.98.
---
## A. ВЫКИНУТЬ / не переносить
1. **WSS-фрейминг и Reality (TLS-мимикрия)**оба выкинуть. Путь zapret-like, а не
маскировка под TLS-сайт; Reality (`reality.rs`) к тому же сложно сделать корректно
с нуля. Удалить из базы 0.2.98 целиком.
2. **Multi-server / urltest-группа** — не нужен. Один сервер на конфиг.
3. Остатки **tun2socks** на Android (`libtun2socks.so`, `tun2socks-arm64`,
`tun_child`, `t2sBinPath`) — давно мёртвый код, только раздувает APK. Не тащить.
> ⚠️ Junk-пакеты и TCP-фрагментация **ОСТАЮТСЯ** (см. Решения и §E) — это уже не
> «мусор». Но junk по UDP нужно сделать так, чтобы сервер его не считал
> `Unauthorized probe` (rate-limit/гейт), иначе лог-флуд и риск самобана.
---
## B. ОБЯЗАТЕЛЬНО перенести (фиксы стабильности)
- **fd limits / EMFILE**`922cf0b`.
- **Lifecycle хелпера**: принудительный `std::process::exit` после остановки, чтобы
не оставался зомби-процесс, держащий адаптер `ostp_tun` и дефолтный маршрут — `b6e78c1`.
- **Bypass-маршрут сервера через `route.exe` по шлюзу** (а не legacy
`CreateIpForwardEntry`, который падал с err 160 из-за рассинхрона индексов
интерфейсов) — `b6e78c1`.
- **IPC хелпера** (ChaCha20Poly1305 + hex) + **единый формат логов**`ee38b15`.
- **Closing-state fix** + `sent_history` на `BTreeMap` (O(log n) NACK) — `47d44fa`.
- **Handshake timeout fixes**`d65af35`, `6eb7b36` (ждать ответ до отправки данных).
- **Buffer / UDP handler**`b5e830a`.
- **Логи**: UoT и unauthorized-probe → debug; rate-limit probe-лога — `1151726`, `fc339b3`.
---
## C. Протокол / крипто — решить и перенести
- **Derived secrets handshake**`f8f27d3`. PSK и obfuscation-key выводятся из
access-key через HKDF; handshake-payload = `[timestamp][session_id][access_key]`;
параметры паддинга деривируются; timestamp anti-replay (±300с).
⚠️ **Ломает совместимость с 0.2.98 wire** (старый клиент не подключится).
Безопаснее старого (raw-PSK + нулевой obfuscation-key). **РЕШЕНИЕ:** переносим ли
(тогда нужен ребилд всех клиентов) — ДА, скорее всего, но осознанно.
- **l4_protocol** для server outbound — `2997bfd`, `ad3a8cb`, `aae9d22`.
---
## D. Транспорты — перенести аккуратно (большие куски)
- **DNS transport (dnstt)** как fallback — `3f1adbc`, `3ced4a1`, `d031b15`,
`10c1772`, `b31da29`. Полезно против блокировок, но объёмно и со своей
фрагментацией/reassembly. Переносить отдельным изолированным модулем.
- UoT (UDP-over-TCP) — уже есть в 0.2.98, проверить что не сломан.
---
## E. Тонкая настройка junk/фрагментации (как в AmneziaWG)
Junk и фрагментацию **оставляем** (Решения), а это — их параметризация. Главное
условие: **координация клиент↔сервер**, иначе junk превращается в probe-флуд.
- `Jc` — кол-во junk-пакетов, `Jmin`/`Jmax` — размеры; **сервер знает и молча отбрасывает**.
- `S1`/`S2` — размеры init/response подгоняются.
- Магические заголовки/сигнатуры пакетов (`H1..H4`).
Реализовать как явные настраиваемые поля (не хардкод). Сервер ОБЯЗАН их понимать.
Сам факт junk/frag — в базе; это «желание» — сделать их настраиваемыми. Можно потом.
---
## F. GUI (desktop) — перенести нужное, без хаоса
- Профили на странице **настроек** (пусто + «Create a new profile» + «+» когда нет
профиля; «+» → меню «из ссылки / вручную»). Главный экран не усложнять.
- **Share** профиля: QR (генерить локально, ключ наружу не отдавать — крейт `qrcode`)
+ копируемая `ostp://` ссылка.
- **Метрики**: байты считать в TUN-инбаунде; rtt брать из round-trip handshake
(а не отдельным TCP-probe).
- **Health/состояние**: «connected» по реальной достижимости сервера на ПРАВИЛЬНОМ
порту (не хардкод :443), а не по факту «процесс запустился».
- **routing**: всегда задавать `default_outbound: "proxy"`; ключи правил —
`domain_suffix` / `ip_cidr` / `process_name` (не `domains/ips/processes`).
- Смена сервера = полный **stop+start**, а не hot-reload (иначе остаётся старый сервер).
- Никаких непрогарженных `addEventListener` на удалённые элементы (краш init).
---
## G. Мобилка (Flutter + JNI) — перенести нужное
- **routing**: тот же `default_outbound` + правильные ключи правил
(без них трафик шёл мимо туннеля — реальный IP).
- **Байты на Android**: считать в обеих задачах fd-пути (read=upload, write=download).
- **rtt**: из handshake (health-probe сокет на Android не protected → до сервера не доходит).
- **Смена сети (WiFi↔LTE)**: реальный reconnect (сейчас `notifyNetworkChanged` — no-op).
- **fd ownership**: НЕ двойное закрытие (Rust `OwnedFd` + Kotlin `close()`) → `detachFd()`.
- **Share** профиля: QR (`qr_flutter`) + ссылка.
- Выкинуть tun2socks (см. §A.3).
---
## H. Инфра / лицензия / брендинг
- Лицензия: **AGPLv3** ✅ (зафиксировано). В 0.2.98 был BSL 1.1 → заменить (`9ce9e6d`).
- **Брендинг — ОСТАВЛЯЕМ** ✅: тёмная тема + орёл на фоне (watermark/логотип) в GUI.
Перенести из текущего `ostp-gui` (assets/logo.svg, тёмная палитра) в чистую переборку.
- Панель/license-check: open-source без license-check — `5782107`, `99ff76d` (если нужно).
- Версионирование/CI build-script — `774d926` и пр.
---
## Инвентаризация базы 0.2.98 (что уже есть / что портировать)
- **Есть в 0.2.98**: WSS (→ удалить), Reality/`reality.rs` (→ удалить),
инфра derived-secrets (`derive_all_secrets`, `obfuscation_key`) — но клиент юзал
dummy-ключи до `f8f27d3`.
- **Нет в 0.2.98 — портировать из пост-0.2.98 кода**: junk-пакеты, TCP-фрагментация,
DNS-transport (dnstt), фикс derived-secrets `f8f27d3`, все фиксы §B, GUI/мобилка §F/§G.
## Что легко упустить (решить до старта)
1. **Версия переборки — 0.4.0** (решено). Сожжённые 0.3.x не переиспользуем.
2. **Серверный конфиг — тоже плоский** и согласован с клиентским. Сервер обязан
поддерживать всё оставленное: derived-secrets (и **отвергать** старый формат),
корректную обработку junk (не probe-флуд), UoT, DNS-transport, management API.
3. **Версия/магический байт протокола ДО крипто-слоя.** Сейчас нельзя отличить старый
handshake от нового — отсюда баг «старый клиент → новый сервер подключился».
Добавить версию в wire → будущие изменения управляемы, сервер чётко режет
несовместимое. Это системный фикс проблемы derived-secrets.
4. **Клиент и сервер обновляются ВМЕСТЕ** — derived-secrets ломает совместимость,
смешивать старое и новое нельзя. Координировать выкладку.
5. **Reality — выкинуть** (решено; в базе 0.2.98 есть `reality.rs` → удалить целиком).
6. **Verify-loop = критерий «готово».** Каждая фича проверяется реальным тестом, не
«на словах»: connect → `curl` показывает IP **сервера**; старый клиент к новому
серверу **не** подключается; смена сети на мобилке восстанавливает туннель.
## Порядок переборки (предложение, 1 сессия)
1. §B (фиксы стабильности) — на чистый 0.2.98.
2. §C (derived-secrets) — и СРАЗУ проверить: старый клиент к новому серверу НЕ
подключается (в прошлый раз был баг — подключался).
3. **Junk + TCP-фрагментация** — перенести (оставляем), но junk по UDP не должен
читаться сервером как `Unauthorized probe` (rate-limit/гейт на сервере).
4. §F/§G по минимуму (routing, метрики, состояние, share) **+ брендинг** (тёмная
тема, орёл на фоне).
5. §D (DNS transport) — если нужно.
6. ВЫКИНУТЬ: **WSS, multi-server, tun2socks** (§A). §E (тюнинг junk) — позже.
7. Конфиг: плоский по сути, секционно-оформленный, один сервер (РЕШЕНО — без
inbounds/outbounds/routing-движка).

View File

@ -29,7 +29,4 @@ tun = { version = "0.8.9", features = ["async"] }
netstack-smoltcp = "0.2.2"
futures = "0.3.32"
libc = "0.2.186"
x25519-dalek = "2.0.1"
chacha20poly1305.workspace = true
hex = "0.4.3"
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }

View File

@ -66,7 +66,6 @@ pub struct Bridge {
pub transport_mode: String,
pub stealth_sni: String,
pub wss: bool,
pub mtu: usize,
pub kill_switch: bool,
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
@ -99,7 +98,6 @@ impl Bridge {
transport_mode: config.transport.mode.clone(),
stealth_sni: config.transport.stealth_sni.clone(),
wss: config.transport.wss,
mtu: config.ostp.mtu,
kill_switch: config.kill_switch,
reload_tx: None,
@ -1026,7 +1024,6 @@ 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.wss = cfg.transport.wss; // Fix: wss was not updated on hot-reload
self.mtu = cfg.ostp.mtu;
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
self.kill_switch = cfg.kill_switch;

View File

@ -70,18 +70,15 @@ pub struct LocalProxyConfig {
}
/// Transport layer configuration.
/// `mode` = "udp" (default) or "uot" (UDP over TCP with xHTTP stealth).
/// `mode` = "udp" (default) or "uot" (UDP over TCP с xHTTP-транспортом).
#[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 stealth routing
/// TLS SNI and HTTP Host for xHTTP routing
#[serde(default)]
pub stealth_sni: String,
/// Enable strict RFC 6455 WebSocket framing
#[serde(default)]
pub wss: bool,
}
fn default_transport_mode() -> String { "udp".to_string() }
@ -91,7 +88,6 @@ impl Default for TransportConfig {
Self {
mode: default_transport_mode(),
stealth_sni: String::new(),
wss: false,
}
}
}
@ -173,7 +169,6 @@ struct RawUnifiedConfig {
struct RawTransportSection {
mode: Option<String>,
stealth_sni: Option<String>,
wss: Option<bool>,
}
#[derive(Debug, Deserialize)]
@ -247,7 +242,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(),
wss: raw.transport.as_ref().and_then(|t| t.wss).unwrap_or(false),
},
exclusions: ExclusionConfig {
domains: exclusions.domains.unwrap_or_default(),

View File

@ -1,394 +0,0 @@
use std::net::IpAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use anyhow::{Result, Context};
use tokio::sync::mpsc;
use hmac::Hmac;
use sha2::Sha256;
use base64::Engine;
use std::pin::Pin;
use std::task::{Context as TaskContext, Poll};
use x25519_dalek::PublicKey;
use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce};
use ostp_core::crypto::reality::{build_client_hello, derive_keys, generate_session_id, generate_x25519_keypair, REALITY_SERVER_HANDSHAKE_RECORDS};
use ostp_core::framing::wss::{encode_wss_frame, decode_wss_frame, WssFrameResult};
type HmacSha256 = Hmac<Sha256>;
pub async fn connect_xhttp(
target_ip: IpAddr,
port: u16,
sni: &str,
access_key: &[u8],
reality_enabled: bool,
wss: bool,
reality_pbk: &str,
reality_sid: &str,
) -> Result<(mpsc::Sender<Bytes>, Arc<tokio::sync::Mutex<mpsc::Receiver<Bytes>>>)> {
let addr = std::net::SocketAddr::new(target_ip, port);
#[cfg(not(target_os = "android"))]
let mut tcp_stream = tokio::time::timeout(
std::time::Duration::from_secs(10),
tokio::net::TcpStream::connect(addr),
)
.await
.map_err(|_| anyhow::anyhow!("TCP connect timeout to {}", addr))?
.with_context(|| format!("failed to connect to {}", addr))?;
#[cfg(target_os = "android")]
let mut tcp_stream = {
let domain = if target_ip.is_ipv6() { socket2::Domain::IPV6 } else { socket2::Domain::IPV4 };
let sock = socket2::Socket::new(domain, socket2::Type::STREAM, Some(socket2::Protocol::TCP))?;
use std::os::unix::io::AsRawFd;
crate::bridge::protect_socket(sock.as_raw_fd());
sock.set_nonblocking(true)?;
let tcp_socket = tokio::net::TcpSocket::from_std_stream(sock.into());
tokio::time::timeout(
std::time::Duration::from_secs(10),
tcp_socket.connect(addr),
)
.await
.map_err(|_| anyhow::anyhow!("TCP connect timeout to {}", addr))?
.with_context(|| format!("failed to connect to {}", addr))?
};
tcp_stream.set_nodelay(true)?;
if reality_enabled {
let pbk_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(reality_pbk)
.context("invalid reality_pbk base64")?;
if pbk_bytes.len() != 32 {
anyhow::bail!("reality_pbk must be 32 bytes");
}
let pbk = PublicKey::from(<[u8; 32]>::try_from(pbk_bytes.as_slice()).unwrap());
let sid_bytes_vec = hex::decode(reality_sid).context("invalid reality_sid hex")?;
if sid_bytes_vec.len() != 8 {
anyhow::bail!("reality_sid must be 8 bytes");
}
let sid: [u8; 8] = sid_bytes_vec.try_into().unwrap();
let (c_priv, c_pub) = generate_x25519_keypair();
let shared_secret = c_priv.diffie_hellman(&pbk);
let (auth_key, data_key) = derive_keys(shared_secret.as_bytes());
let session_id = generate_session_id(&auth_key, &sid);
let client_hello = build_client_hello(if sni.is_empty() { "www.microsoft.com" } else { sni }, &session_id, &c_pub);
tcp_stream.write_all(&client_hello).await?;
// Drain all server handshake records (ServerHello, CCS, fake encrypted records).
// The server sends exactly REALITY_SERVER_HANDSHAKE_RECORDS records before data starts.
// Reading them explicitly prevents RealityStream from seeing non-AppData bytes.
for i in 0..REALITY_SERVER_HANDSHAKE_RECORDS {
let mut head = [0u8; 5];
tcp_stream.read_exact(&mut head).await
.with_context(|| format!("reality handshake: failed reading record {} header", i))?;
if i == 0 && head[0] != 0x16 {
anyhow::bail!("expected ServerHello (0x16), got 0x{:02x}", head[0]);
}
let record_len = u16::from_be_bytes([head[3], head[4]]) as usize;
if record_len > 16384 {
anyhow::bail!("reality handshake: record {} too large: {} bytes", i, record_len);
}
let mut _payload = vec![0u8; record_len];
tcp_stream.read_exact(&mut _payload).await
.with_context(|| format!("reality handshake: failed reading record {} payload", i))?;
}
let reality_stream = RealityStream::new(tcp_stream, data_key);
xhttp_handshake_and_loop(reality_stream, target_ip, sni, access_key, wss).await
} else {
xhttp_handshake_and_loop(tcp_stream, target_ip, sni, access_key, wss).await
}
}
// -----------------------------------------------------------------------
// RealityStream: Wraps a TCP stream in fake TLS Application Data Records
// -----------------------------------------------------------------------
struct RealityStream {
inner: TcpStream,
data_key: ChaCha20Poly1305,
rx_nonce: u64,
tx_nonce: u64,
rx_buf: BytesMut,
plaintext_buf: BytesMut,
tx_buf: BytesMut,
}
impl RealityStream {
fn new(inner: TcpStream, data_key: ChaCha20Poly1305) -> Self {
Self {
inner,
data_key,
rx_nonce: 0,
tx_nonce: 0,
rx_buf: BytesMut::with_capacity(16384),
plaintext_buf: BytesMut::new(),
tx_buf: BytesMut::new(),
}
}
fn make_nonce(seq: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[4..12].copy_from_slice(&seq.to_le_bytes());
nonce
}
}
impl tokio::io::AsyncRead for RealityStream {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
loop {
if !self.plaintext_buf.is_empty() {
let out_len = std::cmp::min(buf.remaining(), self.plaintext_buf.len());
buf.put_slice(&self.plaintext_buf[..out_len]);
self.plaintext_buf.advance(out_len);
return Poll::Ready(Ok(()));
}
if self.rx_buf.len() >= 5 {
let len = u16::from_be_bytes([self.rx_buf[3], self.rx_buf[4]]) as usize;
if self.rx_buf.len() >= 5 + len {
if self.rx_buf[0] != 0x17 {
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "expected application data record")));
}
let ciphertext = &self.rx_buf[5..5+len];
let nonce_bytes = Self::make_nonce(self.rx_nonce);
let nonce = Nonce::from_slice(&nonce_bytes);
match self.data_key.decrypt(nonce, ciphertext) {
Ok(plaintext) => {
self.rx_nonce += 1;
self.plaintext_buf.put_slice(&plaintext);
self.rx_buf.advance(5 + len);
continue;
}
Err(_) => {
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reality decrypt failed")));
}
}
}
}
let mut read_buf = [0u8; 8192];
let mut tokio_buf = tokio::io::ReadBuf::new(&mut read_buf);
match Pin::new(&mut self.inner).poll_read(cx, &mut tokio_buf) {
Poll::Ready(Ok(())) => {
if tokio_buf.filled().is_empty() {
return Poll::Ready(Ok(()));
}
self.rx_buf.put_slice(tokio_buf.filled());
}
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
}
}
impl tokio::io::AsyncWrite for RealityStream {
fn poll_write(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
let this = self.get_mut();
while !this.tx_buf.is_empty() {
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
let nonce_bytes = Self::make_nonce(this.tx_nonce);
let nonce = Nonce::from_slice(&nonce_bytes);
match this.data_key.encrypt(nonce, buf) {
Ok(ciphertext) => {
this.tx_nonce += 1;
this.tx_buf.reserve(5 + ciphertext.len());
this.tx_buf.put_u8(0x17);
this.tx_buf.put_u16(0x0303);
this.tx_buf.put_u16(ciphertext.len() as u16);
this.tx_buf.put_slice(&ciphertext);
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => {}
}
Poll::Ready(Ok(buf.len()))
}
Err(_) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "reality encrypt failed"))),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
while !this.tx_buf.is_empty() {
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
Pin::new(&mut this.inner).poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
while !this.tx_buf.is_empty() {
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
}
Pin::new(&mut this.inner).poll_shutdown(cx)
}
}
async fn xhttp_handshake_and_loop<S>(
mut stream: S,
target_ip: IpAddr,
sni: &str,
access_key: &[u8],
wss: bool,
) -> Result<(mpsc::Sender<Bytes>, Arc<tokio::sync::Mutex<mpsc::Receiver<Bytes>>>)>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
// 1. Generate auth token: [8-byte timestamp BE] ++ [HMAC-SHA256]
let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
let ts_bytes = timestamp.to_be_bytes();
use hmac::Mac;
let mut mac = <HmacSha256 as Mac>::new_from_slice(access_key).unwrap_or_else(|_| <HmacSha256 as Mac>::new_from_slice(b"").unwrap());
mac.update(&ts_bytes);
let mac_bytes = mac.finalize().into_bytes();
let mut sig_bytes = Vec::with_capacity(8 + mac_bytes.len());
sig_bytes.extend_from_slice(&ts_bytes);
sig_bytes.extend_from_slice(&mac_bytes);
let auth_token = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&sig_bytes);
let http_host = if sni.is_empty() { target_ip.to_string() } else { sni.to_string() };
let req = if wss {
format!(
"GET /wss HTTP/1.1\r\n\
Host: {}\r\n\
Upgrade: websocket\r\n\
Connection: upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
Authorization: Bearer {}\r\n\
\r\n",
http_host, auth_token
)
} else {
format!(
"GET /stream HTTP/1.1\r\n\
Host: {}\r\n\
Authorization: Bearer {}\r\n\
\r\n",
http_host, auth_token
)
};
stream.write_all(req.as_bytes()).await?;
// Wait for HTTP 200 OK or 101 Switching Protocols
let mut header_buf = Vec::new();
let mut temp = [0u8; 1];
loop {
let n = stream.read(&mut temp).await?;
if n == 0 {
anyhow::bail!("connection closed by server during handshake");
}
header_buf.push(temp[0]);
if header_buf.ends_with(b"\r\n\r\n") {
break;
}
if header_buf.len() > 8192 {
anyhow::bail!("server response too long");
}
}
let resp_str = String::from_utf8_lossy(&header_buf);
if wss {
if !resp_str.starts_with("HTTP/1.1 101 ") {
anyhow::bail!("failed to switch protocols: {}", resp_str.lines().next().unwrap_or(""));
}
} else {
if !resp_str.starts_with("HTTP/1.1 200 OK") {
anyhow::bail!("server rejected stream: {}", resp_str.lines().next().unwrap_or(""));
}
}
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
let (mut read_half, mut write_half) = tokio::io::split(stream);
let writer_task = tokio::spawn(async move {
while let Some(packet) = rx.recv().await {
if wss {
let header = encode_wss_frame(&packet, true);
if write_half.write_all(&header).await.is_err() { break; }
} else {
let mut out = BytesMut::with_capacity(2 + packet.len());
out.put_u16(packet.len() as u16);
out.put_slice(&packet);
if write_half.write_all(&out).await.is_err() { break; }
}
}
});
let (in_tx, in_rx) = mpsc::channel::<Bytes>(16384);
let in_rx_arc = Arc::new(tokio::sync::Mutex::new(in_rx));
let in_tx_clone = in_tx.clone();
let reader_task = tokio::spawn(async move {
if wss {
let mut read_buf = BytesMut::with_capacity(65536);
let mut tmp = [0u8; 8192];
loop {
match read_half.read(&mut tmp).await {
Ok(0) => break,
Ok(n) => {
read_buf.put_slice(&tmp[..n]);
loop {
match decode_wss_frame(&mut read_buf) {
WssFrameResult::Frame { payload, total_len } => {
if in_tx_clone.send(Bytes::from(payload)).await.is_err() { return; }
read_buf.advance(total_len);
}
WssFrameResult::Incomplete => break,
}
}
}
Err(_) => break,
}
}
} else {
let mut len_buf = [0u8; 2];
loop {
if read_half.read_exact(&mut len_buf).await.is_err() { break; }
let len = u16::from_be_bytes(len_buf) as usize;
if len > 65535 { break; }
let mut data = vec![0u8; len];
if read_half.read_exact(&mut data).await.is_err() { break; }
if in_tx_clone.send(Bytes::from(data)).await.is_err() { break; }
}
}
});
tokio::spawn(async move {
let _ = tokio::join!(writer_task, reader_task);
});
Ok((tx, in_rx_arc))
}

View File

@ -1,279 +0,0 @@
use bytes::{Buf, BufMut, Bytes, BytesMut};
use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use sha2::Sha256;
use x25519_dalek::{PublicKey, StaticSecret};
use rand::{rngs::OsRng, RngCore};
use std::time::{SystemTime, UNIX_EPOCH};
const REALITY_INFO: &[u8] = b"ostp-reality-v1";
const RECORD_HEADER_LEN: usize = 5;
const HANDSHAKE_HEADER_LEN: usize = 4;
/// Number of TLS records sent by the server during the fake handshake phase.
/// Client must read and discard this many records before starting RealityStream.
/// Layout: 1× ServerHello (0x16) + 1× CCS (0x14) + 3× fake encrypted records (0x17)
pub const REALITY_SERVER_HANDSHAKE_RECORDS: usize = 5;
/// Generates an X25519 keypair
pub fn generate_x25519_keypair() -> (StaticSecret, PublicKey) {
let secret = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
(secret, public)
}
/// Derives the Auth Key and Data Key from the X25519 shared secret
pub fn derive_keys(shared_secret: &[u8; 32]) -> (ChaCha20Poly1305, ChaCha20Poly1305) {
let hk = Hkdf::<Sha256>::new(None, shared_secret);
let mut okm = [0u8; 64];
hk.expand(REALITY_INFO, &mut okm).expect("HKDF expand failed");
let auth_key = ChaCha20Poly1305::new_from_slice(&okm[0..32]).unwrap();
let data_key = ChaCha20Poly1305::new_from_slice(&okm[32..64]).unwrap();
(auth_key, data_key)
}
/// Creates an authenticated Session ID payload (32 bytes)
/// sid: 8 bytes, timestamp: 8 bytes. Encrypted with ChaCha20Poly1305 (16 byte tag). Total = 32 bytes.
pub fn generate_session_id(auth_aead: &ChaCha20Poly1305, sid: &[u8; 8]) -> [u8; 32] {
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let mut plaintext = [0u8; 16];
plaintext[0..8].copy_from_slice(sid);
plaintext[8..16].copy_from_slice(&ts.to_be_bytes());
let nonce = Nonce::from_slice(&[0u8; 12]); // Fixed nonce since auth key is ephemeral per connection
let ciphertext = auth_aead.encrypt(nonce, plaintext.as_ref()).expect("encryption failed");
let mut session_id = [0u8; 32];
session_id.copy_from_slice(&ciphertext);
session_id
}
/// Verifies and decrypts the Session ID payload. Returns (sid, timestamp)
pub fn verify_session_id(auth_aead: &ChaCha20Poly1305, session_id: &[u8; 32]) -> Option<([u8; 8], u64)> {
let nonce = Nonce::from_slice(&[0u8; 12]);
let plaintext = auth_aead.decrypt(nonce, session_id.as_ref()).ok()?;
if plaintext.len() != 16 {
return None;
}
let mut sid = [0u8; 8];
sid.copy_from_slice(&plaintext[0..8]);
let mut ts_bytes = [0u8; 8];
ts_bytes.copy_from_slice(&plaintext[8..16]);
let ts = u64::from_be_bytes(ts_bytes);
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
// Allow up to 60 seconds of clock drift
if ts > now + 60 || ts < now.saturating_sub(60) {
return None; // Replay protection / stale connection
}
Some((sid, ts))
}
/// Builds a fake TLS 1.3 ClientHello matching Chrome's fingerprint
pub fn build_client_hello(sni: &str, session_id: &[u8; 32], c_pub: &PublicKey) -> Bytes {
let mut ext = BytesMut::new();
// SNI Extension
let sni_bytes = sni.as_bytes();
ext.put_u16(0x0000); // Type: server_name
ext.put_u16((sni_bytes.len() + 5) as u16);
ext.put_u16((sni_bytes.len() + 3) as u16); // Server Name list length
ext.put_u8(0x00); // Name Type: host_name
ext.put_u16(sni_bytes.len() as u16);
ext.put_slice(sni_bytes);
// Supported Groups
ext.put_u16(0x000a); // Type
ext.put_u16(8); // Length
ext.put_u16(6); // List length
ext.put_u16(0x001d); // x25519
ext.put_u16(0x0017); // secp256r1
ext.put_u16(0x0018); // secp384r1
// Key Share
let pub_bytes = c_pub.as_bytes();
ext.put_u16(0x0033); // Type
ext.put_u16((pub_bytes.len() + 6) as u16); // Length
ext.put_u16((pub_bytes.len() + 4) as u16); // ClientShares length
ext.put_u16(0x001d); // Group: x25519
ext.put_u16(pub_bytes.len() as u16);
ext.put_slice(pub_bytes);
// Supported Versions
ext.put_u16(0x002b); // Type
ext.put_u16(5); // Length
ext.put_u8(4); // List length
ext.put_u16(0x0304); // TLS 1.3
ext.put_u16(0x0303); // TLS 1.2
// ALPN
let alpn = b"\x02h2\x08http/1.1";
ext.put_u16(0x0010); // Type
ext.put_u16((alpn.len() + 2) as u16);
ext.put_u16(alpn.len() as u16);
ext.put_slice(alpn);
// Signature Algorithms
ext.put_u16(0x000d); // Type
ext.put_u16(10); // Length
ext.put_u16(8); // List length
ext.put_u16(0x0403); // ecdsa_secp256r1_sha256
ext.put_u16(0x0804); // rsa_pss_rsae_sha256
ext.put_u16(0x0401); // rsa_pkcs1_sha256
ext.put_u16(0x0503); // ecdsa_secp384r1_sha384
let mut handshake = BytesMut::new();
handshake.put_u16(0x0303); // Client Version
let mut random = [0u8; 32];
OsRng.fill_bytes(&mut random);
handshake.put_slice(&random); // Random
handshake.put_u8(32); // Session ID length
handshake.put_slice(session_id); // Session ID
// Cipher Suites
handshake.put_u16(6); // Length
handshake.put_u16(0x1301); // TLS_AES_128_GCM_SHA256
handshake.put_u16(0x1303); // TLS_CHACHA20_POLY1305_SHA256
handshake.put_u16(0x1302); // TLS_AES_256_GCM_SHA384
// Compression
handshake.put_u8(1); // Length
handshake.put_u8(0); // null
// Extensions
handshake.put_u16(ext.len() as u16);
handshake.put_slice(&ext);
let handshake_len = handshake.len();
let mut record = BytesMut::new();
record.put_u8(0x16); // Handshake
record.put_u16(0x0301); // TLS 1.0 (Compatibility)
record.put_u16((handshake_len + HANDSHAKE_HEADER_LEN) as u16); // Length
record.put_u8(0x01); // ClientHello
record.put_u8((handshake_len >> 16) as u8);
record.put_u8((handshake_len >> 8) as u8);
record.put_u8(handshake_len as u8);
record.put_slice(&handshake);
// Append ChangeCipherSpec for TLS 1.3 middlebox compatibility (RFC 8446 §D.4)
// This makes the flow look like: ClientHello → ServerHello → CCS → AppData
// instead of the DPI-suspicious: ClientHello → AppData directly.
let mut out = BytesMut::new();
out.put_slice(&record);
out.put_slice(&[0x14, 0x03, 0x03, 0x00, 0x01, 0x01]);
out.freeze()
}
pub struct ParsedClientHello {
pub sni: String,
pub session_id: [u8; 32],
pub c_pub: PublicKey,
}
/// Parses a TLS ClientHello. Returns None if invalid or missing required fields.
pub fn parse_client_hello(mut buf: &[u8]) -> Option<ParsedClientHello> {
if buf.len() < RECORD_HEADER_LEN + HANDSHAKE_HEADER_LEN {
return None;
}
// Record Header
let typ = buf.get_u8();
if typ != 0x16 { return None; } // Not a handshake
let _version = buf.get_u16();
let record_len = buf.get_u16() as usize;
if buf.len() < record_len {
return None; // Incomplete record
}
let mut payload = &buf[..record_len];
// Handshake Header
let hs_type = payload.get_u8();
if hs_type != 0x01 { return None; } // Not ClientHello
let hs_len_hi = payload.get_u8() as usize;
let hs_len_mid = payload.get_u8() as usize;
let hs_len_lo = payload.get_u8() as usize;
let hs_len = (hs_len_hi << 16) | (hs_len_mid << 8) | hs_len_lo;
if payload.len() < hs_len { return None; }
let mut ch = &payload[..hs_len];
let _client_version = ch.get_u16();
if ch.len() < 32 { return None; }
ch.advance(32); // Skip Random
let sid_len = ch.get_u8() as usize;
if sid_len != 32 || ch.len() < 32 { return None; }
let mut session_id = [0u8; 32];
session_id.copy_from_slice(&ch[..32]);
ch.advance(32);
let ciphers_len = ch.get_u16() as usize;
if ch.len() < ciphers_len { return None; }
ch.advance(ciphers_len);
let comp_len = ch.get_u8() as usize;
if ch.len() < comp_len { return None; }
ch.advance(comp_len);
let ext_len = ch.get_u16() as usize;
if ch.len() < ext_len { return None; }
let mut exts = &ch[..ext_len];
let mut parsed_sni = None;
let mut parsed_c_pub = None;
while exts.len() >= 4 {
let ext_type = exts.get_u16();
let ext_len = exts.get_u16() as usize;
if exts.len() < ext_len { break; }
let mut ext_data = &exts[..ext_len];
if ext_type == 0x0000 { // SNI
let _list_len = ext_data.get_u16() as usize;
if ext_data.len() >= 3 {
let name_type = ext_data.get_u8();
if name_type == 0x00 { // Hostname
let name_len = ext_data.get_u16() as usize;
if ext_data.len() >= name_len {
if let Ok(name) = std::str::from_utf8(&ext_data[..name_len]) {
parsed_sni = Some(name.to_string());
}
}
}
}
} else if ext_type == 0x0033 { // Key Share
let _client_shares_len = ext_data.get_u16() as usize;
while ext_data.len() >= 4 {
let group = ext_data.get_u16();
let key_ex_len = ext_data.get_u16() as usize;
if ext_data.len() < key_ex_len { break; }
if group == 0x001d && key_ex_len == 32 { // X25519
let mut pub_bytes = [0u8; 32];
pub_bytes.copy_from_slice(&ext_data[..32]);
parsed_c_pub = Some(PublicKey::from(pub_bytes));
}
ext_data.advance(key_ex_len);
}
}
exts.advance(ext_len);
}
match (parsed_sni, parsed_c_pub) {
(Some(sni), Some(c_pub)) => Some(ParsedClientHello { sni, session_id, c_pub }),
_ => None,
}
}

View File

@ -1,7 +1,5 @@
pub mod frame;
pub mod padding;
pub mod wss;
pub use frame::{FrameHeader, FrameKind, FramedPacket};
pub use padding::{AdaptivePadder, PaddingStrategy, TrafficProfile};
pub use wss::{encode_wss_frame, decode_wss_frame, WssFrameResult};

View File

@ -1,74 +0,0 @@
use rand::RngCore;
pub enum WssFrameResult {
Incomplete,
Frame { payload: Vec<u8>, total_len: usize },
}
pub fn encode_wss_frame(payload: &[u8], masked: bool) -> Vec<u8> {
let len = payload.len();
let mut header = Vec::with_capacity(14 + len);
header.push(0x82); // FIN + Binary
let mask_bit = if masked { 0x80 } else { 0x00 };
if len <= 125 {
header.push(mask_bit | (len as u8));
} else if len <= 65535 {
header.push(mask_bit | 126);
header.extend_from_slice(&(len as u16).to_be_bytes());
} else {
header.push(mask_bit | 127);
header.extend_from_slice(&(len as u64).to_be_bytes());
}
if masked {
let mut mask = [0u8; 4];
rand::thread_rng().fill_bytes(&mut mask);
header.extend_from_slice(&mask);
for (i, &b) in payload.iter().enumerate() {
header.push(b ^ mask[i % 4]);
}
} else {
header.extend_from_slice(payload);
}
header
}
pub fn decode_wss_frame(buffer: &[u8]) -> WssFrameResult {
if buffer.len() < 2 {
return WssFrameResult::Incomplete;
}
let is_masked = (buffer[1] & 0x80) != 0;
let payload_len_7 = (buffer[1] & 0x7F) as usize;
let (header_len, payload_len) = if payload_len_7 == 126 {
if buffer.len() < 4 { return WssFrameResult::Incomplete; }
(4, u16::from_be_bytes([buffer[2], buffer[3]]) as usize)
} else if payload_len_7 == 127 {
if buffer.len() < 10 { return WssFrameResult::Incomplete; }
(10, u64::from_be_bytes([buffer[2], buffer[3], buffer[4], buffer[5], buffer[6], buffer[7], buffer[8], buffer[9]]) as usize)
} else {
(2, payload_len_7)
};
let mask_offset = header_len;
let full_header_len = header_len + if is_masked { 4 } else { 0 };
let total_frame_len = full_header_len + payload_len;
if buffer.len() < total_frame_len {
return WssFrameResult::Incomplete;
}
let mut payload = buffer[full_header_len..total_frame_len].to_vec();
if is_masked {
let mask = [buffer[mask_offset], buffer[mask_offset+1], buffer[mask_offset+2], buffer[mask_offset+3]];
for (i, b) in payload.iter_mut().enumerate() {
*b ^= mask[i % 4];
}
}
WssFrameResult::Frame { payload, total_len: total_frame_len }
}

View File

@ -84,7 +84,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final wss = widget.prefs.getBool('wss') ?? false;
final mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
@ -113,7 +112,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"transport": {
"mode": transportMode,
"stealth_sni": stealthSni,
"wss": wss,
},
"multiplex": {
"enabled": muxEnabled,
@ -173,7 +171,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
final wss = widget.prefs.getBool('wss') ?? false;
final mtu = widget.prefs.getString('mtu') ?? '1140';
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
@ -201,7 +198,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"transport": {
"mode": transportMode,
"stealth_sni": stealthSni,
"wss": wss,
},
"multiplex": {
"enabled": muxEnabled,
@ -296,10 +292,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _runAutoMode() async {
final mtus = [1500, 1350, 1280, 1140];
final modes = [
{'t': 'udp', 'w': false, 'r': false},
{'t': 'uot', 'w': false, 'r': false},
{'t': 'uot', 'w': true, 'r': false},
{'t': 'uot', 'w': false, 'r': true},
{'t': 'udp'},
{'t': 'uot'},
];
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
@ -313,13 +307,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
for (var mtu in mtus) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Testing: ${mode['t']} | WSS: ${mode['w']} | XTLS: ${mode['r']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
);
// Update prefs
await widget.prefs.setString('mtu', mtu.toString());
await widget.prefs.setString('transport_mode', mode['t'] as String);
await widget.prefs.setBool('wss', mode['w'] as bool);
_updateLatestConfigJson();
setState(() {

View File

@ -33,12 +33,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
late TextEditingController _ipsCtrl;
late TextEditingController _processesCtrl;
late TextEditingController _stealthSniCtrl;
late TextEditingController _pbkCtrl;
late TextEditingController _sidCtrl;
bool _obscureKey = true;
bool _debugMode = false;
bool _wss = false;
String _transportMode = 'udp'; // 'udp' | 'uot'
String _tunStack = 'ostp'; // 'system' | 'ostp'
bool _muxEnabled = false;
@ -58,9 +55,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
_processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
_stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? '');
_pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? '');
_sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
_wss = widget.prefs.getBool('wss') ?? false;
_transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
_tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
_debugMode = widget.prefs.getBool('debug_mode') ?? false;
@ -81,8 +75,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_ipsCtrl.dispose();
_processesCtrl.dispose();
_stealthSniCtrl.dispose();
_pbkCtrl.dispose();
_sidCtrl.dispose();
_muxSessionsCtrl.dispose();
super.dispose();
}
@ -97,12 +89,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
widget.prefs.setString('ex_processes', _processesCtrl.text.trim());
widget.prefs.setBool('debug_mode', _debugMode);
widget.prefs.setBool('wss', _wss);
widget.prefs.setString('transport_mode', _transportMode);
widget.prefs.setString('tun_stack', _tunStack);
widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim());
widget.prefs.setString('pbk', _pbkCtrl.text.trim());
widget.prefs.setString('sid', _sidCtrl.text.trim());
widget.prefs.setBool('mux_enabled', _muxEnabled);
widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
}
@ -237,9 +226,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_serverCtrl.text = host;
_keyCtrl.text = key;
_stealthSniCtrl.text = uri.queryParameters['sni'] ?? '';
_pbkCtrl.text = uri.queryParameters['pbk'] ?? '';
_sidCtrl.text = uri.queryParameters['sid'] ?? '';
_wss = uri.queryParameters['wss'] == 'true';
final type = uri.queryParameters['type'] ?? 'udp';
_transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp';
_importCtrl.clear();
@ -324,12 +310,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 16),
_buildToggle('WebSocket (WSS)', 'Инкапсулировать транспорт в RFC 6455 (для строгого DPI)', _wss, (val) {
setState(() {
_wss = val;
});
}),
const SizedBox(height: 16),
// Stealth parameters
AnimatedCrossFade(
@ -555,15 +535,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (_stealthSniCtrl.text.trim().isNotEmpty) {
queryParams.add('sni=${Uri.encodeComponent(_stealthSniCtrl.text.trim())}');
}
if (_pbkCtrl.text.trim().isNotEmpty) {
queryParams.add('pbk=${Uri.encodeComponent(_pbkCtrl.text.trim())}');
}
if (_sidCtrl.text.trim().isNotEmpty) {
queryParams.add('sid=${Uri.encodeComponent(_sidCtrl.text.trim())}');
}
if (_wss) {
queryParams.add('wss=true');
}
if (_transportMode != 'udp') {
queryParams.add('type=$_transportMode');
}

View File

@ -57,7 +57,6 @@ struct TunConfig {
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
wss: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -164,7 +163,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()),
wss: raw.transport.as_ref().and_then(|t| t.wss).unwrap_or(false),
},
exclusions: ostp_client::config::ExclusionConfig {
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
@ -322,7 +320,7 @@ async fn get_config() -> Result<String, String> {
"_comment_socks5_bind": "The local port where the system/browser should connect (HTTP/SOCKS5)",
"socks5_bind": "127.0.0.1:1088",
"_comment_tun": "Virtual network adapter settings (requires tun2socks.exe to be present)",
"_comment_tun": "Virtual network adapter settings (native OSTP TUN via wintun.dll)",
"tun": {
"enable": false,
"wintun_path": "./wintun.dll",

View File

@ -206,22 +206,6 @@
<input id="in-stealth-sni" class="field-input" type="text" placeholder="www.microsoft.com" spellcheck="false" />
</div>
<div class="toggle-row" id="group-wss">
<div class="toggle-text">
<span class="toggle-name">WebSocket (WSS)</span>
<span class="toggle-hint">Use RFC 6455 framing for strict DPI bypass</span>
</div>
<label class="toggle">
<input type="checkbox" id="in-wss" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<div class="field-group">
</div>
<div class="field-group">
<label class="field-label" for="in-mtu" data-i18n="label_mtu">MTU Size</label>
<input id="in-mtu" class="field-input" type="number" placeholder="1350" />

View File

@ -46,7 +46,6 @@ const inDns = $('in-dns');
const groupCustomDns = $('group-custom-dns');
const inTransport = $('in-transport');
const inSni = $('in-stealth-sni');
const inWss = $('in-wss');
const inMtu = $('in-mtu');
const inTun = $('in-tun-mode');
const inKillSwitch = $('in-kill-switch');
@ -336,7 +335,6 @@ async function loadConfigIntoForm() {
inSocks.value = c.socks5_bind || '127.0.0.1:1088';
inTransport.value = c.transport?.mode || 'udp';
inSni.value = c.transport?.stealth_sni || '';
inWss.checked = !!c.transport?.wss;
inMtu.value = c.mtu || '';
inTun.checked = !!c.tun?.enable;
@ -400,7 +398,6 @@ async function handleSave(silent = false) {
rawConfig.transport = rawConfig.transport || {};
rawConfig.transport.mode = inTransport.value;
rawConfig.transport.stealth_sni = inSni.value.trim() || undefined;
rawConfig.transport.wss = inWss.checked;
const mtuStr = inMtu.value.trim();
if (mtuStr) rawConfig.mtu = parseInt(mtuStr, 10);
@ -534,21 +531,18 @@ window.addEventListener('DOMContentLoaded', async () => {
try {
const mtus = [1500, 1350, 1280];
const modes = [
{ t: 'udp', w: false, r: false },
{ t: 'uot', w: false, r: false },
{ t: 'uot', w: true, r: false },
{ t: 'uot', w: false, r: true }
{ t: 'udp' },
{ t: 'uot' }
];
for (let mode of modes) {
for (let mtu of mtus) {
showToast(`Testing: ${mode.t} | WSS: ${mode.w} | XTLS: ${mode.r} | MTU: ${mtu}`);
showToast(`Testing: ${mode.t} | MTU: ${mtu}`);
rawConfig.ostp = rawConfig.ostp || {};
rawConfig.ostp.mtu = mtu;
rawConfig.transport = rawConfig.transport || {};
rawConfig.transport.mode = mode.t;
rawConfig.transport.wss = mode.w;

View File

@ -18,5 +18,5 @@ rand.workspace = true
url = "2.5"
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ostp-core = { version = "0.2.68", path = "../ostp-core" }
ostp-core = { path = "../ostp-core" }
colored = "2.1"

View File

@ -85,7 +85,6 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
let mut transport_mode = String::from("udp");
let mut tun_enabled = false;
let mut tun_dns = None;
let mut wss_enabled = false;
for (k, v) in parsed.query_pairs() {
match &*k {
@ -93,7 +92,6 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
"type" => transport_mode = v.into_owned(),
"tun" => tun_enabled = v == "true",
"dns" => tun_dns = Some(v.into_owned()),
"wss" => wss_enabled = v == "true",
_ => {}
}
}
@ -105,7 +103,6 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
transport: Some(TransportConfigRaw {
mode: Some(transport_mode),
stealth_sni: Some(sni.clone()),
wss: Some(wss_enabled),
}),
socks5_bind: Some("127.0.0.1:1088".to_string()),
tun: Some(TunConfig {
@ -320,7 +317,6 @@ struct ClientConfig {
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
wss: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -699,8 +695,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
},
"transport": {
"mode": transport_mode,
"stealth_sni": "www.microsoft.com",
"wss": false
"stealth_sni": "www.microsoft.com"
},
"mux": {
"enabled": mux_enable,
@ -846,12 +841,8 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
let digest: [u8; 32] = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// simple SHA-256 via sha2 would be ideal; we reuse existing pattern from the old script
// fallback: store plaintext-keyed sha256 if sha2 crate not available
// The ostp binary already uses sha256 for reality keys — let's do it properly via python fallback
// Actually: ostp-core likely has sha2 in tree. Let's use hex output.
// We'll use std's hash as placeholder and document; sha2 is not in ostp/Cargo.toml directly.
// Use sha2 via ostp_core if available, else hex of std hasher.
// Panel password hashing. sha2 is not a direct dep of ostp/Cargo.toml,
// so we use std's hasher as a placeholder digest here.
let mut h = DefaultHasher::new();
password.hash(&mut h);
let v = h.finish();
@ -1348,8 +1339,7 @@ async fn run_app() -> Result<()> {
// Transport Mode: "udp" (default WebRTC masquerade) or "uot" (TCP UoT)
"transport": {{
"mode": "udp",
"stealth_sni": "www.microsoft.com",
"wss": false
"stealth_sni": "www.microsoft.com"
}},
"mux": {{
@ -1633,7 +1623,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()),
wss: client_cfg.transport.as_ref().and_then(|t| t.wss).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),