Compare commits

...

14 Commits

Author SHA1 Message Date
ospab b2ee9eb010 Port CLI subcommands + multi-channel release onto the 0.4.0 rebuild
Brings the useful work from the old master/pre-release lineage (5c2b5a0)
onto the clean rebuild, since that lineage never had the multi-server/WSS/
Reality removal or any of the 0.4.0 stability work. This is a manual port,
not a cherry-pick — this file's Args/ClientConfig shape had already
diverged too much for the patch to apply mechanically.

- ostp/src/main.rs: flat Args -> clap subcommands (setup, init, generate-key,
  links, check, connect, uninstall, update, import, proxy-env,
  proxy-env-clear), bridged onto the existing ~500 lines of flag-driven
  dispatch via a LegacyArgs struct so none of that logic had to change.
- Fixed a real bug found while porting: GenerateKey's `count` used
  short='c', colliding with the global `--config` short (also 'c'), which
  clap validates across the whole command tree on first parse() -- a
  duplicate short flag there could break every subcommand's parsing, not
  just generate-key's.
- `update` now takes `-b/--branch` and `-v/--version` explicitly (was a
  bare flag with no way to target a channel or exact version).
- Added the UAC elevation step for TUN mode that this lineage's CLI was
  completely missing (`run_client_directly` went straight to creating the
  TUN adapter unelevated). Also fixed the elevation check itself: it only
  tested `ret <= 32`, but ShellExecuteW returns ERROR_CANCELLED (1223) when
  the user clicks "No" on the UAC prompt -- > 32, so a denied prompt was
  read as success and the process exited silently without starting the
  tunnel. Now ret==1223 is reported explicitly, and a genuine failure logs
  GetLastError() so the real Win32 cause is visible next time.
- scripts/install.sh: added -b/--branch alongside the existing -v/--version,
  and channel-aware release resolution (nightly/pre-release use their own
  rolling tag; stable resolves via the GitHub API's "latest").
- release.yml: added nightly/pre-release branch-push triggers for rolling
  prereleases. Fixed the tag_name logic from 5c2b5a0, which mapped
  `master` pushes to a release tagged "nightly" -- backwards from master
  being the most stable channel. github.ref_name already equals the
  branch or tag name that triggered the run, so no per-branch remapping is
  needed at all; master is intentionally left off the branch-push list --
  it only ever gets real version tags.

Verified: `cargo build -p ostp` succeeds, and `ostp update --help` /
`ostp generate-key --help` / `ostp --help` show the expected flags with no
clap panic.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-08 01:38:44 +03:00
ospab 8ca411a64b Fix github actions: remove working-directory for ostp-control to avoid failure when absent 2026-07-07 18:04:21 +03:00
ospab dbd4ebc4e3 Fix linux build for beta 0.4.1 2026-07-07 17:10:25 +03:00
ospab acab38c551 0.4.1: per-key junk marker, GUI polish, pre-release pipeline
security / protocol:
- Derive a PER-KEY junk marker (obfuscation.rs, info byte 0x04) instead of the
  global constant [0x88,0x1A,0x93,0x5D]. A fixed marker was a universal DPI
  signature identifying ALL OSTP users at once — exactly what the HKDF version
  gate avoids for the handshake. Server drops junk via a new DispatchOutcome::Junk
  inside the existing key-trial loop (secrets already derived → zero extra cost);
  client stamps its own key's marker.
- §E: configurable junk/fragmentation params (junk_pc / junk_ps / frag_chunk / frag_sleep).

GUI (desktop):
- Light theme + toggle, GUI version footer in Settings.
- Fix mouse-wheel scroll on Settings (flex child needed min-height: 0).
- Drop the false "process exclusions unsupported in TUN mode" warning — they DO
  work (native_handler maps port->process via GetExtendedTcpTable).

release / infra:
- build.ps1: add -PreRelease (tag CURRENT version as v<ver>-beta.N, no bump, no
  master commit); guard the panel build when ostp-control ships no source; bump
  the real ostp-gui/package.json instead of the nonexistent ostp-control one.
- release.yml: mark hyphenated tags as GitHub pre-releases; don't hard-fail the
  web-panel step when there is no source (use committed dist/).
- Versions aligned to 0.4.1; README license badge BSL 1.1 -> AGPL v3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 16:33:57 +03:00
ospab e1bf18e653 core_fixes 2026-06-28 17:11:27 +03:00
ospab 39127d30f3 §B: fix Closing-state teardown data loss (from 47d44fa)
In the Closing state the old code force-transitioned to Closed after a
SINGLE inbound packet, so any data/ACKs the peer still had in flight when we
initiated Close were dropped (Closed returns Noop for everything). Stay in
Closing and process inbound normally; handle_inbound already owns the
Close->Closed transition when it actually receives the peer's Close frame.
Also handle Tick in Closing so our own Close frame is retransmitted until
acknowledged.

Ported surgically from 47d44fa — only the Closing-state correctness fix, NOT
that commit's bundled RFC-6298 RTO / congestion rewrite (a behavioural change
to the working base) or the sent_history BTreeMap perf swap (broad hot-path
change for a perf-only gain). cargo test -p ostp-core: 36/36 incl.
test_close_sequence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 01:44:37 +03:00
ospab 5c9ec89821 §F: GUI start_tunnel tears down existing tunnel first (stop+start)
Previously start_tunnel returned early if a tunnel was already running, so
changing the server while connected silently kept the OLD connection. Per
the plan ("server change = full stop+start, not hot-reload"), tear down any
existing InProcess/Helper tunnel before starting a fresh one. For the
elevated helper, wait ~1.2s after sending stop so it releases the ostp_tun
adapter before a new helper recreates it (avoids name clashes). start_tunnel
is only invoked on an explicit connect, so restarting here is safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:40:09 +03:00
ospab 4f7f1ca838 §F: desktop Share — local QR + copyable ostp:// link
The plan's share feature for the (single-config) desktop GUI. The QR is
rendered locally so the access key never leaves the device.

- src-tauri: add `qrcode = "0.14"` (features=["svg"]) + `generate_qr`
  command (string -> SVG), registered in the invoke handler. Ported from
  the current ostp-gui. (cargo check on src-tauri passes.)
- Frontend: "Share" button next to Import builds `ostp://KEY@HOST?sni&type`
  from the current config fields, calls generate_qr, and shows a modal with
  the QR + a read-only link + Copy. Added i18n keys (en/ru) so the new
  data-i18n labels resolve (missing keys would render as the raw key).

Note: Rust side verified via cargo check; the frontend is syntax-checked
(node --check) but not runtime-verified — needs a Tauri build to confirm
visually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:38:21 +03:00
ospab 2a9b099b24 §H/§F: port eagle watermark branding into the desktop GUI
Brand identity per the plan (dark theme + eagle). Ported faithfully from the
current ostp-gui:

- assets/logo.svg + logo.png (the eagle vector/raster).
- .watermark layer: centered, 80%/max-600px, opacity 0.05, behind the
  screens (z-index 0, pointer-events none), with a light-theme invert.
- Watermark div added after the ambient blobs in index.html.

Dark theme already existed in styles.css; this adds the eagle the plan calls
for. Backend-independent (pure HTML/CSS/asset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:28:46 +03:00
ospab db65e3367f §E (base): port junk packets + TCP fragmentation (stream-only)
Anti-DPI obfuscation the project wants to keep, ported from 0.3.x with the
harmful UDP behaviour designed out from the start.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:11:12 +03:00
ospab 89a5eea20d §C: protocol version gate via key derivation (reject old clients)
The base already derives all secrets from the access key, so derived
secrets were never the gap — the gap was that nothing distinguished a
current handshake from an older-format one, so an old client could still
connect to a new server.

Rather than the plan's literal "plaintext version byte before the crypto
layer" (which would add a constant, DPI-visible marker and defeat the
project's stealth north-star), fold the version INTO the HKDF derivation:

- Add PROTOCOL_VERSION (= 4 for 0.4.0), mixed into the IKM of
  derive_all_secrets so a different version yields a completely different
  obfuscation key / psk / padding. No marker ever appears on the wire —
  the output stays indistinguishable from random.
- A pre-0.4.0 peer derives a different obfuscation key, so the 0.4.0
  server cannot recover its handshake header and drops it as an
  unauthorized probe. Bump PROTOCOL_VERSION on any future wire break.

Verified:
- cargo test -p ostp-core: 36/36 incl. new test_protocol_version_gates_
  old_clients (old-version obf key does NOT recover the session_id).
- Loopback E2E: new client <-> new server connects and tunnels HTTPS
  (curl via SOCKS5 returns egress IP).
- Old v0.2.98 client vs new server: handshake times out / aborts, server
  accepts 0 clients — exactly the plan's §C criterion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:57:23 +03:00
ospab 38f2d9e659 §B: port stability fixes from 0.3.x onto the clean base
Ported only the fixes that actually apply to the pre-refactor base
(the handshake fixes 6eb7b36/d65af35 fix bugs the 0.3.1 multi-server
refactor introduced into the new outbounds/ostp.rs; the base bridge.rs
already waits for the handshake response with retransmit + NAT64
fallback, so they are intentionally skipped).

- EMFILE (922cf0b): rlimit::increase_nofile_limit at CLI startup.
- Logs (1151726): UoT connect/disconnect → debug; rate-limit the
  unauthorized-probe log to one line / ~30s so a junk/probe flood can't
  spam the log (and a client running junk-over-UDP can't self-ban).
- Helper lifecycle + bypass routes (b6e78c1):
  * ostp-tun-helper forces std::process::exit(0) after run_server so the
    WinTun adapter and its metric-0 default route are reclaimed instead
    of lingering as a zombie that breaks the next connect.
  * windows_route: delete_routes_for_dest() purges stale /32s, dedupe
    bypass IPs, and log add failures at warn!.
  * windows: retry tun::create through the transient ERROR_INVALID_
    PARAMETER window and widen the adapter-index lookup to ~15s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:30:42 +03:00
ospab 7704e0bdb1 §A.3: remove dead tun2socks code (native OSTP TUN is the only path)
The "system" TUN stack that shelled out to a bundled tun2socks binary is
long-dead — Flutter already hardcodes the native OSTP stack — and only
bloats the build.

- ostp-jni: drop the tun2socks spawn branch and the tun_child handle;
  the native TUN (run_native_tunnel_from_fd) is now unconditional. The
  JNI signature is kept ABI-stable (t2sBinPath/localProxy retained but
  ignored) to avoid breaking the Kotlin linkage without an Android build.
- Delete the committed 10 MB tun2socks-arm64 asset; drop the tun2socks
  download steps from the Android build scripts.
- Remove the dead tun2socks.exe entry from the desktop build_dist.js
  (it required a file nothing downloads, breaking the GUI dist build).
- Reword stale tun2socks references in proxy.rs, the GUI config comment,
  install.ps1, the release workflow matrix, and CONTRIBUTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:29:55 +03:00
ospab 92d6b06d75 §A: remove WSS + Reality (TLS-mimicry); bump to 0.4.0 / AGPL-3.0
Clean-rebuild on the stably-working v0.2.98 base. The project's stealth
path is zapret-like (packet obfuscation / junk / fragmentation), NOT
TLS-mimicry, so WSS and Reality are dropped entirely.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:29:42 +03:00
55 changed files with 2829 additions and 2908 deletions

View File

@ -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 }}

3
.gitignore vendored
View File

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

View File

@ -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.

View File

@ -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.

25
Cargo.lock generated
View File

@ -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"

View File

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

View File

@ -3,7 +3,7 @@
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=for-the-badge&color=blue)
![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=for-the-badge)
![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=for-the-badge)
![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=for-the-badge)
![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=for-the-badge)
![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=for-the-badge)

View File

@ -3,7 +3,7 @@
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
![GitHub Release](https://img.shields.io/github/v/release/ospab/ostp?style=for-the-badge&color=blue)
![License: BSL 1.1](https://img.shields.io/badge/License-BSL%201.1-orange.svg?style=for-the-badge)
![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=for-the-badge)
![Platform: Windows | Linux | macOS | Android](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-green.svg?style=for-the-badge)
![Crypto](https://img.shields.io/badge/Crypto-Noise__NNpsk0-blueviolet?style=for-the-badge)
![Transport](https://img.shields.io/badge/Transport-UDP%20ARQ-informational?style=for-the-badge)

185
REBUILD_PLAN.md Normal file
View File

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

View File

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

View File

@ -66,7 +66,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;
@ -1042,18 +1054,77 @@ impl Bridge {
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
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());
if write_half.write_all(&len_buf).await.is_err() { break; }
if write_half.write_all(&data).await.is_err() { break; }
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; }
}
}
});

View File

@ -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(),

View File

@ -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();

View File

@ -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();

View File

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

View File

@ -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"));

View File

@ -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);
}
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", 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);

View File

@ -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);
}

View File

@ -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;
}
}
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
// 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);
}
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);
}
}

View File

@ -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,
}
}

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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 self.reorder_buffer.len() < self.max_reorder_buffer {
self.reorder_buffer.insert(nonce, action);
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 still full after gap recovery, dropping frame nonce={}", nonce);
}
} else {
tracing::warn!("Reorder buffer full ({}/{}), dropping frame nonce={}",
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
);
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 {

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -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')

View File

@ -1,7 +1,7 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.1.0",
"version": "0.4.1",
"type": "module",
"scripts": {
"tauri": "tauri",

View File

@ -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"

View File

@ -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"

View File

@ -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,8 +797,13 @@ pub fn run() {
if let Ok(listener) = std::net::TcpListener::bind("127.0.0.1:49153") {
let _ = SINGLE_INSTANCE_LOCK.set(listener);
} else {
show_error_dialog("Приложение OSTP GUI уже запущено!");
return;
#[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 }));
@ -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");
}

View File

@ -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.

View File

@ -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

View File

@ -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 с официального сайта',

View File

@ -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>
<!-- 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>
<!-- Form card -->
<div class="card scrollable">
<!-- Client settings -->
<div class="section-divider"><span>Client Settings</span></div>
<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="client-settings-card">
<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"/>
</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">
<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">
<div class="toggle-text">
<span class="toggle-name">WebSocket (WSS)</span>
<span class="toggle-hint">Use RFC 6455 framing for strict DPI bypass</span>
</div>
<label class="toggle">
<input type="checkbox" id="in-wss" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<div class="field-group">
</div>
<div class="field-group">
<label class="field-label" for="in-mtu" data-i18n="label_mtu">MTU Size</label>
<input id="in-mtu" class="field-input" type="number" placeholder="1350" />
</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>
<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>
<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-row sub-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>
<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>
<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>
<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>
<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 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="toggle-row">
<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" data-i18n="label_autoconnect">Auto-connect</span>
<span class="toggle-hint" data-i18n="autoconnect_hint">Connect automatically on startup</span>
<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>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<div class="toggle-row">
<div class="toggle-row" style="border-top:none;">
<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>
<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>
<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="app-version" id="app-version">OSTP GUI</div>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></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>
<!-- Wintun Modal -->
<!-- ── 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="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>
<!-- 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">TCP Fragmentation</span>
<span class="toggle-hint">Split handshake to bypass DPI</span>
</div>
<label class="toggle">
<input type="checkbox" id="pm-tcp-frag" />
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
</div>
<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="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>
</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>
<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>
<!-- ── 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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);
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());
// Native OSTP TUN stack is the only path (tun2socks "system" stack removed in 0.4.0).
if debug {
add_log("Using OSTP native TUN stack.".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());
}
{
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);
}

View File

@ -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.

View File

@ -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 {
tracing::debug!("Unauthorized probe from {peer} ({bytes} bytes)");
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 });
}

View File

@ -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(())
}

View File

@ -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<()> {

View File

@ -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();

View File

@ -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();
let mut seen = std::collections::HashSet::new();
let mask = Ipv4Addr::new(255, 255, 255, 255);
for &ip in ips {
let mask = Ipv4Addr::new(255, 255, 255, 255);
// 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}");
}
}
}

View File

@ -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"

View File

@ -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,
/// Optional mode to initialize the config for (client or server)
#[arg(short, long)]
init: Option<String>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand, Debug)]
enum Commands {
/// Run the interactive setup wizard
#[arg(long)]
setup: bool,
/// Generate a new secure access key and exit
#[arg(short = 'g', long)]
generate_key: bool,
/// 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)]
count: usize,
Setup {
/// Optional mode to initialize the config for (client or server)
#[arg(short, long)]
init: Option<String>,
},
/// 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
// 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(&params_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),

View File

@ -1,13 +1,15 @@
# OSTP Build & Release Pipeline
# Usage:
# .\scripts\build.ps1 Build locally + trigger CI/CD
# .\scripts\build.ps1 -TriggerOnly Skip local builds, trigger CI/CD only
# .\scripts\build.ps1 -Check Run cargo check only (no build, no release)
# .\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)
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]
$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 ---
Write-Output ""
Write-Output "Building frontend control panel..."
Push-Location (Join-Path $ProjectRoot "ostp-control")
& npm install | Out-Null
& npm run build | Out-Null
Pop-Location
# --- 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 $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,25 +268,46 @@ if (-not $TriggerOnly) {
Write-Output ""
Write-Output "--- Phase 3: CI/CD release ---"
Write-Output "Pushing version metadata..."
& git add Cargo.toml Cargo.lock
& git commit -m "CI/CD: release version v$Version" --allow-empty | Out-Null
& git push origin master | Out-Null
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
Write-Output "Creating release tag: v$Version"
& git tag -d "v$Version" 2>&1 | Out-Null
& git tag "v$Version"
Write-Output "Pushing tag to GitHub..."
& git push origin "v$Version" --force
if ($LASTEXITCODE -eq 0) {
Write-Output ""
Write-Output "[ok] Release v$Version triggered on GitHub Actions."
Write-Output " Monitor: https://github.com/ospab/ostp/actions"
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 ""
Write-Output "[error] Failed to push release tag."
Write-Output "Pushing version metadata..."
& git add Cargo.toml Cargo.lock
& git commit -m "CI/CD: release version v$Version" --allow-empty | Out-Null
& git push origin master | Out-Null
Write-Output "Creating release tag: v$Version"
& git tag -d "v$Version" 2>&1 | Out-Null
& git tag "v$Version"
Write-Output "Pushing tag to GitHub..."
& git push origin "v$Version" --force
if ($LASTEXITCODE -eq 0) {
Write-Output ""
Write-Output "[ok] Release v$Version triggered on GitHub Actions."
Write-Output " Monitor: https://github.com/ospab/ostp/actions"
} else {
Write-Output ""
Write-Output "[error] Failed to push release tag."
}
}
Pop-Location

View File

@ -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) {

View File

@ -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..."
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
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."