Compare commits

...

7 Commits

Author SHA1 Message Date
ospab 0ec09d1311 ci: add scripts/gha.ps1 — versioned release cutter with channel memory
Replaces ad-hoc manual tag pushes (which is how the confusing v0.4.1-beta /
v0.4.2-beta / bare "nightly" / "pre-release" release mess happened) with one
script that always goes through the same path: bump every version manifest,
commit, and push in the way release.yml's resolve-channel job actually
expects (branch push for nightly/pre-release, a real "vX.Y.Z" tag for
master — never a hand-pushed "vX.Y.Z-beta"-style tag).

Remembers the last {version, branch, prefix} used in .release-state.json, so
a bare run repeats last time's channel with the patch version bumped, and
-Switch starts a new version line (e.g. 0.3.x -> 0.4.0) without disturbing
which channel is currently being released to.
2026-07-08 18:05:01 +03:00
ospab f81610f939 chore: bump version to 0.4.2 2026-07-08 17:28:14 +03:00
ospab 114011df5a docs: add commit conventions and branch strategy to CONTRIBUTING
- New "Commit Message Conventions" section (type(scope): summary + a body
  only when the why isn't obvious from the diff) — formalizes the style
  already used across this rebuild's history.
- New "Branch Strategy" section documenting the nightly -> pre-release ->
  master promotion model (pre-release/master are fast-forward-only,
  never committed to directly).
- Fixed PR/branch-creation instructions that still said "target master" /
  "branch from master" — contributor work targets nightly now.
- Clarified the ostp-control build step is optional for day-to-day
  core/client/server work (the server embeds a dummy dist/ otherwise).
2026-07-08 17:28:10 +03:00
ospab f96daaf57d feat(client): auto-reconnect on network change or any subsystem drop
run_client_core previously ran once: if the OSTP protocol connection, the
TUN device, or the local proxy listener ended for any reason (network
change stranding the socket/adapter on a dead interface, a transient
crash, a drop the inner Bridge-level "TunnelStopped" retry couldn't
recover from), the whole client returned/errored and just stayed down.

Wrapped the existing body (now run_client_once) in an outer supervising
loop: any non-shutdown-requested exit triggers a full clean restart —
fresh DNS resolution, fresh Bridge, fresh TUN/proxy — with backoff
(1/2/5/10/20/30s, resetting once a run has been stable for 60s). Only an
explicit shutdown request stops the loop. connection_state reports
"connecting" during the retry wait so the UI shows reconnecting, not
disconnected.
2026-07-08 17:28:05 +03:00
ospab 6929d42736 Polish docs/README to match v0.4.x: fix license mismatch, CLI, crypto docs
- README.md/README.ru.md: License section still said "Business Source
  License 1.1 ... converts to MIT in 2030" while the badge right above it,
  Cargo.toml, and LICENSE itself all say AGPL-3.0 — a direct contradiction.
  Now both say AGPL-3.0 and link to LICENSE.
- README.md/README.ru.md: CLI Reference / Quick Start described the old
  flag-based interface (--init, --check, --generate-key, --links, bare
  positional URL) that no longer exists after the subcommand refactor.
  Rewrote both to the current `ostp <command>` surface (run/connect/setup/
  init/check/gk/links/import/update/migrate/prober/proxy-env/uninstall),
  including gk's alias and update's --branch/--version. RU previously had
  no command reference at all; added one to match EN.
- docs/{en,ru}/obfuscation.md: removed the XTLS-Reality section (feature
  removed in §A) and replaced it with an accurate description of junk
  packets + TCP fragmentation, the actual current supplementary stealth
  mechanism, including the per-key junk marker (no global DPI signature).
  Also corrected the key-derivation and masking-algorithm descriptions,
  which described a much older scheme (SHA-256(access_key)[0..8] + static/
  nonce-based XOR) than what derive_all_secrets()/derive_payload_mask()
  actually implement now (HKDF with version-gated, domain-separated
  outputs; HMAC-SHA256 mask keyed on the packet's own ciphertext). The RU
  version was additionally rewritten out of an oddly formal "industrial
  telemetry" register into plain technical Russian.
2026-07-08 03:06:53 +03:00
ospab 5e0ff4a7ef Remove repo-root cruft: scratch files, dead migration script, stale wiki/config
- .ostp_public_ip: a server RUNTIME cache file (its own detected public IP),
  never meant to be tracked — got committed by accident from a dev run in
  the repo root. Removed and gitignored so it can't happen again.
- test.json (2 bytes), test_addr.rs (95 bytes): leftover scratch files with
  no references anywhere in code, CI, or docs.
- refactor.py: a one-off AST-surgery script hardcoding an absolute path to
  a specific dev machine (d:/ospab-projects/ostp/...); its job (splitting
  up bridge.rs::run()) is long done, and it isn't invoked by anything.
- server.json: duplicate/stale example config at repo root — the real
  canonical example already lives at docs/relay-config-example.json, and
  this one still had a "reality" section for the TLS-mimicry feature we
  removed in §A.
- ostp-wiki/: duplicate in-repo copy of wiki content. The old 0.3.x
  lineage already deleted this once ("remove useless ostp-wiki folder from
  root") before this rebuild branched off an earlier point that predates
  that cleanup — removing it here brings v0.4.x back in line with that
  decision.
2026-07-08 02:59:02 +03:00
ospab c330a0abe3 Fix UAC diagnostics parity, gk alias, flutter version, and versioned CI channels
- GUI launch_as_admin now matches the CLI's UAC diagnosis: detects
  ERROR_CANCELLED (1223, user declined the prompt) instead of silently
  treating it as success, and reports GetLastError()+exe path for any other
  ShellExecuteW failure, replacing the old single opaque "denied or missing"
  message that made GUI/TUI failures impossible to tell apart.
- generate-key subcommand renamed to `gk` (kept `generate-key` as an alias).
- Fixed a real short-flag collision: GenerateKey's --count used short='c',
  which collides with the global --config short (propagated into every
  subcommand); clap validates the whole command tree on first parse(), so
  this could break parsing for the entire CLI, not just generate-key/gk.
  --count is now short='n'.
- ostp-flutter/pubspec.yaml version was stuck at 0.2.97+12; bumped to 0.4.1+13.
- release.yml: added a resolve-channel job that computes one release tag per
  run instead of repeating the logic in five upload steps. Rolling channel
  pushes now carry the actual Cargo.toml version instead of a bare channel
  name: `{version}-nightly` for the nightly branch, `{version}-beta` for
  pre-release. workflow_dispatch gained a `channel` input restricted to
  nightly/beta only — a manual run can never accidentally publish a "stable"
  release; that still requires an explicit vX.Y.Z tag push.
2026-07-08 02:52:23 +03:00
27 changed files with 653 additions and 1183 deletions

View File

@ -10,6 +10,19 @@ on:
- nightly
- pre-release
workflow_dispatch:
inputs:
channel:
description: >-
Manually build+release just this rolling channel. Stable releases
are NEVER picked here on purpose — cut those only via a real
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
a "stable" release.
type: choice
required: true
default: nightly
options:
- nightly
- beta
permissions:
contents: write
@ -21,6 +34,55 @@ env:
RUST_BACKTRACE: short
jobs:
# Computes ONE channel + release tag for this whole run, so every build
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
# same release under the exact same tag, instead of repeating this logic
# (and risking it drifting out of sync) in five separate places.
#
# Tag shape:
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
# - push to `nightly` -> "{version}-nightly" (rolling, same tag every push)
# - push to `pre-release` -> "{version}-beta" (rolling, same tag every push)
# - workflow_dispatch -> forced by the `channel` input (nightly|beta only)
resolve-channel:
name: Resolve release channel
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.resolve.outputs.channel }}
tag_name: ${{ steps.resolve.outputs.tag_name }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- name: Resolve channel, version, and release tag
id: resolve
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
CHANNEL="stable"
TAG="${{ github.ref_name }}"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CHANNEL="${{ github.event.inputs.channel }}"
elif [ "${{ github.ref_name }}" = "nightly" ]; then
CHANNEL="nightly"
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
CHANNEL="beta"
else
CHANNEL="nightly"
fi
if [ "$CHANNEL" != "stable" ]; then
TAG="${BASE_VERSION}-${CHANNEL}"
fi
echo "Resolved channel=$CHANNEL tag=$TAG (base version $BASE_VERSION)"
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
echo "prerelease=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
check-and-test:
name: Check & Test
runs-on: ubuntu-latest
@ -58,7 +120,7 @@ jobs:
publish-release-matrix:
name: Release for ${{ matrix.target }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@ -244,22 +306,19 @@ jobs:
- name: Upload to GitHub Release
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, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ${{ matrix.release_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows-gui:
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: windows-latest
strategy:
matrix:
@ -326,22 +385,19 @@ jobs:
- name: Upload to GitHub Release
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, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-windows-gui-${{ matrix.arch }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-linux-gui:
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest
strategy:
matrix:
@ -394,22 +450,19 @@ jobs:
- name: Upload to GitHub Release
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, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-macos-gui:
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: macos-latest
strategy:
matrix:
@ -459,22 +512,19 @@ jobs:
- name: Upload to GitHub Release
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, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android:
name: Build Android Client (Flutter) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest
strategy:
matrix:
@ -535,15 +585,12 @@ jobs:
- name: Upload to GitHub Release
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, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

@ -25,6 +25,10 @@ test_route.ps1
config.json
wintun.dll
# Server runtime cache (public IP autodetect) — must never be committed,
# it's regenerated locally and leaks whatever host it ran on last.
.ostp_public_ip
# Logs
*.log

View File

@ -1 +0,0 @@
127.0.0.1

View File

@ -10,10 +10,12 @@ By contributing to this project, you agree to abide by our code of conduct and l
1. [Development Setup](#development-setup)
2. [Project Structure](#project-structure)
3. [Development Workflow](#development-workflow)
4. [Coding Guidelines](#coding-guidelines)
5. [Submitting Pull Requests](#submitting-pull-requests)
6. [Security Vulnerabilities](#security-vulnerabilities)
3. [Branch Strategy](#branch-strategy)
4. [Development Workflow](#development-workflow)
5. [Commit Message Conventions](#commit-message-conventions)
6. [Coding Guidelines](#coding-guidelines)
7. [Submitting Pull Requests](#submitting-pull-requests)
8. [Security Vulnerabilities](#security-vulnerabilities)
---
@ -33,20 +35,19 @@ To build and test OSTP locally, you will need:
cd ostp
```
2. **Build the control panel frontend**:
```bash
cd ostp-control
npm install
npm run build
cd ..
```
3. **Build the entire Cargo workspace**:
2. **Build the entire Cargo workspace**:
```bash
cargo build
```
`ostp-control` (the web panel) is only needed if you're working on it
specifically — the server build embeds a dummy `dist/` via `rust-embed`
otherwise, so this step is not required for day-to-day core/client/server
work. If you *are* touching the panel:
```bash
cd ostp-control && npm install && npm run build && cd ..
```
4. **Run tests**:
3. **Run tests**:
```bash
cargo test --workspace
```
@ -66,11 +67,28 @@ The repository is organized as a Cargo workspace containing the following crates
---
## Branch Strategy
The repository runs three long-lived branches, in increasing order of stability:
| Branch | Role |
|---|---|
| `nightly` | Active development. All feature work and fixes land here first. |
| `pre-release` | Periodically fast-forwarded from `nightly` once it's had some soak time. Ships as the `{version}-beta` release channel. |
| `master` | Fast-forwarded from `pre-release` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
`pre-release` and `master` are **never** committed to directly — they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `pre-release` or `master`.
**Contributor PRs target `nightly`**, not `master`.
---
## Development Workflow
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
2. **Fork the repository** and create a new branch from `master`:
2. **Fork the repository** and create a new branch from `nightly`:
```bash
git checkout nightly
git checkout -b feat/your-feature-name
```
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
@ -89,6 +107,32 @@ The repository is organized as a Cargo workspace containing the following crates
---
## Commit Message Conventions
```
<type>(<scope>): <short, imperative summary>
<optional body explain WHY, not what; the diff already shows what changed>
```
- **Type** — one of: `feat` (new capability), `fix` (bug fix), `docs`, `refactor` (no behavior change), `perf`, `test`, `chore` (deps/tooling/version bumps), `ci`, `security`.
- **Scope** (optional) — the crate or area touched: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs`, etc. e.g. `fix(client): ...`.
- **Summary** — imperative mood ("add", not "added"/"adds"), no trailing period, ideally under ~70 characters.
- **Body** — only when the *why* isn't obvious from the diff: a prior bug this fixes, a constraint that shaped the approach, a tradeoff you made. Don't restate what the diff already shows. Wrap at ~72 columns.
```
fix(server): drop junk frames by per-key marker instead of a global one
A fixed 4-byte marker on every junk packet is itself a DPI signature any
observer can filter on across every OSTP deployment. Derive the marker
from the access key (HKDF, same scheme as obfuscation_key/psk) so it's
per-user and indistinguishable from the packet's own random payload.
```
Multiple unrelated changes belong in separate commits, not one bundled commit — it keeps `git bisect` and review useful. Squash-merge is fine for a PR with a few "fix typo" / "address review" commits, but don't squash logically distinct changes together.
---
## Coding Guidelines
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
@ -104,7 +148,7 @@ The repository is organized as a Cargo workspace containing the following crates
```bash
git push origin feat/your-feature-name
```
2. Open a Pull Request (PR) targeting the `master` branch.
2. Open a Pull Request (PR) targeting the `nightly` branch (see [Branch Strategy](#branch-strategy) — `master` only receives fast-forwards from `pre-release`, never direct PRs).
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
4. Verify that GitHub Actions CI runs successfully on your PR.

View File

@ -10,10 +10,12 @@
1. [Подготовка окружения](#подготовка-окружения)
2. [Структура проекта](#структура-проекта)
3. [Процесс разработки](#процесс-разработки)
4. [Правила оформления кода](#правила-оформления-кода)
5. [Создание Pull Request](#создание-pull-request)
6. [Уязвимости безопасности](#уязвимости-безопасности)
3. [Стратегия веток](#стратегия-веток)
4. [Процесс разработки](#процесс-разработки)
5. [Оформление коммитов](#оформление-коммитов)
6. [Правила оформления кода](#правила-оформления-кода)
7. [Создание Pull Request](#создание-pull-request)
8. [Уязвимости безопасности](#уязвимости-безопасности)
---
@ -33,20 +35,19 @@
cd ostp
```
2. **Соберите веб-интерфейс панели управления**:
```bash
cd ostp-control
npm install
npm run build
cd ..
```
3. **Соберите весь Cargo-workspace**:
2. **Соберите весь Cargo-workspace**:
```bash
cargo build
```
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
ней — в остальных случаях сервер собирается с пустым `dist/` через
`rust-embed`, и этот шаг не нужен для повседневной работы над
core/client/server. Если вы всё же трогаете панель:
```bash
cd ostp-control && npm install && npm run build && cd ..
```
4. **Запустите тесты**:
3. **Запустите тесты**:
```bash
cargo test --workspace
```
@ -66,11 +67,28 @@
---
## Стратегия веток
В репозитории три долгоживущие ветки, по возрастанию стабильности:
| Ветка | Роль |
|---|---|
| `nightly` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `nightly`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
В `pre-release` и `master` **никогда** не коммитят напрямую — они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен — всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
**PR от контрибьюторов нацелены на `nightly`**, не на `master`.
---
## Процесс разработки
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
2. **Сделайте fork репозитория** и создайте новую ветку от `nightly`:
```bash
git checkout nightly
git checkout -b feat/имя-вашей-фичи
```
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
@ -89,6 +107,33 @@
---
## Оформление коммитов
```
<тип>(<область>): <краткое описание в повелительном наклонении>
<опционально: тело объясняет ПОЧЕМУ, а не что; диф и так показывает что изменилось>
```
- **Тип** — один из: `feat` (новая функциональность), `fix` (исправление бага), `docs`, `refactor` (без изменения поведения), `perf`, `test`, `chore` (зависимости/тулинг/версии), `ci`, `security`.
- **Область** (опционально) — крейт или часть проекта: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs` и т.д., например `fix(client): ...`.
- **Краткое описание** — повелительное наклонение ("добавь", а не "добавил"/"добавляет"), без точки в конце, желательно до ~70 символов.
- **Тело** — только когда причина не очевидна из дифа: какой баг это чинит, какое ограничение определило подход, на какой trade-off вы пошли. Не пересказывайте то, что и так видно в дифе. Перенос строк на ~72 символах.
```
fix(server): отбрасывать junk-фреймы по маркеру для каждого ключа, а не глобальному
Фиксированный 4-байтовый маркер на каждом junk-пакете сам по себе — сигнатура
DPI, по которой можно фильтровать любого наблюдателя во всех деплойментах OSTP
сразу. Выводим маркер из access_key (HKDF, та же схема что у
obfuscation_key/psk), чтобы он был индивидуальным для ключа и неотличимым от
случайной полезной нагрузки пакета.
```
Несколько несвязанных изменений — это несколько отдельных коммитов, а не один сборный. Это сохраняет пользу от `git bisect` и код-ревью. Squash-merge подходит для PR с парой коммитов вроде "fix typo" / "address review", но не сквошьте вместе логически разные изменения.
---
## Правила оформления кода
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
@ -104,7 +149,7 @@
```bash
git push origin feat/имя-вашей-фичи
```
2. Создайте Pull Request (PR) в ветку `master` основного репозитория.
2. Создайте Pull Request (PR) в ветку `nightly` основного репозитория (см. [Стратегия веток](#стратегия-веток) — `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.

12
Cargo.lock generated
View File

@ -1384,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"base64",
@ -1406,7 +1406,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"base64",
@ -1437,7 +1437,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"bytes",
@ -1471,7 +1471,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"axum",
@ -1503,7 +1503,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"libc",
@ -1515,7 +1515,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "AGPL-3.0"
version = "0.4.1"
version = "0.4.2"
[workspace.dependencies]
anyhow = "1.0"

View File

@ -95,10 +95,10 @@ graph TD
```bash
# On your VPS (server):
./ostp --init server
./ostp init server
# On your machine (client):
./ostp --init client
./ostp init client
```
### 2. Edit config
@ -129,16 +129,16 @@ graph TD
### 3. Run
```bash
./ostp # Uses config.json in current directory
./ostp --config /path/to.json # Custom config path
./ostp --check # Validate config without running
./ostp --generate-key # Generate a new access key
./ostp --links # Print client share links
./ostp # Uses config.json in current directory
./ostp --config /path/to.json # Custom config path
./ostp check # Validate config without running
./ostp gk # Generate a new access key
./ostp links # Print client share links
```
### 4. Connect via share link (one-liner)
```bash
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
```
> [!WARNING]
@ -171,21 +171,34 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
## CLI Reference
```
ostp [OPTIONS] [URL]
ostp [--config <PATH>] [COMMAND]
Options:
Commands:
run Run the daemon using the config file (default when no command is given)
connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
setup Interactive setup wizard
init <MODE> Generate a template config (server/client/relay)
check Validate the configuration file and exit
gk Generate a secure access key (alias: generate-key)
--format <FMT> Key format: hex, base64 (default: hex)
-n, --count <N> Number of keys to generate (default: 1)
links Print client share links from the server config
import <URL> Import a share link into the config file
update Update OSTP to the latest release
-b, --branch <NAME> Release channel: stable, pre-release, nightly (default: stable)
-v, --version <VER> Update to an exact version instead of the channel's latest
migrate Force-migrate the configuration file to the current format
prober Run the DNS-transport resolver prober
proxy-env Print shell export commands for the local SOCKS proxy
proxy-env-clear Print shell export commands to unset it
uninstall Stop the service and remove the binary and config
Global options:
--config <PATH> Config file path (default: config.json)
--init <MODE> Generate template config (server/client)
--check Validate configuration and exit
-g, --generate-key Generate a secure access key
-c, --count <N> Number of keys to generate (default: 1)
--format <FMT> Key format: hex, base64 (default: hex)
--links Print client share links from server config
Arguments:
[URL] Connect via share link: ostp://KEY@HOST:PORT
```
Every subcommand also accepts `-h`/`--help` for its own option list.
---
## Protocol Summary
@ -230,8 +243,7 @@ cargo test -p ostp-core -p ostp-server
## License
Business Source License 1.1. Free for personal and non-commercial use.
Converts to MIT License on May 14, 2030.
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
---

View File

@ -84,8 +84,8 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
Создать конфиг по умолчанию:
```bash
./ostp --init server # VPS
./ostp --init client # Локальная машина
./ostp init server # VPS
./ostp init client # Локальная машина
```
### Сервер (`config.json`)
@ -156,6 +156,37 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
./ostp
```
### Справка по командам
```
ostp [--config <PATH>] [КОМАНДА]
Команды:
run Запустить демон по конфигу (по умолчанию, если команда не указана)
connect <URL> Подключиться по share-ссылке: ostp://KEY@HOST:PORT
setup Интерактивный мастер настройки
init <MODE> Сгенерировать шаблон конфига (server/client/relay)
check Проверить конфиг и выйти
gk Сгенерировать access-key (алиас: generate-key)
--format <FMT> Формат ключа: hex, base64 (по умолчанию hex)
-n, --count <N> Количество ключей (по умолчанию 1)
links Вывести client-share-ссылки из серверного конфига
import <URL> Импортировать share-ссылку в конфиг
update Обновить OSTP до актуального релиза
-b, --branch <NAME> Канал релиза: stable, pre-release, nightly (по умолчанию stable)
-v, --version <VER> Обновиться на точную версию вместо последней в канале
migrate Принудительно мигрировать конфиг к текущему формату
prober Запустить DNS-transport prober
proxy-env Вывести shell-команды для локального SOCKS-прокси
proxy-env-clear Вывести shell-команды для их отмены
uninstall Остановить сервис и удалить бинарник с конфигом
Глобальные опции:
--config <PATH> Путь к конфигу (по умолчанию config.json)
```
У каждой подкоманды есть своя справка через `-h`/`--help`.
### TUN-режим (Windows)
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
@ -204,5 +235,4 @@ cross build --release --target x86_64-unknown-linux-gnu
## Лицензия
Business Source License 1.1. Бесплатно для личного и некоммерческого использования.
Переходит в MIT License 14 мая 2030 года.
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст — в файле [LICENSE](LICENSE).

View File

@ -5,40 +5,38 @@ Traditional tunneling protocols (such as TLS, OpenVPN, and WireGuard) exhibit di
---
## Obfuscation Key Derivation
## Secret Derivation
To dynamically mask protocol data, an 8-byte obfuscation key is statically derived from the shared `access_key` configured on both the client and the server:
Every protocol secret — the obfuscation key, the Noise PSK, the handshake padding range, and the per-key junk marker (see below) — is derived from the shared `access_key` via a single HKDF-SHA256 pass, domain-separated by a trailing info byte per output:
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
```
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 bytes)
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 bytes)
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 bytes)
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 bytes)
```
This key is established pre-session and is never transmitted across the wire in any capacity.
The wire protocol version is mixed into the IKM, not sent as a plaintext byte: peers on a different protocol version derive an entirely different `obfuscation_key`, so they simply cannot deobfuscate each other's packets and are rejected as unauthorized — a hard version gate with no recognizable marker ever appearing on the wire. No secret is ever transmitted; both sides derive the same values independently from the shared access key.
---
## Dynamic In-Place Masking Algorithm
OSTP datagrams are processed "in-place" immediately prior to transmission and right after arrival. Two distinct mathematical modes are utilized based on the current handshake phase:
OSTP datagrams are masked "in-place" immediately prior to transmission and right after arrival. The mask itself is **derived from the packet's own ciphertext**, not from a fixed keystream or a counter, so it changes with every packet automatically:
```
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
```
### 1. Handshake Phase Mode (`is_handshake = true`)
During connection initiation (Noise Handshake), the wire packet consists of a 4-byte `session_id` prefixed to the Noise payload. To mask the fixed session ID:
* **Masking**: The first 4 bytes are XORed with the first 4 bytes of the derived obfuscation key:
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
* **De-masking**: A repeated XOR with the identical key bytes recovers the original `session_id`.
The wire packet is `[4-byte session_id][2-byte noise_len][Noise payload]`. The mask is computed over the Noise payload (`raw[6..]`), and its first 6 bytes are XORed onto `session_id || noise_len`.
### 2. Data Transmission Mode (`is_handshake = false`)
Post-handshake, the wire layout contains:
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
The wire packet is `[4-byte session_id][8-byte nonce][AEAD ciphertext]`. The mask is computed over the AEAD ciphertext, and its first 12 bytes are XORed onto `session_id || nonce`.
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied:
1. **Nonce Masking**: The 8-byte `nonce` (sequence counter) is XORed with the full 8-byte static key:
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
2. **Session ID Masking**: The 4-byte `session_id` is masked using high dynamic entropy — the lower 32 bits of the **original (unmasked)** `nonce` value:
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
#### Impact of the Scheme:
Because the `nonce` increments strictly with each outgoing datagram, the session ID's masking keystream continuously changes. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
#### Impact of the Scheme
Because the mask is keyed on both the shared secret and the packet's own ciphertext, no two packets — even consecutive ones from the same session — share a keystream, without needing an explicit counter-based scheme. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
---
@ -50,6 +48,13 @@ The `AdaptivePadder` calculates dynamic dummy byte quantities to append to the p
- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams.
- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths.
## XTLS-Reality Impersonation
---
OSTP provides a custom, dependency-free implementation of the XTLS-Reality protocol. It fully simulates a TLS 1.3 handshake (with realistic ClientHello profiles) to bypass advanced DPI filters. Post-handshake, it utilizes ChaCha20Poly1305 to seamlessly encrypt and tunnel the inner HTTP/WSS connections.
## Junk Packets & TCP Fragmentation
OSTP does not try to impersonate a known protocol (TLS, HTTP, or otherwise) — a fingerprint-matching filter can always be updated to catch an impersonation attempt. Instead it follows a **zapret-like** approach: no recognizable header at all, plus active manipulation of packet boundaries, so there is nothing distinctive to fingerprint in the first place.
- **Junk packets**: before the handshake, the client sends a configurable number (`junk_pc`) of random-size (`junk_ps`) filler datagrams. Each carries a 4-byte marker **derived from the access key** (the `junk_marker` above) rather than a fixed constant — a fixed marker would itself be a universal signature any observer could filter on across every OSTP deployment. The server derives the same per-key marker while trying candidate keys and drops matching junk silently, before it ever reaches the "unauthorized probe" logging path.
- **TCP fragmentation** (UoT/TCP transport only): the first packet (the handshake) is split into small chunks (`frag_chunk` bytes) with short delays (`frag_sleep` ms) between writes, so DPI that inspects only the first TCP segment never sees a complete handshake to fingerprint.
Both are configurable per-profile; neither is sent over plain UDP transport, where a standalone junk datagram would look exactly like a random one-off probe to the server.

View File

@ -1,55 +1,60 @@
# Маскирование энтропии сигналов OSTP
# Обфускация трафика OSTP
## Философия структуры канала
## Философия
Традиционные сетевые протоколы промышленного сбора данных могут обладать фиксированными заголовками, что при анализе статистического распределения байт ведет к предвзятости выборок и искажению телеметрического профиля. Задача механизмов энтропийного маскирования OSTP — достижение **равномерного вероятностного распределения значений байт**, начиная с самого первого пакета. Это делает сигналы шины данных абсолютно однородными и устойчивыми к корреляционному анализу и структурному мониторингу сетевых контроллеров.
Классические туннельные протоколы (TLS, OpenVPN, WireGuard) имеют узнаваемые сигнатуры в хэндшейке или статичные заголовки пакетов. Механизм обфускации OSTP спроектирован так, чтобы **начиная с первого байта** трафик был максимально похож на случайный шум — и для DPI-систем был неотличим от него.
---
## Производная сигнатурная матрица (Keystream Initialization Vector)
## Деривация секретов
Для стабилизации битового распределения используется 8-байтовый вектор, вычисляемый на базе глобального идентификатора регистрации узла (`access_key`):
Все секреты протокола — ключ обфускации, PSK Noise-хэндшейка, диапазон паддинга хэндшейка и маркер junk-пакетов (см. ниже) — выводятся из общего `access_key` одним проходом HKDF-SHA256, с разделением по доменам через последний байт `info`:
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
```
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 байт)
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 байта)
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 байта)
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 байта)
```
Данная последовательность фиксируется на передающем и принимающем узлах и не передается через внешние сетевые шлюзы.
Версия протокола подмешивается в IKM, а не передаётся открытым байтом на проводе: пиры с разной версией протокола выведут разный `obfuscation_key` и просто не смогут деобфусцировать пакеты друг друга — жёсткий version gate без единого узнаваемого маркера на проводе. Ни один секрет никогда не передаётся — обе стороны независимо выводят одинаковые значения из общего access_key.
---
## Алгоритм динамического маскирования пакетов (In-place Masking)
## Алгоритм динамического маскирования
Пакетные структуры OSTP проходят низкоуровневую предобработку непосредственно перед выдачей в канальный уровень (Layer 3) и при получении. В зависимости от фазы жизненного цикла сессии связи выделяют две модели:
Датаграммы OSTP маскируются "на месте" прямо перед отправкой и сразу после получения. Сама маска **выводится из шифротекста самого пакета**, а не из статичного потока ключа или счётчика — поэтому она меняется от пакета к пакету автоматически:
### 1. Этап начального согласования среды (`is_handshake = true`)
В период инициализации канала передачи пакет структурирован как 4-байтовое поле логического адреса порта `session_id` и криптографический блок согласования среды. Для подавления статических компонент ID порта применяется процедура обратимого битового сложения:
```
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
```
* **Обработка**: Первые 4 байта вектора пакета проходят побитовую операцию XOR с первыми 4 байтами сигнатурной матрицы:
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
### 1. Фаза хэндшейка (`is_handshake = true`)
Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`)
После перевода сессии в состояние активности кадр передачи принимает следующий вид:
`[4 байта session_id]` + `[8 байт nonce]` + `[Полезная нагрузка блока]`
### 2. Фаза передачи данных (`is_handshake = false`)
Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
Для максимизации дифференциальной энтропии применяется двухступенчатое динамическое взвешивание:
1. **Коррекция счетчика цикла (Nonce Correction)**: 8-байтовое значение инкрементного счетчика пакета подвергается побитовому сложению с вектором матрицы:
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
2. **Маскирование ID сессии**: 4-байтовое поле логического адреса маскируется с помощью переменной высокочастотной энтропии — младших 32 бит **исходного** показателя системного счетчика пакетов:
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
#### Статистическая устойчивость:
Благодаря инкрементации счетчика на каждом цикле отправки, маскирующий поток (keystream) для поля `session_id` постоянно видоизменяется. Это полностью нивелирует фиксированные битовые паттерны во всем спектре UDP-датаграмм и исключает появление повторяющихся префиксов.
#### Эффект схемы
Поскольку маска зависит одновременно от общего секрета и от содержимого шифротекста конкретного пакета, никакие два пакета — даже два подряд идущих в одной сессии — не используют одинаковый ключевой поток, и для этого не нужна явная схема на основе счётчика. Это полностью убирает корреляции между заголовками пакетов и повторяющиеся байтовые паттерны, делая статистический фингерпринтинг бесполезным.
---
## Выравнивание блоков по границам регистров (Adaptive Alignment)
## Статистический паддинг
Дополнительно к маскировке заголовков, протокол OSTP исключает возможность анализа поведения системы на основе длин пакетов данных. Модуль адаптивного заполнения (`AdaptivePadder`) рассчитывает оптимальный размер буфера выравнивания (`padding`), интегрируемый в структуру пакета до момента активации шифрующего каскада:
Помимо маскирования заголовков, OSTP защищается от анализа длин пакетов (Traffic Length Analysis). `AdaptivePadder` вычисляет случайный размер мусорных байт, добавляемых к полезной нагрузке ещё до шифрования:
- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов.
- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных.
- **Динамическое распределение**: длины паддинга подобраны так, чтобы напоминать профили длин обычного HTTPS-трафика или видеопотоков.
- **Внутри шифротекста**: добавленный паддинг находится внутри области AEAD-шифрования — пассивный наблюдатель не может отличить паддинг от полезной нагрузки и не видит настоящую границу сообщения.
## XTLS-Reality (Имитация TLS 1.3)
---
OSTP предоставляет собственную реализацию протокола XTLS-Reality без сторонних зависимостей. Протокол полностью имитирует рукопожатие TLS 1.3 (с реалистичным профилем ClientHello) для обхода продвинутых DPI фильтров. После успешного рукопожатия применяется ChaCha20Poly1305 для бесшовного шифрования и туннелирования внутренних HTTP/WSS соединений.
## Junk-пакеты и TCP-фрагментация
OSTP не пытается притворяться известным протоколом (TLS, HTTP и т.п.) — фильтр по сигнатуре всегда можно обновить под конкретную имитацию. Вместо этого используется подход **в духе zapret**: никакого узнаваемого заголовка вообще, плюс активная манипуляция границами пакетов — фингерпринтить попросту нечего.
- **Junk-пакеты**: перед хэндшейком клиент отправляет настраиваемое количество (`junk_pc`) мусорных датаграмм случайного размера (`junk_ps`). Каждая несёт 4-байтовый маркер, **выведенный из access_key** (тот самый `junk_marker` выше), а не фиксированную константу — константный маркер сам по себе стал бы универсальной сигнатурой для любого наблюдателя сразу по всем серверам OSTP. Сервер, перебирая кандидатов-ключей, выводит тот же маркер и тихо отбрасывает junk, не доходя до логирования «unauthorized probe».
- **TCP-фрагментация** (только для транспорта UoT/TCP): первый пакет (хэндшейк) режется на мелкие куски (`frag_chunk` байт) с небольшими задержками (`frag_sleep` мс) между записями — DPI, анализирующий только первый TCP-сегмент, никогда не видит цельный хэндшейк для фингерпринтинга.
Обе фичи настраиваются per-профиль; ни одна не применяется поверх обычного UDP-транспорта, где отдельная junk-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.

View File

@ -183,7 +183,64 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
run_client_core(config, metrics, shutdown_rx, None).await
}
/// Runs the client with auto-reconnect: any subsystem ending — a network
/// change stranding the TUN adapter/UDP socket on a dead interface, the OSTP
/// protocol connection dropping in a way the inner Bridge-level retry (see
/// `UiEvent::TunnelStopped` below) couldn't recover from, or a proxy/TUN task
/// crashing outright — triggers a full clean restart (fresh DNS resolution,
/// fresh Bridge, fresh TUN/proxy) with exponential backoff, instead of the
/// client just dying. Only an explicit shutdown request stops this loop.
pub async fn run_client_core(
config: crate::config::ClientConfig,
metrics: Arc<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>,
config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
) -> Result<()> {
use portable_atomic::Ordering;
const BACKOFF_SCHEDULE_SECS: [u64; 6] = [1, 2, 5, 10, 20, 30];
// A run that stayed up at least this long counts as "was actually
// connected", so a later drop restarts the backoff from the top instead
// of inheriting a long delay from a previous flaky stretch.
const STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(60);
let mut backoff_idx = 0usize;
loop {
if *shutdown_rx_ext.borrow() {
return Ok(());
}
let attempt_start = std::time::Instant::now();
let result = run_client_once(config.clone(), metrics.clone(), shutdown_rx_ext.clone(), config_rx.clone()).await;
if *shutdown_rx_ext.borrow() {
// Shutdown was requested during (or right after) this attempt — honor it, don't retry.
return result;
}
if let Err(ref e) = result {
tracing::warn!("client run ended unexpectedly, will auto-reconnect: {e}");
}
if attempt_start.elapsed() >= STABLE_UPTIME {
backoff_idx = 0;
}
let delay = BACKOFF_SCHEDULE_SECS[backoff_idx.min(BACKOFF_SCHEDULE_SECS.len() - 1)];
backoff_idx += 1;
// Reflect the retry wait as "connecting" rather than "disconnected".
metrics.connection_state.store(1, Ordering::Relaxed);
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(delay)) => {}
_ = shutdown_rx_ext.changed() => {
if *shutdown_rx_ext.borrow() {
return Ok(());
}
}
}
}
}
async fn run_client_once(
mut config: crate::config::ClientConfig,
metrics: Arc<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>,

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.2.97+12
version: 0.4.2+14
environment:
sdk: ^3.11.4

View File

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

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2696,7 +2696,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"bytes",
@ -2713,7 +2713,7 @@ dependencies = [
[[package]]
name = "ostp-gui"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"json_comments",
@ -2733,7 +2733,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"libc",

View File

@ -1,6 +1,6 @@
[package]
name = "ostp-gui"
version = "0.4.1"
version = "0.4.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"

View File

@ -762,14 +762,35 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
let params_str = format!("--port {} --token-file \"{}\"", port, token_file.display());
let params_wstr: Vec<u16> = OsStr::new(&params_str).encode_wide().chain(Some(0)).collect();
#[link(name = "shell32")] extern "system" { 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; }
// Use the GUI executable's directory as the working directory so dependencies are found
let cwd_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("."));
let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 0) };
if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); }
// 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 `ret <= 32` check alone treated a user-denied prompt as success,
// silently starting nothing and reporting a single opaque "denied or
// missing" message that could not distinguish "no prompt ever shown"
// (missing exe, ret<=32) from "prompt shown and declined" (ret==1223) from
// any other Win32 failure — exactly the ambiguity blocking diagnosis here.
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 for the TUN helper (ShellExecuteW ret={}, \
GetLastError={}, path={}). If this keeps happening with no prompt ever appearing, \
an unsigned binary can be silently blocked by SmartScreen/antivirus during \
elevation try running ostp-gui.exe as Administrator manually.",
ret, win_err, exe.display()
);
}
Ok(())
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.4.1",
"version": "0.4.2",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"

View File

@ -1,3 +0,0 @@
# OSTP Wiki
This repository contains the documentation and wiki pages for the Ospab Stealth Transport Protocol (OSTP).

View File

@ -1,149 +0,0 @@
# Справочник API управления OSTP
Сервер OSTP предоставляет REST API для управления пользователями, просмотра статистики трафика и интерактивного редактирования конфигурации.
По умолчанию API слушает на порту `9090` (хост настраивается в файле конфигурации).
---
## Авторизация
Все запросы к API (за исключением подписок) должны содержать заголовок `Authorization` с API-токеном (если токен включен в конфигурационном файле):
```http
Authorization: Bearer <ваш_api_токен>
```
Или в упрощенном виде:
```http
Authorization: <ваш_api_токен>
```
---
## Формат ответов
Все ответы API возвращаются в формате JSON следующей структуры:
```json
{
"ok": true,
"data": ...,
"error": null
}
```
В случае ошибки:
```json
{
"ok": false,
"data": null,
"error": "Описание ошибки"
}
```
---
## Список эндпоинтов
### 1. Статус сервера
Возвращает текущую версию, аптайм и количество пользователей.
* **URL**: `/api/server/status`
* **Метод**: `GET`
* **Формат `data`**:
```json
{
"version": "0.2.30",
"uptime_seconds": 12053,
"active_users": 2,
"total_users": 5
}
```
### 2. Получение текущего конфига
Запрашивает полное содержимое файла `config.json` с удалением комментариев для прямой модификации.
* **URL**: `/api/server/config`
* **Метод**: `GET`
* **Формат `data`**: Полный JSON-конфиг сервера.
### 3. Обновление конфига
Записывает новый JSON конфигурации сервера в файл `config.json` на диске. Это автоматически вызывает **hot-reload** ядра (применение ключей доступа и лимитов).
* **URL**: `/api/server/config`
* **Метод**: `PUT`
* **Тело запроса**: JSON нового конфигурационного файла.
* **Формат `data`**: `true` в случае успешного сохранения.
### 4. Список клиентов и их статистики
Возвращает список всех зарегистрированных ключей доступа с их текущей загрузкой, скачиванием, активными сессиями и статусом подключения.
* **URL**: `/api/users`
* **Метод**: `GET`
* **Формат `data`**:
```json
[
{
"access_key": "ostp_key_sample1",
"bytes_up": 2405020,
"bytes_down": 491029402,
"connections": 2,
"limit_bytes": 10737418240,
"online": true,
"name": "Ноутбук"
}
]
```
### 5. Создание клиента
Генерирует новый ключ доступа (или регистрирует пользовательский).
* **URL**: `/api/users`
* **Метод**: `POST`
* **Тело запроса**:
```json
{
"access_key": "my_custom_key_optional",
"name": "Имя клиента",
"limit_bytes": 50000000000
}
```
* **Формат `data`**: Строка созданного ключа доступа.
### 6. Удаление клиента
Отзывает ключ доступа и сбрасывает все связанные активные сессии.
* **URL**: `/api/users/:key`
* **Метод**: `DELETE`
* **Формат `data`**: `"User removed"`
### 7. Обновление клиента
Редактирует имя или лимит трафика для клиента.
* **URL**: `/api/users/:key`
* **Метод**: `PUT`
* **Тело запроса**:
```json
{
"name": "Новое имя",
"limit_bytes": 100000000000
}
```
* **Формат `data`**: `"User updated"`
### 8. Сброс счетчиков трафика
Обнуляет показания загрузки и скачивания для определенного пользователя.
* **URL**: `/api/users/{key}/reset`
* **Метод**: `POST`
* **Формат `data`**: `true`
### 9. Ссылка подписки клиента
Возвращает ссылку подписки или конфигурационный файл для клиента. Авторизация по Bearer-токену **не требуется** (ключ авторизуется сам через URL).
* **URL**: `/api/subscribe/:key`
* **Метод**: `GET`
* **Заголовки**:
- `Accept: text/plain` -> Возвращает текстовую ссылку `ostp://<key>@<host>:<port>?...`
- `Accept: application/json` -> Возвращает полный клиентский JSON-конфиг.

View File

@ -1,125 +0,0 @@
# Руководство по конфигурации OSTP (`config.json`)
Файл `config.json` является основным конфигурационным файлом для сервера, клиента и реле.
Ниже приведено подробное описание структуры для режима работы **Server**.
---
## Полный пример конфигурации
```json
{
"mode": "server",
"log_level": "info",
"listen": "0.0.0.0:50000",
"access_keys": [
"some_simple_key",
{
"access_key": "detailed_key_with_limit",
"name": "Рабочий Ноутбук",
"limit_bytes": 107374182400
}
],
"api": {
"enabled": true,
"bind": "127.0.0.1:9090",
"token": "7a3f8b2c4d9e0f1a2b3c4d5e6f7a8b9c"
},
"fallback": {
"enabled": false,
"listen": "0.0.0.0:443",
"target": "127.0.0.1:8080"
},
"reality": {
"enabled": false,
"dest": "www.microsoft.com:443",
"private_key": "...",
"pbk": "...",
"sid": "...",
"sni_list": ["www.microsoft.com"]
},
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
"default_action": "proxy",
"rules": [
{
"domain_suffix": [".onion"],
"action": "proxy"
}
]
},
"debug": false
}
```
---
## Описание разделов конфигурации
### 1. Основные параметры
- **`mode`** (строка): Режим работы. Возможные варианты: `"server"`, `"client"`, `"relay"`.
- **`log_level`** (строка): Уровень логирования. Варианты: `"debug"`, `"info"`, `"warn"`, `"error"`.
- **`listen`** (строка или массив строк): Порт и интерфейсы, на которых сервер слушает входящие UDP (и опционально TCP/UoT) соединения. Примеры:
- `"0.0.0.0:50000"` (все IPv4 интерфейсы)
- `["0.0.0.0:50000", "[::]:50000"]` (поддержка IPv4 и IPv6 одновременно)
- **`debug`** (логический): Включает подробное отладочное логирование протокола.
---
### 2. Ключи доступа (`access_keys`)
Раздел содержит массив ключей доступа. Поддерживается два формата записи (для обратной совместимости):
1. **Простая строка**: Текст ключа доступа. Лимит трафика отсутствует.
```json
"my_secure_key"
```
2. **Объект с метаданными**:
- `access_key` (строка, обязательно): Текст ключа для подключения.
- `name` (строка, опционально): Человекочитаемое описание клиента.
- `limit_bytes` (число, опционально): Лимит трафика в байтах (загрузка + скачивание).
При достижении `limit_bytes` сессия клиента немедленно сбрасывается и подключение блокируется до обнуления счетчика или расширения лимита.
---
### 3. REST API Управления (`api`)
Используется для интеграции с панелью управления `ostp-control`.
- **`enabled`** (логический): Включение встроенного веб-сервера API.
- **`bind`** (строка): Интерфейс и порт для прослушивания (например, `"127.0.0.1:9090"`).
- **`token`** (строка): Bearer-токен для авторизации администратора. Автоматически генерируется сервером при команде `ostp --init server`.
---
### 4. Встроенный TCP Fallback прокси (`fallback`)
Позволяет маскировать порт под веб-сервер при сканировании активными DPI-зондами.
- **`enabled`** (логический): Включить проксирование TCP.
- **`listen`** (строка): Порт прослушивания TCP/TLS (например, `"0.0.0.0:443"`).
- **`target`** (строка): Локальный веб-сервер (например, `"127.0.0.1:8080"` на nginx/caddy), куда будут пересылаться все обычные запросы (не-OSTP трафик).
---
### 5. Reality Маскировка (`reality`)
Реализует спецификацию XTLS-Reality для бесшовной маскировки трафика под легитимный TLS-сервер.
- **`enabled`** (логический): Включение маскировки.
- **`dest`** (строка): Целевой домен маскировки (например, `"www.microsoft.com:443"`).
- **`private_key`** (строка): Приватный ключ Reality сервера (X25519).
- **`pbk`** (строка): Публичный ключ Reality сервера.
- **`sid`** (строка, 8 байт hex): Идентификатор сессии.
- **`sni_list`** (массив строк): Разрешенные SNI заголовки от клиентов.
---
### 6. Правила маршрутизации (`outbound`)
Позволяет пересылать часть исходящего трафика клиентов через прокси-сервер (например, SOCKS5/TOR).
- **`enabled`** (логический): Включить исходящую маршрутизацию.
- **`protocol`** (строка): Протокол прокси. На данный момент поддерживается `"socks5"`.
- **`address`** (строка): Хост прокси-сервера.
- **`port`** (число): Порт прокси-сервера.
- **`default_action`** (строка): Действие для трафика, не попавшего под правила. Варианты: `"direct"` (напрямую с сервера) или `"proxy"` (через прокси).
- **`rules`** (массив объектов): Список правил перенаправления:
- `domain_suffix` (массив строк): Фильтрация по суффиксу домена.
- `ip_cidr` (массив строк): Фильтрация по IP подсетям.
- `action` (строка): Действие при совпадении (`"direct"` или `"proxy"`).

View File

@ -30,6 +30,7 @@ enum Commands {
mode: String,
},
/// Generate a new secure access key
#[command(name = "gk", alias = "generate-key")]
GenerateKey {
/// Format for generated key (hex, base64)
#[arg(long, default_value = "hex")]

View File

@ -1,658 +0,0 @@
import sys
import re
with open("d:/ospab-projects/ostp/ostp-client/src/bridge.rs", "r", encoding="utf-8") as f:
code = f.read()
start_idx = code.find(" pub async fn run(")
end_idx = -1
brace_count = 0
in_run = False
for i in range(start_idx, len(code)):
if code[i] == '{':
in_run = True
brace_count += 1
elif code[i] == '}':
if in_run:
brace_count -= 1
if brace_count == 0:
end_idx = i + 1
break
prefix = code[:start_idx]
suffix = code[end_idx:]
# Define the new run function and helpers
new_run_and_helpers = """
pub async fn run(
mut self,
tx: mpsc::Sender<UiEvent>,
mut bridge_rx: mpsc::Receiver<BridgeCommand>,
mut shutdown: watch::Receiver<bool>,
mut proxy_rx: mpsc::Receiver<ProxyEvent>,
proxy_tx: mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) -> Result<()> {
let mut metrics_tick = interval(Duration::from_millis(500));
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(self.keepalive_interval_sec.max(1)));
let mut retransmit_tick = tokio::time::interval(Duration::from_millis(10));
let init_msg = if self.mode == "tun" {
"Bridge initialized (TUN mode)".to_string()
} else {
"Bridge initialized (proxy mode)".to_string()
};
tx.send(UiEvent::Log(init_msg)).await.ok();
let mut sessions_opt: Option<Vec<SessionState>> = None;
let mut udp_rx_opt: Option<mpsc::Receiver<(usize, Bytes)>> = None;
let mut proxy_guard: Option<crate::sysproxy::SystemProxyGuard> = None;
let mut stream_map: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
if *shutdown.borrow() {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
proxy_guard = None;
sessions_opt = None;
udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "manual stop");
break;
}
}
udp_msg = async {
match udp_rx_opt.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
}, if self.running => {
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
}
cmd = bridge_rx.recv() => {
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
break;
}
}
_ = metrics_tick.tick() => {
if self.running {
self.emit_metrics(&tx).await;
}
}
_ = keepalive_tick.tick() => {
if self.running {
self.handle_keepalive(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
}
}
_ = retransmit_tick.tick() => {
if self.running {
self.handle_retransmit(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
}
}
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
}).unwrap_or(true) => {
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
}
}
}
tx.send(UiEvent::Log("Bridge stopped".to_string())).await.ok();
Ok(())
}
async fn handle_inbound_udp(
&mut self,
udp_msg: Option<(usize, Bytes)>,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) {
match udp_msg {
Some((session_index, inbound)) => {
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
self.last_valid_recv = Instant::now();
if let Some(sessions) = sessions_opt.as_mut() {
if session_index < sessions.len() {
let session = &mut sessions[session_index];
let initial_action = match session.machine.on_event(OstpEvent::Inbound(inbound)) {
Ok(a) => a,
Err(e) => {
let _ = tx.send(UiEvent::Log(format!("Protocol decrypt error: {e}"))).await;
tracing::warn!("Inbound protocol error (session {}): {}", session_index, e);
return;
}
};
let mut actions_queue = std::collections::VecDeque::new();
actions_queue.push_back(initial_action);
while let Some(current_action) = actions_queue.pop_front() {
match current_action {
ProtocolAction::Multiple(nested) => {
for a in nested {
actions_queue.push_back(a);
}
}
ProtocolAction::DeliverApp(stream_id, dec_payload) => {
match RelayMessage::decode(&dec_payload) {
Ok(relay_msg) => {
match relay_msg {
RelayMessage::ConnectOk => {
let _ = tx.send(UiEvent::Log(format!("Relay CONNECT OK stream_id={stream_id}"))).await;
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::ConnectOk));
}
RelayMessage::Data(data) => {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Data(Bytes::from(data))));
}
RelayMessage::Close => {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Close));
}
RelayMessage::Error(msg) => {
let _ = tx.send(UiEvent::Log(format!("Relay error for stream {stream_id}: {msg}"))).await;
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error(msg)));
}
RelayMessage::Pong(ts) => {
let now = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
self.last_rtt_ms = now.saturating_sub(ts) as f64;
self.metrics.rtt_ms.store(self.last_rtt_ms as u32, Ordering::Relaxed);
}
RelayMessage::UdpAssociate => {}
RelayMessage::UdpData(target, data) => {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::UdpData(target, Bytes::from(data))));
}
RelayMessage::KeepAlive | RelayMessage::Ping(_) | RelayMessage::Connect(_) => {}
}
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("Relay decode error for stream {stream_id}: {err}"))).await;
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("relay decode failed".to_string())));
}
}
}
ProtocolAction::SendDatagram(frame) => {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
_ => {}
}
}
}
}
}
None => {
let _ = tx.send(UiEvent::Log("UDP channel closed, resetting connection".to_string())).await;
self.running = false;
crate::sysproxy::disable_system_proxy();
*sessions_opt = None;
*udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "udp reader closed");
let _ = tx.send(UiEvent::TunnelStopped).await;
}
}
}
async fn handle_bridge_cmd(
&mut self,
cmd: Option<BridgeCommand>,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) -> bool {
match cmd {
Some(BridgeCommand::ToggleTunnel) => {
if self.running {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
*proxy_guard = None;
*sessions_opt = None;
*udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "manual stop");
tx.send(UiEvent::TunnelStopped).await.ok();
let stop_msg = if self.mode == "tun" { "TUN tunnel stopped" } else { "Bridge stopped" };
tx.send(UiEvent::Log(stop_msg.to_string())).await.ok();
} else {
tx.send(UiEvent::Log("Connecting to remote server...".to_string())).await.ok();
tx.send(UiEvent::Metrics { status: ConnectionStatus::Handshaking, rtt_ms: 0.0, throughput_bps: 0 }).await.ok();
self.metrics.connection_state.store(1, Ordering::Relaxed);
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut sessions = Vec::with_capacity(session_count);
let mut rtt_sum = 0.0;
let mut successful_sessions = 0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = sessions.len();
let socket_clone = sock.clone();
let udp_tx_clone = udp_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx_clone.send((session_index, inbound)).await.is_err() {
break;
}
}
Err(e) => {
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
sessions.push(SessionState { socket: sock, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
tx.send(UiEvent::Log(format!("Multiplex session {}/{} handshake failed: {}. Continuing with remaining sessions...", idx + 1, session_count, err))).await.ok();
}
}
}
if sessions.is_empty() {
*proxy_guard = None;
tx.send(UiEvent::Log("All multiplexed handshake attempts failed. Connection aborted.".to_string())).await.ok();
tx.send(UiEvent::TunnelStopped).await.ok();
self.metrics.connection_state.store(0, Ordering::Relaxed);
return True;
}
*udp_rx_opt = Some(udp_rx);
*sessions_opt = Some(sessions);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.running = true;
self.last_sample_at = Instant::now();
self.last_valid_recv = Instant::now();
let sys_proxy_addr = self.proxy_addr.replace("0.0.0.0:", "127.0.0.1:");
*proxy_guard = Some(crate::sysproxy::SystemProxyGuard::enable(&sys_proxy_addr));
tx.send(UiEvent::Metrics {
status: ConnectionStatus::Established,
rtt_ms: self.last_rtt_ms,
throughput_bps: 0,
}).await.ok();
self.metrics.connection_state.store(2, Ordering::Relaxed);
let start_msg = if self.mode == "tun" { "TUN tunnel established" } else { "Connection established" };
tx.send(UiEvent::Log(start_msg.to_string())).await.ok();
for session in sessions_opt.as_mut().unwrap().iter_mut() {
let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let ping_payload = Bytes::from(RelayMessage::Ping(ts).encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ping_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp").await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
}
}
Some(BridgeCommand::NextProfile) => {
self.profile = next_profile(self.profile);
tx.send(UiEvent::ProfileChanged(self.profile)).await.ok();
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
}
Some(BridgeCommand::NetworkChanged) => {
if self.running {
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
self.metrics.connection_state.store(1, Ordering::Relaxed);
self.last_valid_recv = Instant::now() - Duration::from_secs(100);
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut new_sessions = Vec::with_capacity(session_count);
let mut successful_sessions = 0;
let mut rtt_sum = 0.0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = new_sessions.len();
let socket_clone = sock.clone();
let udp_tx_clone = udp_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx_clone.send((session_index, inbound)).await.is_err() { break; }
}
Err(e) => {
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
new_sessions.push(SessionState { socket: sock, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("NetworkChanged reconnect session {}/{} failed: {}", idx + 1, session_count, err))).await;
}
}
}
if !new_sessions.is_empty() {
*sessions_opt = Some(new_sessions);
*udp_rx_opt = Some(udp_rx);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.last_valid_recv = Instant::now();
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "network changed");
self.metrics.connection_state.store(2, Ordering::Relaxed);
let _ = tx.send(UiEvent::Log("NetworkChanged reconnect successful!".to_string())).await;
} else {
let _ = tx.send(UiEvent::Log("NetworkChanged reconnect failed — will retry on keepalive tick".to_string())).await;
}
}
}
Some(BridgeCommand::ReloadConfig) => {
match ClientConfig::reload_from_json_near_binary() {
Ok(cfg) => {
self.apply_runtime_config(&cfg);
tx.send(UiEvent::Log("Runtime config reloaded".to_string())).await.ok();
if self.running {
self.running = false;
self.metrics.connection_state.store(0, Ordering::Relaxed);
*proxy_guard = None;
*sessions_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "config reload");
let _ = tx.send(UiEvent::TunnelStopped).await;
}
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("Config reload failed: {err}"))).await;
}
}
}
Some(BridgeCommand::Shutdown) | None => {
self.running = false;
*proxy_guard = None;
return False;
}
}
True
}
async fn handle_keepalive(
&mut self,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
) {
if self.last_valid_recv.elapsed().as_secs() > 25 {
let elapsed = self.last_valid_recv.elapsed().as_secs();
if elapsed > 180 {
let _ = tx.send(UiEvent::Log("Connection permanently lost (3-minute hard timeout). Stopping tunnel.".into())).await;
self.running = false;
*proxy_guard = None;
*sessions_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "keepalive hard timeout");
let _ = tx.send(UiEvent::TunnelStopped).await;
self.metrics.connection_state.store(0, Ordering::Relaxed);
return;
}
let _ = tx.send(UiEvent::Log(format!("Connection stall detected ({}s silence). Attempting background reconnect...", elapsed))).await;
self.metrics.connection_state.store(1, Ordering::Relaxed);
let session_count = if self.mux_enabled { self.mux_sessions.max(1) } else { 1 };
let (udp_tx, udp_rx) = mpsc::channel(100000);
let mut new_sessions = Vec::with_capacity(session_count);
let mut successful_sessions = 0;
let mut rtt_sum = 0.0;
for idx in 0..session_count {
let session_id: u32 = rand::thread_rng().gen();
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = new_sessions.len();
let socket_clone = sock.clone();
let udp_tx_clone = udp_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx_clone.send((session_index, inbound)).await.is_err() {
break;
}
}
Err(e) => {
tracing::warn!("UDP socket recv error (reconnect session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
});
new_sessions.push(SessionState { socket: sock, machine: mach });
rtt_sum += rtt;
successful_sessions += 1;
}
Err(err) => {
let _ = tx.send(UiEvent::Log(format!("Background reconnect session {}/{} failed: {}", idx + 1, session_count, err))).await;
}
}
}
if !new_sessions.is_empty() {
*sessions_opt = Some(new_sessions);
*udp_rx_opt = Some(udp_rx);
self.last_rtt_ms = rtt_sum / successful_sessions as f64;
self.last_valid_recv = Instant::now();
self.metrics.connection_state.store(2, Ordering::Relaxed);
let _ = tx.send(UiEvent::Log("Background reconnect successful! Connection restored.".into())).await;
for session in sessions_opt.as_mut().unwrap().iter_mut() {
let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let ping_payload = Bytes::from(RelayMessage::Ping(ts).encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ping_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp").await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "background reconnect");
let mut flushed = 0;
while let Ok(stale) = proxy_rx.try_recv() {
if let ProxyEvent::NewStream { stream_id, .. } = stale {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("connection reset".into())));
}
flushed += 1;
}
if flushed > 0 {
let _ = tx.send(UiEvent::Log(format!("Flushed {} stale proxy messages to prevent UDP burst", flushed))).await;
}
} else {
let _ = tx.send(UiEvent::Log("Background reconnect failed. Will retry on next tick...".into())).await;
}
}
if let Some(sessions) = sessions_opt.as_mut() {
for session in sessions.iter_mut() {
let ts = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let ping_payload = Bytes::from(RelayMessage::Ping(ts).encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ping_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
let ka_payload = Bytes::from(RelayMessage::KeepAlive.encode());
if let Ok(ProtocolAction::SendDatagram(frame)) = session.machine.on_event(OstpEvent::Outbound(0, ka_payload)) {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
}
}
}
async fn handle_retransmit(
&mut self,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) {
let mut fatal_err = None;
if let Some(sessions) = sessions_opt.as_mut() {
for session in sessions.iter_mut() {
match session.machine.on_event(OstpEvent::Tick) {
Ok(action) => {
let mut queue = vec![action];
while let Some(current_action) = queue.pop() {
match current_action {
ProtocolAction::Multiple(nested) => {
for a in nested {
queue.push(a);
}
}
ProtocolAction::SendDatagram(frame) => {
let _ = send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await;
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
}
_ => {}
}
}
}
Err(e) => {
fatal_err = Some(e);
break;
}
}
}
}
if let Some(e) = fatal_err {
let _ = tx.send(UiEvent::Log(format!("Protocol tick fatal error: {e}"))).await;
self.running = false;
*proxy_guard = None;
*sessions_opt = None;
*udp_rx_opt = None;
stream_map.clear();
self.reset_proxy_streams(&tx, &proxy_tx, "protocol fatal error");
let _ = tx.send(UiEvent::TunnelStopped).await;
self.metrics.connection_state.store(0, Ordering::Relaxed);
}
}
async fn handle_proxy_event(
&mut self,
proxy_ev: Option<ProxyEvent>,
sessions_opt: &mut Option<Vec<SessionState>>,
stream_map: &mut std::collections::HashMap<u16, usize>,
tx: &mpsc::Sender<UiEvent>,
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
) {
if let Some(ev) = proxy_ev {
if let Some(sessions) = sessions_opt.as_mut() {
if sessions.is_empty() {
if let ProxyEvent::NewStream { stream_id, .. } = ev {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("tunnel stopped".into())));
}
return;
}
let (stream_id, relay_msg, is_close) = match ev {
ProxyEvent::NewStream { stream_id, target } => {
let _ = tx.send(UiEvent::Log(format!("Proxy CONNECT stream_id={stream_id} target={target}"))).await;
(stream_id, RelayMessage::Connect(target), false)
}
ProxyEvent::UdpAssociate { stream_id } => {
let _ = tx.send(UiEvent::Log(format!("Proxy UDP ASSOCIATE stream_id={stream_id}"))).await;
(stream_id, RelayMessage::UdpAssociate, false)
}
ProxyEvent::UdpData { stream_id, target, payload } => {
(stream_id, RelayMessage::UdpData(target, payload.to_vec()), false)
}
ProxyEvent::Data { stream_id, payload } => (stream_id, RelayMessage::Data(payload.to_vec()), false),
ProxyEvent::Close { stream_id } => {
let _ = tx.send(UiEvent::Log(format!("Proxy CLOSE stream_id={stream_id}"))).await;
(stream_id, RelayMessage::Close, true)
}
};
let len = sessions.len();
let session_index = *stream_map.entry(stream_id).or_insert_with(|| {
rand::thread_rng().gen_range(0..len)
});
if is_close {
stream_map.remove(&stream_id);
}
let session = &mut sessions[session_index];
let out_payload = Bytes::from(relay_msg.encode());
match session.machine.on_event(OstpEvent::Outbound(stream_id, out_payload)) {
Ok(ProtocolAction::SendDatagram(frame)) => {
if send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await.is_ok() {
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
tracing::trace!("Outbound datagram sent stream_id={stream_id} bytes={}", frame.len());
}
}
Ok(ProtocolAction::Multiple(list)) => {
let mut sent = 0usize;
for item in list {
if let ProtocolAction::SendDatagram(frame) = item {
if send_datagram(&session.socket, &frame, self.transport_mode == "udp" ).await.is_ok() {
self.metrics.bytes_sent.fetch_add(frame.len() as u64, Ordering::Relaxed);
sent += 1;
}
}
}
tracing::trace!("Outbound datagram batch stream_id={stream_id} sent={sent}");
}
Ok(ProtocolAction::Noop) => {
tracing::trace!("Outbound datagram noop stream_id={stream_id}");
}
Ok(_) => {
tracing::trace!("Outbound datagram unexpected action stream_id={stream_id}");
}
Err(e) => {
tracing::warn!("Protocol error packing outbound stream_id={}: {}", stream_id, e);
let _ = tx.send(UiEvent::Log(format!("Protocol error packing TCP: {e}"))).await;
}
}
} else {
if let ProxyEvent::NewStream { stream_id, .. } = ev {
let _ = proxy_tx.send((stream_id, ProxyToClientMsg::Error("tunnel stopped".into())));
}
}
}
}
"""
with open("d:/ospab-projects/ostp/ostp-client/src/bridge.rs", "w", encoding="utf-8") as f:
f.write(prefix + new_run_and_helpers + suffix)
print("Done")

201
scripts/gha.ps1 Normal file
View File

@ -0,0 +1,201 @@
<#
.SYNOPSIS
Cuts a new OSTP release and pushes it to the channel that triggers the
matching GitHub Actions build (see .github/workflows/release.yml).
.DESCRIPTION
Three release channels, in increasing order of stability:
nightly -> pushes the `nightly` branch -> tag "{version}-nightly"
pre-release -> pushes the `pre-release` branch -> tag "{version}-beta"
master -> pushes an actual "v{version}" tag -> real stable release
Promoting to pre-release/master first fast-forwards that branch to
`nightly` (--ff-only this always succeeds cleanly as long as nobody ever
commits directly to pre-release/master, per CONTRIBUTING.md's branch
strategy), so a release always ships nightly's latest, not a stale branch.
Remembers the last {version, branch, prefix} it used in .release-state.json
at the repo root. Running with no arguments repeats last time's branch and
prefix, auto-incrementing the patch version. -Switch starts a new version
line (e.g. 0.3.x -> 0.4.0) without changing branch/prefix. -Branch/-Prefix
override just that one setting for this run (and become the new default).
.PARAMETER Switch
Set an exact version (e.g. "0.4.0") instead of auto-incrementing the patch
of the last released version. Becomes the new baseline for future bare runs.
.PARAMETER Branch
Which branch to release from: master, pre-release, or nightly.
Defaults to whatever was used last time (see .release-state.json).
.PARAMETER Prefix
Tag suffix for non-stable channels: beta or nightly. Ignored (forced empty)
when -Branch master, since stable releases are bare "vX.Y.Z" tags.
Defaults to whatever was used last time.
.EXAMPLE
.\scripts\gha.ps1
Re-releases the same branch/prefix as last time, with the patch version bumped by 1.
.EXAMPLE
.\scripts\gha.ps1 -Switch 0.4.0
Starts releasing the 0.4.x line from now on; this run ships exactly 0.4.0.
.EXAMPLE
.\scripts\gha.ps1 -Branch pre-release -Prefix beta
Promotes nightly -> pre-release and ships "{version}-beta".
#>
[CmdletBinding()]
param(
[string]$Switch,
[ValidateSet('master', 'pre-release', 'nightly')]
[string]$Branch,
[ValidateSet('beta', 'nightly')]
[string]$Prefix
)
$ErrorActionPreference = "Stop"
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Warn2($msg) { Write-Host "!! $msg" -ForegroundColor Yellow }
function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 }
# ── Locate repo root, regardless of where this script was invoked from ──────
$RepoRoot = (git rev-parse --show-toplevel 2>$null)
if (-not $RepoRoot) { Fail "Not inside a git repository." }
Set-Location $RepoRoot
$StateFile = Join-Path $RepoRoot ".release-state.json"
# ── Refuse to run on a dirty tree: this script commits, and an autocommit ──
# ── silently sweeping up unrelated WIP changes would be a nasty surprise. ──
$dirty = git status --porcelain
if ($dirty) {
Write-Host $dirty
Fail "Working tree has uncommitted changes. Commit or stash them first."
}
# ── Load remembered state (branch/prefix/version from the last release) ────
$State = $null
if (Test-Path $StateFile) {
$State = Get-Content $StateFile -Raw | ConvertFrom-Json
}
$ResolvedBranch = if ($Branch) { $Branch } elseif ($State) { $State.branch } else { "nightly" }
$ResolvedPrefix = if ($Prefix) { $Prefix } elseif ($State) { $State.prefix } else { "nightly" }
# Stable releases are always a bare "vX.Y.Z" tag, never suffixed — master
# never carries a prefix regardless of what was remembered or passed in.
if ($ResolvedBranch -eq "master") {
if ($Prefix) { Write-Warn2 "-Prefix is ignored for -Branch master (stable releases are bare 'vX.Y.Z' tags)." }
$ResolvedPrefix = ""
}
# ── Resolve the version: exact via -Switch, else auto-increment the patch ──
$CurrentVersion = if ($State) { $State.version } else {
(Select-String -Path (Join-Path $RepoRoot "Cargo.toml") -Pattern '^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Matches[0].Groups[1].Value
}
if ($Switch) {
if ($Switch -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-Switch must be a bare X.Y.Z version, got '$Switch'." }
$NewVersion = $Switch
} else {
$parts = $CurrentVersion.Split('.')
$NewVersion = "{0}.{1}.{2}" -f $parts[0], $parts[1], ([int]$parts[2] + 1)
}
Write-Step "Releasing $NewVersion on '$ResolvedBranch'$(if ($ResolvedPrefix) { " (tag suffix: -$ResolvedPrefix)" } else { " (stable, tag v$NewVersion)" })"
# ── Checkout the target branch, promoting it from nightly first ────────────
$CurrentBranch = git rev-parse --abbrev-ref HEAD
if ($CurrentBranch -ne $ResolvedBranch) {
Write-Step "Checking out $ResolvedBranch"
git checkout $ResolvedBranch 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) { Fail "Could not check out branch '$ResolvedBranch'." }
}
if ($ResolvedBranch -ne "nightly") {
Write-Step "Fast-forwarding $ResolvedBranch to nightly (promotion)"
git merge nightly --ff-only 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
$msg = "'$ResolvedBranch' has diverged from nightly and can't fast-forward. " +
"Per CONTRIBUTING.md, nothing should ever be committed directly to " +
"$ResolvedBranch — check what's there before forcing anything."
Fail $msg
}
}
# ── Bump the version across every manifest that carries one ────────────────
Write-Step "Bumping version $CurrentVersion -> $NewVersion"
function Set-VersionLine($Path, $Pattern, $Replacement) {
$full = Join-Path $RepoRoot $Path
$text = Get-Content $full -Raw
$updated = $text -replace $Pattern, $Replacement
if ($updated -eq $text) { Fail "Version pattern not found in $Path — refusing to proceed with a stale file." }
[System.IO.File]::WriteAllText($full, $updated)
}
Set-VersionLine "Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`""
Set-VersionLine "ostp-gui/src-tauri/Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$NewVersion`""
Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`""
Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$NewVersion`""
# Flutter build number must increase monotonically (Android versionCode) —
# bump it alongside the version string, don't just rewrite the version part.
$pubspecPath = Join-Path $RepoRoot "ostp-flutter/pubspec.yaml"
$pubspecText = Get-Content $pubspecPath -Raw
if ($pubspecText -match 'version: [0-9]+\.[0-9]+\.[0-9]+\+([0-9]+)') {
$nextBuild = [int]$Matches[1] + 1
$pubspecText = $pubspecText -replace 'version: [0-9]+\.[0-9]+\.[0-9]+\+[0-9]+', "version: $NewVersion+$nextBuild"
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
} else {
Fail "Version pattern not found in ostp-flutter/pubspec.yaml."
}
# ── Refresh Cargo.lock's per-package version entries ────────────────────────
# ostp-gui/src-tauri is excluded from the main workspace (its own Tauri build
# graph), so it has its own separate Cargo.lock that the main `cargo check`
# below never touches — needs its own pass or it'd drift from Cargo.toml.
Write-Step "Running cargo check to refresh Cargo.lock (main workspace)"
cargo check --workspace --exclude ostp-jni --quiet
if ($LASTEXITCODE -ne 0) { Fail "cargo check failed after the version bump — not committing a broken build." }
Write-Step "Running cargo check to refresh Cargo.lock (ostp-gui/src-tauri)"
Push-Location (Join-Path $RepoRoot "ostp-gui/src-tauri")
cargo check --quiet
$tauriCheckExit = $LASTEXITCODE
Pop-Location
if ($tauriCheckExit -ne 0) { Fail "cargo check failed in ostp-gui/src-tauri after the version bump." }
# ── Persist the new state ───────────────────────────────────────────────────
[PSCustomObject]@{
version = $NewVersion
branch = $ResolvedBranch
prefix = $ResolvedPrefix
} | ConvertTo-Json | Set-Content $StateFile
# ── Commit ───────────────────────────────────────────────────────────────────
$suffixLabel = if ($ResolvedPrefix) { "-$ResolvedPrefix" } else { "" }
$commitMsg = "chore: release $NewVersion$suffixLabel on $ResolvedBranch"
Write-Step "Committing: $commitMsg"
git add Cargo.toml Cargo.lock ostp-gui/src-tauri/Cargo.toml ostp-gui/src-tauri/Cargo.lock `
ostp-gui/src-tauri/tauri.conf.json ostp-gui/package.json ostp-flutter/pubspec.yaml `
.release-state.json
git commit -m $commitMsg | Out-Null
# ── Push: branch push for nightly/pre-release (CI computes the tag itself), ─
# ── a real "vX.Y.Z" tag for master (the only path that yields a stable ─
# ── release per release.yml's resolve-channel job). ─
if ($ResolvedBranch -eq "master") {
$tag = "v$NewVersion"
Write-Step "Tagging $tag and pushing master + tag"
git tag $tag
git push origin master
git push origin $tag
} else {
Write-Step "Pushing $ResolvedBranch"
git push origin $ResolvedBranch
}
Write-Host ""
Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green

View File

@ -1,62 +0,0 @@
{
// OSTP Server Configuration
"mode": "server",
"log_level": "info",
// The address and port the server listens on for incoming OSTP connections.
"listen": "0.0.0.0:50000",
// List of valid keys. Clients must use one of these to connect.
"access_keys": [
"a1d8795a93553c08b4e89b017a16ca52"
],
// Optional proxy for outbound traffic.
"outbound": {
"enabled": false,
"protocol": "socks5",
"address": "127.0.0.1",
"port": 9050,
// default_action: 'proxy' (all through proxy) or 'direct' (bypass proxy by default).
"default_action": "proxy",
"rules": [
{
"domain_suffix": [".onion"],
"action": "proxy"
}
]
},
// Web control panel & Management API
"api": {
"enabled": false,
"bind": "0.0.0.0:9090",
// Static API token for Relay servers (optional)
"token": "",
// Secret URL path to hide panel from scanners (e.g. "mySecret123")
"webpath": "",
// Login credentials for web panel (password stored as SHA256 hash)
"username": "",
"password_hash": ""
},
// Fallback TCP proxy: unrecognized connections are proxied to a web server (anti-DPI).
"fallback": {
"enabled": false,
"listen": "0.0.0.0:443",
// Target web server (e.g., local nginx or caddy)
"target": "127.0.0.1:8080"
},
// Reality (XTLS) / UoT Masquerade parameters
"reality": {
"enabled": false,
"dest": "www.microsoft.com:443",
"private_key": "6FVg53jUBTt-dJ52F1Zu1RBCcW1gr9K84WdynBb7i80",
"pbk": "c9QjERoaqFGoKBd-9ZpNzj51E8B93fcnEQT_cohEk2E",
"sid": "960223edfa174fc5",
"sni_list": ["www.microsoft.com"]
},
"debug": false,
}

View File

@ -1 +0,0 @@


View File

@ -1,3 +0,0 @@
use std::net::SocketAddr; fn main() { println!(\
:?
\, \[::1]:80\.parse::<SocketAddr>()); }