mirror of https://github.com/ospab/ostp.git
Compare commits
14 Commits
80b4ad8d54
...
b2ee9eb010
| Author | SHA1 | Date |
|---|---|---|
|
|
b2ee9eb010 | |
|
|
8ca411a64b | |
|
|
dbd4ebc4e3 | |
|
|
acab38c551 | |
|
|
e1bf18e653 | |
|
|
39127d30f3 | |
|
|
5c9ec89821 | |
|
|
4f7f1ca838 | |
|
|
2a9b099b24 | |
|
|
db65e3367f | |
|
|
89a5eea20d | |
|
|
38f2d9e659 | |
|
|
7704e0bdb1 | |
|
|
92d6b06d75 |
|
|
@ -6,6 +6,9 @@ on:
|
|||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- nightly
|
||||
- pre-release
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -146,11 +149,17 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Build Web Panel
|
||||
working-directory: ostp-control
|
||||
- name: Build Web Panel (skip if no source; use committed dist/)
|
||||
shell: bash
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p ostp-control/dist
|
||||
cd ostp-control
|
||||
if [ -f package.json ]; then
|
||||
npm install && npm run build
|
||||
else
|
||||
echo "ostp-control has no package.json — using committed dist/"
|
||||
[ -f dist/index.html ] || echo '<!doctype html><title>OSTP</title>' > dist/index.html
|
||||
fi
|
||||
|
||||
# ── Rust toolchain ─────────────────────────────────────────────────────
|
||||
- name: Setup Rust toolchain
|
||||
|
|
@ -233,9 +242,17 @@ jobs:
|
|||
|
||||
# ── Upload ─────────────────────────────────────────────────────────────
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
files: ${{ matrix.release_name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -307,9 +324,17 @@ jobs:
|
|||
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -367,9 +392,17 @@ jobs:
|
|||
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -424,9 +457,17 @@ jobs:
|
|||
tar -czf ostp-macos-gui-${{ matrix.arch }}.tar.gz ostp-macos-gui-${{ matrix.arch }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
@ -444,7 +485,6 @@ jobs:
|
|||
- arch: armeabi-v7a
|
||||
rust_target: armv7-linux-androideabi
|
||||
flutter_target: android-arm
|
||||
tun2socks_arch: linux-armv7
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -493,9 +533,17 @@ jobs:
|
|||
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ To build and test OSTP locally, you will need:
|
|||
The repository is organized as a Cargo workspace containing the following crates:
|
||||
|
||||
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Core protocol logic, including packet formatting, serialization, selective ACK/NACK (ARQ) state machine, and the Noise protocol (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`) handshake.
|
||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Client implementations, including SOCKS5/HTTP local proxies, `tun2socks` integration, native TUN interface routing, and split-tunneling bypass mechanisms.
|
||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Client implementations, including SOCKS5/HTTP local proxies, the native OSTP TUN interface routing, and split-tunneling bypass mechanisms.
|
||||
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Server logic, session dispatcher, anti-probing fallback server proxying, access key database, and the REST API for control panel communication.
|
||||
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): A modern web dashboard for server administration (user management, real-time metrics, bandwidth limits).
|
||||
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Tauri-based desktop GUI application for Windows and Linux.
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
Репозиторий представляет собой единый Cargo-workspace со следующими компонентами:
|
||||
|
||||
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Базовая логика протокола: форматирование пакетов, сериализация, конечный автомат выборочного подтверждения (ARQ/ACK/NACK) и рукопожатие Noise (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`).
|
||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Клиентская часть: локальные SOCKS5/HTTP прокси-серверы, интеграция с драйвером `wintun` / `tun2socks` и реализация раздельного туннелирования для прямого обхода трафика.
|
||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Клиентская часть: локальные SOCKS5/HTTP прокси-серверы, нативный OSTP TUN-интерфейс (через драйвер `wintun`) и реализация раздельного туннелирования для прямого обхода трафика.
|
||||
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Серверная часть: диспетчеризация сессий, маскировка под классические веб-серверы при активном сканировании, база данных ключей доступа и REST API панели управления.
|
||||
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): Панель администратора (пользователи, статистика трафика в реальном времени, лимиты скорости и объема данных).
|
||||
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Настольное приложение-клиент для Windows и Linux на платформе Tauri.
|
||||
|
|
|
|||
|
|
@ -1384,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||
|
||||
[[package]]
|
||||
name = "ostp"
|
||||
version = "0.2.98"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1395,6 +1395,7 @@ dependencies = [
|
|||
"ostp-core",
|
||||
"ostp-server",
|
||||
"rand 0.8.5",
|
||||
"rlimit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
@ -1405,16 +1406,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.2.98"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"json_comments",
|
||||
"libc",
|
||||
|
|
@ -1434,12 +1433,11 @@ dependencies = [
|
|||
"tun",
|
||||
"webpki-roots 0.26.11",
|
||||
"winapi",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.2.98"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1473,7 +1471,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-server"
|
||||
version = "0.2.98"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1505,7 +1503,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.2.98"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
@ -1517,7 +1515,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun-helper"
|
||||
version = "0.2.98"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1856,6 +1854,15 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rlimit"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
|
|
|
|||
|
|
@ -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.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
|
|
|||
|
|
@ -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-движка).
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ pub struct Bridge {
|
|||
|
||||
pub transport_mode: String,
|
||||
pub stealth_sni: String,
|
||||
pub wss: bool,
|
||||
pub tcp_fragmentation: bool,
|
||||
pub frag_chunk: usize,
|
||||
pub frag_sleep: u64,
|
||||
pub junk_pc: [usize; 2],
|
||||
pub junk_ps: [usize; 2],
|
||||
pub mtu: usize,
|
||||
pub kill_switch: bool,
|
||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||
|
|
@ -99,7 +103,11 @@ impl Bridge {
|
|||
|
||||
transport_mode: config.transport.mode.clone(),
|
||||
stealth_sni: config.transport.stealth_sni.clone(),
|
||||
wss: config.transport.wss,
|
||||
tcp_fragmentation: config.transport.tcp_fragmentation,
|
||||
frag_chunk: config.transport.frag_chunk,
|
||||
frag_sleep: config.transport.frag_sleep,
|
||||
junk_pc: config.transport.junk_pc,
|
||||
junk_ps: config.transport.junk_ps,
|
||||
mtu: config.ostp.mtu,
|
||||
kill_switch: config.kill_switch,
|
||||
reload_tx: None,
|
||||
|
|
@ -342,7 +350,7 @@ impl Bridge {
|
|||
Err(e) => {
|
||||
if is_uot {
|
||||
// TCP is dead — drop sender to signal bridge via channel close
|
||||
tracing::warn!("UoT session {} disconnected: {}", session_index, e);
|
||||
tracing::debug!("UoT session {} disconnected: {}", session_index, e);
|
||||
break;
|
||||
} else {
|
||||
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
|
||||
|
|
@ -436,7 +444,7 @@ impl Bridge {
|
|||
}
|
||||
Err(e) => {
|
||||
if is_uot {
|
||||
tracing::warn!("UoT network-change session {} disconnected: {}", session_index, e);
|
||||
tracing::debug!("UoT network-change session {} disconnected: {}", session_index, e);
|
||||
break;
|
||||
} else {
|
||||
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
|
||||
|
|
@ -574,7 +582,7 @@ impl Bridge {
|
|||
}
|
||||
Err(e) => {
|
||||
if is_uot {
|
||||
tracing::warn!("UoT reconnect session {} disconnected: {}", session_index, e);
|
||||
tracing::debug!("UoT reconnect session {} disconnected: {}", session_index, e);
|
||||
break;
|
||||
} else {
|
||||
tracing::warn!("UDP socket recv error (reconnect session {}): {}", session_index, e);
|
||||
|
|
@ -1026,7 +1034,11 @@ 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.tcp_fragmentation = cfg.transport.tcp_fragmentation;
|
||||
self.frag_chunk = cfg.transport.frag_chunk.max(1);
|
||||
self.frag_sleep = cfg.transport.frag_sleep;
|
||||
self.junk_pc = cfg.transport.junk_pc;
|
||||
self.junk_ps = cfg.transport.junk_ps;
|
||||
self.mtu = cfg.ostp.mtu;
|
||||
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
||||
self.kill_switch = cfg.kill_switch;
|
||||
|
|
@ -1043,18 +1055,77 @@ impl Bridge {
|
|||
let _ = stream.set_nodelay(true);
|
||||
let (mut read_half, mut write_half) = stream.into_split();
|
||||
|
||||
let tcp_fragmentation = self.tcp_fragmentation;
|
||||
let frag_chunk = self.frag_chunk;
|
||||
let frag_sleep = self.frag_sleep;
|
||||
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
||||
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
||||
// Per-key junk marker (derived from the access key) — NOT a global
|
||||
// constant, so junk frames carry no universal DPI signature.
|
||||
let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker;
|
||||
|
||||
{
|
||||
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>> = {
|
||||
let mut rng = rand::thread_rng();
|
||||
let min_c = junk_pc_min;
|
||||
let max_c = junk_pc_max.max(min_c);
|
||||
let num_junk = rng.gen_range(min_c..=max_c);
|
||||
(0..num_junk)
|
||||
.map(|_| {
|
||||
let min_s = junk_ps_min.max(1);
|
||||
let max_s = junk_ps_max.max(min_s);
|
||||
let junk_len = rng.gen_range(min_s..=max_s);
|
||||
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..]);
|
||||
// Stamp this key's derived junk marker so the server drops it silently.
|
||||
if junk_len >= 4 {
|
||||
frame[start..start+4].copy_from_slice(&junk_marker);
|
||||
}
|
||||
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(frag_chunk) {
|
||||
if write_half.write_all(chunk).await.is_err() { broke = true; break; }
|
||||
tokio::time::sleep(std::time::Duration::from_millis(frag_sleep)).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
|
||||
|
|
|
|||
|
|
@ -70,28 +70,48 @@ 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,
|
||||
/// 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,
|
||||
/// TCP chunk size (bytes)
|
||||
#[serde(default = "default_frag_chunk")]
|
||||
pub frag_chunk: usize,
|
||||
/// TCP sleep duration between chunks (ms)
|
||||
#[serde(default = "default_frag_sleep")]
|
||||
pub frag_sleep: u64,
|
||||
/// [min, max] junk packet count
|
||||
#[serde(default = "default_junk_count")]
|
||||
pub junk_pc: [usize; 2],
|
||||
/// [min, max] junk packet size in bytes
|
||||
#[serde(default = "default_junk_size")]
|
||||
pub junk_ps: [usize; 2],
|
||||
}
|
||||
|
||||
fn default_transport_mode() -> String { "udp".to_string() }
|
||||
fn default_frag_chunk() -> usize { 2 }
|
||||
fn default_frag_sleep() -> u64 { 2 }
|
||||
fn default_junk_count() -> [usize; 2] { [2, 5] }
|
||||
fn default_junk_size() -> [usize; 2] { [100, 1000] }
|
||||
|
||||
impl Default for TransportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: default_transport_mode(),
|
||||
stealth_sni: String::new(),
|
||||
wss: false,
|
||||
tcp_fragmentation: false,
|
||||
frag_chunk: default_frag_chunk(),
|
||||
frag_sleep: default_frag_sleep(),
|
||||
junk_pc: default_junk_count(),
|
||||
junk_ps: default_junk_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +193,11 @@ struct RawUnifiedConfig {
|
|||
struct RawTransportSection {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
wss: Option<bool>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
frag_chunk: Option<usize>,
|
||||
frag_sleep: Option<u64>,
|
||||
junk_pc: Option<[usize; 2]>,
|
||||
junk_ps: Option<[usize; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -247,7 +271,11 @@ 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),
|
||||
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),
|
||||
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count),
|
||||
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size),
|
||||
},
|
||||
exclusions: ExclusionConfig {
|
||||
domains: exclusions.domains.unwrap_or_default(),
|
||||
|
|
|
|||
|
|
@ -250,10 +250,6 @@ pub async fn run_client_core(
|
|||
None
|
||||
};
|
||||
|
||||
if config.mode == "tun" && !config.exclusions.processes.is_empty() {
|
||||
println!("[ostp] Process exclusions are not supported in TUN mode");
|
||||
}
|
||||
|
||||
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256);
|
||||
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
||||
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ fn refresh_wininet() {
|
|||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn enable_system_proxy(proxy_addr: &str) {
|
||||
let parts: Vec<&str> = proxy_addr.split(':').collect();
|
||||
let host = parts.get(0).unwrap_or(&"127.0.0.1");
|
||||
let host = parts.first().unwrap_or(&"127.0.0.1");
|
||||
let port = parts.get(1).unwrap_or(&"1088");
|
||||
|
||||
let is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -126,7 +126,6 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
|||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
let mut target_inode = None;
|
||||
let hex_port = format!("{:04X}", port);
|
||||
|
||||
let check_net_file = |path: &str| -> Option<u64> {
|
||||
|
|
@ -146,7 +145,7 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
|||
None
|
||||
};
|
||||
|
||||
target_inode = check_net_file("/proc/net/tcp")
|
||||
let target_inode = check_net_file("/proc/net/tcp")
|
||||
.or_else(|| check_net_file("/proc/net/tcp6"))
|
||||
.or_else(|| check_net_file("/proc/net/udp"))
|
||||
.or_else(|| check_net_file("/proc/net/udp6"));
|
||||
|
|
|
|||
|
|
@ -208,10 +208,7 @@ pub async fn run_local_socks5_proxy(
|
|||
.await
|
||||
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
||||
|
||||
if true {
|
||||
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
||||
tracing::info!("Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
|
||||
}
|
||||
|
||||
let physical_if_index = tokio::task::spawn_blocking(get_windows_physical_if_index).await.unwrap_or(None);
|
||||
let physical_if_name = tokio::task::spawn_blocking(get_linux_physical_if_name).await.unwrap_or(None);
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ pub async fn run_udp_nat(
|
|||
async fn start_udp_bypass_session(
|
||||
client_src: SocketAddr,
|
||||
phys_if_index: Option<u32>,
|
||||
phys_if_name: Option<String>,
|
||||
_phys_if_name: Option<String>,
|
||||
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
|
||||
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -134,7 +134,7 @@ async fn start_udp_bypass_session(
|
|||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref name) = phys_if_name {
|
||||
if let Some(ref name) = _phys_if_name {
|
||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
//! bandwidth and minimum RTT to determine the optimal sending rate.
|
||||
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
|
||||
//! congestion window that responds to network conditions.
|
||||
//!
|
||||
//! RTO calculation follows RFC 6298:
|
||||
//! SRTT = (1 - α) * SRTT + α * RTT (α = 1/8)
|
||||
//! RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| (β = 1/4)
|
||||
//! RTO = SRTT + 4 * RTTVAR
|
||||
//! clamped to [RTO_MIN, RTO_MAX]
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -15,8 +21,14 @@ pub struct CongestionController {
|
|||
ssthresh: u64,
|
||||
/// Current phase
|
||||
phase: Phase,
|
||||
/// Minimum RTT observed
|
||||
/// Minimum RTT observed (for BBR-style bandwidth estimation)
|
||||
min_rtt: Duration,
|
||||
/// Smoothed RTT (RFC 6298 SRTT)
|
||||
srtt: Duration,
|
||||
/// RTT variance (RFC 6298 RTTVAR)
|
||||
rttvar: Duration,
|
||||
/// Whether we have received a first RTT sample
|
||||
rtt_initialized: bool,
|
||||
/// Bytes currently in flight (unacknowledged)
|
||||
bytes_in_flight: u64,
|
||||
/// Total bytes acknowledged (for bandwidth estimation)
|
||||
|
|
@ -37,31 +49,43 @@ pub struct CongestionController {
|
|||
enum Phase {
|
||||
/// Exponential growth until loss or ssthresh
|
||||
SlowStart,
|
||||
/// Probe bandwidth: cycle through pacing gains
|
||||
/// Probe bandwidth: additive increase
|
||||
ProbeBandwidth,
|
||||
}
|
||||
|
||||
/// Initial congestion window: 10 packets × MTU
|
||||
const INITIAL_CWND_PACKETS: u64 = 10;
|
||||
/// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
|
||||
const INITIAL_CWND_PACKETS: u64 = 32;
|
||||
/// Minimum cwnd: 2 packets
|
||||
const MIN_CWND_PACKETS: u64 = 2;
|
||||
/// Min RTT expiry window (after which we re-probe)
|
||||
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
||||
const RTO_MIN: Duration = Duration::from_millis(50);
|
||||
/// Maximum RTO
|
||||
const RTO_MAX: Duration = Duration::from_secs(16);
|
||||
/// Initial RTT estimate — 30 ms is reasonable for a well-connected VPN server.
|
||||
/// Will be replaced by first real measurement within milliseconds.
|
||||
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
||||
|
||||
impl CongestionController {
|
||||
pub fn new(mtu: u64) -> Self {
|
||||
let now = Instant::now();
|
||||
let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
|
||||
// Initial pacing: deliver cwnd in ~2 RTTs to fill the pipe quickly
|
||||
let initial_pacing = initial_cwnd * 1_000_000 / INITIAL_RTT.as_micros().max(1) as u64;
|
||||
Self {
|
||||
cwnd: initial_cwnd,
|
||||
ssthresh: u64::MAX,
|
||||
phase: Phase::SlowStart,
|
||||
min_rtt: Duration::from_millis(100), // Conservative initial estimate
|
||||
min_rtt: INITIAL_RTT,
|
||||
srtt: INITIAL_RTT,
|
||||
rttvar: INITIAL_RTT / 2,
|
||||
rtt_initialized: false,
|
||||
bytes_in_flight: 0,
|
||||
total_acked: 0,
|
||||
last_ack_time: now,
|
||||
loss_count: 0,
|
||||
pacing_rate: initial_cwnd * 10, // initial: ~10 windows/sec
|
||||
pacing_rate: initial_pacing,
|
||||
mtu,
|
||||
min_rtt_stamp: now,
|
||||
}
|
||||
|
|
@ -82,9 +106,20 @@ impl CongestionController {
|
|||
self.pacing_rate
|
||||
}
|
||||
|
||||
/// Returns the smoothed RTT estimate.
|
||||
/// Returns the smoothed RTT estimate (SRTT).
|
||||
pub fn smoothed_rtt(&self) -> Duration {
|
||||
self.min_rtt
|
||||
self.srtt
|
||||
}
|
||||
|
||||
/// Returns the adaptive RTO computed per RFC 6298:
|
||||
/// RTO = SRTT + 4 * RTTVAR, clamped to [RTO_MIN, RTO_MAX].
|
||||
///
|
||||
/// This replaces the static `rto_ms` field in ProtocolMachine so that
|
||||
/// retransmit timers automatically track changing network conditions.
|
||||
pub fn rto(&self) -> Duration {
|
||||
let rttvar4 = self.rttvar.saturating_mul(4);
|
||||
let rto = self.srtt.saturating_add(rttvar4);
|
||||
rto.clamp(RTO_MIN, RTO_MAX)
|
||||
}
|
||||
|
||||
/// Returns how many bytes can still be sent.
|
||||
|
|
@ -115,16 +150,13 @@ impl CongestionController {
|
|||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||
|
||||
// Update RTT
|
||||
// Update RTT measurements
|
||||
self.update_rtt(rtt, now);
|
||||
|
||||
// Update bandwidth estimate
|
||||
self.update_bandwidth(bytes, now);
|
||||
|
||||
// State machine
|
||||
match self.phase {
|
||||
Phase::SlowStart => {
|
||||
// Exponential growth: increase cwnd by acked bytes
|
||||
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
||||
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||
if self.cwnd >= self.ssthresh {
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
|
|
@ -164,32 +196,49 @@ impl CongestionController {
|
|||
self.update_pacing_rate();
|
||||
}
|
||||
|
||||
/// Called periodically to update state.
|
||||
pub fn on_tick(&mut self) {
|
||||
// Nothing special needed per-tick -- state updates happen on ACK/loss
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
|
||||
// Track windowed minimum RTT
|
||||
// Update windowed minimum RTT (for pacing)
|
||||
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
|
||||
self.min_rtt = rtt;
|
||||
self.min_rtt_stamp = now;
|
||||
}
|
||||
|
||||
// Update SRTT and RTTVAR per RFC 6298
|
||||
if !self.rtt_initialized {
|
||||
// First measurement: initialize directly
|
||||
self.srtt = rtt;
|
||||
self.rttvar = rtt / 2;
|
||||
self.rtt_initialized = true;
|
||||
} else {
|
||||
// RTTVAR = (3/4) * RTTVAR + (1/4) * |SRTT - R|
|
||||
let diff = if rtt > self.srtt {
|
||||
rtt - self.srtt
|
||||
} else {
|
||||
self.srtt - rtt
|
||||
};
|
||||
// Integer-safe: RTTVAR = RTTVAR - RTTVAR/4 + diff/4
|
||||
self.rttvar = self.rttvar
|
||||
.saturating_sub(self.rttvar / 4)
|
||||
.saturating_add(diff / 4);
|
||||
|
||||
// SRTT = (7/8) * SRTT + (1/8) * R
|
||||
self.srtt = self.srtt
|
||||
.saturating_sub(self.srtt / 8)
|
||||
.saturating_add(rtt / 8);
|
||||
}
|
||||
|
||||
fn update_bandwidth(&mut self, _acked_bytes: u64, now: Instant) {
|
||||
let elapsed = now.duration_since(self.last_ack_time);
|
||||
if elapsed.as_micros() > 0 {
|
||||
// Removed bw_samples tracking
|
||||
tracing::trace!(
|
||||
srtt_ms = self.srtt.as_millis(),
|
||||
rttvar_ms = self.rttvar.as_millis(),
|
||||
rto_ms = self.rto().as_millis(),
|
||||
"congestion: RTT updated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn update_pacing_rate(&mut self) {
|
||||
// Pacing rate = cwnd / min_rtt (with gain)
|
||||
// Pacing rate = cwnd / min_rtt (delivery rate target)
|
||||
let rtt_us = self.min_rtt.as_micros().max(1) as u64;
|
||||
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
|
||||
}
|
||||
|
|
@ -202,19 +251,18 @@ mod tests {
|
|||
#[test]
|
||||
fn test_initial_state() {
|
||||
let cc = CongestionController::new(1200);
|
||||
assert_eq!(cc.cwnd(), 12000); // 10 * 1200
|
||||
assert_eq!(cc.cwnd(), 32 * 1200); // 32 * 1200
|
||||
assert!(cc.can_send());
|
||||
assert_eq!(cc.cwnd_packets(), 10);
|
||||
assert_eq!(cc.cwnd_packets(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slow_start_growth() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Simulate sending and ACKing
|
||||
let initial = cc.cwnd();
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(50));
|
||||
// cwnd should grow
|
||||
assert!(cc.cwnd() > 12000);
|
||||
assert!(cc.cwnd() > initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -229,7 +277,7 @@ mod tests {
|
|||
fn test_can_send_limits() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Send until cwnd is exhausted
|
||||
for _ in 0..10 {
|
||||
for _ in 0..32 {
|
||||
cc.on_send(1200);
|
||||
}
|
||||
assert!(!cc.can_send()); // cwnd exhausted
|
||||
|
|
@ -244,10 +292,46 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_rtt_tracking() {
|
||||
fn test_rtt_tracking_first_sample() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(25));
|
||||
// After first sample: SRTT = 25ms, RTTVAR = 12ms
|
||||
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_rfc6298() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// After first sample with RTT=50ms: SRTT=50ms, RTTVAR=25ms, RTO=150ms
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(50));
|
||||
let rto = cc.rto();
|
||||
// RTO = 50 + 4*25 = 150ms; clamped to [50ms, 16s]
|
||||
assert!(rto >= RTO_MIN);
|
||||
assert!(rto <= RTO_MAX);
|
||||
assert_eq!(rto, Duration::from_millis(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_clamp_min() {
|
||||
let cc = CongestionController::new(1200);
|
||||
// Even with no RTT samples, RTO should not go below RTO_MIN
|
||||
assert!(cc.rto() >= RTO_MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_adapts_after_multiple_samples() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Feed several consistent RTT samples
|
||||
for _ in 0..8 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(20));
|
||||
}
|
||||
// After convergence, RTTVAR should be small → RTO close to SRTT + small margin
|
||||
let rto = cc.rto();
|
||||
// Should be well below 100ms (the old hardcoded default)
|
||||
assert!(rto < Duration::from_millis(200));
|
||||
assert!(rto >= RTO_MIN);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,9 +59,33 @@ pub struct DerivedSecrets {
|
|||
pub psk: [u8; 32],
|
||||
pub handshake_pad_min: usize,
|
||||
pub handshake_pad_max: usize,
|
||||
/// Per-key 4-byte prefix stamped on junk frames so the server can drop them
|
||||
/// without a GLOBAL constant marker (which would be a universal DPI signature
|
||||
/// for all OSTP users — exactly what the version gate avoids for the handshake).
|
||||
pub junk_marker: [u8; 4],
|
||||
}
|
||||
|
||||
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
||||
/// wire) so peers running incompatible versions derive entirely different
|
||||
/// secrets and therefore cannot deobfuscate / decrypt each other's traffic.
|
||||
///
|
||||
/// This is a hard, deterministic version gate that needs NO plaintext version
|
||||
/// byte on the wire — a constant marker would defeat the project's stealth
|
||||
/// north-star ("no recognizable header"). A pre-0.4.0 client (which derived
|
||||
/// without a version) produces a different obfuscation key, so a 0.4.0 server
|
||||
/// cannot recover its handshake header and rejects it as an unauthorized probe.
|
||||
///
|
||||
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4.
|
||||
pub const PROTOCOL_VERSION: u8 = 4;
|
||||
|
||||
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
/// Version-parameterised derivation. `derive_all_secrets` always pins the
|
||||
/// current `PROTOCOL_VERSION`; this form exists so tests can prove that a
|
||||
/// different version yields incompatible secrets (the version gate).
|
||||
pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> DerivedSecrets {
|
||||
// Split the key hash into two halves for salt/info separation.
|
||||
// This avoids using any hardcoded strings while still providing
|
||||
// domain separation between the derived values.
|
||||
|
|
@ -70,8 +94,16 @@ pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
|||
let salt = &key_hash[..16];
|
||||
let info_base = &key_hash[16..];
|
||||
|
||||
// Extract PRK from access key using its own hash as salt
|
||||
let prk = hkdf_extract(salt, access_key);
|
||||
// Mix the protocol version into the IKM so a different version produces a
|
||||
// completely different PRK → different obf_key / psk / padding. This is the
|
||||
// wire-version gate: it is invisible on the wire (only the derived output,
|
||||
// which is already indistinguishable from random, ever leaves the host).
|
||||
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||
ikm.extend_from_slice(access_key);
|
||||
ikm.push(version);
|
||||
|
||||
// Extract PRK from version-tagged access key using its hash as salt
|
||||
let prk = hkdf_extract(salt, &ikm);
|
||||
|
||||
// Derive obfuscation key (8 bytes) — info = key_hash[16..] || 0x01
|
||||
let mut obf_info = info_base.to_vec();
|
||||
|
|
@ -97,11 +129,22 @@ pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
|||
let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
|
||||
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175
|
||||
|
||||
// Derive junk marker (4 bytes) — info = key_hash[16..] || 0x04.
|
||||
// Per-key: to an outsider it is indistinguishable from the random junk
|
||||
// payload, so there is no cross-user signature; the server, knowing the key,
|
||||
// derives the same marker and drops the junk silently.
|
||||
let mut junk_info = info_base.to_vec();
|
||||
junk_info.push(0x04);
|
||||
let junk_bytes = hkdf_expand(&prk, &junk_info, 4);
|
||||
let mut junk_marker = [0u8; 4];
|
||||
junk_marker.copy_from_slice(&junk_bytes);
|
||||
|
||||
DerivedSecrets {
|
||||
obfuscation_key,
|
||||
psk,
|
||||
handshake_pad_min: pad_min,
|
||||
handshake_pad_max: pad_max,
|
||||
junk_marker,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,37 @@ mod tests {
|
|||
assert_eq!(correct_sid, session_id, "correct key must recover session_id");
|
||||
}
|
||||
|
||||
/// §C version gate: a peer on a different PROTOCOL_VERSION derives
|
||||
/// different secrets, so a handshake obfuscated with the OLD version's key
|
||||
/// does NOT deobfuscate to a valid session_id under the current version.
|
||||
/// This is exactly what makes an old (pre-0.4.0) client fail to connect to
|
||||
/// a new server — with no plaintext version marker on the wire.
|
||||
#[test]
|
||||
fn test_protocol_version_gates_old_clients() {
|
||||
let key = b"shared_access_key_across_versions";
|
||||
let new = derive_all_secrets(key); // == derive_all_secrets_versioned(key, PROTOCOL_VERSION)
|
||||
let old = derive_all_secrets_versioned(key, PROTOCOL_VERSION.wrapping_sub(1));
|
||||
|
||||
// Different protocol version → different derived secrets.
|
||||
assert_ne!(new.obfuscation_key, old.obfuscation_key, "version must change obf_key");
|
||||
assert_ne!(new.psk, old.psk, "version must change psk");
|
||||
|
||||
// Concretely: a handshake the old client obfuscated with its key does
|
||||
// not recover a valid session_id when the new server deobfuscates it.
|
||||
let session_id: u32 = 0x11223344;
|
||||
let noise = [0x33u8; 48];
|
||||
let mut pkt = Vec::new();
|
||||
pkt.extend_from_slice(&session_id.to_be_bytes());
|
||||
pkt.extend_from_slice(&(noise.len() as u16).to_be_bytes());
|
||||
pkt.extend_from_slice(&noise);
|
||||
pkt.extend_from_slice(&[0u8; 32]);
|
||||
|
||||
obfuscate_packet_inplace(&mut pkt, &old.obfuscation_key, true); // old client
|
||||
deobfuscate_packet_inplace(&mut pkt, &new.obfuscation_key, true); // new server
|
||||
let recovered = u32::from_be_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]);
|
||||
assert_ne!(recovered, session_id, "old-version client must NOT be accepted by new server");
|
||||
}
|
||||
|
||||
/// Verifies data packet obfuscation round-trip (non-handshake path).
|
||||
#[test]
|
||||
fn test_data_packet_obfuscation_roundtrip() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -207,13 +207,16 @@ impl ProtocolMachine {
|
|||
.map(ProtocolAction::SendDatagram)
|
||||
}
|
||||
(OstpState::Closing, OstpEvent::Inbound(raw)) => {
|
||||
// Process final in-flight packets to prevent data loss during teardown.
|
||||
// The remote may still have data or ACKs in transit when we initiated Close.
|
||||
let result = self.handle_inbound(raw);
|
||||
self.state = OstpState::Closed;
|
||||
result
|
||||
// The remote may still have data or ACKs in transit when we initiated
|
||||
// Close. Stay in Closing and process them; handle_inbound transitions to
|
||||
// Closed only when it actually receives the peer's Close frame — the old
|
||||
// code force-closed after a single inbound packet, losing in-flight data.
|
||||
// (Ported from 0.3.x 47d44fa.)
|
||||
self.handle_inbound(raw)
|
||||
}
|
||||
(OstpState::Established, OstpEvent::Tick) => self.handle_tick(),
|
||||
// Retransmit our Close frame (and drain pending) while waiting for teardown.
|
||||
(OstpState::Closing, OstpEvent::Tick) => self.handle_tick(),
|
||||
(OstpState::Closed, _) => Ok(ProtocolAction::Noop),
|
||||
(_, OstpEvent::Close) => {
|
||||
self.state = OstpState::Closed;
|
||||
|
|
@ -392,18 +395,20 @@ impl ProtocolMachine {
|
|||
self.last_recv_advance = Instant::now();
|
||||
} else {
|
||||
// Gap detected
|
||||
if nonce >= self.expected_recv_nonce {
|
||||
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
||||
self.reorder_buffer.insert(nonce, action);
|
||||
} else {
|
||||
tracing::warn!("Reorder buffer full ({}/{}), dropping frame nonce={}",
|
||||
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
|
||||
);
|
||||
tracing::warn!("Reorder buffer still full after gap recovery, dropping frame nonce={}", nonce);
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Frame nonce={} arrived too late after gap recovery, dropping", nonce);
|
||||
}
|
||||
|
||||
// Rate-limited NACK: send at most once per 30ms to prevent retransmit storms.
|
||||
// Under high load with natural UDP reordering, sending a NACK per packet
|
||||
// causes exponential retransmit explosion that saturates the channel.
|
||||
let nack_cooldown = Duration::from_millis(30);
|
||||
// Rate-limited NACK: send at most once per (rto/2) to prevent retransmit storms.
|
||||
// Using rto/2 means we send a NACK before the sender's timer fires, prompting
|
||||
// fast retransmit without flooding. Floor at 10ms to handle very low-RTT links.
|
||||
let nack_cooldown = (self.cc.rto() / 2).max(Duration::from_millis(10));
|
||||
if self.last_nack_sent.elapsed() >= nack_cooldown {
|
||||
self.last_nack_sent = Instant::now();
|
||||
let nack_payload = self.expected_recv_nonce.to_be_bytes();
|
||||
|
|
@ -511,44 +516,18 @@ impl ProtocolMachine {
|
|||
fn handle_tick(&mut self) -> Result<ProtocolAction, ProtocolError> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
// ── Gap Recovery ──────────────────────────────────────────────
|
||||
// If expected_recv_nonce hasn't advanced for 500ms+ and there
|
||||
// are buffered frames waiting, the sender likely evicted the lost
|
||||
// frame from sent_history. Skip the gap to restore data flow.
|
||||
// This trades a small amount of data loss for connection liveness.
|
||||
if !self.reorder_buffer.is_empty()
|
||||
&& self.last_recv_advance.elapsed() > Duration::from_millis(500)
|
||||
{
|
||||
if let Some(&first_buffered) = self.reorder_buffer.keys().next() {
|
||||
let skipped = first_buffered.saturating_sub(self.expected_recv_nonce);
|
||||
self.expected_recv_nonce = first_buffered;
|
||||
self.last_recv_advance = Instant::now();
|
||||
|
||||
let mut delivered = 0u64;
|
||||
while let Some(buffered_action) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
||||
actions.push(buffered_action);
|
||||
self.expected_recv_nonce = self.expected_recv_nonce.saturating_add(1);
|
||||
delivered += 1;
|
||||
}
|
||||
self.ack_pending = true;
|
||||
tracing::debug!("Gap recovery: skipped {} lost frames, delivered {} buffered frames (reorder_buf={})",
|
||||
skipped, delivered, self.reorder_buffer.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending ACK flush ─────────────────────────────────────────
|
||||
if let Some(ack_frame) = self.build_ack_if_due()? {
|
||||
actions.push(ProtocolAction::SendDatagram(ack_frame));
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let base_rto_ms = self.rto.as_millis().max(1) as u64;
|
||||
// Use the adaptive RTO from the congestion controller (RFC 6298 SRTT + 4*RTTVAR).
|
||||
// Falls back to rto_initial before the first ACK is received.
|
||||
let base_rto_ms = self.cc.rto().max(self.rto).as_millis().max(1) as u64;
|
||||
|
||||
// ── Zombie frame eviction ────────────────────────────────────
|
||||
// Evict frames that exceeded max_retries + 2 grace retries.
|
||||
// Shorter grace period than before (was +4) to free memory faster
|
||||
// after high-throughput bursts.
|
||||
let grace = self.max_retries.saturating_add(2);
|
||||
let before = self.sent_history.len();
|
||||
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace);
|
||||
|
|
@ -559,14 +538,15 @@ impl ProtocolMachine {
|
|||
|
||||
// ── Retransmit expired frames ────────────────────────────────
|
||||
// Limit retransmits per tick to prevent bandwidth saturation
|
||||
// Backoff starts from retry #0 (immediately effective):
|
||||
// effective_rto = base_rto * 2^retries, capped at 2^6 = 64×
|
||||
let mut retransmit_budget: usize = self.cc.retransmit_budget();
|
||||
for frame in self.sent_history.iter_mut() {
|
||||
if !frame.is_retransmittable {
|
||||
continue;
|
||||
}
|
||||
|
||||
let retry_over = frame.retries.saturating_sub(self.max_retries);
|
||||
let backoff_factor = 1u64 << retry_over.min(6);
|
||||
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
||||
|
||||
if now.duration_since(frame.last_sent) >= effective_rto {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -4,9 +4,9 @@ Write-Host "==============================================" -ForegroundColor Cya
|
|||
Write-Host " OSTP Android App Release Build Pipeline " -ForegroundColor Cyan
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
|
||||
# Step 1: Run JNI build script to compile Rust core and download tun2socks
|
||||
# Step 1: Run JNI build script to compile the Rust core into Android .so libs
|
||||
Write-Host ""
|
||||
Write-Host "[1/3] Compiling Rust JNI Core & Downloading tun2socks..." -ForegroundColor Yellow
|
||||
Write-Host "[1/3] Compiling Rust JNI Core..." -ForegroundColor Yellow
|
||||
$jniScript = Join-Path $PSScriptRoot "build_android_jni.ps1"
|
||||
if (Test-Path $jniScript) {
|
||||
& $jniScript
|
||||
|
|
|
|||
|
|
@ -11,24 +11,8 @@ Push-Location "$PSScriptRoot\..\ostp-jni"
|
|||
Write-Host "Compiling for aarch64-linux-android and armv7-linux-androideabi..."
|
||||
cargo ndk -t arm64-v8a -t armeabi-v7a -o "$jniLibs" build --release
|
||||
|
||||
$tun2socksArm64 = "$jniLibs\arm64-v8a\libtun2socks.so"
|
||||
$tun2socksArmv7 = "$jniLibs\armeabi-v7a\libtun2socks.so"
|
||||
|
||||
if (-not (Test-Path $tun2socksArm64)) {
|
||||
Write-Host "Downloading tun2socks for arm64-v8a..."
|
||||
Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-linux-arm64.zip" -OutFile "$jniLibs\t2s64.zip"
|
||||
Expand-Archive "$jniLibs\t2s64.zip" "$jniLibs\t2s64_tmp" -Force
|
||||
Copy-Item "$jniLibs\t2s64_tmp\tun2socks-linux-arm64" $tun2socksArm64 -Force
|
||||
Remove-Item "$jniLibs\t2s64.zip", "$jniLibs\t2s64_tmp" -Recurse -Force
|
||||
}
|
||||
|
||||
if (-not (Test-Path $tun2socksArmv7)) {
|
||||
Write-Host "Downloading tun2socks for armeabi-v7a..."
|
||||
Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-linux-armv7.zip" -OutFile "$jniLibs\t2s32.zip"
|
||||
Expand-Archive "$jniLibs\t2s32.zip" "$jniLibs\t2s32_tmp" -Force
|
||||
Copy-Item "$jniLibs\t2s32_tmp\tun2socks-linux-armv7" $tun2socksArmv7 -Force
|
||||
Remove-Item "$jniLibs\t2s32.zip", "$jniLibs\t2s32_tmp" -Recurse -Force
|
||||
}
|
||||
# tun2socks removed in 0.4.0 — the native OSTP TUN stack is the only path,
|
||||
# so no external tun2socks binary is downloaded or bundled.
|
||||
|
||||
Pop-Location
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,6 @@ const filesToCopy = [
|
|||
src: path.join(workspaceRoot, 'target', 'release', 'ostp-tun-helper.exe'),
|
||||
dest: path.join(distDir, 'ostp-tun-helper.exe')
|
||||
},
|
||||
{
|
||||
src: path.join(workspaceRoot, 't2s_tmp', 'tun2socks-windows-amd64.exe'),
|
||||
dest: path.join(distDir, 'tun2socks.exe')
|
||||
},
|
||||
{
|
||||
src: path.join(workspaceRoot, 'target', 'release', 'wintun.dll'),
|
||||
dest: path.join(distDir, 'wintun.dll')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ostp-gui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
|
|
|
|||
|
|
@ -2665,16 +2665,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.2.97"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"json_comments",
|
||||
"libc",
|
||||
|
|
@ -2694,12 +2692,11 @@ dependencies = [
|
|||
"tun",
|
||||
"webpki-roots 0.26.11",
|
||||
"winapi",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.2.97"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2716,13 +2713,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-gui"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"json_comments",
|
||||
"ostp-client",
|
||||
"portable-atomic",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"rlimit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
|
@ -2734,7 +2733,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.2.97"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
@ -3092,6 +3091,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
|
|
@ -3261,6 +3266,15 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rlimit"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ostp-gui"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
|
@ -29,4 +29,6 @@ ostp-client = { path = "../../ostp-client" }
|
|||
portable-atomic = "1"
|
||||
json_comments = "0.2"
|
||||
rand = "0.8"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
|
||||
rlimit = "0.11.0"
|
||||
|
|
|
|||
|
|
@ -57,7 +57,11 @@ struct TunConfig {
|
|||
struct TransportConfigRaw {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
wss: Option<bool>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
frag_chunk: Option<usize>,
|
||||
frag_sleep: Option<u64>,
|
||||
junk_pc: Option<[usize; 2]>,
|
||||
junk_ps: Option<[usize; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
|
|
@ -164,7 +168,11 @@ 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),
|
||||
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),
|
||||
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]),
|
||||
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]),
|
||||
},
|
||||
exclusions: ostp_client::config::ExclusionConfig {
|
||||
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
||||
|
|
@ -322,7 +330,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",
|
||||
|
|
@ -483,18 +491,43 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
/// Render a share link to an SVG QR code locally. The access key never leaves
|
||||
/// the device — unlike an online QR service. (Ported from the current ostp-gui.)
|
||||
#[tauri::command]
|
||||
fn generate_qr(text: String) -> Result<String, String> {
|
||||
let code = qrcode::QrCode::new(text.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let svg = code
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(220, 220)
|
||||
.dark_color(qrcode::render::svg::Color("#000000"))
|
||||
.light_color(qrcode::render::svg::Color("#ffffff"))
|
||||
.build();
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
|
||||
let mut guard = state.0.lock().await;
|
||||
|
||||
if let Some(ref t) = guard.tunnel {
|
||||
match t {
|
||||
TunnelHandle::InProcess(s) if !s.handle.is_finished() => return Ok(true),
|
||||
TunnelHandle::Helper(_) => return Ok(true),
|
||||
_ => {}
|
||||
// Tear down any existing tunnel before starting a fresh one — otherwise a
|
||||
// server change would silently keep the old connection/server. start_tunnel
|
||||
// is only ever invoked on an explicit connect, so restarting here is safe.
|
||||
// This implements the plan's "server change = full stop+start, not hot-reload".
|
||||
match guard.tunnel.take() {
|
||||
None => {}
|
||||
Some(TunnelHandle::InProcess(mut s)) => {
|
||||
if let Some(tx) = s.shutdown_tx.take() { let _ = tx.send(true); }
|
||||
s.handle.abort();
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), s.handle).await;
|
||||
}
|
||||
Some(TunnelHandle::Helper(h)) => {
|
||||
let stop_cmd = serde_json::json!({ "cmd": "stop", "token": h.token }).to_string();
|
||||
let _ = h.cmd_tx.send(format!("{}\n", stop_cmd)).await;
|
||||
// Let the elevated helper stop the tunnel and release the ostp_tun
|
||||
// adapter before a new helper tries to create it (avoids name clashes).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
|
||||
}
|
||||
}
|
||||
guard.tunnel = None;
|
||||
|
||||
let path = get_config_path();
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
|
|
@ -764,9 +797,14 @@ pub fn run() {
|
|||
if let Ok(listener) = std::net::TcpListener::bind("127.0.0.1:49153") {
|
||||
let _ = SINGLE_INSTANCE_LOCK.set(listener);
|
||||
} else {
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
show_error_dialog("Приложение OSTP GUI уже запущено!");
|
||||
return;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
println!("WARNING: OSTP GUI is already running, ignoring in debug mode.");
|
||||
}
|
||||
|
||||
let state = AppState(Mutex::new(AppStateInner { tunnel: None }));
|
||||
tauri::Builder::default()
|
||||
|
|
@ -859,7 +897,7 @@ pub fn run() {
|
|||
}
|
||||
_ => {}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, get_wintun_install_path, set_autostart, get_autostart, list_running_processes])
|
||||
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, get_wintun_install_path, set_autostart, get_autostart, list_running_processes, generate_qr])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
let _ = rlimit::increase_nofile_limit(1048576);
|
||||
ostp_client::logging::setup_panic_hook();
|
||||
|
||||
// Read config BEFORE init_tracing so we can use the correct log level from config.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ostp-gui",
|
||||
"version": "0.2.97",
|
||||
"version": "0.4.1",
|
||||
"identifier": "com.ospab.ostp",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -54,6 +54,11 @@ const translations = {
|
|||
label_launch_startup: 'Launch at Startup',
|
||||
launch_startup_hint: 'Start OSTP with Windows',
|
||||
cancel_btn: 'Cancel',
|
||||
share_btn: 'Share',
|
||||
share_title: 'Share configuration',
|
||||
share_desc: 'Scan the QR or copy the link. The QR is generated locally — the access key never leaves this device.',
|
||||
copy_btn: 'Copy link',
|
||||
close_btn: 'Close',
|
||||
wintun_missing_title: 'Wintun Driver Missing',
|
||||
wintun_missing_desc: 'TUN mode requires the Wintun network driver (wintun.dll).',
|
||||
wintun_step1: 'Download wintun.zip from the official site',
|
||||
|
|
@ -114,6 +119,11 @@ const translations = {
|
|||
label_launch_startup: 'Запуск вместе с Windows',
|
||||
launch_startup_hint: 'Автозапуск OSTP при входе в систему',
|
||||
cancel_btn: 'Отмена',
|
||||
share_btn: 'Поделиться',
|
||||
share_title: 'Поделиться конфигурацией',
|
||||
share_desc: 'Отсканируйте QR или скопируйте ссылку. QR генерируется локально — ключ доступа не покидает устройство.',
|
||||
copy_btn: 'Копировать ссылку',
|
||||
close_btn: 'Закрыть',
|
||||
wintun_missing_title: 'Отсутствует драйвер Wintun',
|
||||
wintun_missing_desc: 'Режим TUN требует сетевой драйвер Wintun (wintun.dll).',
|
||||
wintun_step1: 'Скачайте wintun.zip с официального сайта',
|
||||
|
|
|
|||
|
|
@ -6,47 +6,41 @@
|
|||
<title>OSTP</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-root">
|
||||
|
||||
<!-- Ambient light blobs -->
|
||||
<div class="ambient" aria-hidden="true">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<!-- Eagle watermark — behind everything, every screen -->
|
||||
<div class="watermark" aria-hidden="true">
|
||||
<img src="assets/logo.svg" alt="" />
|
||||
</div>
|
||||
|
||||
<!-- ── HOME SCREEN ──────────────────────────────────────────── -->
|
||||
<!-- ── HOME SCREEN ──────────────────────────────────────── -->
|
||||
<div id="home-screen" class="screen active">
|
||||
|
||||
<!-- Top bar -->
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-dot" id="brand-dot"></div>
|
||||
<span class="brand-name">OSTP</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button id="btn-auto-connect" class="icon-btn" aria-label="Auto">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||
<button id="btn-auto-connect" class="icon-btn" aria-label="Auto" title="Auto-connect">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-theme-toggle" class="theme-toggle-btn" aria-label="Toggle theme">
|
||||
<!-- Sun icon (shown in dark mode) -->
|
||||
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
<button id="btn-theme" class="icon-btn" aria-label="Toggle theme" title="Toggle theme">
|
||||
<svg id="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
|
||||
</svg>
|
||||
<!-- Moon icon (shown in light mode) -->
|
||||
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg id="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-go-settings" class="icon-btn" aria-label="Settings">
|
||||
<!-- Gear icon -->
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
|
|
@ -57,15 +51,16 @@
|
|||
<!-- Center stage -->
|
||||
<main class="stage">
|
||||
|
||||
<!-- Orbit rings -->
|
||||
<!-- Orbit rings (animated when connecting/connected) -->
|
||||
<div class="orbit-wrap" id="orbit-wrap">
|
||||
<div class="orbit orbit-1"></div>
|
||||
<div class="orbit orbit-2"></div>
|
||||
<div class="orbit orbit-3"></div>
|
||||
|
||||
<!-- Power button -->
|
||||
<button id="btn-connect" class="power-btn" aria-label="Connect">
|
||||
<button id="btn-connect" class="power-btn" aria-label="Connect / Disconnect">
|
||||
<div class="power-icon">
|
||||
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
|
||||
<line x1="12" y1="2" x2="12" y2="12"/>
|
||||
</svg>
|
||||
|
|
@ -73,16 +68,19 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status block -->
|
||||
<!-- Status text -->
|
||||
<div class="status-block">
|
||||
<div id="status-text" class="status-label" data-i18n="status_disconnected">Disconnected</div>
|
||||
<div id="uptime-text" class="status-sub" data-i18n="hint_tap">Tap to protect your traffic</div>
|
||||
<div id="status-text" class="status-label">Disconnected</div>
|
||||
<div id="uptime-text" class="status-sub">Tap to protect your traffic</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection info (shown when connected) -->
|
||||
<!-- Error banner -->
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
|
||||
<!-- Connection info (visible when connected) -->
|
||||
<div id="connection-info" class="connection-info hidden">
|
||||
<div class="server-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||
|
|
@ -91,53 +89,56 @@
|
|||
<span id="server-badge-text">—</span>
|
||||
</div>
|
||||
|
||||
<div class="ping-test-box">
|
||||
<div class="ping-test-left">
|
||||
<span class="ping-test-title">CONNECTION TEST</span>
|
||||
<span id="ping-text-value" class="ping-test-value">Target Ping: -- ms</span>
|
||||
<!-- Live RTT + speeds -->
|
||||
<div class="live-stats">
|
||||
<div class="live-stat">
|
||||
<span class="live-stat-label">RTT</span>
|
||||
<span id="live-rtt" class="live-stat-value">--</span>
|
||||
</div>
|
||||
<div class="live-stat-sep"></div>
|
||||
<div class="live-stat">
|
||||
<span class="live-stat-label">↓</span>
|
||||
<span id="live-down-speed" class="live-stat-value">0 B/s</span>
|
||||
</div>
|
||||
<div class="live-stat-sep"></div>
|
||||
<div class="live-stat">
|
||||
<span class="live-stat-label">↑</span>
|
||||
<span id="live-up-speed" class="live-stat-value">0 B/s</span>
|
||||
</div>
|
||||
<button id="btn-test-ping" class="ping-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
<span>Test Ping</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Traffic metrics bar -->
|
||||
<!-- Total traffic bar -->
|
||||
<footer class="metrics-bar">
|
||||
<div class="metric">
|
||||
<div class="metric-icon down-icon">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label" data-i18n="download">Download</span>
|
||||
<span class="metric-label">Download</span>
|
||||
<span id="metric-down" class="metric-value">0 B</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-sep"></div>
|
||||
|
||||
<div class="metric">
|
||||
<div class="metric-icon up-icon">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-label" data-i18n="upload">Upload</span>
|
||||
<span class="metric-label">Upload</span>
|
||||
<span id="metric-up" class="metric-value">0 B</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- ── SETTINGS SCREEN ──────────────────────────────────────── -->
|
||||
<!-- ── SETTINGS SCREEN ──────────────────────────────────── -->
|
||||
<div id="settings-screen" class="screen">
|
||||
|
||||
<header class="topbar">
|
||||
|
|
@ -146,237 +147,296 @@
|
|||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="topbar-title" data-i18n="settings_title">Configuration</span>
|
||||
<div style="width:36px"></div>
|
||||
<span class="topbar-title">Profiles</span>
|
||||
<button id="btn-add-profile" class="icon-btn add-btn" aria-label="Add profile">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="settings-body">
|
||||
|
||||
<!-- Quick import -->
|
||||
<div class="import-row">
|
||||
<input id="in-import-url"
|
||||
class="import-input"
|
||||
type="text"
|
||||
data-i18n-placeholder="import_placeholder"
|
||||
placeholder="Paste ostp:// share link..." />
|
||||
<button id="btn-import-url" class="accent-btn" data-i18n="import_btn">Import</button>
|
||||
</div>
|
||||
|
||||
<!-- Form card -->
|
||||
<div class="card scrollable">
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-server" data-i18n="label_server">Server Address</label>
|
||||
<input id="in-server" class="field-input" type="text" placeholder="host:port" spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-key" data-i18n="label_key">Access Key</label>
|
||||
<div class="input-wrap">
|
||||
<input id="in-key" class="field-input has-icon" type="password" data-i18n-placeholder="ph_key" placeholder="Secure access key" spellcheck="false" />
|
||||
<button class="peek-btn" id="btn-peek-key" tabindex="-1" aria-label="Show key">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<!-- Profile list -->
|
||||
<div id="profile-list" class="profile-list">
|
||||
<div id="profile-empty" class="profile-empty">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
<p>No profiles yet.<br/>Tap <strong>+</strong> to add one.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client settings -->
|
||||
<div class="section-divider"><span>Client Settings</span></div>
|
||||
|
||||
<div class="client-settings-card">
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">TUN Mode</span>
|
||||
<span class="toggle-hint">Route all system traffic</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-tun-mode" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row sub-row" id="group-kill-switch" style="display:none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">Kill Switch</span>
|
||||
<span class="toggle-hint">Block traffic if VPN drops</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-kill-switch" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">Multiplexing</span>
|
||||
<span class="toggle-hint">Multiple streams over one connection</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-mux-mode" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="inline-field sub-row" id="group-mux-sessions" style="display:none;">
|
||||
<span class="field-label">Sessions</span>
|
||||
<input id="in-mux-sessions" class="field-input compact" type="number" placeholder="2" min="1" max="8" />
|
||||
</div>
|
||||
|
||||
<div class="inline-field">
|
||||
<span class="field-label">MTU</span>
|
||||
<input id="in-mtu" class="field-input compact" type="number" placeholder="1350" />
|
||||
</div>
|
||||
|
||||
<div class="inline-field">
|
||||
<span class="field-label">DNS</span>
|
||||
<input id="in-dns" class="field-input compact" type="text" placeholder="1.1.1.1" />
|
||||
</div>
|
||||
|
||||
<div class="inline-field">
|
||||
<span class="field-label">Local Proxy</span>
|
||||
<input id="in-socks" class="field-input compact" type="text" placeholder="127.0.0.1:1088" />
|
||||
</div>
|
||||
|
||||
<div class="section-divider-mini"><span>Exceptions / Routing</span></div>
|
||||
|
||||
<div class="field-group" style="padding: 10px 14px; margin-bottom: 0;">
|
||||
<label class="field-label" for="in-ex-domains">Excluded Domains</label>
|
||||
<textarea id="in-ex-domains" class="field-input mono" placeholder="google.com, mycompany.internal" rows="2" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field-group" style="padding: 0 14px 10px; margin-bottom: 0;">
|
||||
<label class="field-label" for="in-ex-ips">Excluded IPs / Subnets</label>
|
||||
<textarea id="in-ex-ips" class="field-input mono" placeholder="192.168.1.0/24, 10.0.0.1" rows="2" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field-group" style="padding: 0 14px 10px; margin-bottom: 0; border-bottom: 1px solid rgba(255,255,255,0.04);">
|
||||
<label class="field-label" for="in-ex-procs">Excluded Processes</label>
|
||||
<textarea id="in-ex-procs" class="field-input mono" placeholder="chrome.exe, spotify.exe" rows="2" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="section-divider-mini"><span>Application</span></div>
|
||||
|
||||
<div class="toggle-row" style="border-top:none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">Auto-connect</span>
|
||||
<span class="toggle-hint">Connect on startup</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-autoconnect" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row" style="border-top:none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">Launch at Startup</span>
|
||||
<span class="toggle-hint">Start with Windows</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-launch-startup" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row" style="border-top:none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">Debug Logs</span>
|
||||
<span class="toggle-hint">Verbose output to .log file</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-debug" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="app-version" id="app-version">OSTP GUI</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── ADD PROFILE DROPDOWN ─────────────────────────────── -->
|
||||
<div id="add-menu" class="add-menu hidden">
|
||||
<button id="add-from-link" class="add-menu-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
From link
|
||||
</button>
|
||||
<button id="add-from-clipboard" class="add-menu-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
From clipboard
|
||||
</button>
|
||||
<button id="add-manually" class="add-menu-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Manually
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── LINK INPUT MODAL ─────────────────────────────────── -->
|
||||
<div id="link-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content compact">
|
||||
<h3 class="modal-title">Paste link</h3>
|
||||
<div class="field-group">
|
||||
<input id="link-input" class="field-input mono" type="text" placeholder="ostp://key@host:port" spellcheck="false" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-link-cancel" class="btn secondary">Cancel</button>
|
||||
<button id="btn-link-import" class="btn primary">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PROFILE EDITOR MODAL ─────────────────────────────── -->
|
||||
<div id="profile-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title" id="profile-modal-title">New Profile</h3>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="pm-name">Name</label>
|
||||
<input id="pm-name" class="field-input" type="text" placeholder="My Server" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="pm-server">Server</label>
|
||||
<input id="pm-server" class="field-input mono" type="text" placeholder="host:port" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="pm-key">Access Key</label>
|
||||
<div class="input-wrap">
|
||||
<input id="pm-key" class="field-input mono has-icon" type="password" placeholder="Secure access key" spellcheck="false" />
|
||||
<button class="peek-btn" id="btn-peek-pm" tabindex="-1" aria-label="Show key">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label>
|
||||
<input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-dns" data-i18n="label_dns">Custom DNS Server</label>
|
||||
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-transport" data-i18n="label_transport">Transport Protocol</label>
|
||||
<select id="in-transport" class="field-input">
|
||||
<label class="field-label" for="pm-transport">Transport</label>
|
||||
<select id="pm-transport" class="field-input">
|
||||
<option value="udp">UDP (Default)</option>
|
||||
<option value="uot">TCP (UoT)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-stealth-sni" data-i18n="label_sni">Stealth SNI</label>
|
||||
<input id="in-stealth-sni" class="field-input" type="text" placeholder="www.microsoft.com" spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<div class="toggle-row" id="group-wss">
|
||||
<!-- Advanced TCP/UoT Settings (visible only if uot is selected) -->
|
||||
<div id="pm-tcp-settings" style="display:none; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 15px;">
|
||||
<div class="toggle-row" style="padding:0; border:none; margin-bottom:10px;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">WebSocket (WSS)</span>
|
||||
<span class="toggle-hint">Use RFC 6455 framing for strict DPI bypass</span>
|
||||
<span class="toggle-name">TCP Fragmentation</span>
|
||||
<span class="toggle-hint">Split handshake to bypass DPI</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-wss" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<input type="checkbox" id="pm-tcp-frag" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<div id="pm-frag-details" style="display:none;">
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
||||
<span class="field-label">Chunk Size</span>
|
||||
<input id="pm-frag-chunk" class="field-input compact" type="number" placeholder="2" min="1" />
|
||||
</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" />
|
||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
||||
<span class="field-label">Sleep (ms)</span>
|
||||
<input id="pm-frag-sleep" class="field-input compact" type="number" placeholder="2" min="0" />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="in-mux-sessions" data-i18n="label_mux_sessions">Mux Sessions</label>
|
||||
<input id="in-mux-sessions" class="field-input" type="number" placeholder="1" />
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name" data-i18n="label_tun">TUN Mode</span>
|
||||
<span class="toggle-hint" data-i18n="tun_hint">Route all system traffic</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-tun-mode" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row" id="group-kill-switch" style="display: none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name" data-i18n="label_kill_switch">Kill Switch</span>
|
||||
<span class="toggle-hint" data-i18n="kill_switch_hint">Block traffic if connection drops</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-kill-switch" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name" data-i18n="label_mux">Multiplexing (Mux)</span>
|
||||
<span class="toggle-hint" data-i18n="mux_hint">Run multiple streams over one connection</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-mux-mode" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name" data-i18n="label_launch_startup">Launch at Startup</span>
|
||||
<span class="toggle-hint" data-i18n="launch_startup_hint">Start with Windows</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-launch-startup" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name" data-i18n="label_autoconnect">Auto-connect</span>
|
||||
<span class="toggle-hint" data-i18n="autoconnect_hint">Connect automatically on startup</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-autoconnect" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name" data-i18n="label_debug">Debug Logs</span>
|
||||
<span class="toggle-hint" data-i18n="debug_hint">Verbose output</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="in-debug" />
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Split Tunneling / Exclusions -->
|
||||
<div class="section-head">
|
||||
<span data-i18n="excl_title">Exclusions</span>
|
||||
<span class="section-hint" data-i18n="excl_hint">traffic that bypasses the tunnel</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="tag-input-domains" data-i18n="excl_domains">Bypass Domains</label>
|
||||
<div class="tag-input-wrap" id="tag-wrap-domains">
|
||||
<div class="tag-list" id="tag-list-domains"></div>
|
||||
<input id="tag-input-domains" class="tag-input-field" type="text"
|
||||
placeholder="example.com" spellcheck="false" autocomplete="off" />
|
||||
</div>
|
||||
<span class="field-hint">Enter domain suffix and press Enter. Example: google.com, *.local</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="tag-input-ips" data-i18n="excl_ips">Bypass IPs / CIDR</label>
|
||||
<div class="tag-input-wrap" id="tag-wrap-ips">
|
||||
<div class="tag-list" id="tag-list-ips"></div>
|
||||
<input id="tag-input-ips" class="tag-input-field" type="text"
|
||||
placeholder="192.168.1.0/24" spellcheck="false" autocomplete="off" />
|
||||
</div>
|
||||
<span class="field-hint">Local network ranges bypass the tunnel automatically</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="tag-input-processes" data-i18n="excl_processes">Bypass Processes</label>
|
||||
<div class="tag-input-wrap" id="tag-wrap-processes">
|
||||
<div class="tag-list" id="tag-list-processes"></div>
|
||||
<input id="tag-input-processes" class="tag-input-field" type="text"
|
||||
placeholder="chrome.exe" spellcheck="false" autocomplete="off" />
|
||||
</div>
|
||||
<span class="field-hint" id="proc-hint">Type process name and press Enter.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="section-divider-mini" style="margin-top:0;"><span>Junk Packets</span></div>
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
||||
<span class="field-label">Count (Min)</span>
|
||||
<input id="pm-junk-pc-min" class="field-input compact" type="number" placeholder="2" min="0" />
|
||||
</div>
|
||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
||||
<span class="field-label">Count (Max)</span>
|
||||
<input id="pm-junk-pc-max" class="field-input compact" type="number" placeholder="5" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
||||
<span class="field-label">Size (Min)</span>
|
||||
<input id="pm-junk-ps-min" class="field-input compact" type="number" placeholder="100" min="0" />
|
||||
</div>
|
||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
||||
<span class="field-label">Size (Max)</span>
|
||||
<input id="pm-junk-ps-max" class="field-input compact" type="number" placeholder="1000" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-profile-cancel" class="btn secondary">Cancel</button>
|
||||
<button id="btn-profile-delete" class="btn danger" style="display:none;">Delete</button>
|
||||
<button id="btn-profile-save" class="btn primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wintun Modal -->
|
||||
<!-- ── SHARE MODAL ──────────────────────────────────────── -->
|
||||
<div id="share-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">Share Profile</h3>
|
||||
<p class="modal-text">QR generated locally — the key never leaves this device.</p>
|
||||
<div id="share-qr" class="share-qr"></div>
|
||||
<input id="share-link" class="field-input mono" type="text" readonly />
|
||||
<div class="modal-actions">
|
||||
<button id="btn-share-close" class="btn secondary">Close</button>
|
||||
<button id="btn-share-copy" class="btn primary">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── WINTUN MODAL ─────────────────────────────────────── -->
|
||||
<div id="wintun-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title" data-i18n="wintun_missing_title">Wintun Driver Missing</h3>
|
||||
<p class="modal-text" data-i18n="wintun_missing_desc">TUN mode requires the Wintun network driver.</p>
|
||||
<h3 class="modal-title">Wintun Driver Missing</h3>
|
||||
<p class="modal-text">TUN mode requires the Wintun network driver.</p>
|
||||
<ol class="modal-steps">
|
||||
<li data-i18n="wintun_step1">Download <strong>wintun.zip</strong> from the official site</li>
|
||||
<li data-i18n="wintun_step2">Extract <code>amd64\wintun.dll</code> from the archive</li>
|
||||
<li><span data-i18n="wintun_step3">Place it here:</span> <code id="wintun-install-path">...</code></li>
|
||||
<li data-i18n="wintun_step4">Restart the connection</li>
|
||||
<li>Download <strong>wintun.zip</strong> from wintun.net</li>
|
||||
<li>Extract <code>amd64\wintun.dll</code></li>
|
||||
<li>Place it here: <code id="wintun-install-path">...</code></li>
|
||||
<li>Restart the connection</li>
|
||||
</ol>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-wintun-cancel" class="btn secondary" data-i18n="cancel_btn">Cancel</button>
|
||||
<a id="btn-wintun-open" href="https://www.wintun.net" target="_blank" class="btn primary" data-i18n="wintun_open_btn">Open wintun.net ↗</a>
|
||||
<button id="btn-wintun-cancel" class="btn secondary">Cancel</button>
|
||||
<a id="btn-wintun-open" href="https://www.wintun.net" target="_blank" class="btn primary">Open wintun.net ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
</div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
1202
ostp-gui/src/main.js
1202
ostp-gui/src/main.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -64,7 +64,6 @@ struct SdkState {
|
|||
runtime: Option<Runtime>,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
metrics: Option<Arc<BridgeMetrics>>,
|
||||
tun_child: Option<std::process::Child>,
|
||||
cmd_tx: Option<mpsc::Sender<BridgeCommand>>,
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +73,6 @@ impl SdkState {
|
|||
runtime: None,
|
||||
shutdown_tx: None,
|
||||
metrics: None,
|
||||
tun_child: None,
|
||||
cmd_tx: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -100,8 +98,10 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient(
|
|||
_class: JClass,
|
||||
config_json: JString,
|
||||
fd: jni::sys::jint,
|
||||
t2s_bin_path: JString,
|
||||
local_proxy: JString,
|
||||
// tun2socks ("system" TUN stack) removed in 0.4.0 — native OSTP TUN is the only path.
|
||||
// These two args are retained to keep the JNI signature ABI-stable with Kotlin; unused.
|
||||
_t2s_bin_path: JString,
|
||||
_local_proxy: JString,
|
||||
) -> jboolean {
|
||||
let mut state = match STATE.write() {
|
||||
Ok(s) => s,
|
||||
|
|
@ -160,16 +160,6 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient(
|
|||
Err(_) => return jni::sys::JNI_FALSE,
|
||||
};
|
||||
|
||||
let t2s_path: String = match env.get_string(&t2s_bin_path) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return jni::sys::JNI_FALSE,
|
||||
};
|
||||
|
||||
let proxy_addr: String = match env.get_string(&local_proxy) {
|
||||
Ok(s) => s.into(),
|
||||
Err(_) => return jni::sys::JNI_FALSE,
|
||||
};
|
||||
|
||||
// Parse config from JSON
|
||||
let config: ClientConfig = match serde_json::from_str(&config_str) {
|
||||
Ok(cfg) => cfg,
|
||||
|
|
@ -256,81 +246,11 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStartClient(
|
|||
let _ = cmd_tx_clone.send(BridgeCommand::ToggleTunnel).await;
|
||||
});
|
||||
|
||||
if config.tun_stack == "system" {
|
||||
// Spawn tun2socks
|
||||
let fd_str = format!("fd://{}", fd);
|
||||
let proxy_str = format!("socks5://{}", proxy_addr);
|
||||
|
||||
// Native OSTP TUN stack is the only path (tun2socks "system" stack removed in 0.4.0).
|
||||
if debug {
|
||||
add_log(format!("Spawning tun2socks: {} -device {} -proxy {}", t2s_path, fd_str, proxy_str));
|
||||
}
|
||||
|
||||
let mut cmd = std::process::Command::new(&t2s_path);
|
||||
cmd.arg("-device")
|
||||
.arg(&fd_str)
|
||||
.arg("-proxy")
|
||||
.arg(&proxy_str);
|
||||
|
||||
if config.ostp.mtu > 0 {
|
||||
cmd.arg("-mtu").arg(config.ostp.mtu.to_string());
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
add_log(format!("Failed to spawn tun2socks from Rust: {e}"));
|
||||
return jni::sys::JNI_FALSE;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = match child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
add_log("Failed to capture tun2socks stdout".to_string());
|
||||
return jni::sys::JNI_FALSE;
|
||||
}
|
||||
};
|
||||
let stderr = match child.stderr.take() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
add_log("Failed to capture tun2socks stderr".to_string());
|
||||
return jni::sys::JNI_FALSE;
|
||||
}
|
||||
};
|
||||
|
||||
// Read stdout
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
if let Ok(l) = line {
|
||||
if debug {
|
||||
add_log(format!("tun2socks: {}", l));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Read stderr & wait
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines() {
|
||||
if let Ok(l) = line {
|
||||
if debug {
|
||||
add_log(format!("tun2socks ERROR: {}", l));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
state.tun_child = Some(child);
|
||||
} else {
|
||||
if debug {
|
||||
add_log("Using OSTP native TUN stack. Bypassing tun2socks.".to_string());
|
||||
add_log("Using OSTP native TUN stack.".to_string());
|
||||
}
|
||||
{
|
||||
let shutdown_rx_clone = shutdown_tx.subscribe();
|
||||
let config_clone = config.clone();
|
||||
let (exclusions_tx, exclusions_rx) = tokio::sync::watch::channel(config.exclusions.clone());
|
||||
|
|
@ -368,24 +288,18 @@ pub extern "system" fn Java_net_ostp_client_OstpClientSdk_nativeStopClient(
|
|||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jboolean {
|
||||
let (tun_child, shutdown_tx, runtime) = {
|
||||
let (shutdown_tx, runtime) = {
|
||||
let mut state = match STATE.write() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return jni::sys::JNI_FALSE,
|
||||
};
|
||||
let c = state.tun_child.take();
|
||||
let s = state.shutdown_tx.take();
|
||||
let r = state.runtime.take();
|
||||
state.cmd_tx = None;
|
||||
state.metrics = None;
|
||||
(c, s, r)
|
||||
(s, r)
|
||||
};
|
||||
|
||||
if let Some(mut child) = tun_child {
|
||||
let _ = child.kill();
|
||||
add_log("Killed tun2socks process".to_string());
|
||||
}
|
||||
|
||||
if let Some(s) = shutdown_tx {
|
||||
let _ = s.send(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const MAX_SESSIONS: usize = 1024;
|
|||
|
||||
pub enum DispatchOutcome {
|
||||
Unauthorized,
|
||||
/// Packet matched a registered key's per-key junk marker — drop silently.
|
||||
Junk,
|
||||
Accepted {
|
||||
responses: Vec<Bytes>,
|
||||
app_payloads: Vec<(u32, u16, Bytes)>, // session_id, stream_id, payload
|
||||
|
|
@ -306,6 +308,13 @@ impl Dispatcher {
|
|||
for candidate_key in keys_snapshot {
|
||||
let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes());
|
||||
|
||||
// Junk frames carry this key's per-key derived marker (no global
|
||||
// constant → no universal DPI signature). Drop silently — the secrets
|
||||
// for this key are already derived here, so the check is free.
|
||||
if packet.len() >= 4 && packet[0..4] == secrets.junk_marker {
|
||||
return Ok(DispatchOutcome::Junk);
|
||||
}
|
||||
|
||||
// Decode the session_id using this key's obfuscation
|
||||
// The handshake mask is derived from the Noise payload at bytes [6..],
|
||||
// so we must deobfuscate the full packet, not just the header.
|
||||
|
|
|
|||
|
|
@ -285,6 +285,10 @@ pub async fn run_server(
|
|||
|
||||
// Headless event logger
|
||||
tokio::spawn(async move {
|
||||
// Rate-limit unauthorized-probe logging so a junk/probe flood can't spam the log
|
||||
// (and so a client running junk-over-UDP can't trigger a self-ban via log noise).
|
||||
let mut probe_window_start: Option<Instant> = None;
|
||||
let mut probe_suppressed: u64 = 0;
|
||||
while let Some(ev) = ui_event_rx.recv().await {
|
||||
match ev {
|
||||
UiEvent::Log(msg) => {
|
||||
|
|
@ -303,7 +307,23 @@ pub async fn run_server(
|
|||
}
|
||||
UiEvent::UnauthorizedProbe { peer, bytes } => {
|
||||
if debug {
|
||||
let now = Instant::now();
|
||||
let elapsed = probe_window_start
|
||||
.map(|s| now.duration_since(s))
|
||||
.unwrap_or(Duration::MAX);
|
||||
if elapsed >= Duration::from_secs(30) {
|
||||
if probe_suppressed > 0 {
|
||||
tracing::debug!(
|
||||
"(+{} more unauthorized probes suppressed in the previous ~30s)",
|
||||
probe_suppressed
|
||||
);
|
||||
}
|
||||
probe_window_start = Some(now);
|
||||
probe_suppressed = 0;
|
||||
tracing::debug!("Unauthorized probe from {peer} ({bytes} bytes)");
|
||||
} else {
|
||||
probe_suppressed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
UiEvent::PeerSeen { .. } => {}
|
||||
|
|
@ -531,7 +551,8 @@ async fn handle_udp_packet(
|
|||
last_empty_app_log: &mut Instant,
|
||||
) -> Result<()> {
|
||||
let size = packet.len();
|
||||
match dispatcher.on_datagram(peer, packet) {
|
||||
match dispatcher.on_datagram(peer, packet.clone()) {
|
||||
Ok(DispatchOutcome::Junk) => return Ok(()),
|
||||
Ok(DispatchOutcome::Unauthorized) => {
|
||||
let _ = ui_event_tx.send(UiEvent::UnauthorizedProbe { peer: peer.ip(), bytes: size });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ use std::net::SocketAddr;
|
|||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tracing::info;
|
||||
|
||||
pub async fn handle_tcp_connection<S>(
|
||||
stream: S,
|
||||
|
|
@ -16,7 +15,7 @@ pub async fn handle_tcp_connection<S>(
|
|||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
info!("UoT client connected from {}", peer_addr);
|
||||
tracing::debug!("UoT client connected from {}", peer_addr);
|
||||
|
||||
// Register this connection in the map
|
||||
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
|
||||
|
|
@ -54,6 +53,6 @@ where
|
|||
});
|
||||
|
||||
let _ = tokio::join!(writer_task, reader_task);
|
||||
info!("UoT client disconnected: {}", peer_addr);
|
||||
tracing::debug!("UoT client disconnected: {}", peer_addr);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,19 @@ async fn main() -> Result<()> {
|
|||
log_to_file(&format!("Fatal error: {}", e));
|
||||
}
|
||||
log_to_file("Helper exiting");
|
||||
Ok(())
|
||||
|
||||
// The WinTun blocking `receive` runs on a thread that `task.abort()` cannot
|
||||
// cancel, so it keeps the adapter handle — and the default route bound to it —
|
||||
// alive and prevents the tokio runtime from shutting down. Without this the
|
||||
// process lingers as a zombie: `ostp_tun` stays Up, its metric-0 default route
|
||||
// competes with the physical one, and the NEXT connect fails to install the
|
||||
// server bypass route, so traffic loops back into a dead tunnel (no internet).
|
||||
// The GUI launches a fresh helper for every connect, so this process has no
|
||||
// more work once run_server returns. Give the synchronous route/firewall
|
||||
// teardown a moment to finish, then force the process to exit so the kernel
|
||||
// reclaims the adapter and every route bound to it. (Ported from 0.3.x b6e78c1.)
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
async fn run_server(expected_token: String, port: u16) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -72,32 +72,59 @@ pub async fn create(opts: OstpTunOptions) -> Result<OstpTunInterface> {
|
|||
.mtu(opts.mtu)
|
||||
.up();
|
||||
|
||||
let dev = tun::create(&tun_cfg).map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
|
||||
// The IpHelper calls the `tun` crate performs right after Adapter::create
|
||||
// (set address / mtu) can transiently fail with ERROR_INVALID_PARAMETER
|
||||
// (os error 87) when the freshly created interface is not yet registered
|
||||
// in the IP stack. Retry a few times; on retry the crate reuses the
|
||||
// existing adapter via Adapter::open. (Ported from 0.3.x b6e78c1.)
|
||||
let dev = {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
match tun::create(&tun_cfg) {
|
||||
Ok(d) => break d,
|
||||
Err(e) if attempt < 5 => {
|
||||
tracing::warn!(
|
||||
"TUN device creation attempt {}/5 failed: {} — retrying in 300ms",
|
||||
attempt, e
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
}
|
||||
Err(e) => return Err(anyhow!("Failed to create TUN device after {} attempts: {}", attempt, e)),
|
||||
}
|
||||
}
|
||||
};
|
||||
let dev = tun::AsyncDevice::new(dev).map_err(|e| anyhow!("TUN device async failed: {}", e))?;
|
||||
tracing::info!("TUN device 'ostp_tun' created.");
|
||||
|
||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||
|
||||
// A freshly created WinTun adapter can take several seconds to appear in
|
||||
// GetAdaptersAddresses (it only shows up once it has an operational IPv4
|
||||
// binding). The default route via the TUN is what actually captures
|
||||
// traffic, so this lookup is critical — give it a generous window (~15s).
|
||||
let mut tun_index = None;
|
||||
for _ in 0..20 {
|
||||
for _ in 0..75 {
|
||||
if let Some(idx) = windows_route::sys::get_interface_index("ostp_tun") {
|
||||
tun_index = Some(idx);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
if let Some(idx) = tun_index {
|
||||
let _ = windows_route::sys::add_ipv4_route(
|
||||
match windows_route::sys::add_ipv4_route(
|
||||
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||
std::net::Ipv4Addr::new(0, 0, 0, 0),
|
||||
std::net::Ipv4Addr::new(10, 1, 0, 1),
|
||||
idx,
|
||||
5,
|
||||
);
|
||||
tracing::info!("Default route via TUN (if_index={idx}, metric=5) added.");
|
||||
) {
|
||||
Ok(()) => tracing::info!("Default route via TUN (if_index={idx}, metric=5) added."),
|
||||
Err(e) => tracing::error!("Failed to add default route via TUN (if_index={idx}): {e} — traffic will NOT be captured."),
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Could not find ostp_tun index in routing table — traffic may not be captured.");
|
||||
tracing::error!("Could not find ostp_tun index in routing table after 15s — traffic will NOT be captured.");
|
||||
}
|
||||
|
||||
let exe1 = current_exe.clone();
|
||||
|
|
|
|||
|
|
@ -168,6 +168,34 @@ pub mod sys {
|
|||
}
|
||||
}
|
||||
|
||||
/// Delete every routing-table entry whose destination is `dest`/`mask`,
|
||||
/// regardless of its gateway or interface. Used to purge stale bypass routes
|
||||
/// left by a previous session (possibly pointing at an old gateway after a
|
||||
/// network change) so a fresh, correct one can be installed. (Ported from 0.3.x b6e78c1.)
|
||||
pub fn delete_routes_for_dest(dest: Ipv4Addr, mask: Ipv4Addr) {
|
||||
unsafe {
|
||||
let mut size: ULONG = 0;
|
||||
if GetIpForwardTable(ptr::null_mut(), &mut size, 0) != ERROR_INSUFFICIENT_BUFFER {
|
||||
return;
|
||||
}
|
||||
let mut buf: Vec<u8> = vec![0; size as usize];
|
||||
let table = buf.as_mut_ptr() as *mut MIB_IPFORWARDTABLE;
|
||||
if GetIpForwardTable(table, &mut size, 0) != NO_ERROR {
|
||||
return;
|
||||
}
|
||||
let want_dest = ipv4_to_dword(dest);
|
||||
let want_mask = ipv4_to_dword(mask);
|
||||
let entries =
|
||||
std::slice::from_raw_parts_mut((*table).table.as_mut_ptr(), (*table).dwNumEntries as usize);
|
||||
for row in entries {
|
||||
if row.dwForwardDest == want_dest && row.dwForwardMask == want_mask {
|
||||
// Delete the exact existing row (its own nexthop/ifindex).
|
||||
let _ = DeleteIpForwardEntry(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add bypass routes for a list of resolved IP addresses (typically from exclusion config).
|
||||
/// Each IP gets a /32 host route via the physical gateway so it bypasses the TUN.
|
||||
/// Returns list of (ip, gw, if_index) that were successfully added, for later cleanup.
|
||||
|
|
@ -178,15 +206,24 @@ pub mod sys {
|
|||
metric: u32,
|
||||
) -> Vec<(Ipv4Addr, Ipv4Addr, u32)> {
|
||||
let mut added = Vec::new();
|
||||
for &ip in ips {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mask = Ipv4Addr::new(255, 255, 255, 255);
|
||||
for &ip in ips {
|
||||
// The server IP is passed both as server_ip and inside bypass_ips, so
|
||||
// dedupe to avoid a guaranteed "already exists" failure on the second add.
|
||||
if !seen.insert(ip) {
|
||||
continue;
|
||||
}
|
||||
// Purge any pre-existing /32 for this dest (e.g. a stale route via an
|
||||
// old gateway from a previous session) so add_ipv4_route below installs
|
||||
// the correct one instead of failing with ERROR_OBJECT_ALREADY_EXISTS.
|
||||
delete_routes_for_dest(ip, mask);
|
||||
match add_ipv4_route(ip, mask, gw, if_index, metric) {
|
||||
Ok(()) => {
|
||||
added.push((ip, gw, if_index));
|
||||
}
|
||||
Err(e) => {
|
||||
// 87 = ERROR_INVALID_PARAMETER (route may already exist)
|
||||
tracing::debug!("bypass route add {ip}/32 via {gw}: {e}");
|
||||
tracing::warn!("bypass route add {ip}/32 via {gw} (if {if_index}) failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@ 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"
|
||||
rlimit = "0.11.0"
|
||||
|
|
|
|||
272
ostp/src/main.rs
272
ostp/src/main.rs
|
|
@ -9,59 +9,88 @@ use colored::Colorize;
|
|||
#[command(author, version, about = "OSTP Core - Ospab Stealth Transport Protocol", long_about = None)]
|
||||
struct Args {
|
||||
/// Path to the JSON configuration file
|
||||
#[cfg_attr(unix, arg(long, default_value = "/etc/ostp/config.json"))]
|
||||
#[cfg_attr(windows, arg(long, default_value = "config.json"))]
|
||||
#[cfg_attr(unix, arg(short, long, default_value = "/etc/ostp/config.json", global = true))]
|
||||
#[cfg_attr(windows, arg(short, long, default_value = "config.json", global = true))]
|
||||
config: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Run the interactive setup wizard
|
||||
Setup {
|
||||
/// Optional mode to initialize the config for (client or server)
|
||||
#[arg(short, long)]
|
||||
init: Option<String>,
|
||||
|
||||
/// Run the interactive setup wizard
|
||||
#[arg(long)]
|
||||
setup: bool,
|
||||
|
||||
/// Generate a new secure access key and exit
|
||||
#[arg(short = 'g', long)]
|
||||
generate_key: bool,
|
||||
|
||||
},
|
||||
/// Initialize config for client, server, or relay mode
|
||||
Init {
|
||||
mode: String,
|
||||
},
|
||||
/// Generate a new secure access key
|
||||
GenerateKey {
|
||||
/// Format for generated key (hex, base64)
|
||||
#[arg(long, default_value = "hex")]
|
||||
format: String,
|
||||
|
||||
/// Number of keys to generate
|
||||
#[arg(short = 'c', long, default_value_t = 1)]
|
||||
// NOT short='c' — `--config` is a global arg (propagated into every
|
||||
// subcommand's scope), so a local '-c' here would collide with it.
|
||||
// Clap validates the whole command tree on the first parse() and
|
||||
// panics on a duplicate short flag, breaking the ENTIRE CLI.
|
||||
#[arg(short = 'n', long, default_value_t = 1)]
|
||||
count: usize,
|
||||
|
||||
},
|
||||
/// Output ready-to-use client sharing links (ostp://...) from the server configuration
|
||||
#[arg(long)]
|
||||
links: bool,
|
||||
|
||||
/// Validate configuration file and exit
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
|
||||
/// Optional client connection share link (ostp://ACCESS_KEY@HOST:PORT) to run instantly
|
||||
url: Option<String>,
|
||||
|
||||
Links,
|
||||
/// Validate configuration file
|
||||
Check,
|
||||
/// Connect using a share link (ostp://ACCESS_KEY@HOST:PORT)
|
||||
Connect {
|
||||
url: String,
|
||||
},
|
||||
/// Uninstall OSTP: stop service, remove binary and configuration files
|
||||
#[arg(long)]
|
||||
uninstall: bool,
|
||||
|
||||
Uninstall,
|
||||
/// Update OSTP: re-run the install script to fetch and install the latest version
|
||||
#[arg(long)]
|
||||
Update {
|
||||
/// Release branch to update from (stable, pre-release, nightly)
|
||||
#[arg(short = 'b', long, default_value = "stable")]
|
||||
branch: String,
|
||||
/// Exact release version to update to (e.g. 0.4.1 or 0.4.1-beta.3),
|
||||
/// overriding the latest release on the selected branch
|
||||
#[arg(short = 'v', long, value_name = "VERSION")]
|
||||
version: Option<String>,
|
||||
},
|
||||
/// Import a share link (ostp://...) into the configuration file
|
||||
Import {
|
||||
url: String,
|
||||
},
|
||||
/// Output shell export commands for proxy (eval $(ostp proxy-env))
|
||||
ProxyEnv,
|
||||
/// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear))
|
||||
ProxyEnvClear,
|
||||
}
|
||||
|
||||
/// Bridges the new subcommand-based CLI onto the original flat-flag dispatch
|
||||
/// below, so the ~500 lines of existing command logic don't need to change —
|
||||
/// only how they get populated does.
|
||||
struct LegacyArgs {
|
||||
config: PathBuf,
|
||||
init: Option<String>,
|
||||
setup: bool,
|
||||
generate_key: bool,
|
||||
format: String,
|
||||
count: usize,
|
||||
links: bool,
|
||||
check: bool,
|
||||
url: Option<String>,
|
||||
uninstall: bool,
|
||||
update: bool,
|
||||
|
||||
/// Import a share link (ostp://...) into the configuration file and exit
|
||||
#[arg(long)]
|
||||
update_branch: String,
|
||||
target_version: Option<String>,
|
||||
import: Option<String>,
|
||||
|
||||
/// Output shell export commands for proxy (eval $(ostp --proxy-env))
|
||||
#[arg(long)]
|
||||
proxy_env: bool,
|
||||
|
||||
/// Output shell export commands to clear proxy (eval $(ostp --proxy-env-clear))
|
||||
#[arg(long)]
|
||||
proxy_env_clear: bool,
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +114,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 +121,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 +132,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
|||
transport: Some(TransportConfigRaw {
|
||||
mode: Some(transport_mode),
|
||||
stealth_sni: Some(sni.clone()),
|
||||
wss: Some(wss_enabled),
|
||||
tcp_fragmentation: None,
|
||||
}),
|
||||
socks5_bind: Some("127.0.0.1:1088".to_string()),
|
||||
tun: Some(TunConfig {
|
||||
|
|
@ -320,7 +347,7 @@ struct ClientConfig {
|
|||
struct TransportConfigRaw {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
wss: Option<bool>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
|
|
@ -367,6 +394,10 @@ struct MuxConfig {
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Raise the open-file-descriptor limit to avoid EMFILE under many concurrent
|
||||
// connections (ported from 0.3.x fix 922cf0b). No-op / best-effort on platforms
|
||||
// where it does not apply.
|
||||
let _ = rlimit::increase_nofile_limit(1048576);
|
||||
ostp_client::logging::setup_panic_hook();
|
||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
|
|
@ -699,8 +730,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 +876,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();
|
||||
|
|
@ -1025,14 +1051,48 @@ fn wizard_register_windows_service(config_path: &std::path::Path) -> Result<()>
|
|||
}
|
||||
|
||||
async fn run_app() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let raw_args = Args::parse();
|
||||
let mut args = LegacyArgs {
|
||||
config: raw_args.config.clone(),
|
||||
init: None,
|
||||
setup: false,
|
||||
generate_key: false,
|
||||
format: "hex".to_string(),
|
||||
count: 1,
|
||||
links: false,
|
||||
check: false,
|
||||
url: None,
|
||||
uninstall: false,
|
||||
update: false,
|
||||
update_branch: "stable".to_string(),
|
||||
target_version: None,
|
||||
import: None,
|
||||
proxy_env: false,
|
||||
proxy_env_clear: false,
|
||||
};
|
||||
|
||||
if let Some(cmd) = raw_args.command {
|
||||
match cmd {
|
||||
Commands::Setup { init } => { args.setup = true; args.init = init; }
|
||||
Commands::Init { mode } => { args.init = Some(mode); }
|
||||
Commands::GenerateKey { format, count } => { args.generate_key = true; args.format = format; args.count = count; }
|
||||
Commands::Links => { args.links = true; }
|
||||
Commands::Check => { args.check = true; }
|
||||
Commands::Connect { url } => { args.url = Some(url); }
|
||||
Commands::Uninstall => { args.uninstall = true; }
|
||||
Commands::Update { branch, version } => { args.update = true; args.update_branch = branch; args.target_version = version; }
|
||||
Commands::Import { url } => { args.import = Some(url); }
|
||||
Commands::ProxyEnv => { args.proxy_env = true; }
|
||||
Commands::ProxyEnvClear => { args.proxy_env_clear = true; }
|
||||
}
|
||||
}
|
||||
|
||||
if args.uninstall {
|
||||
return cmd_uninstall();
|
||||
}
|
||||
|
||||
if args.update {
|
||||
return cmd_update();
|
||||
return cmd_update(args.update_branch, args.target_version);
|
||||
}
|
||||
|
||||
// ── Setup wizard: explicit flag or first-time (no config) ────────
|
||||
|
|
@ -1348,8 +1408,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": {{
|
||||
|
|
@ -1581,12 +1640,26 @@ fn cmd_uninstall() -> Result<()> {
|
|||
// Update command
|
||||
// ---------------------------------------------------------------------------
|
||||
#[cfg(unix)]
|
||||
fn cmd_update() -> Result<()> {
|
||||
fn cmd_update(branch: String, version: Option<String>) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
println!("[ostp] Updating OSTP...");
|
||||
println!("[ostp] Updating OSTP (branch={branch})...");
|
||||
|
||||
let mut script_args = vec!["-c".to_string()];
|
||||
if let Some(v) = version {
|
||||
script_args.push(format!(
|
||||
"bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh) --branch {} -v {}",
|
||||
branch, v
|
||||
));
|
||||
} else {
|
||||
script_args.push(format!(
|
||||
"bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh) --branch {}",
|
||||
branch
|
||||
));
|
||||
}
|
||||
|
||||
let status = Command::new("bash")
|
||||
.args(["-c", "bash <(curl -Ls https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.sh)"])
|
||||
.args(&script_args)
|
||||
.status()
|
||||
.map_err(|e| anyhow!("Failed to run update: {e}"))?;
|
||||
|
||||
|
|
@ -1597,14 +1670,91 @@ fn cmd_update() -> Result<()> {
|
|||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn cmd_update() -> Result<()> {
|
||||
fn cmd_update(_branch: String, _version: Option<String>) -> Result<()> {
|
||||
anyhow::bail!("The 'update' command is only supported on Linux/Unix systems.");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_elevated_for_tun() -> Result<()> {
|
||||
#[link(name = "shell32")]
|
||||
extern "system" {
|
||||
fn IsUserAnAdmin() -> i32;
|
||||
fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn GetLastError() -> u32;
|
||||
}
|
||||
|
||||
let is_admin = unsafe { IsUserAnAdmin() != 0 };
|
||||
if is_admin {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let exe_wstr: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let verb_wstr: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
|
||||
|
||||
// Reconstruct arguments so the elevated relaunch runs the same command.
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let params_str = args.iter().map(|s| format!("\"{}\"", s)).collect::<Vec<_>>().join(" ");
|
||||
let params_wstr: Vec<u16> = OsStr::new(¶ms_str).encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let cwd = std::env::current_dir()?;
|
||||
let cwd_wstr: Vec<u16> = cwd.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
println!("{}", "[ostp] TUN mode requires administrator privileges. Requesting elevation...".yellow());
|
||||
|
||||
let ret = unsafe {
|
||||
ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
verb_wstr.as_ptr(),
|
||||
exe_wstr.as_ptr(),
|
||||
params_wstr.as_ptr(),
|
||||
cwd_wstr.as_ptr(),
|
||||
1, // SW_SHOWNORMAL
|
||||
)
|
||||
};
|
||||
|
||||
// ShellExecuteW's return is a pseudo-HINSTANCE: > 32 means the call itself
|
||||
// "succeeded" — but that range INCLUDES ERROR_CANCELLED (1223), which is
|
||||
// exactly what Windows returns when the user clicks "No" on the UAC
|
||||
// prompt. The old check (`ret <= 32` only) treated a user-denied prompt
|
||||
// as success and silently exited without ever starting the tunnel.
|
||||
if ret == 1223 {
|
||||
anyhow::bail!("UAC elevation was denied. TUN mode requires administrator privileges.");
|
||||
}
|
||||
if ret <= 32 {
|
||||
let win_err = unsafe { GetLastError() };
|
||||
anyhow::bail!(
|
||||
"Failed to request UAC elevation (ShellExecuteW ret={}, GetLastError={}). \
|
||||
If this keeps happening, an unsigned binary can be silently blocked by \
|
||||
SmartScreen/antivirus during elevation — try running this as Administrator manually.",
|
||||
ret, win_err
|
||||
);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
|
||||
let mode_str = if is_tun_enabled { "tun" } else { "proxy" };
|
||||
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan()); let client_conf = ostp_client::config::ClientConfig {
|
||||
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan());
|
||||
|
||||
// TUN mode needs admin rights to create the WinTun adapter. This was
|
||||
// missing entirely before — the CLI would just try to create the
|
||||
// adapter unelevated and fail at the driver level with no UAC prompt
|
||||
// ever shown, which is what "UAC denied regardless of GUI or TUI"
|
||||
// actually was for this code path: TUI never asked for elevation at all.
|
||||
#[cfg(target_os = "windows")]
|
||||
if is_tun_enabled {
|
||||
ensure_elevated_for_tun()?;
|
||||
}
|
||||
|
||||
let client_conf = ostp_client::config::ClientConfig {
|
||||
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() },
|
||||
tun_stack: "native".to_string(),
|
||||
debug: client_cfg.debug.unwrap_or(false),
|
||||
|
|
@ -1633,7 +1783,11 @@ 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),
|
||||
tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
||||
frag_chunk: 2,
|
||||
frag_sleep: 2,
|
||||
junk_pc: [2, 5],
|
||||
junk_ps: [100, 1000],
|
||||
},
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
# OSTP Build & Release Pipeline
|
||||
# Usage:
|
||||
# .\scripts\build.ps1 Build locally + trigger CI/CD
|
||||
# .\scripts\build.ps1 Build locally + trigger CI/CD (stable release)
|
||||
# .\scripts\build.ps1 -TriggerOnly Skip local builds, trigger CI/CD only
|
||||
# .\scripts\build.ps1 -TriggerOnly -PreRelease Beta: tag CURRENT version as pre-release (no bump, no master commit)
|
||||
# .\scripts\build.ps1 -Check Run cargo check only (no build, no release)
|
||||
|
||||
param(
|
||||
[switch]$Flatten,
|
||||
[switch]$TriggerOnly,
|
||||
[switch]$Check
|
||||
[switch]$Check,
|
||||
[switch]$PreRelease
|
||||
)
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
|
@ -17,22 +19,27 @@ Push-Location $ProjectRoot
|
|||
Write-Output "Synchronizing with origin master..."
|
||||
& git pull origin master --rebase --autostash | Out-Null
|
||||
|
||||
# --- Version bump ---
|
||||
# --- Version resolution / bump ---
|
||||
$CargoToml = Join-Path $ProjectRoot "Cargo.toml"
|
||||
$Version = "0.2.0"
|
||||
if (Test-Path $CargoToml) {
|
||||
$Content = [System.IO.File]::ReadAllText($CargoToml)
|
||||
# Match version only in [workspace.package] section (first occurrence)
|
||||
$Content = if (Test-Path $CargoToml) { [System.IO.File]::ReadAllText($CargoToml) } else { "" }
|
||||
|
||||
if ($Content -match '\[workspace\.package\][\s\S]*?version\s*=\s*"(\d+)\.(\d+)\.(\d+)"') {
|
||||
$Major = [int]$Matches[1]
|
||||
$Minor = [int]$Matches[2]
|
||||
$Patch = [int]$Matches[3]
|
||||
|
||||
if ($PreRelease) {
|
||||
# Beta: build the CURRENT version as a pre-release. No bump, no manifest rewrites.
|
||||
$Version = "{0}.{1}.{2}" -f $Major, $Minor, $Patch
|
||||
Write-Output "[ok] Pre-release build of current v$Version (no version bump)"
|
||||
} else {
|
||||
$NewPatch = $Patch + 1
|
||||
$Version = "{0}.{1}.{2}" -f $Major, $Minor, $NewPatch
|
||||
# Replace only the workspace version line, not dependency versions
|
||||
|
||||
# Replace only the workspace version line (first occurrence), not dependency versions
|
||||
$OldVersionStr = 'version = "{0}.{1}.{2}"' -f $Major, $Minor, $Patch
|
||||
$NewVersionStr = 'version = "' + $Version + '"'
|
||||
# Use .NET Replace to swap only the first occurrence
|
||||
$idx = $Content.IndexOf($OldVersionStr)
|
||||
if ($idx -ge 0) {
|
||||
$NewContent = $Content.Remove($idx, $OldVersionStr.Length).Insert($idx, $NewVersionStr)
|
||||
|
|
@ -40,24 +47,22 @@ if (Test-Path $CargoToml) {
|
|||
}
|
||||
Write-Output "[ok] Version: v$Version"
|
||||
|
||||
# Bump Tauri GUI
|
||||
# Bump Tauri GUI config
|
||||
$TauriConf = Join-Path $ProjectRoot "ostp-gui\src-tauri\tauri.conf.json"
|
||||
if (Test-Path $TauriConf) {
|
||||
$TauriContent = [System.IO.File]::ReadAllText($TauriConf)
|
||||
$TauriRegex = [regex] '"version":\s*"[^"]+"'
|
||||
$TauriContent = $TauriRegex.Replace($TauriContent, ('"version": "' + $Version + '"'), 1)
|
||||
$TauriContent = ([regex]'"version":\s*"[^"]+"').Replace($TauriContent, ('"version": "' + $Version + '"'), 1)
|
||||
[System.IO.File]::WriteAllText($TauriConf, $TauriContent)
|
||||
Write-Output " [ok] Updated tauri.conf.json"
|
||||
}
|
||||
|
||||
# Bump React Control Panel
|
||||
$PackageJson = Join-Path $ProjectRoot "ostp-control\package.json"
|
||||
if (Test-Path $PackageJson) {
|
||||
$PkgContent = [System.IO.File]::ReadAllText($PackageJson)
|
||||
$PkgRegex = [regex] '"version":\s*"[^"]+"'
|
||||
$PkgContent = $PkgRegex.Replace($PkgContent, ('"version": "' + $Version + '"'), 1)
|
||||
[System.IO.File]::WriteAllText($PackageJson, $PkgContent)
|
||||
Write-Output " [ok] Updated package.json"
|
||||
# Bump GUI package.json
|
||||
$GuiPkg = Join-Path $ProjectRoot "ostp-gui\package.json"
|
||||
if (Test-Path $GuiPkg) {
|
||||
$GuiContent = [System.IO.File]::ReadAllText($GuiPkg)
|
||||
$GuiContent = ([regex]'"version":\s*"[^"]+"').Replace($GuiContent, ('"version": "' + $Version + '"'), 1)
|
||||
[System.IO.File]::WriteAllText($GuiPkg, $GuiContent)
|
||||
Write-Output " [ok] Updated ostp-gui/package.json"
|
||||
}
|
||||
|
||||
# Bump Flutter App
|
||||
|
|
@ -66,8 +71,7 @@ if (Test-Path $CargoToml) {
|
|||
$PubContent = [System.IO.File]::ReadAllText($Pubspec)
|
||||
if ($PubContent -match 'version:\s*(\d+\.\d+\.\d+)\+(\d+)') {
|
||||
$BuildNumber = [int]$Matches[2] + 1
|
||||
$PubRegex = [regex] 'version:\s*\d+\.\d+\.\d+\+\d+'
|
||||
$PubContent = $PubRegex.Replace($PubContent, ("version: $Version+$BuildNumber"), 1)
|
||||
$PubContent = ([regex]'version:\s*\d+\.\d+\.\d+\+\d+').Replace($PubContent, ("version: $Version+$BuildNumber"), 1)
|
||||
[System.IO.File]::WriteAllText($Pubspec, $PubContent)
|
||||
Write-Output " [ok] Updated pubspec.yaml"
|
||||
}
|
||||
|
|
@ -75,13 +79,18 @@ if (Test-Path $CargoToml) {
|
|||
}
|
||||
}
|
||||
|
||||
# --- Pre-flight: frontend build ---
|
||||
# --- Pre-flight: frontend build (only if the panel ships source) ---
|
||||
$ControlDir = Join-Path $ProjectRoot "ostp-control"
|
||||
if (Test-Path (Join-Path $ControlDir "package.json")) {
|
||||
Write-Output ""
|
||||
Write-Output "Building frontend control panel..."
|
||||
Push-Location (Join-Path $ProjectRoot "ostp-control")
|
||||
Push-Location $ControlDir
|
||||
& npm install | Out-Null
|
||||
& npm run build | Out-Null
|
||||
Pop-Location
|
||||
} else {
|
||||
Write-Output "[skip] ostp-control has no package.json — using prebuilt dist/."
|
||||
}
|
||||
|
||||
# --- Pre-flight: cargo check ---
|
||||
Write-Output ""
|
||||
|
|
@ -259,6 +268,26 @@ if (-not $TriggerOnly) {
|
|||
Write-Output ""
|
||||
Write-Output "--- Phase 3: CI/CD release ---"
|
||||
|
||||
if ($PreRelease) {
|
||||
# Beta: tag the CURRENT commit as a pre-release. Do NOT bump/commit master.
|
||||
# The workflow marks any tag containing '-' as a GitHub pre-release.
|
||||
$existingBetas = @(& git tag -l "v$Version-beta.*")
|
||||
$BetaNum = $existingBetas.Count + 1
|
||||
$Tag = "v$Version-beta.$BetaNum"
|
||||
Write-Output "Creating pre-release tag: $Tag"
|
||||
& git tag $Tag
|
||||
Write-Output "Pushing tag to GitHub..."
|
||||
& git push origin $Tag
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Output ""
|
||||
Write-Output "[ok] Pre-release $Tag triggered on GitHub Actions (marked as pre-release)."
|
||||
Write-Output " Monitor: https://github.com/ospab/ostp/actions"
|
||||
} else {
|
||||
Write-Output ""
|
||||
Write-Output "[error] Failed to push pre-release tag."
|
||||
}
|
||||
} else {
|
||||
Write-Output "Pushing version metadata..."
|
||||
& git add Cargo.toml Cargo.lock
|
||||
& git commit -m "CI/CD: release version v$Version" --allow-empty | Out-Null
|
||||
|
|
@ -279,5 +308,6 @@ if ($LASTEXITCODE -eq 0) {
|
|||
Write-Output ""
|
||||
Write-Output "[error] Failed to push release tag."
|
||||
}
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
|||
$extractedFiles = Get-ChildItem -Path $extractPath -File -Recurse
|
||||
if ($extractedFiles.Count -gt 0) {
|
||||
Write-Host "Stopping active instances..."
|
||||
Stop-Process -Name "ostp", "tun2socks" -Force -ErrorAction SilentlyContinue
|
||||
Stop-Process -Name "ostp", "ostp-gui", "ostp-tun-helper" -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
foreach ($file in $extractedFiles) {
|
||||
|
|
|
|||
|
|
@ -85,10 +85,47 @@ esac
|
|||
|
||||
echo "Platform: linux/$ARCH"
|
||||
|
||||
# ── Parse arguments ────────────────────────────────────────────────────
|
||||
TARGET_VERSION=""
|
||||
TARGET_BRANCH="stable"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-v|--version)
|
||||
TARGET_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--branch)
|
||||
TARGET_BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Download binary ──────────────────────────────────────────────────
|
||||
|
||||
echo "Fetching latest release..."
|
||||
if [ -n "$TARGET_VERSION" ]; then
|
||||
LATEST_RELEASE="$TARGET_VERSION"
|
||||
# Ensure it starts with 'v' if it's supposed to (only for real stable
|
||||
# semver tags — the nightly/pre-release channels use bare tag names).
|
||||
if [[ ! "$LATEST_RELEASE" =~ ^v ]] && [ "$TARGET_BRANCH" == "stable" ]; then
|
||||
LATEST_RELEASE="v$LATEST_RELEASE"
|
||||
fi
|
||||
echo "Fetching requested release $LATEST_RELEASE..."
|
||||
else
|
||||
if [ "$TARGET_BRANCH" == "nightly" ]; then
|
||||
echo "Fetching nightly release..."
|
||||
LATEST_RELEASE="nightly"
|
||||
elif [ "$TARGET_BRANCH" == "pre-release" ]; then
|
||||
echo "Fetching pre-release..."
|
||||
LATEST_RELEASE="pre-release"
|
||||
else
|
||||
echo "Fetching latest stable release..."
|
||||
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$LATEST_RELEASE" ] || [[ "$LATEST_RELEASE" == *"null"* ]]; then
|
||||
echo "[notice] Could not determine latest release automatically."
|
||||
|
|
|
|||
Loading…
Reference in New Issue