mirror of https://github.com/ospab/ostp.git
Compare commits
7 Commits
b2ee9eb010
...
0ec09d1311
| Author | SHA1 | Date |
|---|---|---|
|
|
0ec09d1311 | |
|
|
f81610f939 | |
|
|
114011df5a | |
|
|
f96daaf57d | |
|
|
6929d42736 | |
|
|
5e0ff4a7ef | |
|
|
c330a0abe3 |
|
|
@ -10,6 +10,19 @@ on:
|
||||||
- nightly
|
- nightly
|
||||||
- pre-release
|
- pre-release
|
||||||
workflow_dispatch:
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
@ -21,6 +34,55 @@ env:
|
||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
|
|
||||||
jobs:
|
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:
|
check-and-test:
|
||||||
name: Check & Test
|
name: Check & Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -58,7 +120,7 @@ jobs:
|
||||||
|
|
||||||
publish-release-matrix:
|
publish-release-matrix:
|
||||||
name: Release for ${{ matrix.target }}
|
name: Release for ${{ matrix.target }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
@ -244,22 +306,19 @@ jobs:
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
# release; branch pushes (nightly/pre-release) roll a release named
|
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||||
# after the branch itself — no name remapping needed since
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
# github.ref_name is already the tag OR the branch name as-is.
|
# real stable release.
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
# Any branch push is a rolling prerelease; for real version tags,
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
|
||||||
# semver tag (v0.4.1) is a stable release.
|
|
||||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
|
||||||
files: ${{ matrix.release_name }}
|
files: ${{ matrix.release_name }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-windows-gui:
|
build-windows-gui:
|
||||||
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -326,22 +385,19 @@ jobs:
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
# release; branch pushes (nightly/pre-release) roll a release named
|
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||||
# after the branch itself — no name remapping needed since
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
# github.ref_name is already the tag OR the branch name as-is.
|
# real stable release.
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
# Any branch push is a rolling prerelease; for real version tags,
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
|
||||||
# semver tag (v0.4.1) is a stable release.
|
|
||||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
|
||||||
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-linux-gui:
|
build-linux-gui:
|
||||||
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -394,22 +450,19 @@ jobs:
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
# release; branch pushes (nightly/pre-release) roll a release named
|
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||||
# after the branch itself — no name remapping needed since
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
# github.ref_name is already the tag OR the branch name as-is.
|
# real stable release.
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
# Any branch push is a rolling prerelease; for real version tags,
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
|
||||||
# semver tag (v0.4.1) is a stable release.
|
|
||||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
|
||||||
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-macos-gui:
|
build-macos-gui:
|
||||||
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -459,22 +512,19 @@ jobs:
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
# release; branch pushes (nightly/pre-release) roll a release named
|
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||||
# after the branch itself — no name remapping needed since
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
# github.ref_name is already the tag OR the branch name as-is.
|
# real stable release.
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
# Any branch push is a rolling prerelease; for real version tags,
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
|
||||||
# semver tag (v0.4.1) is a stable release.
|
|
||||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
|
||||||
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -535,15 +585,12 @@ jobs:
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
# release; branch pushes (nightly/pre-release) roll a release named
|
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||||
# after the branch itself — no name remapping needed since
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
# github.ref_name is already the tag OR the branch name as-is.
|
# real stable release.
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
# Any branch push is a rolling prerelease; for real version tags,
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
|
||||||
# semver tag (v0.4.1) is a stable release.
|
|
||||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
|
||||||
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ test_route.ps1
|
||||||
config.json
|
config.json
|
||||||
wintun.dll
|
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
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
127.0.0.1
|
|
||||||
|
|
@ -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)
|
1. [Development Setup](#development-setup)
|
||||||
2. [Project Structure](#project-structure)
|
2. [Project Structure](#project-structure)
|
||||||
3. [Development Workflow](#development-workflow)
|
3. [Branch Strategy](#branch-strategy)
|
||||||
4. [Coding Guidelines](#coding-guidelines)
|
4. [Development Workflow](#development-workflow)
|
||||||
5. [Submitting Pull Requests](#submitting-pull-requests)
|
5. [Commit Message Conventions](#commit-message-conventions)
|
||||||
6. [Security Vulnerabilities](#security-vulnerabilities)
|
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
|
cd ostp
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Build the control panel frontend**:
|
2. **Build the entire Cargo workspace**:
|
||||||
```bash
|
|
||||||
cd ostp-control
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Build the entire Cargo workspace**:
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
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
|
```bash
|
||||||
cargo test --workspace
|
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
|
## Development Workflow
|
||||||
|
|
||||||
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
|
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
|
```bash
|
||||||
|
git checkout nightly
|
||||||
git checkout -b feat/your-feature-name
|
git checkout -b feat/your-feature-name
|
||||||
```
|
```
|
||||||
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
|
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
|
## 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.
|
* **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
|
```bash
|
||||||
git push origin feat/your-feature-name
|
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.
|
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.
|
4. Verify that GitHub Actions CI runs successfully on your PR.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@
|
||||||
|
|
||||||
1. [Подготовка окружения](#подготовка-окружения)
|
1. [Подготовка окружения](#подготовка-окружения)
|
||||||
2. [Структура проекта](#структура-проекта)
|
2. [Структура проекта](#структура-проекта)
|
||||||
3. [Процесс разработки](#процесс-разработки)
|
3. [Стратегия веток](#стратегия-веток)
|
||||||
4. [Правила оформления кода](#правила-оформления-кода)
|
4. [Процесс разработки](#процесс-разработки)
|
||||||
5. [Создание Pull Request](#создание-pull-request)
|
5. [Оформление коммитов](#оформление-коммитов)
|
||||||
6. [Уязвимости безопасности](#уязвимости-безопасности)
|
6. [Правила оформления кода](#правила-оформления-кода)
|
||||||
|
7. [Создание Pull Request](#создание-pull-request)
|
||||||
|
8. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -33,20 +35,19 @@
|
||||||
cd ostp
|
cd ostp
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Соберите веб-интерфейс панели управления**:
|
2. **Соберите весь Cargo-workspace**:
|
||||||
```bash
|
|
||||||
cd ostp-control
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Соберите весь Cargo-workspace**:
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
cargo build
|
||||||
```
|
```
|
||||||
|
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
|
||||||
|
ней — в остальных случаях сервер собирается с пустым `dist/` через
|
||||||
|
`rust-embed`, и этот шаг не нужен для повседневной работы над
|
||||||
|
core/client/server. Если вы всё же трогаете панель:
|
||||||
|
```bash
|
||||||
|
cd ostp-control && npm install && npm run build && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
4. **Запустите тесты**:
|
3. **Запустите тесты**:
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
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) для обсуждения предлагаемых изменений.
|
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
|
||||||
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
|
2. **Сделайте fork репозитория** и создайте новую ветку от `nightly`:
|
||||||
```bash
|
```bash
|
||||||
|
git checkout nightly
|
||||||
git checkout -b feat/имя-вашей-фичи
|
git checkout -b feat/имя-вашей-фичи
|
||||||
```
|
```
|
||||||
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
|
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: ...`.
|
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
|
||||||
|
|
@ -104,7 +149,7 @@
|
||||||
```bash
|
```bash
|
||||||
git push origin feat/имя-вашей-фичи
|
git push origin feat/имя-вашей-фичи
|
||||||
```
|
```
|
||||||
2. Создайте Pull Request (PR) в ветку `master` основного репозитория.
|
2. Создайте Pull Request (PR) в ветку `nightly` основного репозитория (см. [Стратегия веток](#стратегия-веток) — `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
|
||||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1384,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1406,7 +1406,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1437,7 +1437,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1471,7 +1471,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1503,7 +1503,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1515,7 +1515,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ resolver = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
|
||||||
54
README.md
54
README.md
|
|
@ -95,10 +95,10 @@ graph TD
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your VPS (server):
|
# On your VPS (server):
|
||||||
./ostp --init server
|
./ostp init server
|
||||||
|
|
||||||
# On your machine (client):
|
# On your machine (client):
|
||||||
./ostp --init client
|
./ostp init client
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Edit config
|
### 2. Edit config
|
||||||
|
|
@ -129,16 +129,16 @@ graph TD
|
||||||
### 3. Run
|
### 3. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ostp # Uses config.json in current directory
|
./ostp # Uses config.json in current directory
|
||||||
./ostp --config /path/to.json # Custom config path
|
./ostp --config /path/to.json # Custom config path
|
||||||
./ostp --check # Validate config without running
|
./ostp check # Validate config without running
|
||||||
./ostp --generate-key # Generate a new access key
|
./ostp gk # Generate a new access key
|
||||||
./ostp --links # Print client share links
|
./ostp links # Print client share links
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Connect via share link (one-liner)
|
### 4. Connect via share link (one-liner)
|
||||||
```bash
|
```bash
|
||||||
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
|
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|
@ -171,21 +171,34 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
|
||||||
## CLI Reference
|
## 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)
|
--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
|
## Protocol Summary
|
||||||
|
|
@ -230,8 +243,7 @@ cargo test -p ostp-core -p ostp-server
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Business Source License 1.1. Free for personal and non-commercial use.
|
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
|
||||||
Converts to MIT License on May 14, 2030.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
38
README.ru.md
38
README.ru.md
|
|
@ -84,8 +84,8 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
||||||
|
|
||||||
Создать конфиг по умолчанию:
|
Создать конфиг по умолчанию:
|
||||||
```bash
|
```bash
|
||||||
./ostp --init server # VPS
|
./ostp init server # VPS
|
||||||
./ostp --init client # Локальная машина
|
./ostp init client # Локальная машина
|
||||||
```
|
```
|
||||||
|
|
||||||
### Сервер (`config.json`)
|
### Сервер (`config.json`)
|
||||||
|
|
@ -156,6 +156,37 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
||||||
./ostp
|
./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)
|
### TUN-режим (Windows)
|
||||||
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
||||||
|
|
||||||
|
|
@ -204,5 +235,4 @@ cross build --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Business Source License 1.1. Бесплатно для личного и некоммерческого использования.
|
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст — в файле [LICENSE](LICENSE).
|
||||||
Переходит в MIT License 14 мая 2030 года.
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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`)
|
### 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:
|
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`.
|
||||||
|
|
||||||
* **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`.
|
|
||||||
|
|
||||||
### 2. Data Transmission Mode (`is_handshake = false`)
|
### 2. Data Transmission Mode (`is_handshake = false`)
|
||||||
Post-handshake, the wire layout contains:
|
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`.
|
||||||
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
|
|
||||||
|
|
||||||
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied:
|
#### 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.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
@ -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 байтами сигнатурной матрицы:
|
### 1. Фаза хэндшейка (`is_handshake = true`)
|
||||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
|
||||||
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
|
|
||||||
|
|
||||||
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`)
|
### 2. Фаза передачи данных (`is_handshake = false`)
|
||||||
После перевода сессии в состояние активности кадр передачи принимает следующий вид:
|
Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
|
||||||
`[4 байта session_id]` + `[8 байт 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-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.
|
||||||
|
|
@ -183,7 +183,64 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
||||||
run_client_core(config, metrics, shutdown_rx, None).await
|
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(
|
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,
|
mut config: crate::config::ClientConfig,
|
||||||
metrics: Arc<BridgeMetrics>,
|
metrics: Arc<BridgeMetrics>,
|
||||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "ostp-gui",
|
"name": "ostp-gui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|
|
||||||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2696,7 +2696,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"json_comments",
|
"json_comments",
|
||||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -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_str = format!("--port {} --token-file \"{}\"", port, token_file.display());
|
||||||
let params_wstr: Vec<u16> = OsStr::new(¶ms_str).encode_wide().chain(Some(0)).collect();
|
let params_wstr: Vec<u16> = OsStr::new(¶ms_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 = "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
|
// 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 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 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) };
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ostp-gui",
|
"productName": "ostp-gui",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"identifier": "com.ospab.ostp",
|
"identifier": "com.ospab.ostp",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# OSTP Wiki
|
|
||||||
|
|
||||||
This repository contains the documentation and wiki pages for the Ospab Stealth Transport Protocol (OSTP).
|
|
||||||
|
|
@ -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-конфиг.
|
|
||||||
|
|
@ -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"`).
|
|
||||||
|
|
@ -30,6 +30,7 @@ enum Commands {
|
||||||
mode: String,
|
mode: String,
|
||||||
},
|
},
|
||||||
/// Generate a new secure access key
|
/// Generate a new secure access key
|
||||||
|
#[command(name = "gk", alias = "generate-key")]
|
||||||
GenerateKey {
|
GenerateKey {
|
||||||
/// Format for generated key (hex, base64)
|
/// Format for generated key (hex, base64)
|
||||||
#[arg(long, default_value = "hex")]
|
#[arg(long, default_value = "hex")]
|
||||||
|
|
|
||||||
658
refactor.py
658
refactor.py
|
|
@ -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")
|
|
||||||
|
|
@ -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
|
||||||
62
server.json
62
server.json
|
|
@ -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,
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
use std::net::SocketAddr; fn main() { println!(\
|
|
||||||
:?
|
|
||||||
\, \[::1]:80\.parse::<SocketAddr>()); }
|
|
||||||
Loading…
Reference in New Issue