mirror of https://github.com/ospab/ostp.git
Compare commits
15 Commits
b2ee9eb010
...
db581ca391
| Author | SHA1 | Date |
|---|---|---|
|
|
db581ca391 | |
|
|
a547ebff17 | |
|
|
d065f6ceca | |
|
|
d822f48891 | |
|
|
26665a826f | |
|
|
7b43e1dcf7 | |
|
|
b17e5499eb | |
|
|
ec947ec9d1 | |
|
|
0ec09d1311 | |
|
|
f81610f939 | |
|
|
114011df5a | |
|
|
f96daaf57d | |
|
|
6929d42736 | |
|
|
5e0ff4a7ef | |
|
|
c330a0abe3 |
|
|
@ -1,6 +1,20 @@
|
|||
name: CI/CD
|
||||
|
||||
run-name: "CI/CD: release version ${{ github.ref_name }}"
|
||||
|
||||
# `run-name` is evaluated at workflow-start, BEFORE any job runs — it cannot
|
||||
# see resolve-channel's computed tag_name (e.g. "0.4.3-nightly"), only the
|
||||
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
|
||||
# the bare branch name ("nightly"/"pre-release") for every run, which reads
|
||||
# exactly like a literal release tag and caused real confusion — the actual
|
||||
# release tag has been correct (versioned) all along; only this label lied
|
||||
# about it. Spell out "channel" so nobody mistakes one for the other again.
|
||||
# NOTE: this value MUST be quoted. The GHA string literal below contains
|
||||
# "Release build: {0}" — an unquoted YAML plain scalar treats ": " (colon
|
||||
# then space) as starting a nested mapping, which is exactly what broke every
|
||||
# single push since this line was introduced: GitHub rejected the whole
|
||||
# workflow file at parse time (before any job runs), silently burning an
|
||||
# Actions-minutes-billed run per push for nothing.
|
||||
run-name: "${{ startsWith(github.ref, 'refs/tags/') && format('Release build: {0}', github.ref_name) || format('Release build: {0} channel', github.ref_name) }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -10,6 +24,19 @@ on:
|
|||
- nightly
|
||||
- pre-release
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: >-
|
||||
Manually build+release just this rolling channel. Stable releases
|
||||
are NEVER picked here on purpose — cut those only via a real
|
||||
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
|
||||
a "stable" release.
|
||||
type: choice
|
||||
required: true
|
||||
default: nightly
|
||||
options:
|
||||
- nightly
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -21,6 +48,55 @@ env:
|
|||
RUST_BACKTRACE: short
|
||||
|
||||
jobs:
|
||||
# Computes ONE channel + release tag for this whole run, so every build
|
||||
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
|
||||
# same release under the exact same tag, instead of repeating this logic
|
||||
# (and risking it drifting out of sync) in five separate places.
|
||||
#
|
||||
# Tag shape:
|
||||
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
|
||||
# - push to `nightly` -> "{version}-nightly" (rolling, same tag every push)
|
||||
# - push to `pre-release` -> "{version}-beta" (rolling, same tag every push)
|
||||
# - workflow_dispatch -> forced by the `channel` input (nightly|beta only)
|
||||
resolve-channel:
|
||||
name: Resolve release channel
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.resolve.outputs.channel }}
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
prerelease: ${{ steps.resolve.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve channel, version, and release tag
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
CHANNEL="stable"
|
||||
TAG="${{ github.ref_name }}"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
elif [ "${{ github.ref_name }}" = "nightly" ]; then
|
||||
CHANNEL="nightly"
|
||||
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
|
||||
CHANNEL="beta"
|
||||
else
|
||||
CHANNEL="nightly"
|
||||
fi
|
||||
|
||||
if [ "$CHANNEL" != "stable" ]; then
|
||||
TAG="v${BASE_VERSION}-${CHANNEL}"
|
||||
fi
|
||||
|
||||
echo "Resolved channel=$CHANNEL tag=$TAG (base version $BASE_VERSION)"
|
||||
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
check-and-test:
|
||||
name: Check & Test
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -58,7 +134,7 @@ jobs:
|
|||
|
||||
publish-release-matrix:
|
||||
name: Release for ${{ matrix.target }}
|
||||
needs: check-and-test
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -244,22 +320,19 @@ jobs:
|
|||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ${{ matrix.release_name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-windows-gui:
|
||||
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: check-and-test
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -326,22 +399,19 @@ jobs:
|
|||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-linux-gui:
|
||||
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: check-and-test
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -394,22 +464,19 @@ jobs:
|
|||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-macos-gui:
|
||||
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: check-and-test
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -459,22 +526,19 @@ jobs:
|
|||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-android:
|
||||
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
||||
needs: check-and-test
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -535,15 +599,12 @@ jobs:
|
|||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
|
||||
# release; branch pushes (nightly/pre-release) roll a release named
|
||||
# after the branch itself — no name remapping needed since
|
||||
# github.ref_name is already the tag OR the branch name as-is.
|
||||
tag_name: ${{ github.ref_name }}
|
||||
# Any branch push is a rolling prerelease; for real version tags,
|
||||
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
|
||||
# semver tag (v0.4.1) is a stable release.
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
|
||||
# Computed once in resolve-channel so every platform/job in this run
|
||||
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
|
||||
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||
# real stable release.
|
||||
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ test_route.ps1
|
|||
config.json
|
||||
wintun.dll
|
||||
|
||||
# Server runtime cache (public IP autodetect) — must never be committed,
|
||||
# it's regenerated locally and leaks whatever host it ran on last.
|
||||
.ostp_public_ip
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
127.0.0.1
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"version": "0.4.4",
|
||||
"branch": "nightly",
|
||||
"prefix": "nightly"
|
||||
}
|
||||
|
|
@ -10,10 +10,12 @@ By contributing to this project, you agree to abide by our code of conduct and l
|
|||
|
||||
1. [Development Setup](#development-setup)
|
||||
2. [Project Structure](#project-structure)
|
||||
3. [Development Workflow](#development-workflow)
|
||||
4. [Coding Guidelines](#coding-guidelines)
|
||||
5. [Submitting Pull Requests](#submitting-pull-requests)
|
||||
6. [Security Vulnerabilities](#security-vulnerabilities)
|
||||
3. [Branch Strategy](#branch-strategy)
|
||||
4. [Development Workflow](#development-workflow)
|
||||
5. [Commit Message Conventions](#commit-message-conventions)
|
||||
6. [Coding Guidelines](#coding-guidelines)
|
||||
7. [Submitting Pull Requests](#submitting-pull-requests)
|
||||
8. [Security Vulnerabilities](#security-vulnerabilities)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,20 +35,19 @@ To build and test OSTP locally, you will need:
|
|||
cd ostp
|
||||
```
|
||||
|
||||
2. **Build the control panel frontend**:
|
||||
```bash
|
||||
cd ostp-control
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **Build the entire Cargo workspace**:
|
||||
2. **Build the entire Cargo workspace**:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
`ostp-control` (the web panel) is only needed if you're working on it
|
||||
specifically — the server build embeds a dummy `dist/` via `rust-embed`
|
||||
otherwise, so this step is not required for day-to-day core/client/server
|
||||
work. If you *are* touching the panel:
|
||||
```bash
|
||||
cd ostp-control && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
4. **Run tests**:
|
||||
3. **Run tests**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
|
@ -66,11 +67,28 @@ The repository is organized as a Cargo workspace containing the following crates
|
|||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
The repository runs three long-lived branches, in increasing order of stability:
|
||||
|
||||
| Branch | Role |
|
||||
|---|---|
|
||||
| `nightly` | Active development. All feature work and fixes land here first. |
|
||||
| `pre-release` | Periodically fast-forwarded from `nightly` once it's had some soak time. Ships as the `{version}-beta` release channel. |
|
||||
| `master` | Fast-forwarded from `pre-release` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
|
||||
|
||||
`pre-release` and `master` are **never** committed to directly — they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `pre-release` or `master`.
|
||||
|
||||
**Contributor PRs target `nightly`**, not `master`.
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
|
||||
2. **Fork the repository** and create a new branch from `master`:
|
||||
2. **Fork the repository** and create a new branch from `nightly`:
|
||||
```bash
|
||||
git checkout nightly
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
|
||||
|
|
@ -89,6 +107,32 @@ The repository is organized as a Cargo workspace containing the following crates
|
|||
|
||||
---
|
||||
|
||||
## Commit Message Conventions
|
||||
|
||||
```
|
||||
<type>(<scope>): <short, imperative summary>
|
||||
|
||||
<optional body — explain WHY, not what; the diff already shows what changed>
|
||||
```
|
||||
|
||||
- **Type** — one of: `feat` (new capability), `fix` (bug fix), `docs`, `refactor` (no behavior change), `perf`, `test`, `chore` (deps/tooling/version bumps), `ci`, `security`.
|
||||
- **Scope** (optional) — the crate or area touched: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs`, etc. e.g. `fix(client): ...`.
|
||||
- **Summary** — imperative mood ("add", not "added"/"adds"), no trailing period, ideally under ~70 characters.
|
||||
- **Body** — only when the *why* isn't obvious from the diff: a prior bug this fixes, a constraint that shaped the approach, a tradeoff you made. Don't restate what the diff already shows. Wrap at ~72 columns.
|
||||
|
||||
```
|
||||
fix(server): drop junk frames by per-key marker instead of a global one
|
||||
|
||||
A fixed 4-byte marker on every junk packet is itself a DPI signature any
|
||||
observer can filter on across every OSTP deployment. Derive the marker
|
||||
from the access key (HKDF, same scheme as obfuscation_key/psk) so it's
|
||||
per-user and indistinguishable from the packet's own random payload.
|
||||
```
|
||||
|
||||
Multiple unrelated changes belong in separate commits, not one bundled commit — it keeps `git bisect` and review useful. Squash-merge is fine for a PR with a few "fix typo" / "address review" commits, but don't squash logically distinct changes together.
|
||||
|
||||
---
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
|
||||
|
|
@ -104,7 +148,7 @@ The repository is organized as a Cargo workspace containing the following crates
|
|||
```bash
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
2. Open a Pull Request (PR) targeting the `master` branch.
|
||||
2. Open a Pull Request (PR) targeting the `nightly` branch (see [Branch Strategy](#branch-strategy) — `master` only receives fast-forwards from `pre-release`, never direct PRs).
|
||||
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
||||
4. Verify that GitHub Actions CI runs successfully on your PR.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
|
||||
1. [Подготовка окружения](#подготовка-окружения)
|
||||
2. [Структура проекта](#структура-проекта)
|
||||
3. [Процесс разработки](#процесс-разработки)
|
||||
4. [Правила оформления кода](#правила-оформления-кода)
|
||||
5. [Создание Pull Request](#создание-pull-request)
|
||||
6. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||
3. [Стратегия веток](#стратегия-веток)
|
||||
4. [Процесс разработки](#процесс-разработки)
|
||||
5. [Оформление коммитов](#оформление-коммитов)
|
||||
6. [Правила оформления кода](#правила-оформления-кода)
|
||||
7. [Создание Pull Request](#создание-pull-request)
|
||||
8. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,20 +35,19 @@
|
|||
cd ostp
|
||||
```
|
||||
|
||||
2. **Соберите веб-интерфейс панели управления**:
|
||||
```bash
|
||||
cd ostp-control
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **Соберите весь Cargo-workspace**:
|
||||
2. **Соберите весь Cargo-workspace**:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
|
||||
ней — в остальных случаях сервер собирается с пустым `dist/` через
|
||||
`rust-embed`, и этот шаг не нужен для повседневной работы над
|
||||
core/client/server. Если вы всё же трогаете панель:
|
||||
```bash
|
||||
cd ostp-control && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
4. **Запустите тесты**:
|
||||
3. **Запустите тесты**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
|
@ -66,11 +67,28 @@
|
|||
|
||||
---
|
||||
|
||||
## Стратегия веток
|
||||
|
||||
В репозитории три долгоживущие ветки, по возрастанию стабильности:
|
||||
|
||||
| Ветка | Роль |
|
||||
|---|---|
|
||||
| `nightly` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
||||
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `nightly`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||
|
||||
В `pre-release` и `master` **никогда** не коммитят напрямую — они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен — всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
|
||||
|
||||
**PR от контрибьюторов нацелены на `nightly`**, не на `master`.
|
||||
|
||||
---
|
||||
|
||||
## Процесс разработки
|
||||
|
||||
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `nightly`:
|
||||
```bash
|
||||
git checkout nightly
|
||||
git checkout -b feat/имя-вашей-фичи
|
||||
```
|
||||
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
|
||||
|
|
@ -89,6 +107,33 @@
|
|||
|
||||
---
|
||||
|
||||
## Оформление коммитов
|
||||
|
||||
```
|
||||
<тип>(<область>): <краткое описание в повелительном наклонении>
|
||||
|
||||
<опционально: тело — объясняет ПОЧЕМУ, а не что; диф и так показывает что изменилось>
|
||||
```
|
||||
|
||||
- **Тип** — один из: `feat` (новая функциональность), `fix` (исправление бага), `docs`, `refactor` (без изменения поведения), `perf`, `test`, `chore` (зависимости/тулинг/версии), `ci`, `security`.
|
||||
- **Область** (опционально) — крейт или часть проекта: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs` и т.д., например `fix(client): ...`.
|
||||
- **Краткое описание** — повелительное наклонение ("добавь", а не "добавил"/"добавляет"), без точки в конце, желательно до ~70 символов.
|
||||
- **Тело** — только когда причина не очевидна из дифа: какой баг это чинит, какое ограничение определило подход, на какой trade-off вы пошли. Не пересказывайте то, что и так видно в дифе. Перенос строк на ~72 символах.
|
||||
|
||||
```
|
||||
fix(server): отбрасывать junk-фреймы по маркеру для каждого ключа, а не глобальному
|
||||
|
||||
Фиксированный 4-байтовый маркер на каждом junk-пакете сам по себе — сигнатура
|
||||
DPI, по которой можно фильтровать любого наблюдателя во всех деплойментах OSTP
|
||||
сразу. Выводим маркер из access_key (HKDF, та же схема что у
|
||||
obfuscation_key/psk), чтобы он был индивидуальным для ключа и неотличимым от
|
||||
случайной полезной нагрузки пакета.
|
||||
```
|
||||
|
||||
Несколько несвязанных изменений — это несколько отдельных коммитов, а не один сборный. Это сохраняет пользу от `git bisect` и код-ревью. Squash-merge подходит для PR с парой коммитов вроде "fix typo" / "address review", но не сквошьте вместе логически разные изменения.
|
||||
|
||||
---
|
||||
|
||||
## Правила оформления кода
|
||||
|
||||
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
|
||||
|
|
@ -104,7 +149,7 @@
|
|||
```bash
|
||||
git push origin feat/имя-вашей-фичи
|
||||
```
|
||||
2. Создайте Pull Request (PR) в ветку `master` основного репозитория.
|
||||
2. Создайте Pull Request (PR) в ветку `nightly` основного репозитория (см. [Стратегия веток](#стратегия-веток) — `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
|
||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||
|
||||
|
|
|
|||
|
|
@ -1384,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||
|
||||
[[package]]
|
||||
name = "ostp"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1406,7 +1406,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1437,7 +1437,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1471,7 +1471,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-server"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1503,7 +1503,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
@ -1515,7 +1515,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun-helper"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ resolver = "2"
|
|||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0"
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -95,10 +95,10 @@ graph TD
|
|||
|
||||
```bash
|
||||
# On your VPS (server):
|
||||
./ostp --init server
|
||||
./ostp init server
|
||||
|
||||
# On your machine (client):
|
||||
./ostp --init client
|
||||
./ostp init client
|
||||
```
|
||||
|
||||
### 2. Edit config
|
||||
|
|
@ -129,16 +129,16 @@ graph TD
|
|||
### 3. Run
|
||||
|
||||
```bash
|
||||
./ostp # Uses config.json in current directory
|
||||
./ostp --config /path/to.json # Custom config path
|
||||
./ostp --check # Validate config without running
|
||||
./ostp --generate-key # Generate a new access key
|
||||
./ostp --links # Print client share links
|
||||
./ostp # Uses config.json in current directory
|
||||
./ostp --config /path/to.json # Custom config path
|
||||
./ostp check # Validate config without running
|
||||
./ostp gk # Generate a new access key
|
||||
./ostp links # Print client share links
|
||||
```
|
||||
|
||||
### 4. Connect via share link (one-liner)
|
||||
```bash
|
||||
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
|
||||
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
|
|
@ -171,21 +171,34 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
|
|||
## CLI Reference
|
||||
|
||||
```
|
||||
ostp [OPTIONS] [URL]
|
||||
ostp [--config <PATH>] [COMMAND]
|
||||
|
||||
Options:
|
||||
Commands:
|
||||
run Run the daemon using the config file (default when no command is given)
|
||||
connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
|
||||
setup Interactive setup wizard
|
||||
init <MODE> Generate a template config (server/client/relay)
|
||||
check Validate the configuration file and exit
|
||||
gk Generate a secure access key (alias: generate-key)
|
||||
--format <FMT> Key format: hex, base64 (default: hex)
|
||||
-n, --count <N> Number of keys to generate (default: 1)
|
||||
links Print client share links from the server config
|
||||
import <URL> Import a share link into the config file
|
||||
update Update OSTP to the latest release
|
||||
-b, --branch <NAME> Release channel: stable, pre-release, nightly (default: stable)
|
||||
-v, --version <VER> Update to an exact version instead of the channel's latest
|
||||
migrate Force-migrate the configuration file to the current format
|
||||
prober Run the DNS-transport resolver prober
|
||||
proxy-env Print shell export commands for the local SOCKS proxy
|
||||
proxy-env-clear Print shell export commands to unset it
|
||||
uninstall Stop the service and remove the binary and config
|
||||
|
||||
Global options:
|
||||
--config <PATH> Config file path (default: config.json)
|
||||
--init <MODE> Generate template config (server/client)
|
||||
--check Validate configuration and exit
|
||||
-g, --generate-key Generate a secure access key
|
||||
-c, --count <N> Number of keys to generate (default: 1)
|
||||
--format <FMT> Key format: hex, base64 (default: hex)
|
||||
--links Print client share links from server config
|
||||
|
||||
Arguments:
|
||||
[URL] Connect via share link: ostp://KEY@HOST:PORT
|
||||
```
|
||||
|
||||
Every subcommand also accepts `-h`/`--help` for its own option list.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Summary
|
||||
|
|
@ -230,8 +243,7 @@ cargo test -p ostp-core -p ostp-server
|
|||
|
||||
## License
|
||||
|
||||
Business Source License 1.1. Free for personal and non-commercial use.
|
||||
Converts to MIT License on May 14, 2030.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
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
|
||||
./ostp --init server # VPS
|
||||
./ostp --init client # Локальная машина
|
||||
./ostp init server # VPS
|
||||
./ostp init client # Локальная машина
|
||||
```
|
||||
|
||||
### Сервер (`config.json`)
|
||||
|
|
@ -156,6 +156,37 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
./ostp
|
||||
```
|
||||
|
||||
### Справка по командам
|
||||
|
||||
```
|
||||
ostp [--config <PATH>] [КОМАНДА]
|
||||
|
||||
Команды:
|
||||
run Запустить демон по конфигу (по умолчанию, если команда не указана)
|
||||
connect <URL> Подключиться по share-ссылке: ostp://KEY@HOST:PORT
|
||||
setup Интерактивный мастер настройки
|
||||
init <MODE> Сгенерировать шаблон конфига (server/client/relay)
|
||||
check Проверить конфиг и выйти
|
||||
gk Сгенерировать access-key (алиас: generate-key)
|
||||
--format <FMT> Формат ключа: hex, base64 (по умолчанию hex)
|
||||
-n, --count <N> Количество ключей (по умолчанию 1)
|
||||
links Вывести client-share-ссылки из серверного конфига
|
||||
import <URL> Импортировать share-ссылку в конфиг
|
||||
update Обновить OSTP до актуального релиза
|
||||
-b, --branch <NAME> Канал релиза: stable, pre-release, nightly (по умолчанию stable)
|
||||
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||
migrate Принудительно мигрировать конфиг к текущему формату
|
||||
prober Запустить DNS-transport prober
|
||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||
proxy-env-clear Вывести shell-команды для их отмены
|
||||
uninstall Остановить сервис и удалить бинарник с конфигом
|
||||
|
||||
Глобальные опции:
|
||||
--config <PATH> Путь к конфигу (по умолчанию config.json)
|
||||
```
|
||||
|
||||
У каждой подкоманды есть своя справка через `-h`/`--help`.
|
||||
|
||||
### TUN-режим (Windows)
|
||||
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
||||
|
||||
|
|
@ -204,5 +235,4 @@ cross build --release --target x86_64-unknown-linux-gnu
|
|||
|
||||
## Лицензия
|
||||
|
||||
Business Source License 1.1. Бесплатно для личного и некоммерческого использования.
|
||||
Переходит в MIT License 14 мая 2030 года.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст — в файле [LICENSE](LICENSE).
|
||||
|
|
|
|||
|
|
@ -5,40 +5,38 @@ Traditional tunneling protocols (such as TLS, OpenVPN, and WireGuard) exhibit di
|
|||
|
||||
---
|
||||
|
||||
## Obfuscation Key Derivation
|
||||
## Secret Derivation
|
||||
|
||||
To dynamically mask protocol data, an 8-byte obfuscation key is statically derived from the shared `access_key` configured on both the client and the server:
|
||||
Every protocol secret — the obfuscation key, the Noise PSK, the handshake padding range, and the per-key junk marker (see below) — is derived from the shared `access_key` via a single HKDF-SHA256 pass, domain-separated by a trailing info byte per output:
|
||||
|
||||
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
|
||||
```
|
||||
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
|
||||
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 bytes)
|
||||
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 bytes)
|
||||
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 bytes)
|
||||
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 bytes)
|
||||
```
|
||||
|
||||
This key is established pre-session and is never transmitted across the wire in any capacity.
|
||||
The wire protocol version is mixed into the IKM, not sent as a plaintext byte: peers on a different protocol version derive an entirely different `obfuscation_key`, so they simply cannot deobfuscate each other's packets and are rejected as unauthorized — a hard version gate with no recognizable marker ever appearing on the wire. No secret is ever transmitted; both sides derive the same values independently from the shared access key.
|
||||
|
||||
---
|
||||
|
||||
## Dynamic In-Place Masking Algorithm
|
||||
|
||||
OSTP datagrams are processed "in-place" immediately prior to transmission and right after arrival. Two distinct mathematical modes are utilized based on the current handshake phase:
|
||||
OSTP datagrams are masked "in-place" immediately prior to transmission and right after arrival. The mask itself is **derived from the packet's own ciphertext**, not from a fixed keystream or a counter, so it changes with every packet automatically:
|
||||
|
||||
```
|
||||
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
|
||||
```
|
||||
|
||||
### 1. Handshake Phase Mode (`is_handshake = true`)
|
||||
During connection initiation (Noise Handshake), the wire packet consists of a 4-byte `session_id` prefixed to the Noise payload. To mask the fixed session ID:
|
||||
|
||||
* **Masking**: The first 4 bytes are XORed with the first 4 bytes of the derived obfuscation key:
|
||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
||||
* **De-masking**: A repeated XOR with the identical key bytes recovers the original `session_id`.
|
||||
The wire packet is `[4-byte session_id][2-byte noise_len][Noise payload]`. The mask is computed over the Noise payload (`raw[6..]`), and its first 6 bytes are XORed onto `session_id || noise_len`.
|
||||
|
||||
### 2. Data Transmission Mode (`is_handshake = false`)
|
||||
Post-handshake, the wire layout contains:
|
||||
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
|
||||
The wire packet is `[4-byte session_id][8-byte nonce][AEAD ciphertext]`. The mask is computed over the AEAD ciphertext, and its first 12 bytes are XORed onto `session_id || nonce`.
|
||||
|
||||
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied:
|
||||
|
||||
1. **Nonce Masking**: The 8-byte `nonce` (sequence counter) is XORed with the full 8-byte static key:
|
||||
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
|
||||
2. **Session ID Masking**: The 4-byte `session_id` is masked using high dynamic entropy — the lower 32 bits of the **original (unmasked)** `nonce` value:
|
||||
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
|
||||
|
||||
#### Impact of the Scheme:
|
||||
Because the `nonce` increments strictly with each outgoing datagram, the session ID's masking keystream continuously changes. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
|
||||
#### Impact of the Scheme
|
||||
Because the mask is keyed on both the shared secret and the packet's own ciphertext, no two packets — even consecutive ones from the same session — share a keystream, without needing an explicit counter-based scheme. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -50,6 +48,13 @@ The `AdaptivePadder` calculates dynamic dummy byte quantities to append to the p
|
|||
- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams.
|
||||
- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths.
|
||||
|
||||
## XTLS-Reality Impersonation
|
||||
---
|
||||
|
||||
OSTP provides a custom, dependency-free implementation of the XTLS-Reality protocol. It fully simulates a TLS 1.3 handshake (with realistic ClientHello profiles) to bypass advanced DPI filters. Post-handshake, it utilizes ChaCha20Poly1305 to seamlessly encrypt and tunnel the inner HTTP/WSS connections.
|
||||
## Junk Packets & TCP Fragmentation
|
||||
|
||||
OSTP does not try to impersonate a known protocol (TLS, HTTP, or otherwise) — a fingerprint-matching filter can always be updated to catch an impersonation attempt. Instead it follows a **zapret-like** approach: no recognizable header at all, plus active manipulation of packet boundaries, so there is nothing distinctive to fingerprint in the first place.
|
||||
|
||||
- **Junk packets**: before the handshake, the client sends a configurable number (`junk_pc`) of random-size (`junk_ps`) filler datagrams. Each carries a 4-byte marker **derived from the access key** (the `junk_marker` above) rather than a fixed constant — a fixed marker would itself be a universal signature any observer could filter on across every OSTP deployment. The server derives the same per-key marker while trying candidate keys and drops matching junk silently, before it ever reaches the "unauthorized probe" logging path.
|
||||
- **TCP fragmentation** (UoT/TCP transport only): the first packet (the handshake) is split into small chunks (`frag_chunk` bytes) with short delays (`frag_sleep` ms) between writes, so DPI that inspects only the first TCP segment never sees a complete handshake to fingerprint.
|
||||
|
||||
Both are configurable per-profile; neither is sent over plain UDP transport, where a standalone junk datagram would look exactly like a random one-off probe to the server.
|
||||
|
|
@ -1,55 +1,60 @@
|
|||
# Маскирование энтропии сигналов OSTP
|
||||
# Обфускация трафика OSTP
|
||||
|
||||
## Философия структуры канала
|
||||
## Философия
|
||||
|
||||
Традиционные сетевые протоколы промышленного сбора данных могут обладать фиксированными заголовками, что при анализе статистического распределения байт ведет к предвзятости выборок и искажению телеметрического профиля. Задача механизмов энтропийного маскирования OSTP — достижение **равномерного вероятностного распределения значений байт**, начиная с самого первого пакета. Это делает сигналы шины данных абсолютно однородными и устойчивыми к корреляционному анализу и структурному мониторингу сетевых контроллеров.
|
||||
Классические туннельные протоколы (TLS, OpenVPN, WireGuard) имеют узнаваемые сигнатуры в хэндшейке или статичные заголовки пакетов. Механизм обфускации OSTP спроектирован так, чтобы **начиная с первого байта** трафик был максимально похож на случайный шум — и для DPI-систем был неотличим от него.
|
||||
|
||||
---
|
||||
|
||||
## Производная сигнатурная матрица (Keystream Initialization Vector)
|
||||
## Деривация секретов
|
||||
|
||||
Для стабилизации битового распределения используется 8-байтовый вектор, вычисляемый на базе глобального идентификатора регистрации узла (`access_key`):
|
||||
Все секреты протокола — ключ обфускации, PSK Noise-хэндшейка, диапазон паддинга хэндшейка и маркер junk-пакетов (см. ниже) — выводятся из общего `access_key` одним проходом HKDF-SHA256, с разделением по доменам через последний байт `info`:
|
||||
|
||||
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
|
||||
```
|
||||
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
|
||||
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 байт)
|
||||
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 байта)
|
||||
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 байта)
|
||||
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 байта)
|
||||
```
|
||||
|
||||
Данная последовательность фиксируется на передающем и принимающем узлах и не передается через внешние сетевые шлюзы.
|
||||
Версия протокола подмешивается в IKM, а не передаётся открытым байтом на проводе: пиры с разной версией протокола выведут разный `obfuscation_key` и просто не смогут деобфусцировать пакеты друг друга — жёсткий version gate без единого узнаваемого маркера на проводе. Ни один секрет никогда не передаётся — обе стороны независимо выводят одинаковые значения из общего access_key.
|
||||
|
||||
---
|
||||
|
||||
## Алгоритм динамического маскирования пакетов (In-place Masking)
|
||||
## Алгоритм динамического маскирования
|
||||
|
||||
Пакетные структуры OSTP проходят низкоуровневую предобработку непосредственно перед выдачей в канальный уровень (Layer 3) и при получении. В зависимости от фазы жизненного цикла сессии связи выделяют две модели:
|
||||
Датаграммы OSTP маскируются "на месте" прямо перед отправкой и сразу после получения. Сама маска **выводится из шифротекста самого пакета**, а не из статичного потока ключа или счётчика — поэтому она меняется от пакета к пакету автоматически:
|
||||
|
||||
### 1. Этап начального согласования среды (`is_handshake = true`)
|
||||
В период инициализации канала передачи пакет структурирован как 4-байтовое поле логического адреса порта `session_id` и криптографический блок согласования среды. Для подавления статических компонент ID порта применяется процедура обратимого битового сложения:
|
||||
```
|
||||
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
|
||||
```
|
||||
|
||||
* **Обработка**: Первые 4 байта вектора пакета проходят побитовую операцию XOR с первыми 4 байтами сигнатурной матрицы:
|
||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
||||
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
|
||||
### 1. Фаза хэндшейка (`is_handshake = true`)
|
||||
Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
|
||||
|
||||
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`)
|
||||
После перевода сессии в состояние активности кадр передачи принимает следующий вид:
|
||||
`[4 байта session_id]` + `[8 байт nonce]` + `[Полезная нагрузка блока]`
|
||||
### 2. Фаза передачи данных (`is_handshake = false`)
|
||||
Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
|
||||
|
||||
Для максимизации дифференциальной энтропии применяется двухступенчатое динамическое взвешивание:
|
||||
|
||||
1. **Коррекция счетчика цикла (Nonce Correction)**: 8-байтовое значение инкрементного счетчика пакета подвергается побитовому сложению с вектором матрицы:
|
||||
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
|
||||
2. **Маскирование ID сессии**: 4-байтовое поле логического адреса маскируется с помощью переменной высокочастотной энтропии — младших 32 бит **исходного** показателя системного счетчика пакетов:
|
||||
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
|
||||
|
||||
#### Статистическая устойчивость:
|
||||
Благодаря инкрементации счетчика на каждом цикле отправки, маскирующий поток (keystream) для поля `session_id` постоянно видоизменяется. Это полностью нивелирует фиксированные битовые паттерны во всем спектре UDP-датаграмм и исключает появление повторяющихся префиксов.
|
||||
#### Эффект схемы
|
||||
Поскольку маска зависит одновременно от общего секрета и от содержимого шифротекста конкретного пакета, никакие два пакета — даже два подряд идущих в одной сессии — не используют одинаковый ключевой поток, и для этого не нужна явная схема на основе счётчика. Это полностью убирает корреляции между заголовками пакетов и повторяющиеся байтовые паттерны, делая статистический фингерпринтинг бесполезным.
|
||||
|
||||
---
|
||||
|
||||
## Выравнивание блоков по границам регистров (Adaptive Alignment)
|
||||
## Статистический паддинг
|
||||
|
||||
Дополнительно к маскировке заголовков, протокол OSTP исключает возможность анализа поведения системы на основе длин пакетов данных. Модуль адаптивного заполнения (`AdaptivePadder`) рассчитывает оптимальный размер буфера выравнивания (`padding`), интегрируемый в структуру пакета до момента активации шифрующего каскада:
|
||||
Помимо маскирования заголовков, OSTP защищается от анализа длин пакетов (Traffic Length Analysis). `AdaptivePadder` вычисляет случайный размер мусорных байт, добавляемых к полезной нагрузке ещё до шифрования:
|
||||
|
||||
- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов.
|
||||
- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных.
|
||||
- **Динамическое распределение**: длины паддинга подобраны так, чтобы напоминать профили длин обычного HTTPS-трафика или видеопотоков.
|
||||
- **Внутри шифротекста**: добавленный паддинг находится внутри области AEAD-шифрования — пассивный наблюдатель не может отличить паддинг от полезной нагрузки и не видит настоящую границу сообщения.
|
||||
|
||||
## XTLS-Reality (Имитация TLS 1.3)
|
||||
---
|
||||
|
||||
OSTP предоставляет собственную реализацию протокола XTLS-Reality без сторонних зависимостей. Протокол полностью имитирует рукопожатие TLS 1.3 (с реалистичным профилем ClientHello) для обхода продвинутых DPI фильтров. После успешного рукопожатия применяется ChaCha20Poly1305 для бесшовного шифрования и туннелирования внутренних HTTP/WSS соединений.
|
||||
## Junk-пакеты и TCP-фрагментация
|
||||
|
||||
OSTP не пытается притворяться известным протоколом (TLS, HTTP и т.п.) — фильтр по сигнатуре всегда можно обновить под конкретную имитацию. Вместо этого используется подход **в духе zapret**: никакого узнаваемого заголовка вообще, плюс активная манипуляция границами пакетов — фингерпринтить попросту нечего.
|
||||
|
||||
- **Junk-пакеты**: перед хэндшейком клиент отправляет настраиваемое количество (`junk_pc`) мусорных датаграмм случайного размера (`junk_ps`). Каждая несёт 4-байтовый маркер, **выведенный из access_key** (тот самый `junk_marker` выше), а не фиксированную константу — константный маркер сам по себе стал бы универсальной сигнатурой для любого наблюдателя сразу по всем серверам OSTP. Сервер, перебирая кандидатов-ключей, выводит тот же маркер и тихо отбрасывает junk, не доходя до логирования «unauthorized probe».
|
||||
- **TCP-фрагментация** (только для транспорта UoT/TCP): первый пакет (хэндшейк) режется на мелкие куски (`frag_chunk` байт) с небольшими задержками (`frag_sleep` мс) между записями — DPI, анализирующий только первый TCP-сегмент, никогда не видит цельный хэндшейк для фингерпринтинга.
|
||||
|
||||
Обе фичи настраиваются per-профиль; ни одна не применяется поверх обычного UDP-транспорта, где отдельная junk-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.
|
||||
|
|
@ -293,3 +293,249 @@ impl ClientConfig {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// On-disk config.json shapes — client, server, and relay.
|
||||
//
|
||||
// This is the ONE place these are defined. They used to be declared locally
|
||||
// inside ostp/src/main.rs (the CLI binary) with no other consumer able to
|
||||
// see them, which is exactly how ostp-client::migrate ended up working
|
||||
// against loosely-typed serde_json::Value instead of a real schema, and how
|
||||
// the CLI, the migrator, and this crate's own hot-reload path could each
|
||||
// silently drift out of sync with what a config.json actually looks like.
|
||||
// main.rs now imports these instead of re-declaring them (see the `use
|
||||
// ostp_client::config::{...}` at its top).
|
||||
//
|
||||
// These are DELIBERATELY separate from ClientConfig/OstpConfig/etc. above:
|
||||
// this section is the friendly, minimal shape a user actually edits by
|
||||
// hand; the types above are what the running engine needs internally
|
||||
// (handshake/io timeouts, resolved addresses, ...) and are built FROM one
|
||||
// of these via the mapping in ostp/src/main.rs::run_client_directly. Only
|
||||
// `ClientConfig` collides by name with the runtime type above, so the
|
||||
// on-disk one is `ClientFileConfig` — everything else keeps its natural name.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "mode", rename_all = "lowercase")]
|
||||
pub enum AppMode {
|
||||
Server(ServerConfig),
|
||||
Client(ClientFileConfig),
|
||||
Relay(RelayServerConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UnifiedConfig {
|
||||
#[serde(flatten)]
|
||||
pub mode: AppMode,
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
impl UnifiedConfig {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match &self.mode {
|
||||
AppMode::Server(cfg) => {
|
||||
if cfg.access_keys.is_empty() {
|
||||
anyhow::bail!("Server configuration must contain at least one access_key.");
|
||||
}
|
||||
if let Some(outbound) = &cfg.outbound {
|
||||
if outbound.enabled {
|
||||
let action = outbound.default_action.as_deref().unwrap_or("direct");
|
||||
if action == "direct" && outbound.rules.is_empty() {
|
||||
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
|
||||
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
|
||||
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMode::Client(cfg) => {
|
||||
if cfg.access_key.is_empty() {
|
||||
anyhow::bail!("Client configuration must contain an access_key.");
|
||||
}
|
||||
}
|
||||
AppMode::Relay(cfg) => {
|
||||
if cfg.upstream_tcp.is_empty() {
|
||||
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
|
||||
}
|
||||
if cfg.upstream_api_url.is_empty() {
|
||||
anyhow::bail!("Relay configuration must specify upstream_api_url.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum UserConfig {
|
||||
Detailed {
|
||||
access_key: String,
|
||||
name: Option<String>,
|
||||
limit_bytes: Option<u64>,
|
||||
},
|
||||
KeyOnly(String),
|
||||
}
|
||||
|
||||
impl UserConfig {
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
UserConfig::KeyOnly(k) => k.clone(),
|
||||
UserConfig::Detailed { access_key, .. } => access_key.clone(),
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> Option<String> {
|
||||
match self {
|
||||
UserConfig::KeyOnly(_) => None,
|
||||
UserConfig::Detailed { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
pub fn limit(&self) -> Option<u64> {
|
||||
match self {
|
||||
UserConfig::KeyOnly(_) => None,
|
||||
UserConfig::Detailed { limit_bytes, .. } => *limit_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ServerConfig {
|
||||
pub listen: ListenConfig,
|
||||
pub access_keys: Vec<UserConfig>,
|
||||
pub debug: Option<bool>,
|
||||
pub outbound: Option<OutboundConfig>,
|
||||
pub api: Option<ApiConfig>,
|
||||
pub fallback: Option<FallbackCfg>,
|
||||
pub transport: Option<TransportConfigRaw>,
|
||||
// Left untyped: ostp-client does not (and should not) depend on
|
||||
// ostp-server just to name its DnsConfig type. The CLI binary — which
|
||||
// already depends on both crates — deserializes this into
|
||||
// ostp_server::dns::DnsConfig right before handing it to run_server().
|
||||
pub dns: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Relay-node config.json shape.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RelayServerConfig {
|
||||
/// Listen address(es) (UDP + TCP UoT)
|
||||
pub listen: ListenConfig,
|
||||
/// Upstream address for TCP (UoT) traffic
|
||||
pub upstream_tcp: String,
|
||||
/// Upstream address for UDP traffic
|
||||
pub upstream_udp: String,
|
||||
/// Target server's API URL, for key sync
|
||||
pub upstream_api_url: String,
|
||||
/// Bearer token for the target server's API
|
||||
#[serde(default)]
|
||||
pub upstream_api_token: String,
|
||||
/// Key sync interval in seconds (default 30)
|
||||
#[serde(default = "default_sync_interval")]
|
||||
pub sync_interval_secs: u64,
|
||||
pub debug: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_sync_interval() -> u64 { 30 }
|
||||
|
||||
/// Supports both a single string "0.0.0.0:50000" and an array
|
||||
/// ["0.0.0.0:50000", "[::]:50000"].
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum ListenConfig {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl ListenConfig {
|
||||
pub fn addresses(&self) -> Vec<String> {
|
||||
match self {
|
||||
ListenConfig::Single(s) => vec![s.clone()],
|
||||
ListenConfig::Multiple(v) => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> String {
|
||||
match self {
|
||||
ListenConfig::Single(s) => s.clone(),
|
||||
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ApiConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub bind: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub webpath: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct FallbackCfg {
|
||||
pub enabled: Option<bool>,
|
||||
pub listen: Option<String>,
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ClientFileConfig {
|
||||
pub server: String,
|
||||
pub access_key: String,
|
||||
pub mtu: Option<usize>,
|
||||
pub socks5_bind: Option<String>,
|
||||
pub tun: Option<TunConfig>,
|
||||
pub debug: Option<bool>,
|
||||
pub exclude: Option<ExcludeConfig>,
|
||||
pub mux: Option<MuxConfig>,
|
||||
pub transport: Option<TransportConfigRaw>,
|
||||
pub gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct TransportConfigRaw {
|
||||
pub mode: Option<String>,
|
||||
pub stealth_sni: Option<String>,
|
||||
pub tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct TunConfig {
|
||||
pub enable: bool,
|
||||
pub wintun_path: Option<String>,
|
||||
pub ipv4_address: Option<String>,
|
||||
pub dns: Option<String>,
|
||||
pub kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct OutboundConfig {
|
||||
pub enabled: bool,
|
||||
pub protocol: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<OutboundRule>,
|
||||
pub default_action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct OutboundRule {
|
||||
pub domain_suffix: Option<Vec<String>>,
|
||||
pub ip_cidr: Option<Vec<String>>,
|
||||
pub protocol: Option<String>,
|
||||
pub action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ExcludeConfig {
|
||||
pub domains: Option<Vec<String>>,
|
||||
pub ips: Option<Vec<String>>,
|
||||
pub processes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MuxConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub sessions: Option<usize>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod app;
|
||||
pub mod bridge;
|
||||
pub mod config;
|
||||
pub mod migrate;
|
||||
pub mod signal;
|
||||
pub mod sysproxy;
|
||||
pub mod transport;
|
||||
|
|
|
|||
|
|
@ -71,9 +71,28 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
|||
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
|
||||
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
|
||||
|
||||
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
|
||||
// Write the startup banner directly to the log file, bypassing the
|
||||
// tracing subscriber entirely. Emitting it via tracing::info!() hits
|
||||
// BOTH layers below (file AND stderr), so every one-shot CLI command
|
||||
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
|
||||
// terminal on every single invocation — pure noise for anything that
|
||||
// isn't the long-running daemon. It's still genuinely useful for
|
||||
// whoever's reading the log file later, so keep it there, just not on
|
||||
// screen for commands that aren't the daemon.
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
|
||||
app_name,
|
||||
version,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
level,
|
||||
path.display(),
|
||||
);
|
||||
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_line_number(true)
|
||||
|
|
@ -81,7 +100,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
|||
.with_thread_names(false)
|
||||
.with_ansi(false)
|
||||
.with_writer(file_writer);
|
||||
|
||||
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
|
|
@ -91,17 +110,7 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
|||
.with(fmt_layer)
|
||||
.with(stderr_layer)
|
||||
.try_init();
|
||||
|
||||
tracing::info!(
|
||||
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
|
||||
app_name,
|
||||
version,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
level,
|
||||
path.display(),
|
||||
);
|
||||
|
||||
|
||||
Some(guard)
|
||||
} else {
|
||||
// Fallback: stderr only
|
||||
|
|
|
|||
|
|
@ -0,0 +1,542 @@
|
|||
//! The ONE authoritative place that upgrades an old `config.json` to the
|
||||
//! current schema. Reachable only via the explicit `ostp migrate` command —
|
||||
//! nothing else in this codebase silently rewrites a user's config on their
|
||||
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
|
||||
//! log warning; that's exactly the kind of "invisible until something looks
|
||||
//! wrong" behavior this module replaces).
|
||||
//!
|
||||
//! Every field this module cannot map forward is reported explicitly in
|
||||
//! `MigrationReport.notes`, never silently dropped without a trace.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MigrationReport {
|
||||
/// Whether anything was actually different from the current schema.
|
||||
pub changed: bool,
|
||||
/// Human-readable line per field added, converted, or dropped.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl MigrationReport {
|
||||
fn note(&mut self, msg: impl Into<String>) {
|
||||
self.changed = true;
|
||||
self.notes.push(msg.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs
|
||||
/// from before that tag existed are sniffed structurally as a fallback.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigKind {
|
||||
Client,
|
||||
Server,
|
||||
Relay,
|
||||
}
|
||||
|
||||
pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
|
||||
match json.get("mode").and_then(|v| v.as_str()) {
|
||||
Some("client") => return Some(ConfigKind::Client),
|
||||
Some("server") => return Some(ConfigKind::Server),
|
||||
Some("relay") => return Some(ConfigKind::Relay),
|
||||
_ => {}
|
||||
}
|
||||
// No (or unrecognized) "mode" tag — this is an older config from before
|
||||
// it was mandatory. Sniff by the fields that have been present on each
|
||||
// shape since the earliest surviving config format.
|
||||
if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() {
|
||||
Some(ConfigKind::Relay)
|
||||
} else if json.get("access_keys").is_some() || json.get("listen").is_some() {
|
||||
Some(ConfigKind::Server)
|
||||
} else if json.get("access_key").is_some() || json.get("server").is_some() {
|
||||
Some(ConfigKind::Client)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrates a client config of any known past shape to the current flat
|
||||
/// schema. Returns the migrated JSON and a report of every change made.
|
||||
///
|
||||
/// Known input shapes, oldest first:
|
||||
/// - **v0.3.1–v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays
|
||||
/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this
|
||||
/// line no longer supports multiple simultaneous servers); every other
|
||||
/// `ostp` outbound is reported by tag+address so nothing vanishes
|
||||
/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no
|
||||
/// equivalent and are dropped (reported).
|
||||
/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today
|
||||
/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...)
|
||||
/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail,
|
||||
/// never user-meaningful data) and `transport.wss` (the WSS framing
|
||||
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
|
||||
/// explicit note; everything else maps 1:1, nothing to convert.
|
||||
/// - **current flat schema**: no-op, `changed = false`.
|
||||
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
||||
let mut report = MigrationReport::default();
|
||||
|
||||
let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some();
|
||||
let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some();
|
||||
|
||||
if has_inbounds && has_outbounds {
|
||||
return migrate_client_from_modular(json, report);
|
||||
}
|
||||
|
||||
// Flat shape already (current or pre-0.3.1) — normalize obsolete fields
|
||||
// in place rather than rebuilding the whole document from scratch, so
|
||||
// any field this module doesn't know about yet still survives untouched.
|
||||
let mut out = json;
|
||||
|
||||
if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) {
|
||||
for dead_field in ["wintun_path", "ipv4_address"] {
|
||||
if tun.remove(dead_field).is_some() {
|
||||
report.note(format!(
|
||||
"Dropped tun.{dead_field} — internal driver detail from an older WinTun \
|
||||
integration, not applicable to the current TUN implementation."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) {
|
||||
if transport.remove("wss").is_some() {
|
||||
report.note(
|
||||
"Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \
|
||||
(the project follows a zapret-like approach: no protocol mimicry, \
|
||||
just packet-level obfuscation/manipulation, so there is no successor field)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) {
|
||||
report.changed = true; // the shape itself is being replaced regardless of field-level detail
|
||||
|
||||
let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let routing = json.get("routing").cloned().unwrap_or(json!({}));
|
||||
let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
// ── Pick the primary "ostp" outbound ────────────────────────────────
|
||||
// Prefer the one routing.default_outbound points at (directly, or via a
|
||||
// urltest/selector group that references it); otherwise take the first
|
||||
// ostp outbound in file order. Every other ostp outbound is reported by
|
||||
// tag+address, not silently discarded.
|
||||
let ostp_outbounds: Vec<&Value> = outbounds
|
||||
.iter()
|
||||
.filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
|
||||
.collect();
|
||||
|
||||
// default_outbound might name an ostp outbound directly, OR name a
|
||||
// urltest/selector GROUP whose first member is the one to actually use —
|
||||
// check both, since a plain `.or_else` here would never even attempt the
|
||||
// group lookup while default_outbound is Some(_) (which it almost always
|
||||
// is), silently falling through to "just take the first ostp outbound in
|
||||
// file order" instead — exactly the kind of silent wrong answer this
|
||||
// migrator exists to avoid.
|
||||
let primary_tag: Option<String> = default_outbound.as_deref().and_then(|def_tag| {
|
||||
if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) {
|
||||
return Some(def_tag.to_string());
|
||||
}
|
||||
outbounds.iter().find_map(|o| {
|
||||
let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector"));
|
||||
let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag);
|
||||
if is_group && tag_matches {
|
||||
o.get("outbounds")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let primary = primary_tag
|
||||
.as_deref()
|
||||
.and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag)))
|
||||
.copied()
|
||||
.or_else(|| ostp_outbounds.first().copied());
|
||||
|
||||
let Some(primary) = primary else {
|
||||
report.note(
|
||||
"No 'ostp'-type outbound found in the old modular config — nothing to migrate \
|
||||
the server connection from. Wrote a placeholder; you MUST fill in server/access_key \
|
||||
by hand or re-import a share link."
|
||||
.to_string(),
|
||||
);
|
||||
return (
|
||||
json!({
|
||||
"server": "127.0.0.1:50000",
|
||||
"access_key": "",
|
||||
}),
|
||||
report,
|
||||
);
|
||||
};
|
||||
|
||||
for other in &ostp_outbounds {
|
||||
if !std::ptr::eq(*other, primary) {
|
||||
let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
report.note(format!(
|
||||
"Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \
|
||||
failover is no longer supported; only one server per config now. Kept the \
|
||||
one from routing.default_outbound (or the first one if that wasn't set)."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
|
||||
let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000);
|
||||
let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let transport_type = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("type").or_else(|| t.get("mode")))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("udp")
|
||||
.to_string();
|
||||
let stealth_sni = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("stealth_sni"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let tcp_fragmentation = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("tcp_fragmentation"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
|
||||
// ── TUN + local proxy inbounds ───────────────────────────────────────
|
||||
let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun"));
|
||||
let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy"));
|
||||
|
||||
let tun_enable = tun_inbound.is_some();
|
||||
let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64());
|
||||
|
||||
let socks5_bind = proxy_inbound
|
||||
.map(|p| {
|
||||
let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1");
|
||||
let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088);
|
||||
format!("{listen}:{port}")
|
||||
})
|
||||
.unwrap_or_else(|| "127.0.0.1:1088".to_string());
|
||||
|
||||
// ── Exclusions from routing.rules → direct ──────────────────────────
|
||||
let mut ex_domains: Vec<String> = Vec::new();
|
||||
let mut ex_ips: Vec<String> = Vec::new();
|
||||
let mut ex_processes: Vec<String> = Vec::new();
|
||||
if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) {
|
||||
for rule in rules {
|
||||
if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") {
|
||||
continue; // only "route to direct" rules were ever exclusions in the old format
|
||||
}
|
||||
if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) {
|
||||
ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) {
|
||||
ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) {
|
||||
ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for other_rule_outbound in routing
|
||||
.get("rules")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| r.get("outbound").and_then(|v| v.as_str()))
|
||||
.filter(|o| *o != "direct")
|
||||
{
|
||||
report.note(format!(
|
||||
"Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \
|
||||
\"route to direct\" rules map to today's exclusions; anything else \
|
||||
(custom per-domain outbound selection) has no equivalent anymore."
|
||||
));
|
||||
}
|
||||
|
||||
let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug");
|
||||
|
||||
let mut client = json!({
|
||||
"server": server,
|
||||
"port": port,
|
||||
"access_key": access_key,
|
||||
"socks5_bind": socks5_bind,
|
||||
"debug": debug,
|
||||
"tun": {
|
||||
"enable": tun_enable,
|
||||
"dns": null,
|
||||
"kill_switch": false,
|
||||
},
|
||||
"exclude": {
|
||||
"domains": ex_domains,
|
||||
"ips": ex_ips,
|
||||
"processes": ex_processes,
|
||||
},
|
||||
"mux": {
|
||||
"enabled": mux_enabled,
|
||||
"sessions": mux_sessions,
|
||||
},
|
||||
"transport": {
|
||||
"mode": transport_type,
|
||||
"stealth_sni": stealth_sni,
|
||||
"tcp_fragmentation": tcp_fragmentation,
|
||||
},
|
||||
});
|
||||
if let Some(mtu) = mtu {
|
||||
client["mtu"] = json!(mtu);
|
||||
}
|
||||
if let Some(gui) = json.get("gui") {
|
||||
client["gui"] = gui.clone();
|
||||
}
|
||||
|
||||
(client, report)
|
||||
}
|
||||
|
||||
/// Migrates a server config. The server shape has stayed structurally
|
||||
/// identical since the earliest surviving version — this only backfills the
|
||||
/// `api` section (added after some configs already existed) and drops the
|
||||
/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used
|
||||
/// to live in `scripts/install.sh` and only ran at install/update time.
|
||||
pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
|
||||
let mut report = MigrationReport::default();
|
||||
let mut out = json;
|
||||
|
||||
let obj = match out.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return (out, report),
|
||||
};
|
||||
|
||||
let api = obj.entry("api").or_insert_with(|| json!({}));
|
||||
if let Some(api_obj) = api.as_object_mut() {
|
||||
let defaults: [(&str, Value); 5] = [
|
||||
("enabled", json!(false)),
|
||||
("bind", json!("0.0.0.0:9090")),
|
||||
("webpath", json!("")),
|
||||
("username", json!("")),
|
||||
("password_hash", json!("")),
|
||||
];
|
||||
for (key, default) in defaults {
|
||||
if !api_obj.contains_key(key) {
|
||||
report.note(format!("Added api.{key} = {default} (missing default)"));
|
||||
api_obj.insert(key.to_string(), default);
|
||||
}
|
||||
}
|
||||
if api_obj.remove("token").is_some() {
|
||||
report.note(
|
||||
"Dropped legacy api.token — superseded by api.password_hash; \
|
||||
set a new admin password with the management API or panel."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds,
|
||||
/// a single ostp outbound, exclusion rules, mux) — mirrors the actual
|
||||
/// shape from that tag, field for field.
|
||||
#[test]
|
||||
fn modular_single_server_preserves_every_field() {
|
||||
let old = json!({
|
||||
"version": "0.3.21",
|
||||
"log": { "level": "debug" },
|
||||
"inbounds": [
|
||||
{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 },
|
||||
{ "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 }
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "ostp", "tag": "proxy",
|
||||
"server": "203.0.113.5", "port": 50000, "access_key": "sekrit123",
|
||||
"transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true },
|
||||
"multiplex": { "enabled": true, "sessions": 4 }
|
||||
},
|
||||
{ "type": "direct", "tag": "direct" },
|
||||
{ "type": "block", "tag": "block" }
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" },
|
||||
{ "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" },
|
||||
{ "process_name": ["steam.exe"], "outbound": "direct" }
|
||||
],
|
||||
"default_outbound": "proxy"
|
||||
}
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
assert!(report.changed);
|
||||
assert_eq!(new["server"], "203.0.113.5");
|
||||
assert_eq!(new["port"], 50000);
|
||||
assert_eq!(new["access_key"], "sekrit123");
|
||||
assert_eq!(new["socks5_bind"], "127.0.0.1:1088");
|
||||
assert_eq!(new["mtu"], 1350);
|
||||
assert_eq!(new["debug"], true);
|
||||
assert_eq!(new["tun"]["enable"], true);
|
||||
assert_eq!(new["transport"]["mode"], "uot");
|
||||
assert_eq!(new["transport"]["stealth_sni"], "vk.com");
|
||||
assert_eq!(new["transport"]["tcp_fragmentation"], true);
|
||||
assert_eq!(new["mux"]["enabled"], true);
|
||||
assert_eq!(new["mux"]["sessions"], 4);
|
||||
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
|
||||
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
|
||||
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
|
||||
}
|
||||
|
||||
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
|
||||
/// must keep the one routing.default_outbound points at and report every
|
||||
/// other one by name/address rather than picking silently.
|
||||
#[test]
|
||||
fn modular_multi_server_keeps_default_and_reports_the_rest() {
|
||||
let old = json!({
|
||||
"inbounds": [],
|
||||
"outbounds": [
|
||||
{ "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" },
|
||||
{ "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" },
|
||||
{
|
||||
"type": "urltest", "tag": "proxy",
|
||||
"outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com"
|
||||
}
|
||||
],
|
||||
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
// urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked.
|
||||
assert_eq!(new["server"], "2.2.2.2");
|
||||
assert_eq!(new["access_key"], "k2");
|
||||
assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1")));
|
||||
}
|
||||
|
||||
/// Pre-0.3.1 flat config carrying fields that no longer exist
|
||||
/// (tun.wintun_path, tun.ipv4_address, transport.wss) — those get
|
||||
/// dropped with a note; every field that's still meaningful passes
|
||||
/// through untouched, byte for byte.
|
||||
#[test]
|
||||
fn flat_legacy_drops_only_dead_fields() {
|
||||
let old = json!({
|
||||
"server": "198.51.100.9:50000",
|
||||
"access_key": "oldkey",
|
||||
"mtu": 1200,
|
||||
"socks5_bind": "127.0.0.1:1090",
|
||||
"tun": {
|
||||
"enable": true,
|
||||
"wintun_path": "C:\\Program Files\\wintun\\wintun.dll",
|
||||
"ipv4_address": "10.0.0.2",
|
||||
"dns": "1.1.1.1",
|
||||
"kill_switch": true
|
||||
},
|
||||
"exclude": { "domains": ["a.com"], "ips": null, "processes": null },
|
||||
"mux": { "enabled": false, "sessions": 1 },
|
||||
"transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true }
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
assert!(report.changed);
|
||||
// Untouched fields survive exactly as they were.
|
||||
assert_eq!(new["server"], "198.51.100.9:50000");
|
||||
assert_eq!(new["access_key"], "oldkey");
|
||||
assert_eq!(new["mtu"], 1200);
|
||||
assert_eq!(new["tun"]["enable"], true);
|
||||
assert_eq!(new["tun"]["dns"], "1.1.1.1");
|
||||
assert_eq!(new["tun"]["kill_switch"], true);
|
||||
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
|
||||
assert_eq!(new["transport"]["stealth_sni"], "bing.com");
|
||||
// Dead fields are gone...
|
||||
assert!(new["tun"].get("wintun_path").is_none());
|
||||
assert!(new["tun"].get("ipv4_address").is_none());
|
||||
assert!(new["transport"].get("wss").is_none());
|
||||
// ...and their removal was reported, not silent.
|
||||
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("wss")));
|
||||
}
|
||||
|
||||
/// A config already in the current shape must be a true no-op: report
|
||||
/// says nothing changed, and every field is untouched.
|
||||
#[test]
|
||||
fn current_flat_config_is_a_no_op() {
|
||||
let current = json!({
|
||||
"server": "example.com:50000",
|
||||
"access_key": "k",
|
||||
"tun": { "enable": false, "dns": null, "kill_switch": false },
|
||||
"exclude": { "domains": [], "ips": [], "processes": [] },
|
||||
"mux": { "enabled": false, "sessions": 1 },
|
||||
"transport": { "mode": "udp", "stealth_sni": "", "tcp_fragmentation": false }
|
||||
});
|
||||
let (new, report) = migrate_client_json(current.clone());
|
||||
assert!(!report.changed);
|
||||
assert_eq!(new, current);
|
||||
}
|
||||
|
||||
/// Every migrated output must actually deserialize into the ONE
|
||||
/// canonical schema (`crate::config`) — this is the same check
|
||||
/// `cmd_migrate` runs at runtime before ever touching a user's file,
|
||||
/// exercised here directly so a schema/migrator drift fails a fast unit
|
||||
/// test instead of surfacing as "your migrated config won't load".
|
||||
#[test]
|
||||
fn every_migrated_output_matches_the_canonical_schema() {
|
||||
let modular = json!({
|
||||
"inbounds": [{ "type": "tun", "tag": "tun-in", "mtu": 1350 }],
|
||||
"outbounds": [
|
||||
{ "type": "ostp", "tag": "proxy", "server": "1.2.3.4", "port": 50000, "access_key": "k" },
|
||||
{ "type": "direct", "tag": "direct" }
|
||||
],
|
||||
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||
});
|
||||
let (new, _) = migrate_client_json(modular);
|
||||
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||
.expect("modular->flat migration output must match ClientFileConfig");
|
||||
|
||||
let legacy_flat = json!({
|
||||
"server": "1.2.3.4:50000",
|
||||
"access_key": "k",
|
||||
"tun": { "enable": true, "wintun_path": "x", "ipv4_address": "y" }
|
||||
});
|
||||
let (new, _) = migrate_client_json(legacy_flat);
|
||||
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||
.expect("legacy-flat migration output must match ClientFileConfig");
|
||||
|
||||
let server = json!({ "listen": "0.0.0.0:50000", "access_keys": ["k"] });
|
||||
let (new, _) = migrate_server_json(server);
|
||||
serde_json::from_value::<crate::config::ServerConfig>(new)
|
||||
.expect("server migration output must match ServerConfig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_config_backfills_api_defaults_and_drops_legacy_token() {
|
||||
let old = json!({
|
||||
"listen": "0.0.0.0:50000",
|
||||
"access_keys": ["k1"],
|
||||
"api": { "token": "old-plain-token" }
|
||||
});
|
||||
let (new, report) = migrate_server_json(old);
|
||||
assert!(report.changed);
|
||||
assert_eq!(new["api"]["enabled"], false);
|
||||
assert_eq!(new["api"]["bind"], "0.0.0.0:9090");
|
||||
assert!(new["api"].get("token").is_none());
|
||||
assert!(report.notes.iter().any(|n| n.contains("api.token")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
|
||||
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
|
||||
assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server));
|
||||
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
|
||||
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +183,64 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
|||
run_client_core(config, metrics, shutdown_rx, None).await
|
||||
}
|
||||
|
||||
/// Runs the client with auto-reconnect: any subsystem ending — a network
|
||||
/// change stranding the TUN adapter/UDP socket on a dead interface, the OSTP
|
||||
/// protocol connection dropping in a way the inner Bridge-level retry (see
|
||||
/// `UiEvent::TunnelStopped` below) couldn't recover from, or a proxy/TUN task
|
||||
/// crashing outright — triggers a full clean restart (fresh DNS resolution,
|
||||
/// fresh Bridge, fresh TUN/proxy) with exponential backoff, instead of the
|
||||
/// client just dying. Only an explicit shutdown request stops this loop.
|
||||
pub async fn run_client_core(
|
||||
config: crate::config::ClientConfig,
|
||||
metrics: Arc<BridgeMetrics>,
|
||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||
config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||
) -> Result<()> {
|
||||
use portable_atomic::Ordering;
|
||||
|
||||
const BACKOFF_SCHEDULE_SECS: [u64; 6] = [1, 2, 5, 10, 20, 30];
|
||||
// A run that stayed up at least this long counts as "was actually
|
||||
// connected", so a later drop restarts the backoff from the top instead
|
||||
// of inheriting a long delay from a previous flaky stretch.
|
||||
const STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut backoff_idx = 0usize;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let attempt_start = std::time::Instant::now();
|
||||
let result = run_client_once(config.clone(), metrics.clone(), shutdown_rx_ext.clone(), config_rx.clone()).await;
|
||||
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
// Shutdown was requested during (or right after) this attempt — honor it, don't retry.
|
||||
return result;
|
||||
}
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!("client run ended unexpectedly, will auto-reconnect: {e}");
|
||||
}
|
||||
|
||||
if attempt_start.elapsed() >= STABLE_UPTIME {
|
||||
backoff_idx = 0;
|
||||
}
|
||||
let delay = BACKOFF_SCHEDULE_SECS[backoff_idx.min(BACKOFF_SCHEDULE_SECS.len() - 1)];
|
||||
backoff_idx += 1;
|
||||
|
||||
// Reflect the retry wait as "connecting" rather than "disconnected".
|
||||
metrics.connection_state.store(1, Ordering::Relaxed);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(delay)) => {}
|
||||
_ = shutdown_rx_ext.changed() => {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_client_once(
|
||||
mut config: crate::config::ClientConfig,
|
||||
metrics: Arc<BridgeMetrics>,
|
||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.2.97+12
|
||||
version: 0.4.4+16
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ostp-gui",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
|
|
|
|||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -2696,7 +2696,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-gui"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"json_comments",
|
||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ostp-gui"
|
||||
version = "0.4.1"
|
||||
version = "0.4.4"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
|
@ -762,14 +762,40 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
|||
let params_str = format!("--port {} --token-file \"{}\"", port, token_file.display());
|
||||
let params_wstr: Vec<u16> = OsStr::new(¶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 = "kernel32")] extern "system" { fn GetLastError() -> u32; }
|
||||
|
||||
// Use the GUI executable's directory as the working directory so dependencies are found
|
||||
let cwd_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 0) };
|
||||
|
||||
if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); }
|
||||
|
||||
// Remove Mark of the Web (Zone.Identifier) so SmartScreen doesn't block UAC
|
||||
let zone_id = format!("{}:Zone.Identifier", exe.display());
|
||||
let _ = std::fs::remove_file(zone_id);
|
||||
|
||||
// Use SW_SHOWNORMAL (1) instead of SW_HIDE (0) because runas with SW_HIDE is automatically blocked by UAC
|
||||
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 1) };
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ostp-gui",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.4",
|
||||
"identifier": "com.ospab.ostp",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
|
|
|
|||
|
|
@ -280,10 +280,11 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> <!-- client-settings-card -->
|
||||
|
||||
<div class="app-version" id="app-version">OSTP GUI</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- settings-body -->
|
||||
</div> <!-- settings-screen -->
|
||||
|
||||
<!-- ── ADD PROFILE DROPDOWN ─────────────────────────────── -->
|
||||
<div id="add-menu" class="add-menu hidden">
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
|
||||
/* ── Reset ───────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; background: var(--c-bg); overflow: hidden; user-select: none; }
|
||||
html, body { width: 100%; height: 100%; background: var(--c-bg); user-select: none; }
|
||||
button { cursor: pointer; font-family: inherit; border: none; background: none; }
|
||||
input, textarea, select { font-family: inherit; }
|
||||
a { text-decoration: none; }
|
||||
|
|
@ -356,8 +356,12 @@ a { text-decoration: none; }
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 10px 18px;
|
||||
padding: 10px 4px;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-stat-label {
|
||||
font-size: 0.6rem;
|
||||
|
|
@ -408,19 +412,13 @@ a { text-decoration: none; }
|
|||
.settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.07) transparent;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.settings-body::-webkit-scrollbar { width: 3px; }
|
||||
.settings-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.07); border-radius: 10px; }
|
||||
.settings-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Profile list ────────────────────────────────────────────────────── */
|
||||
.profile-list {
|
||||
|
|
|
|||
|
|
@ -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"`).
|
||||
435
ostp/src/main.rs
435
ostp/src/main.rs
|
|
@ -1,6 +1,5 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use colored::Colorize;
|
||||
|
|
@ -30,6 +29,7 @@ enum Commands {
|
|||
mode: String,
|
||||
},
|
||||
/// Generate a new secure access key
|
||||
#[command(name = "gk", alias = "generate-key")]
|
||||
GenerateKey {
|
||||
/// Format for generated key (hex, base64)
|
||||
#[arg(long, default_value = "hex")]
|
||||
|
|
@ -70,6 +70,11 @@ enum Commands {
|
|||
ProxyEnv,
|
||||
/// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear))
|
||||
ProxyEnvClear,
|
||||
/// Upgrade the configuration file to the current schema. This is the
|
||||
/// ONLY place config migration ever runs — never automatically at
|
||||
/// startup or during install/update, so a config never changes shape
|
||||
/// without you asking it to.
|
||||
Migrate,
|
||||
}
|
||||
|
||||
/// Bridges the new subcommand-based CLI onto the original flat-flag dispatch
|
||||
|
|
@ -92,6 +97,60 @@ struct LegacyArgs {
|
|||
import: Option<String>,
|
||||
proxy_env: bool,
|
||||
proxy_env_clear: bool,
|
||||
migrate: bool,
|
||||
}
|
||||
|
||||
/// Asks the same TUN/mux/debug questions regardless of how a share link
|
||||
/// reached this config — connecting directly (`ostp connect <url>`) or
|
||||
/// importing it to disk (`ostp import <url>`). Previously only the connect
|
||||
/// path asked; `import` just wrote flat defaults with no way to turn any of
|
||||
/// this on short of hand-editing the resulting config.json.
|
||||
fn prompt_client_options(client_cfg: &mut ClientConfig) {
|
||||
use std::io::Write;
|
||||
let mut input = String::new();
|
||||
|
||||
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
|
||||
std::io::stdout().flush().unwrap();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
if input.trim().eq_ignore_ascii_case("y") {
|
||||
if let Some(tun) = &mut client_cfg.tun {
|
||||
tun.enable = true;
|
||||
}
|
||||
}
|
||||
|
||||
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
|
||||
std::io::stdout().flush().unwrap();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
if input.trim().eq_ignore_ascii_case("y") {
|
||||
print!("How many sessions? [5]: ");
|
||||
std::io::stdout().flush().unwrap();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let mut sessions = 5;
|
||||
if !input.trim().is_empty() {
|
||||
if let Ok(s) = input.trim().parse() {
|
||||
sessions = s;
|
||||
}
|
||||
}
|
||||
if client_cfg.mux.is_none() {
|
||||
client_cfg.mux = Some(MuxConfig {
|
||||
enabled: Some(true),
|
||||
sessions: Some(sessions),
|
||||
});
|
||||
} else if let Some(mux) = &mut client_cfg.mux {
|
||||
mux.enabled = Some(true);
|
||||
mux.sessions = Some(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
print!("Enable debug mode? [y/N]: ");
|
||||
std::io::stdout().flush().unwrap();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
if input.trim().eq_ignore_ascii_case("y") {
|
||||
client_cfg.debug = Some(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
||||
|
|
@ -171,226 +230,19 @@ fn parse_outbound_action(value: Option<String>) -> ostp_server::OutboundAction {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "mode", rename_all = "lowercase")]
|
||||
enum AppMode {
|
||||
Server(ServerConfig),
|
||||
Client(ClientConfig),
|
||||
Relay(RelayServerConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct UnifiedConfig {
|
||||
#[serde(flatten)]
|
||||
mode: AppMode,
|
||||
log_level: Option<String>,
|
||||
}
|
||||
|
||||
impl UnifiedConfig {
|
||||
fn validate(&self) -> Result<()> {
|
||||
match &self.mode {
|
||||
AppMode::Server(cfg) => {
|
||||
if cfg.access_keys.is_empty() {
|
||||
anyhow::bail!("Server configuration must contain at least one access_key.");
|
||||
}
|
||||
if let Some(outbound) = &cfg.outbound {
|
||||
if outbound.enabled {
|
||||
let action = outbound.default_action.as_deref().unwrap_or("direct");
|
||||
if action == "direct" && outbound.rules.is_empty() {
|
||||
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
|
||||
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
|
||||
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMode::Client(cfg) => {
|
||||
if cfg.access_key.is_empty() {
|
||||
anyhow::bail!("Client configuration must contain an access_key.");
|
||||
}
|
||||
}
|
||||
AppMode::Relay(cfg) => {
|
||||
if cfg.upstream_tcp.is_empty() {
|
||||
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
|
||||
}
|
||||
if cfg.upstream_api_url.is_empty() {
|
||||
anyhow::bail!("Relay configuration must specify upstream_api_url.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum UserConfig {
|
||||
Detailed {
|
||||
access_key: String,
|
||||
name: Option<String>,
|
||||
limit_bytes: Option<u64>,
|
||||
},
|
||||
KeyOnly(String),
|
||||
}
|
||||
|
||||
impl UserConfig {
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
UserConfig::KeyOnly(k) => k.clone(),
|
||||
UserConfig::Detailed { access_key, .. } => access_key.clone(),
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> Option<String> {
|
||||
match self {
|
||||
UserConfig::KeyOnly(_) => None,
|
||||
UserConfig::Detailed { name, .. } => name.clone(),
|
||||
}
|
||||
}
|
||||
pub fn limit(&self) -> Option<u64> {
|
||||
match self {
|
||||
UserConfig::KeyOnly(_) => None,
|
||||
UserConfig::Detailed { limit_bytes, .. } => limit_bytes.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ServerConfig {
|
||||
listen: ListenConfig,
|
||||
access_keys: Vec<UserConfig>,
|
||||
debug: Option<bool>,
|
||||
outbound: Option<OutboundConfig>,
|
||||
api: Option<ApiConfig>,
|
||||
fallback: Option<FallbackCfg>,
|
||||
transport: Option<TransportConfigRaw>,
|
||||
dns: Option<ostp_server::dns::DnsConfig>,
|
||||
}
|
||||
|
||||
/// Конфигурация Relay-узла в config.json
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct RelayServerConfig {
|
||||
/// Адрес(а) прослушивания (UDP + TCP UoT)
|
||||
listen: ListenConfig,
|
||||
/// Адрес upstream для TCP (UoT) трафика
|
||||
upstream_tcp: String,
|
||||
/// Адрес upstream для UDP трафика
|
||||
upstream_udp: String,
|
||||
/// URL API целевого сервера для синхронизации ключей
|
||||
upstream_api_url: String,
|
||||
/// Bearer-токен для API целевого сервера
|
||||
#[serde(default)]
|
||||
upstream_api_token: String,
|
||||
/// Интервал синхронизации ключей в секундах (по умолчанию 30)
|
||||
#[serde(default = "default_sync_interval")]
|
||||
sync_interval_secs: u64,
|
||||
debug: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_sync_interval() -> u64 { 30 }
|
||||
|
||||
/// Supports both single string "0.0.0.0:50000" and array ["0.0.0.0:50000", "[::]:50000"]
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum ListenConfig {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl ListenConfig {
|
||||
fn addresses(&self) -> Vec<String> {
|
||||
match self {
|
||||
ListenConfig::Single(s) => vec![s.clone()],
|
||||
ListenConfig::Multiple(v) => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn primary(&self) -> String {
|
||||
match self {
|
||||
ListenConfig::Single(s) => s.clone(),
|
||||
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ApiConfig {
|
||||
enabled: Option<bool>,
|
||||
bind: Option<String>,
|
||||
token: Option<String>,
|
||||
webpath: Option<String>,
|
||||
username: Option<String>,
|
||||
password_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct FallbackCfg {
|
||||
enabled: Option<bool>,
|
||||
listen: Option<String>,
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ClientConfig {
|
||||
server: String,
|
||||
access_key: String,
|
||||
mtu: Option<usize>,
|
||||
socks5_bind: Option<String>,
|
||||
tun: Option<TunConfig>,
|
||||
debug: Option<bool>,
|
||||
exclude: Option<ExcludeConfig>,
|
||||
mux: Option<MuxConfig>,
|
||||
transport: Option<TransportConfigRaw>,
|
||||
gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct TransportConfigRaw {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct TunConfig {
|
||||
enable: bool,
|
||||
wintun_path: Option<String>,
|
||||
ipv4_address: Option<String>,
|
||||
dns: Option<String>,
|
||||
kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct OutboundConfig {
|
||||
enabled: bool,
|
||||
protocol: String,
|
||||
address: String,
|
||||
port: u16,
|
||||
#[serde(default)]
|
||||
rules: Vec<OutboundRule>,
|
||||
default_action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct OutboundRule {
|
||||
domain_suffix: Option<Vec<String>>,
|
||||
ip_cidr: Option<Vec<String>>,
|
||||
protocol: Option<String>,
|
||||
action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ExcludeConfig {
|
||||
domains: Option<Vec<String>>,
|
||||
ips: Option<Vec<String>>,
|
||||
processes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct MuxConfig {
|
||||
enabled: Option<bool>,
|
||||
sessions: Option<usize>,
|
||||
}
|
||||
// The on-disk config.json shapes (client/server/relay + all nested types)
|
||||
// live in ostp_client::config now — this used to be ~220 lines of struct
|
||||
// definitions duplicated here with no other consumer able to see them,
|
||||
// which is exactly why ostp_client::migrate had to work against loosely
|
||||
// typed JSON instead of a real schema. `ClientFileConfig` is aliased back to
|
||||
// the bare `ClientConfig` name used throughout the rest of this file, so it
|
||||
// doesn't collide with `ostp_client::config::ClientConfig` (the RUNTIME
|
||||
// shape the engine actually uses — a different thing on purpose; see the
|
||||
// doc comment on that struct).
|
||||
use ostp_client::config::{
|
||||
AppMode, ClientFileConfig as ClientConfig, MuxConfig, TransportConfigRaw, TunConfig,
|
||||
UnifiedConfig,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
|
@ -1069,6 +921,7 @@ async fn run_app() -> Result<()> {
|
|||
import: None,
|
||||
proxy_env: false,
|
||||
proxy_env_clear: false,
|
||||
migrate: false,
|
||||
};
|
||||
|
||||
if let Some(cmd) = raw_args.command {
|
||||
|
|
@ -1084,6 +937,7 @@ async fn run_app() -> Result<()> {
|
|||
Commands::Import { url } => { args.import = Some(url); }
|
||||
Commands::ProxyEnv => { args.proxy_env = true; }
|
||||
Commands::ProxyEnvClear => { args.proxy_env_clear = true; }
|
||||
Commands::Migrate => { args.migrate = true; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1095,6 +949,10 @@ async fn run_app() -> Result<()> {
|
|||
return cmd_update(args.update_branch, args.target_version);
|
||||
}
|
||||
|
||||
if args.migrate {
|
||||
return cmd_migrate(&args.config);
|
||||
}
|
||||
|
||||
// ── Setup wizard: explicit flag or first-time (no config) ────────
|
||||
if args.setup {
|
||||
return run_setup_wizard(&args.config);
|
||||
|
|
@ -1180,8 +1038,9 @@ async fn run_app() -> Result<()> {
|
|||
|
||||
if let Some(import_url) = args.import {
|
||||
println!("{} Importing configuration from share link...", "[ostp]".cyan().bold());
|
||||
let client_cfg = parse_ostp_link(&import_url)
|
||||
let mut client_cfg = parse_ostp_link(&import_url)
|
||||
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
||||
prompt_client_options(&mut client_cfg);
|
||||
let unified = UnifiedConfig {
|
||||
mode: AppMode::Client(client_cfg),
|
||||
log_level: Some("info".to_string()),
|
||||
|
|
@ -1201,53 +1060,7 @@ async fn run_app() -> Result<()> {
|
|||
println!("{} Connecting via share link...", "[ostp]".cyan().bold());
|
||||
let mut client_cfg = parse_ostp_link(&url)
|
||||
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
|
||||
|
||||
// Interactive prompt for URL launch
|
||||
use std::io::Write;
|
||||
|
||||
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
|
||||
std::io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
if input.trim().eq_ignore_ascii_case("y") {
|
||||
if let Some(tun) = &mut client_cfg.tun {
|
||||
tun.enable = true;
|
||||
}
|
||||
}
|
||||
|
||||
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
|
||||
std::io::stdout().flush().unwrap();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
if input.trim().eq_ignore_ascii_case("y") {
|
||||
print!("How many sessions? [5]: ");
|
||||
std::io::stdout().flush().unwrap();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let mut sessions = 5;
|
||||
if !input.trim().is_empty() {
|
||||
if let Ok(s) = input.trim().parse() {
|
||||
sessions = s;
|
||||
}
|
||||
}
|
||||
if client_cfg.mux.is_none() {
|
||||
client_cfg.mux = Some(MuxConfig {
|
||||
enabled: Some(true),
|
||||
sessions: Some(sessions),
|
||||
});
|
||||
} else if let Some(mux) = &mut client_cfg.mux {
|
||||
mux.enabled = Some(true);
|
||||
mux.sessions = Some(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
print!("Enable debug mode? [y/N]: ");
|
||||
std::io::stdout().flush().unwrap();
|
||||
input.clear();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
if input.trim().eq_ignore_ascii_case("y") {
|
||||
client_cfg.debug = Some(true);
|
||||
}
|
||||
prompt_client_options(&mut client_cfg);
|
||||
|
||||
return run_client_directly(client_cfg).await;
|
||||
}
|
||||
|
|
@ -1549,8 +1362,16 @@ async fn run_app() -> Result<()> {
|
|||
})
|
||||
}).collect::<Vec<_>>();
|
||||
let host = get_or_ask_public_ip(&args.config);
|
||||
// Build DNS config and set owndns flag in subscribe links if DNS enabled
|
||||
let dns_cfg = server_cfg.dns;
|
||||
// Build DNS config and set owndns flag in subscribe links if DNS enabled.
|
||||
// Kept untyped (serde_json::Value) in the shared ServerConfig so
|
||||
// ostp-client doesn't need a dependency on ostp-server just to
|
||||
// name this type — deserialize it here instead, where both
|
||||
// crates are already in scope.
|
||||
let dns_cfg: Option<ostp_server::dns::DnsConfig> = server_cfg
|
||||
.dns
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?;
|
||||
// Pass all listen addresses for multi-listener support
|
||||
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
|
||||
}
|
||||
|
|
@ -1674,6 +1495,78 @@ fn cmd_update(_branch: String, _version: Option<String>) -> Result<()> {
|
|||
anyhow::bail!("The 'update' command is only supported on Linux/Unix systems.");
|
||||
}
|
||||
|
||||
/// The ONLY place config migration ever runs — see ostp_client::migrate for
|
||||
/// why (and for the actual field-by-field mapping). Never called
|
||||
/// automatically; only this explicit command touches an existing config's
|
||||
/// shape.
|
||||
fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
|
||||
if !config_path.exists() {
|
||||
anyhow::bail!("Configuration file not found at {:?}", config_path);
|
||||
}
|
||||
|
||||
let raw_content = fs::read_to_string(config_path)?;
|
||||
let mut stripped = json_comments::StripComments::new(raw_content.as_bytes());
|
||||
let mut content_str = String::new();
|
||||
{
|
||||
use std::io::Read;
|
||||
stripped.read_to_string(&mut content_str)?;
|
||||
}
|
||||
let parsed: serde_json::Value = serde_json::from_str(&content_str)
|
||||
.map_err(|e| anyhow!("Failed to parse {:?} as JSON: {}", config_path, e))?;
|
||||
|
||||
let kind = ostp_client::migrate::detect_kind(&parsed)
|
||||
.ok_or_else(|| anyhow!("Could not determine whether {:?} is a client, server, or relay config.", config_path))?;
|
||||
|
||||
let (migrated, report) = match kind {
|
||||
ostp_client::migrate::ConfigKind::Client => {
|
||||
let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed);
|
||||
if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); }
|
||||
(v, r)
|
||||
}
|
||||
ostp_client::migrate::ConfigKind::Server => {
|
||||
let (mut v, r) = ostp_client::migrate::migrate_server_json(parsed);
|
||||
if v.get("mode").is_none() { v["mode"] = serde_json::json!("server"); }
|
||||
(v, r)
|
||||
}
|
||||
ostp_client::migrate::ConfigKind::Relay => {
|
||||
// The relay shape hasn't changed since it was introduced — nothing to migrate yet.
|
||||
(parsed, ostp_client::migrate::MigrationReport::default())
|
||||
}
|
||||
};
|
||||
|
||||
if !report.changed {
|
||||
println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Prove the migrator's output actually matches the ONE canonical schema
|
||||
// (ostp_client::config) before ever touching the user's file — this is
|
||||
// what makes "single source of truth" a guarantee instead of just an
|
||||
// intention: if migrate.rs's hand-built JSON ever drifts from what
|
||||
// UnifiedConfig actually expects, this catches it here, not as a
|
||||
// corrupted config.json on someone's server.
|
||||
serde_json::from_value::<ostp_client::config::UnifiedConfig>(migrated.clone())
|
||||
.map_err(|e| anyhow!(
|
||||
"Internal error: the migrated config does not match the current schema ({e}). \
|
||||
Nothing was written — this is a bug in the migrator, please report it."
|
||||
))?;
|
||||
|
||||
let backup_path = config_path.with_extension("json.bak");
|
||||
fs::copy(config_path, &backup_path)?;
|
||||
println!("{} Original config backed up to {:?}", "[ostp]".cyan().bold(), backup_path);
|
||||
|
||||
let new_content = serde_json::to_string_pretty(&migrated)?;
|
||||
fs::write(config_path, new_content)?;
|
||||
|
||||
println!("{} Migrated {:?} — changes made:", "[ostp]".green().bold(), config_path);
|
||||
for note in &report.notes {
|
||||
println!(" - {note}");
|
||||
}
|
||||
println!("\n{} Run 'ostp check' to validate the migrated config.", "[ostp]".cyan().bold());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_elevated_for_tun() -> Result<()> {
|
||||
#[link(name = "shell32")]
|
||||
|
|
|
|||
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
|
||||
|
|
@ -178,52 +178,15 @@ if [ -f "$CONFIG_FILE" ]; then
|
|||
echo "Existing configuration found at $CONFIG_FILE."
|
||||
echo "Binary updated to ${LATEST_RELEASE:-latest}."
|
||||
|
||||
# ── Config migration: add new fields, preserve existing values ──
|
||||
echo "Checking for new config fields..."
|
||||
python3 << 'PYEOF'
|
||||
import json, sys
|
||||
|
||||
CONFIG = '/etc/ostp/config.json'
|
||||
|
||||
with open(CONFIG) as f:
|
||||
raw = f.read()
|
||||
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
|
||||
cfg = json.loads('\n'.join(lines))
|
||||
|
||||
changed = False
|
||||
|
||||
# Ensure api section has all modern fields
|
||||
if cfg.get('mode') == 'server':
|
||||
if 'api' not in cfg:
|
||||
cfg['api'] = {}
|
||||
changed = True
|
||||
|
||||
api_defaults = {
|
||||
'enabled': False,
|
||||
'bind': '0.0.0.0:9090',
|
||||
'webpath': '',
|
||||
'username': '',
|
||||
'password_hash': '',
|
||||
}
|
||||
for k, v in api_defaults.items():
|
||||
if k not in cfg['api']:
|
||||
cfg['api'][k] = v
|
||||
changed = True
|
||||
print(f'[migration] Added api.{k} = {json.dumps(v)}')
|
||||
|
||||
# Remove legacy "token" field if present
|
||||
if 'token' in cfg['api']:
|
||||
del cfg['api']['token']
|
||||
changed = True
|
||||
print('[migration] Removed legacy api.token field')
|
||||
|
||||
if changed:
|
||||
with open(CONFIG, 'w') as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
print('[ok] Config migrated: new fields added, existing data preserved.')
|
||||
else:
|
||||
print('[ok] Config is up to date, no migration needed.')
|
||||
PYEOF
|
||||
# Config SCHEMA migration does NOT happen here (or anywhere automatic) —
|
||||
# it used to be an ad-hoc Python snippet embedded right in this script,
|
||||
# silently rewriting config.json on every update. That's exactly the kind
|
||||
# of surprise this project no longer does: the ONE place a config's shape
|
||||
# is ever changed is the explicit `ostp migrate` command (see
|
||||
# ostp-client::migrate), which backs up the original file first and
|
||||
# prints exactly what it changed. If your config predates this install,
|
||||
# run it yourself:
|
||||
echo "If this config is from an older OSTP version, run 'ostp migrate' to upgrade it."
|
||||
|
||||
# Update systemd service to use new paths
|
||||
if [ -f "/etc/systemd/system/ostp.service" ]; then
|
||||
|
|
|
|||
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