Compare commits
48 Commits
244d3ad374
...
dee0288f2a
| Author | SHA1 | Date |
|---|---|---|
|
|
dee0288f2a | |
|
|
10ec253fa0 | |
|
|
cb59a5343f | |
|
|
b7bd8c20a5 | |
|
|
d725a4440b | |
|
|
caab8698ba | |
|
|
3e9e8845f1 | |
|
|
e52087ee8e | |
|
|
2092f6c716 | |
|
|
223f02287a | |
|
|
1d1a1ea5af | |
|
|
1b3390a3cf | |
|
|
7d9e5faeec | |
|
|
eda2a0eba7 | |
|
|
22c2d5edd8 | |
|
|
fa7ec2cd9a | |
|
|
794ea5251b | |
|
|
c6d506e6c0 | |
|
|
61091b6d56 | |
|
|
aa1c4ccd52 | |
|
|
5ab6833eab | |
|
|
5dc3a60017 | |
|
|
a33e5d3874 | |
|
|
e21acc2ee1 | |
|
|
1568db3323 | |
|
|
edb2d8e229 | |
|
|
d609a3e883 | |
|
|
43914055b3 | |
|
|
3df5d5fccf | |
|
|
3d531ee0d9 | |
|
|
2819e2b3c2 | |
|
|
b89c6b0950 | |
|
|
992c212c76 | |
|
|
db581ca391 | |
|
|
a547ebff17 | |
|
|
d065f6ceca | |
|
|
d822f48891 | |
|
|
26665a826f | |
|
|
7b43e1dcf7 | |
|
|
b17e5499eb | |
|
|
ec947ec9d1 | |
|
|
0ec09d1311 | |
|
|
f81610f939 | |
|
|
114011df5a | |
|
|
f96daaf57d | |
|
|
6929d42736 | |
|
|
5e0ff4a7ef | |
|
|
c330a0abe3 |
|
|
@ -1,39 +1,107 @@
|
|||
name: CI/CD
|
||||
|
||||
# `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
|
||||
|
||||
# `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-alpha"), 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
|
||||
# the bare branch name ("alpha"/"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
|
||||
# "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/') && (contains(github.ref_name, 'beta') && format('CI/CD: beta version {0}', github.ref_name) || contains(github.ref_name, 'nightly') && format('CI/CD: nightly version {0}', github.ref_name) || format('CI/CD: release version {0}', github.ref_name)) || format('CI/CD: {0} channel build', github.ref_name) }}"
|
||||
run-name: "${{ startsWith(github.ref, 'refs/tags/') && (contains(github.ref_name, 'beta') && format('CI/CD: beta version {0}', github.ref_name) || contains(github.ref_name, 'alpha') && format('CI/CD: alpha version {0}', github.ref_name) || format('CI/CD: release version {0}', github.ref_name)) || format('CI/CD: {0} channel build', github.ref_name) }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- 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: alpha
|
||||
options:
|
||||
- alpha
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# ── Global defaults ─────────────────────────────────────────────────────────
|
||||
# -- Global defaults ---------------------------------------------------------
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
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 `alpha` -> "{version}-alpha" (rolling, same tag every push)
|
||||
# - push to `pre-release` -> "{version}-beta" (rolling, same tag every push)
|
||||
# - workflow_dispatch -> forced by the `channel` input (alpha|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
|
||||
# A pushed tag is authoritative — use it AS-IS (never recompute it
|
||||
# from Cargo.toml, or the release would upload to a different tag than
|
||||
# the one that triggered this run). The channel, and thus prerelease,
|
||||
# is decided by the tag's suffix: v0.4.7-beta / v0.4.7-alpha are
|
||||
# prereleases; a bare vX.Y.Z is the only thing that becomes stable.
|
||||
TAG="${{ github.ref_name }}"
|
||||
case "$TAG" in
|
||||
*-alpha*) CHANNEL="alpha" ;;
|
||||
*-beta*) CHANNEL="beta" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
else
|
||||
# No tag (workflow_dispatch, or a legacy branch push): pick the
|
||||
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
|
||||
CHANNEL="beta"
|
||||
else
|
||||
CHANNEL="alpha"
|
||||
fi
|
||||
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
|
||||
|
|
@ -71,13 +139,13 @@ 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
|
||||
matrix:
|
||||
include:
|
||||
# ── Windows ──────────────────────────────────────────────────────
|
||||
# -- Windows ------------------------------------------------------
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: ostp.exe
|
||||
|
|
@ -96,7 +164,7 @@ jobs:
|
|||
release_name: ostp-windows-arm64.zip
|
||||
wintun_arch: arm64
|
||||
|
||||
# ── macOS ─────────────────────────────────────────────────────────
|
||||
# -- macOS ---------------------------------------------------------
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: ostp
|
||||
|
|
@ -107,7 +175,7 @@ jobs:
|
|||
artifact_name: ostp
|
||||
release_name: ostp-darwin-arm64.tar.gz
|
||||
|
||||
# ── Linux native ──────────────────────────────────────────────────
|
||||
# -- Linux native --------------------------------------------------
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
|
|
@ -119,7 +187,7 @@ jobs:
|
|||
release_name: ostp-linux-386.tar.gz
|
||||
use_cross: true
|
||||
|
||||
# ── Linux cross ───────────────────────────────────────────────────
|
||||
# -- Linux cross ---------------------------------------------------
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
|
|
@ -157,7 +225,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ── Frontend Build ─────────────────────────────────────────────────────
|
||||
# -- Frontend Build -----------------------------------------------------
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
@ -170,18 +238,18 @@ jobs:
|
|||
if [ -f package.json ]; then
|
||||
npm install && npm run build
|
||||
else
|
||||
echo "ostp-control has no package.json — using committed dist/"
|
||||
echo "ostp-control has no package.json - using committed dist/"
|
||||
[ -f dist/index.html ] || echo '<!doctype html><title>OSTP</title>' > dist/index.html
|
||||
fi
|
||||
|
||||
# ── Rust toolchain ─────────────────────────────────────────────────────
|
||||
# -- Rust toolchain -----------------------------------------------------
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain || 'stable' }}
|
||||
targets: ${{ !matrix.use_cross && matrix.target || '' }}
|
||||
|
||||
# ── Cargo cache (shared per target) ───────────────────────────────────
|
||||
# -- Cargo cache (shared per target) -----------------------------------
|
||||
- name: Restore Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
|
@ -194,18 +262,18 @@ jobs:
|
|||
restore-keys: |
|
||||
cargo-${{ matrix.target }}-
|
||||
|
||||
# ── MUSL tools for native Linux musl builds ────────────────────────────
|
||||
# -- MUSL tools for native Linux musl builds ----------------------------
|
||||
- name: Install musl-tools
|
||||
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
# ── Native build ───────────────────────────────────────────────────────
|
||||
# -- Native build -------------------------------------------------------
|
||||
- name: Build (native)
|
||||
if: ${{ !matrix.use_cross }}
|
||||
shell: bash
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
# ── Cross build ────────────────────────────────────────────────────────
|
||||
# -- Cross build --------------------------------------------------------
|
||||
- name: Restore cross binary cache
|
||||
if: ${{ matrix.use_cross }}
|
||||
id: cross-cache
|
||||
|
|
@ -222,7 +290,7 @@ jobs:
|
|||
if: ${{ matrix.use_cross }}
|
||||
run: cross build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
# ── Driver dependencies ────────────────────────────────────────────────
|
||||
# -- Driver dependencies ------------------------------------------------
|
||||
- name: Download wintun (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
|
|
@ -234,7 +302,7 @@ jobs:
|
|||
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/"
|
||||
Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
|
||||
|
||||
# ── Package ────────────────────────────────────────────────────────────
|
||||
# -- Package ------------------------------------------------------------
|
||||
- name: Package (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
|
|
@ -253,26 +321,23 @@ jobs:
|
|||
FILES="${{ matrix.artifact_name }}"
|
||||
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
|
||||
|
||||
# ── Upload ─────────────────────────────────────────────────────────────
|
||||
# -- Upload -------------------------------------------------------------
|
||||
- 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}-alpha" / "{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:
|
||||
|
|
@ -339,22 +404,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}-alpha" / "{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:
|
||||
|
|
@ -407,22 +469,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}-alpha" / "{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:
|
||||
|
|
@ -472,22 +531,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}-alpha" / "{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:
|
||||
|
|
@ -548,15 +604,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}-alpha" / "{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 }}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
**/*.rs.bk
|
||||
.idea/
|
||||
.vscode/
|
||||
**/node_modules/
|
||||
|
||||
# Binaries & libraries
|
||||
*.exe
|
||||
|
|
@ -25,6 +26,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
|
||||
|
||||
|
|
@ -39,3 +44,6 @@ ostp-brain/
|
|||
|
||||
# Management panel built assets (built separately; dummy dist created for rust-embed build)
|
||||
ostp-control/
|
||||
|
||||
.agents/
|
||||
netstack-smoltcp/
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
127.0.0.1
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"target_version": "0.4.1",
|
||||
"branch": "master",
|
||||
"alpha_iteration": 0,
|
||||
"beta_iteration": 0
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|---|---|
|
||||
| `alpha` | Active development. All feature work and fixes land here first. |
|
||||
| `pre-release` | Periodically fast-forwarded from `alpha` 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 `alpha`**, 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 `alpha`:
|
||||
```bash
|
||||
git checkout alpha
|
||||
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 `alpha` 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 @@
|
|||
|
||||
---
|
||||
|
||||
## Стратегия веток
|
||||
|
||||
В репозитории три долгоживущие ветки, по возрастанию стабильности:
|
||||
|
||||
| Ветка | Роль |
|
||||
|---|---|
|
||||
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
||||
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||
|
||||
В `pre-release` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
|
||||
|
||||
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
|
||||
|
||||
---
|
||||
|
||||
## Процесс разработки
|
||||
|
||||
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `alpha`:
|
||||
```bash
|
||||
git checkout alpha
|
||||
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) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
|
||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||
|
||||
|
|
|
|||
701
LICENSE
|
|
@ -1,74 +1,661 @@
|
|||
Business Source License 1.1
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Parameters
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Licensor: Ospab Foundation (represented by Syralev Georgiy)
|
||||
Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all
|
||||
associated workspace crates, utilities, and documents.
|
||||
Additional Use Grant: The Licensor hereby grants you the right to copy,
|
||||
modify, create derivative works, redistribute, and
|
||||
make non-production and non-commercial use of the
|
||||
Licensed Work. You are also permitted to use the
|
||||
Licensed Work in production for personal, private
|
||||
utility and non-profit organizations.
|
||||
Change Date: May 14, 2030
|
||||
Change License: MIT License (as defined below)
|
||||
Preamble
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
Terms
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
1. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
||||
redistribute, and make use of the Licensed Work only as permitted by the
|
||||
Additional Use Grant.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
2. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
||||
redistribute, and make use of the Licensed Work under the terms of the Change
|
||||
License on and after the Change Date.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
3. To the extent that any term of this License (including the Additional Use Grant
|
||||
and the Change License) is in conflict with the Terms of this License, these
|
||||
Terms shall take precedence.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
4. Every copy of the Licensed Work and any derivative work must include this
|
||||
License and all other copyright, trademark, and proprietary notices included
|
||||
with the Licensed Work.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
5. Any use of the Licensed Work that is not permitted by this License is a breach
|
||||
of this License and may terminate your rights under this License.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED
|
||||
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR
|
||||
WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY
|
||||
OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE,
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
|
||||
WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL,
|
||||
CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE
|
||||
USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
0. Definitions.
|
||||
|
||||
Change License Text (MIT License)
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
Copyright (c) 2026 Syralev Georgiy (Ospab Foundation)
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
69
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# OSTP — Ospab Stealth Transport Protocol
|
||||
# OSTP - Ospab Stealth Transport Protocol
|
||||
|
||||
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
||||
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
> A fast, custom encrypted transport protocol written in Rust.
|
||||
|
||||
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
|
||||
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire - including packet headers - is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,16 +35,16 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Full Traffic Obfuscation** | Every packet — including headers — is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||
| **Full Traffic Obfuscation** | Every packet - including headers - is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
|
||||
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption — tracked by session-ID, not IP. |
|
||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption - tracked by session-ID, not IP. |
|
||||
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
|
||||
| **Fallback Server** | TCP fallback proxy to a web server — makes OSTP indistinguishable from nginx during active probing. |
|
||||
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
|
||||
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
||||
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
||||
| **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel that completely hides traffic. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
||||
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
||||
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
||||
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
||||
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
||||
|
|
@ -95,15 +95,15 @@ 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
|
||||
|
||||
**Server** — set your access keys:
|
||||
**Server** - set your access keys:
|
||||
```jsonc
|
||||
{
|
||||
"mode": "server",
|
||||
|
|
@ -114,14 +114,14 @@ graph TD
|
|||
}
|
||||
```
|
||||
|
||||
**Client** — point to your server:
|
||||
**Client** - point to your server:
|
||||
```jsonc
|
||||
{
|
||||
"mode": "client",
|
||||
"server": "YOUR_SERVER_IP:50000",
|
||||
"access_key": "YOUR_SECRET_KEY",
|
||||
"socks5_bind": "127.0.0.1:1088",
|
||||
"transport": { "mode": "udp", "stealth_sni": "vk.com" },
|
||||
"transport": { "mode": "udp" },
|
||||
"tun": { "enable": false, "dns": "1.1.1.1" }
|
||||
}
|
||||
```
|
||||
|
|
@ -131,14 +131,14 @@ graph TD
|
|||
```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 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,33 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
|
|||
## CLI Reference
|
||||
|
||||
```
|
||||
ostp [OPTIONS] [URL]
|
||||
ostp [--config <PATH>] [COMMAND]
|
||||
|
||||
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)
|
||||
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)
|
||||
--links Print client share links from server config
|
||||
-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, alpha (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
|
||||
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
|
||||
|
||||
Arguments:
|
||||
[URL] Connect via share link: ostp://KEY@HOST:PORT
|
||||
Global options:
|
||||
--config <PATH> Config file path (default: config.json)
|
||||
```
|
||||
|
||||
Every subcommand also accepts `-h`/`--help` for its own option list.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Summary
|
||||
|
|
@ -218,7 +230,7 @@ cargo test -p ostp-core -p ostp-server
|
|||
|
||||
## Documentation
|
||||
|
||||
- **[Wiki](https://github.com/ospab/ostp/wiki)** — Full documentation
|
||||
- **[Wiki](https://github.com/ospab/ostp/wiki)** - Full documentation
|
||||
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
|
||||
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
|
||||
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||
|
|
@ -230,8 +242,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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
50
README.ru.md
|
|
@ -1,4 +1,4 @@
|
|||
# OSTP — Ospab Stealth Transport Protocol
|
||||
# OSTP - Ospab Stealth Transport Protocol
|
||||
|
||||
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
||||
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
> Быстрый кастомный зашифрованный транспортный протокол на Rust.
|
||||
|
||||
**OSTP** (Ospab Stealth Transport Protocol) — кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
|
||||
**OSTP** (Ospab Stealth Transport Protocol) - кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -19,12 +19,12 @@
|
|||
| Возможность | Описание |
|
||||
|-------------|----------|
|
||||
| **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. Session ID и nonce маскируются HMAC-ключами, уникальными для каждого пакета. |
|
||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
|
||||
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии — сервер отслеживает session-ID, а не IP-адрес. |
|
||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
|
||||
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
||||
| **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, который полностью скрывает трафик. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
||||
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
||||
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
||||
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
||||
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
||||
|
|
@ -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`)
|
||||
|
|
@ -116,8 +116,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
"debug": false,
|
||||
// Настройки транспорта (udp или uot)
|
||||
"transport": {
|
||||
"mode": "udp",
|
||||
"stealth_sni": "vk.com"
|
||||
"mode": "udp"
|
||||
},
|
||||
// TUN-режим (полносистемный VPN)
|
||||
"tun": {
|
||||
|
|
@ -156,6 +155,36 @@ 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, alpha (по умолчанию stable)
|
||||
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||
migrate Принудительно мигрировать конфиг к текущему формату
|
||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||
proxy-env-clear Вывести shell-команды для их отмены
|
||||
uninstall Остановить сервис и удалить бинарник с конфигом
|
||||
|
||||
Глобальные опции:
|
||||
--config <PATH> Путь к конфигу (по умолчанию config.json)
|
||||
```
|
||||
|
||||
У каждой подкоманды есть своя справка через `-h`/`--help`.
|
||||
|
||||
### TUN-режим (Windows)
|
||||
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
||||
|
||||
|
|
@ -204,5 +233,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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 0c5c52a57d899c05428c116898941761a2ed83c2
|
||||
|
|
@ -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-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.
|
||||
|
After Width: | Height: | Size: 769 KiB |
|
|
@ -65,7 +65,6 @@ pub struct Bridge {
|
|||
pub mux_sessions: usize,
|
||||
|
||||
pub transport_mode: String,
|
||||
pub stealth_sni: String,
|
||||
pub tcp_fragmentation: bool,
|
||||
pub frag_chunk: usize,
|
||||
pub frag_sleep: u64,
|
||||
|
|
@ -102,7 +101,6 @@ impl Bridge {
|
|||
mux_sessions: config.multiplex.sessions.max(1),
|
||||
|
||||
transport_mode: config.transport.mode.clone(),
|
||||
stealth_sni: config.transport.stealth_sni.clone(),
|
||||
tcp_fragmentation: config.transport.tcp_fragmentation,
|
||||
frag_chunk: config.transport.frag_chunk,
|
||||
frag_sleep: config.transport.frag_sleep,
|
||||
|
|
@ -1033,7 +1031,6 @@ impl Bridge {
|
|||
self.mux_enabled = cfg.multiplex.enabled;
|
||||
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
||||
self.transport_mode = cfg.transport.mode.clone();
|
||||
self.stealth_sni = cfg.transport.stealth_sni.clone();
|
||||
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
|
||||
self.frag_chunk = cfg.transport.frag_chunk.max(1);
|
||||
self.frag_sleep = cfg.transport.frag_sleep;
|
||||
|
|
@ -1060,9 +1057,14 @@ impl Bridge {
|
|||
let frag_sleep = self.frag_sleep;
|
||||
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
||||
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
||||
// Per-key junk marker (derived from the access key) — NOT a global
|
||||
// constant, so junk frames carry no universal DPI signature.
|
||||
let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker;
|
||||
// Time-rotating per-key junk marker — NOT a global constant and NOT
|
||||
// even a static per-user value: it changes every window, so junk
|
||||
// carries no fixed DPI signature on the wire. All frames in this
|
||||
// burst are sent within milliseconds, so one window applies to all.
|
||||
let junk_marker = ostp_core::crypto::derive_junk_marker(
|
||||
&self.access_key,
|
||||
ostp_core::crypto::current_junk_window(),
|
||||
);
|
||||
|
||||
{
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
|
|
|||
|
|
@ -70,15 +70,13 @@ pub struct LocalProxyConfig {
|
|||
}
|
||||
|
||||
/// Transport layer configuration.
|
||||
/// `mode` = "udp" (default) or "uot" (UDP over TCP с xHTTP-транспортом).
|
||||
/// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry —
|
||||
/// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransportConfig {
|
||||
/// "udp" or "uot"
|
||||
#[serde(default = "default_transport_mode")]
|
||||
pub mode: String,
|
||||
/// TLS SNI and HTTP Host for xHTTP routing
|
||||
#[serde(default)]
|
||||
pub stealth_sni: String,
|
||||
/// Split the first UoT/TCP packet (handshake) into tiny TCP segments to
|
||||
/// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
|
||||
pub tcp_fragmentation: bool,
|
||||
|
|
@ -106,7 +104,6 @@ impl Default for TransportConfig {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
mode: default_transport_mode(),
|
||||
stealth_sni: String::new(),
|
||||
tcp_fragmentation: false,
|
||||
frag_chunk: default_frag_chunk(),
|
||||
frag_sleep: default_frag_sleep(),
|
||||
|
|
@ -192,7 +189,6 @@ struct RawUnifiedConfig {
|
|||
#[derive(Debug, Deserialize)]
|
||||
struct RawTransportSection {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
frag_chunk: Option<usize>,
|
||||
frag_sleep: Option<u64>,
|
||||
|
|
@ -270,7 +266,6 @@ impl ClientConfig {
|
|||
},
|
||||
transport: TransportConfig {
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),
|
||||
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
||||
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk),
|
||||
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
|
||||
|
|
@ -293,3 +288,248 @@ 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 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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,53 @@ use std::io::Write;
|
|||
use std::path::PathBuf;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
/// The single canonical log file for the whole core. Every process (CLI daemon,
|
||||
/// GUI, TUN helper) and every subsystem (tracing, the core event logger, the
|
||||
/// helper IPC, panics) writes here — no more per-binary / per-subsystem sprawl
|
||||
/// (`ostp-cli.log` + `ostp-core.log` + `ostp-helper.log` + `ostp-crash.log`).
|
||||
pub const LOG_FILE_NAME: &str = "ostp.log";
|
||||
|
||||
/// Absolute path to the shared log file, next to the running executable.
|
||||
pub fn log_file_path() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(LOG_FILE_NAME)))
|
||||
.unwrap_or_else(|| PathBuf::from(LOG_FILE_NAME))
|
||||
}
|
||||
|
||||
/// True if this invocation is the long-running daemon (a client/server run),
|
||||
/// as opposed to a one-shot subcommand (`gk`, `check`, `init`, `-V`, ...).
|
||||
///
|
||||
/// Used to gate log truncation: only the daemon clears the log at startup, so a
|
||||
/// one-shot command run while a daemon is live can never wipe the daemon's log.
|
||||
/// A daemon invocation is simply one that carries none of the one-shot tokens
|
||||
/// (`ostp`, `ostp run`, `ostp connect <url>` → daemon; everything else → one-shot).
|
||||
pub fn invocation_is_daemon<I: IntoIterator<Item = String>>(args: I) -> bool {
|
||||
const ONE_SHOT: &[&str] = &[
|
||||
"gk", "generate-key", "check", "init", "setup", "links", "import",
|
||||
"update", "migrate", "prober", "proxy-env", "proxy-env-clear",
|
||||
"uninstall", "-V", "--version", "-h", "--help", "help",
|
||||
];
|
||||
!args
|
||||
.into_iter()
|
||||
.skip(1) // program name
|
||||
.any(|a| ONE_SHOT.contains(&a.as_str()))
|
||||
}
|
||||
|
||||
/// Append a single timestamped line to the shared log file. Used by the manual
|
||||
/// writers (core event logger, TUN helper IPC) so their output lands in the same
|
||||
/// `ostp.log` as the tracing subscriber instead of a separate file.
|
||||
pub fn append_line(msg: &str) {
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let payload = info.payload();
|
||||
|
|
@ -29,19 +76,16 @@ pub fn setup_panic_hook() {
|
|||
eprintln!("{}", crash_msg);
|
||||
tracing::error!("{}", crash_msg);
|
||||
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("ostp-crash.log")))
|
||||
.unwrap_or_else(|| PathBuf::from("ostp-crash.log"));
|
||||
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
// Crashes land in the same shared log file (append — a crash must never
|
||||
// truncate, and the tracing worker may already be dead so we write direct).
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||
let _ = file.write_all(crash_msg.as_bytes());
|
||||
let _ = file.write_all(b"\n===================================================\n");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Initialises tracing and writes to `<app_name>.log` next to the executable.
|
||||
/// Initialises tracing and writes to the shared `ostp.log` next to the executable.
|
||||
///
|
||||
/// The `level` parameter controls the minimum log level:
|
||||
/// - `"error"` — only errors
|
||||
|
|
@ -51,7 +95,17 @@ pub fn setup_panic_hook() {
|
|||
/// - `"trace"` — all messages including very verbose internal state
|
||||
///
|
||||
/// The environment variable `RUST_LOG` overrides this value if set.
|
||||
pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
///
|
||||
/// `truncate`: clear the log at startup. Honoured **only on Windows** — Linux
|
||||
/// servers keep their history (OS-rotated). Pass `true` only from the daemon's
|
||||
/// own entrypoint; one-shot commands and child processes (the TUN helper) pass
|
||||
/// `false` so they append instead of wiping a running daemon's log.
|
||||
pub fn init_tracing(
|
||||
level: &str,
|
||||
app_name: &str,
|
||||
version: &str,
|
||||
truncate: bool,
|
||||
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
// RUST_LOG overrides the config-derived level
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| {
|
||||
|
|
@ -66,12 +120,39 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
|||
}
|
||||
});
|
||||
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
|
||||
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
|
||||
let path = log_file_path();
|
||||
|
||||
let mut open_opts = OpenOptions::new();
|
||||
open_opts.create(true);
|
||||
// Truncate-on-startup is Windows-only and daemon-only. Everywhere else append:
|
||||
// Linux keeps server history, and one-shot commands / the TUN helper must not
|
||||
// wipe a running daemon's log.
|
||||
if truncate && cfg!(windows) {
|
||||
open_opts.write(true).truncate(true);
|
||||
} else {
|
||||
open_opts.append(true);
|
||||
}
|
||||
|
||||
if let Ok(mut file) = open_opts.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(),
|
||||
);
|
||||
|
||||
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
|
|
@ -92,16 +173,6 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
|||
.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,559 @@
|
|||
//! 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.
|
||||
/// - **configs carrying a leftover `transport.stealth_sni`**: dropped with a
|
||||
/// note, same reasoning as `wss` — it never fed into anything on the wire
|
||||
/// (no TLS/HTTP mimicry exists in this project), so there is no successor
|
||||
/// field. Not tied to a specific version: it lingered in the schema well
|
||||
/// past when the mimicry work it was meant for got removed.
|
||||
/// - **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(),
|
||||
);
|
||||
}
|
||||
if transport.remove("stealth_sni").is_some() {
|
||||
report.note(
|
||||
"Dropped transport.stealth_sni — never actually used to construct any wire \
|
||||
bytes (no TLS/HTTP mimicry exists in this project — same zapret-like \
|
||||
reasoning as transport.wss), so it was unused config plumbing with no effect."
|
||||
.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();
|
||||
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
|
||||
if !sni.is_empty() {
|
||||
report.note(format!(
|
||||
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
|
||||
any wire bytes; unused config plumbing with no successor field."
|
||||
));
|
||||
}
|
||||
}
|
||||
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,
|
||||
"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"]["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"]));
|
||||
// stealth_sni never fed into any wire bytes — dropped, not carried forward.
|
||||
assert!(new["transport"].get("stealth_sni").is_none());
|
||||
assert!(report.notes.iter().any(|n| n.contains("stealth_sni") && n.contains("vk.com")));
|
||||
}
|
||||
|
||||
/// 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, transport.stealth_sni)
|
||||
/// — 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"]));
|
||||
// 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());
|
||||
assert!(new["transport"].get("stealth_sni").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")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("stealth_sni")));
|
||||
}
|
||||
|
||||
/// 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", "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));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,9 @@ use std::fs::OpenOptions;
|
|||
use std::io::Write as _;
|
||||
|
||||
fn log_to_core_file(msg: &str) {
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("ostp-core.log")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("ostp-core.log"));
|
||||
// Writes into the single shared ostp.log (same file as the tracing appender),
|
||||
// not a separate ostp-core.log — see logging::LOG_FILE_NAME.
|
||||
let path = crate::logging::log_file_path();
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
|
||||
}
|
||||
|
|
@ -183,7 +182,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>,
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ pub fn enable_system_proxy(proxy_addr: &str) {
|
|||
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
||||
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
||||
println!("configure your system proxy. To route traffic from this terminal, run:");
|
||||
println!("\n eval $(ostp --proxy-env)\n");
|
||||
println!("\n eval $(ostp proxy-env)\n");
|
||||
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
||||
println!("===================================================================\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ pub use noise::{NoiseRole, NoiseSession};
|
|||
pub use obfuscation::{
|
||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
||||
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -59,11 +59,10 @@ pub struct DerivedSecrets {
|
|||
pub psk: [u8; 32],
|
||||
pub handshake_pad_min: usize,
|
||||
pub handshake_pad_max: usize,
|
||||
/// Per-key 4-byte prefix stamped on junk frames so the server can drop them
|
||||
/// without a GLOBAL constant marker (which would be a universal DPI signature
|
||||
/// for all OSTP users — exactly what the version gate avoids for the handshake).
|
||||
pub junk_marker: [u8; 4],
|
||||
}
|
||||
// NOTE: the junk marker is NOT part of DerivedSecrets — it is time-rotating and
|
||||
// derived separately per window via `derive_junk_marker` (see below), so it
|
||||
// carries no static per-user signature.
|
||||
|
||||
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
||||
/// wire) so peers running incompatible versions derive entirely different
|
||||
|
|
@ -129,25 +128,61 @@ pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> De
|
|||
let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
|
||||
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175
|
||||
|
||||
// Derive junk marker (4 bytes) — info = key_hash[16..] || 0x04.
|
||||
// Per-key: to an outsider it is indistinguishable from the random junk
|
||||
// payload, so there is no cross-user signature; the server, knowing the key,
|
||||
// derives the same marker and drops the junk silently.
|
||||
let mut junk_info = info_base.to_vec();
|
||||
junk_info.push(0x04);
|
||||
let junk_bytes = hkdf_expand(&prk, &junk_info, 4);
|
||||
let mut junk_marker = [0u8; 4];
|
||||
junk_marker.copy_from_slice(&junk_bytes);
|
||||
|
||||
DerivedSecrets {
|
||||
obfuscation_key,
|
||||
psk,
|
||||
handshake_pad_min: pad_min,
|
||||
handshake_pad_max: pad_max,
|
||||
junk_marker,
|
||||
}
|
||||
}
|
||||
|
||||
/// Window length (seconds) for the rotating junk marker. The marker changes
|
||||
/// every window, so junk carries no static per-user fingerprint on the wire;
|
||||
/// the server checks the current and previous window to absorb clock skew.
|
||||
pub const JUNK_MARKER_WINDOW_SECS: u64 = 60;
|
||||
|
||||
/// The current junk-marker time window (unix seconds / window length).
|
||||
pub fn current_junk_window() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() / JUNK_MARKER_WINDOW_SECS)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Derive the 4-byte junk marker for a given time `window`.
|
||||
///
|
||||
/// Uses the same version-gated HKDF scheme as [`derive_all_secrets`], with the
|
||||
/// window folded into the `info` (label byte `0x04`). Folding in the window
|
||||
/// makes the marker rotate: to an on-path observer the junk prefix changes every
|
||||
/// window (no fixed signature), and a captured marker is only valid for ~1
|
||||
/// window. Only a holder of the access key can compute it, so an outsider cannot
|
||||
/// forge a silently-dropped junk packet.
|
||||
pub fn derive_junk_marker(access_key: &[u8], window: u64) -> [u8; 4] {
|
||||
derive_junk_marker_versioned(access_key, window, PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
pub(crate) fn derive_junk_marker_versioned(access_key: &[u8], window: u64, version: u8) -> [u8; 4] {
|
||||
use sha2::Digest;
|
||||
let key_hash = sha2::Sha256::digest(access_key);
|
||||
let salt = &key_hash[..16];
|
||||
let info_base = &key_hash[16..];
|
||||
|
||||
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||
ikm.extend_from_slice(access_key);
|
||||
ikm.push(version);
|
||||
let prk = hkdf_extract(salt, &ikm);
|
||||
|
||||
// info = key_hash[16..] || 0x04 || window(LE) — same label byte as before,
|
||||
// now parameterised by the time window.
|
||||
let mut info = info_base.to_vec();
|
||||
info.push(0x04);
|
||||
info.extend_from_slice(&window.to_le_bytes());
|
||||
let bytes = hkdf_expand(&prk, &info, 4);
|
||||
let mut marker = [0u8; 4];
|
||||
marker.copy_from_slice(&bytes);
|
||||
marker
|
||||
}
|
||||
|
||||
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
||||
|
||||
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
||||
|
|
|
|||
|
|
@ -191,4 +191,29 @@ mod tests {
|
|||
assert_eq!(recovered_nonce, nonce);
|
||||
assert_eq!(&packet[12..], &ciphertext);
|
||||
}
|
||||
|
||||
/// The junk marker must: be stable within a window (client and server agree),
|
||||
/// rotate across windows (no static on-wire fingerprint), and differ per key
|
||||
/// (one user's marker never silently-drops on another user's flow).
|
||||
#[test]
|
||||
fn test_junk_marker_rotation() {
|
||||
let key_a = b"access-key-alpha";
|
||||
let key_b = b"access-key-bravo";
|
||||
|
||||
// Stable within a window.
|
||||
assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000));
|
||||
|
||||
// Rotates across adjacent windows.
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001));
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999));
|
||||
|
||||
// Distinct per key within the same window.
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000));
|
||||
|
||||
// A different protocol version yields a different marker (version gate).
|
||||
assert_ne!(
|
||||
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION),
|
||||
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,12 +92,29 @@ class MainActivity : FlutterActivity() {
|
|||
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
||||
result.success(metrics ?: "{}")
|
||||
} catch (e: Throwable) {
|
||||
// Surfaced into the in-app log viewer (not just logcat) so a
|
||||
// broken traffic counter is diagnosable from a user's bug
|
||||
// report without adb access.
|
||||
android.util.Log.e("MainActivity", "getMetrics failed", e)
|
||||
try {
|
||||
net.ostp.client.OstpClientSdk.addLog("getMetrics failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} catch (_: Throwable) {}
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"getInstalledApps" -> {
|
||||
try {
|
||||
// MethodChannel handlers run on the main/UI thread by default.
|
||||
// Enumerating every installed package AND decoding+re-encoding
|
||||
// each one's icon to PNG/base64 is expensive (100+ apps is
|
||||
// common) — done inline here it blocked the main thread for
|
||||
// 10-15s, during which Flutter couldn't render ANY frame, not
|
||||
// even the "loading" spinner, so the screen just appeared to
|
||||
// hang before jumping straight to the fully-loaded list.
|
||||
// Do the work on a background thread; only the final
|
||||
// `result.success(...)` needs to hop back onto the UI thread.
|
||||
val pm = packageManager
|
||||
Thread {
|
||||
try {
|
||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
val list = apps.map { app ->
|
||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
||||
|
|
@ -110,10 +127,11 @@ class MainActivity : FlutterActivity() {
|
|||
"icon" to (iconBase64 ?: "")
|
||||
)
|
||||
}
|
||||
result.success(list)
|
||||
runOnUiThread { result.success(list) }
|
||||
} catch (e: Exception) {
|
||||
result.error("ERROR", e.message, null)
|
||||
runOnUiThread { result.error("ERROR", e.message, null) }
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -26,11 +26,11 @@ class OstpApp extends StatelessWidget {
|
|||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: const Color(0xFF08080F),
|
||||
scaffoldBackgroundColor: const Color(0xFF000000),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: Color(0xFF6C72FF),
|
||||
secondary: Color(0xFF22D3A5),
|
||||
surface: Color(0xFF151522),
|
||||
primary: Color(0xFFFFFFFF),
|
||||
secondary: Color(0xFFAAAAAA),
|
||||
surface: Color(0xFF111111),
|
||||
),
|
||||
fontFamily: 'Inter',
|
||||
useMaterial3: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:convert';
|
||||
|
||||
/// A saved server profile. Field shape mirrors the desktop GUI's profile
|
||||
/// object (ostp-gui/src/main.js) 1:1 — server/key/transport/tcp_fragmentation/
|
||||
/// frag_chunk/frag_sleep/junk_pc/junk_ps — so behavior matches across
|
||||
/// platforms. `wss` was dropped: the core no longer supports TLS-mimicry
|
||||
/// transports (only plain UDP / UoT), so there is nothing left to carry it.
|
||||
class OstpProfile {
|
||||
String id;
|
||||
String name;
|
||||
String serverAddr;
|
||||
String accessKey;
|
||||
String transportMode; // 'udp' | 'uot'
|
||||
bool active;
|
||||
|
||||
// Junk packets + TCP fragmentation — per-profile, exactly like ostp-gui's
|
||||
// profile editor. Defaults match ostp_client::config::TransportConfig's
|
||||
// own defaults (frag_chunk=2, frag_sleep=2, junk_pc=[2,5], junk_ps=[100,1000]).
|
||||
bool tcpFragmentation;
|
||||
int fragChunk;
|
||||
int fragSleep;
|
||||
int junkPcMin;
|
||||
int junkPcMax;
|
||||
int junkPsMin;
|
||||
int junkPsMax;
|
||||
|
||||
OstpProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.serverAddr,
|
||||
required this.accessKey,
|
||||
this.transportMode = 'udp',
|
||||
this.active = false,
|
||||
this.tcpFragmentation = false,
|
||||
this.fragChunk = 2,
|
||||
this.fragSleep = 2,
|
||||
this.junkPcMin = 2,
|
||||
this.junkPcMax = 5,
|
||||
this.junkPsMin = 100,
|
||||
this.junkPsMax = 1000,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'serverAddr': serverAddr,
|
||||
'accessKey': accessKey,
|
||||
'transportMode': transportMode,
|
||||
'active': active,
|
||||
'tcpFragmentation': tcpFragmentation,
|
||||
'fragChunk': fragChunk,
|
||||
'fragSleep': fragSleep,
|
||||
'junkPcMin': junkPcMin,
|
||||
'junkPcMax': junkPcMax,
|
||||
'junkPsMin': junkPsMin,
|
||||
'junkPsMax': junkPsMax,
|
||||
};
|
||||
}
|
||||
|
||||
factory OstpProfile.fromJson(Map<String, dynamic> json) {
|
||||
return OstpProfile(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? 'Unnamed Profile',
|
||||
serverAddr: json['serverAddr'] as String? ?? '',
|
||||
accessKey: json['accessKey'] as String? ?? '',
|
||||
transportMode: json['transportMode'] as String? ?? 'udp',
|
||||
active: json['active'] as bool? ?? false,
|
||||
tcpFragmentation: json['tcpFragmentation'] as bool? ?? false,
|
||||
fragChunk: json['fragChunk'] as int? ?? 2,
|
||||
fragSleep: json['fragSleep'] as int? ?? 2,
|
||||
junkPcMin: json['junkPcMin'] as int? ?? 2,
|
||||
junkPcMax: json['junkPcMax'] as int? ?? 5,
|
||||
junkPsMin: json['junkPsMin'] as int? ?? 100,
|
||||
junkPsMax: json['junkPsMax'] as int? ?? 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<OstpProfile> decodeProfiles(String? json) {
|
||||
if (json == null || json.isEmpty) return [];
|
||||
try {
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
return decoded.map((e) => OstpProfile.fromJson(e)).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
String encodeProfiles(List<OstpProfile> profiles) =>
|
||||
jsonEncode(profiles.map((e) => e.toJson()).toList());
|
||||
|
|
@ -15,6 +15,13 @@ class AppRoutingScreen extends StatefulWidget {
|
|||
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
|
||||
}
|
||||
|
||||
/// Picks readable black/white text for a given (opaque) background color.
|
||||
/// The monochrome theme's `primary` is pure white — hardcoded white text on
|
||||
/// top of it was invisible; this picks the contrasting color instead.
|
||||
Color _onColor(Color bg) {
|
||||
return ThemeData.estimateBrightnessForColor(bg) == Brightness.light ? Colors.black : Colors.white;
|
||||
}
|
||||
|
||||
class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||
|
||||
|
|
@ -154,10 +161,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
|||
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Bypass Mode',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _routingMode == 'bypass' ? _onColor(theme.colorScheme.primary) : Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -181,10 +191,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
|||
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Proxy Mode',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _routingMode == 'proxy' ? _onColor(theme.colorScheme.secondary) : Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import '../models/connection_state_enum.dart';
|
||||
import '../models/ostp_profile.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'logs_screen.dart';
|
||||
import 'app_routing_screen.dart';
|
||||
import 'qr_scanner_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
final SharedPreferences prefs;
|
||||
|
|
@ -28,12 +24,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
Timer? _uptimeTimer;
|
||||
int _uptimeSecs = 0;
|
||||
|
||||
String _serverAddr = '127.0.0.1:443';
|
||||
String _accessKey = 'default_key';
|
||||
// Single active profile — the core only ever connects to one server at a
|
||||
// time (no multi-server/urltest failover since the 0.4.x flat config),
|
||||
// matching how the desktop GUI picks exactly one profile as `activeId`.
|
||||
OstpProfile? _activeProfile;
|
||||
|
||||
String _download = '0 B';
|
||||
String _upload = '0 B';
|
||||
|
||||
// Live throughput (bytes/sec, computed from deltas between polls) and RTT
|
||||
// are optional, same as the desktop GUI's "Show Speed" / "Show RTT" toggles
|
||||
// in client settings — default on, persisted in prefs.
|
||||
bool _showSpeed = true;
|
||||
bool _showRtt = true;
|
||||
String _downSpeed = '0 B/s';
|
||||
String _upSpeed = '0 B/s';
|
||||
int _prevBytesRecv = 0;
|
||||
int _prevBytesSent = 0;
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _spinController;
|
||||
|
||||
|
|
@ -70,37 +78,43 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
|
||||
void _loadSettings() {
|
||||
setState(() {
|
||||
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
|
||||
_accessKey = widget.prefs.getString('access_key') ?? '';
|
||||
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
|
||||
// Single-select: if more than one is somehow marked active (shouldn't
|
||||
// happen — the editor enforces exclusivity — but don't crash on stale data).
|
||||
final actives = profiles.where((p) => p.active).toList();
|
||||
_activeProfile = actives.isNotEmpty ? actives.first : null;
|
||||
_showSpeed = widget.prefs.getBool('show_speed') ?? true;
|
||||
_showRtt = widget.prefs.getBool('show_rtt') ?? true;
|
||||
});
|
||||
_updateLatestConfigJson();
|
||||
}
|
||||
|
||||
void _updateLatestConfigJson() {
|
||||
|
||||
/// Builds the exact JSON the native core (ostp-jni) deserializes as
|
||||
/// `ostp_client::config::ClientConfig`. Field names/nesting must match that
|
||||
/// struct precisely — unknown keys are silently ignored by serde, so a typo
|
||||
/// here doesn't fail loudly, it just quietly does nothing.
|
||||
Map<String, dynamic> _buildConfigMap() {
|
||||
final p = _activeProfile;
|
||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||
final dnsServer = widget.prefs.getString('dns_server');
|
||||
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||
final tunStack = 'ostp';
|
||||
const tunStack = 'ostp';
|
||||
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||
|
||||
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||
final configMap = {
|
||||
|
||||
return {
|
||||
"mode": "client",
|
||||
"debug": debugMode,
|
||||
"ostp": {
|
||||
"server_addr": _serverAddr,
|
||||
"server_addr": p?.serverAddr ?? '',
|
||||
"local_bind_addr": "0.0.0.0:0",
|
||||
"access_key": _accessKey,
|
||||
"access_key": p?.accessKey ?? '',
|
||||
"handshake_timeout_ms": 10000,
|
||||
"io_timeout_ms": 5000,
|
||||
"mtu": int.tryParse(mtu) ?? 1140,
|
||||
|
|
@ -109,34 +123,40 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
"bind_addr": localBind,
|
||||
"connect_timeout_ms": 15000,
|
||||
},
|
||||
// Junk packets + TCP fragmentation are per-profile settings — same
|
||||
// shape as the desktop GUI's profile object — not global toggles.
|
||||
"transport": {
|
||||
"mode": transportMode,
|
||||
"stealth_sni": stealthSni,
|
||||
"mode": p?.transportMode ?? 'udp',
|
||||
"tcp_fragmentation": p?.tcpFragmentation ?? false,
|
||||
"frag_chunk": p?.fragChunk ?? 2,
|
||||
"frag_sleep": p?.fragSleep ?? 2,
|
||||
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
|
||||
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
|
||||
},
|
||||
"multiplex": {
|
||||
"enabled": muxEnabled,
|
||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||
},
|
||||
"tun": {
|
||||
"enable": true,
|
||||
"stack": tunStack
|
||||
},
|
||||
"exclusions": {
|
||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||
// No per-process exclusion field on mobile — Android's per-app
|
||||
// selection (app_rules below) is the equivalent, and correct, control.
|
||||
"processes": const [],
|
||||
},
|
||||
"app_rules": {
|
||||
"mode": appRoutingMode,
|
||||
"packages": appRoutingPackages,
|
||||
},
|
||||
"dns_server": effectiveDnsServer,
|
||||
"tun_stack": tunStack
|
||||
"tun_stack": tunStack,
|
||||
};
|
||||
}
|
||||
|
||||
void _updateLatestConfigJson() {
|
||||
final configMap = _buildConfigMap();
|
||||
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||
platform.invokeMethod('saveConfig', {
|
||||
"configJson": jsonEncode(configMap)
|
||||
});
|
||||
platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -150,9 +170,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
|
||||
Future<void> _toggleConnection() async {
|
||||
if (_state == ConnectionStateEnum.disconnected) {
|
||||
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
||||
if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please configure Server and Key in Settings')),
|
||||
const SnackBar(content: Text('Please select or add a profile in Settings')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -163,73 +183,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
_pulseController.repeat(reverse: true);
|
||||
_spinController.repeat();
|
||||
|
||||
final dnsServer = widget.prefs.getString('dns_server');
|
||||
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
||||
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||
final tunStack = 'ostp';
|
||||
|
||||
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||
|
||||
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||
final configMap = {
|
||||
"mode": "client",
|
||||
"debug": debugMode,
|
||||
"ostp": {
|
||||
"server_addr": _serverAddr,
|
||||
"local_bind_addr": "0.0.0.0:0",
|
||||
"access_key": _accessKey,
|
||||
"handshake_timeout_ms": 10000,
|
||||
"io_timeout_ms": 5000,
|
||||
"mtu": int.tryParse(mtu) ?? 1140,
|
||||
},
|
||||
"local_proxy": {
|
||||
"bind_addr": localBind,
|
||||
"connect_timeout_ms": 15000,
|
||||
},
|
||||
"transport": {
|
||||
"mode": transportMode,
|
||||
"stealth_sni": stealthSni,
|
||||
},
|
||||
"multiplex": {
|
||||
"enabled": muxEnabled,
|
||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||
},
|
||||
"tun": {
|
||||
"enable": true,
|
||||
"stack": tunStack
|
||||
},
|
||||
"exclusions": {
|
||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||
},
|
||||
"app_rules": {
|
||||
"mode": appRoutingMode,
|
||||
"packages": appRoutingPackages,
|
||||
},
|
||||
"dns_server": dnsServer,
|
||||
"tun_stack": tunStack
|
||||
};
|
||||
|
||||
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||
|
||||
final configMap = _buildConfigMap();
|
||||
final configStr = jsonEncode(configMap);
|
||||
widget.prefs.setString('latest_config_json', configStr);
|
||||
|
||||
try {
|
||||
await platform.invokeMethod('saveConfig', {
|
||||
"configJson": jsonEncode(configMap)
|
||||
});
|
||||
await platform.invokeMethod('startTunnel', {
|
||||
"configJson": jsonEncode(configMap)
|
||||
});
|
||||
await platform.invokeMethod('saveConfig', {"configJson": configStr});
|
||||
await platform.invokeMethod('startTunnel', {"configJson": configStr});
|
||||
|
||||
bool started = false;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
|
|
@ -289,30 +249,34 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cycles transport mode x MTU to find a working combination against the
|
||||
/// active profile's server. WSS/Reality are gone (the core dropped
|
||||
/// TLS-mimicry transports entirely — see §A), so this only has udp/uot x
|
||||
/// MTU left to probe; junk/frag stay at whatever the active profile has set.
|
||||
Future<void> _runAutoMode() async {
|
||||
final mtus = [1500, 1350, 1280, 1140];
|
||||
final modes = [
|
||||
{'t': 'udp'},
|
||||
{'t': 'uot'},
|
||||
];
|
||||
final modes = ['udp', 'uot'];
|
||||
|
||||
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
||||
final active = _activeProfile;
|
||||
if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please configure Server and Key first')),
|
||||
const SnackBar(content: Text('Please select a profile with a server and key first')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var mode in modes) {
|
||||
for (var mtu in mtus) {
|
||||
final originalMode = active.transportMode;
|
||||
final originalMtu = widget.prefs.getString('mtu') ?? '1140';
|
||||
|
||||
for (final mode in modes) {
|
||||
for (final mtu in mtus) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
||||
SnackBar(content: Text('Testing: $mode | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
|
||||
// Update prefs
|
||||
await widget.prefs.setString('mtu', mtu.toString());
|
||||
await widget.prefs.setString('transport_mode', mode['t'] as String);
|
||||
active.transportMode = mode;
|
||||
_updateLatestConfigJson();
|
||||
|
||||
setState(() {
|
||||
|
|
@ -337,7 +301,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
|
||||
if (started) {
|
||||
_setConnected();
|
||||
// Wait to see if connection is stable and ping is successful
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
try {
|
||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||
|
|
@ -345,30 +308,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||
if (rttMs > 0) {
|
||||
// Working combo found — persist it onto the profile.
|
||||
_persistActiveProfile();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')),
|
||||
SnackBar(content: Text('Success! Found working config: $mode (MTU $mtu)')),
|
||||
);
|
||||
}
|
||||
return; // Stop on first working config
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore metrics error
|
||||
} catch (_) {
|
||||
// Ignore metrics error, fall through to try next combo.
|
||||
}
|
||||
|
||||
// Connection seems unstable or no ping, stop and try next
|
||||
await platform.invokeMethod('stopTunnel');
|
||||
_setDisconnected();
|
||||
} else {
|
||||
_setDisconnected();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
_setDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No working combo found — revert the active profile/mtu to what they
|
||||
// were before probing so we don't leave it on a broken guess.
|
||||
active.transportMode = originalMode;
|
||||
await widget.prefs.setString('mtu', originalMtu);
|
||||
_updateLatestConfigJson();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Auto search finished. No working config found.')),
|
||||
|
|
@ -376,6 +346,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
}
|
||||
}
|
||||
|
||||
void _persistActiveProfile() {
|
||||
final active = _activeProfile;
|
||||
if (active == null) return;
|
||||
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
|
||||
final idx = profiles.indexWhere((p) => p.id == active.id);
|
||||
if (idx >= 0) {
|
||||
profiles[idx] = active;
|
||||
widget.prefs.setString('profiles_json', encodeProfiles(profiles));
|
||||
}
|
||||
}
|
||||
|
||||
void _setConnected() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
@ -433,6 +414,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
setState(() {
|
||||
_download = _formatBytes(bytesRecv);
|
||||
_upload = _formatBytes(bytesSent);
|
||||
final dRecv = bytesRecv > _prevBytesRecv ? bytesRecv - _prevBytesRecv : 0;
|
||||
final dSent = bytesSent > _prevBytesSent ? bytesSent - _prevBytesSent : 0;
|
||||
_prevBytesRecv = bytesRecv;
|
||||
_prevBytesSent = bytesSent;
|
||||
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||
if (rttMs > 0 && !_isCheckingPing) {
|
||||
_pingText = 'Server Ping: $rttMs ms';
|
||||
if (rttMs < 100) {
|
||||
|
|
@ -469,7 +456,30 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
_pingColor = Colors.white70;
|
||||
});
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
try {
|
||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||
if (metricsJson != null && metricsJson.isNotEmpty) {
|
||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (rttMs > 0) {
|
||||
_pingText = 'Server Ping: $rttMs ms';
|
||||
_pingColor = rttMs < 100
|
||||
? const Color(0xFF22D3A5)
|
||||
: rttMs < 250
|
||||
? Colors.amberAccent
|
||||
: Colors.redAccent;
|
||||
} else {
|
||||
_pingText = 'Server Ping: -- ms';
|
||||
_pingColor = Colors.white54;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Failed to check latency: $e");
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -484,6 +494,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
_state = ConnectionStateEnum.disconnected;
|
||||
_download = '0 B';
|
||||
_upload = '0 B';
|
||||
_downSpeed = '0 B/s';
|
||||
_upSpeed = '0 B/s';
|
||||
_prevBytesRecv = 0;
|
||||
_prevBytesSent = 0;
|
||||
_pingText = 'Target Ping: -- ms';
|
||||
_pingColor = Colors.white54;
|
||||
_isCheckingPing = false;
|
||||
|
|
@ -510,31 +524,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -150, right: -100,
|
||||
child: Container(
|
||||
width: 400, height: 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.primary.withOpacity(0.15),
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.1,
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/logo.png',
|
||||
width: MediaQuery.of(context).size.shortestSide * 0.6,
|
||||
// No color tint needed — the asset now carries real alpha
|
||||
// (background pixels' luminance was baked into alpha, see
|
||||
// git history), so it's already a pure white silhouette.
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||
child: Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -100, left: -100,
|
||||
child: Container(
|
||||
width: 350, height: 350,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.secondary.withOpacity(0.1),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
||||
child: Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -745,12 +745,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
AnimatedOpacity(
|
||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -764,7 +758,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_serverAddr,
|
||||
_activeProfile?.name ?? 'No profile selected',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 15,
|
||||
|
|
@ -775,8 +769,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
|
||||
if (_showRtt)
|
||||
AnimatedOpacity(
|
||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -839,7 +839,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
@ -856,15 +855,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary),
|
||||
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary, _showSpeed ? _downSpeed : null),
|
||||
Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)),
|
||||
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary),
|
||||
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary, _showSpeed ? _upSpeed : null),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricItem(IconData icon, String label, String value, Color color) {
|
||||
Widget _buildMetricItem(IconData icon, String label, String value, Color color, [String? speed]) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
|
@ -903,6 +902,19 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (speed != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
speed,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -911,4 +923,3 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.1+19
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
|
|
@ -72,9 +72,8 @@ flutter:
|
|||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/logo.png
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
[package]
|
||||
name = "ostp-gui"
|
||||
version = "0.4.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
description = "OSTP desktop GUI"
|
||||
authors = ["ospab"]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ struct TunConfig {
|
|||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
struct TransportConfigRaw {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
frag_chunk: Option<usize>,
|
||||
frag_sleep: Option<u64>,
|
||||
|
|
@ -167,7 +166,6 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi
|
|||
|
||||
transport: ostp_client::config::TransportConfig {
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
|
||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
|
||||
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
||||
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or(2),
|
||||
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2),
|
||||
|
|
@ -767,6 +765,7 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
|||
// 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();
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ fn main() {
|
|||
// Read config BEFORE init_tracing so we can use the correct log level from config.
|
||||
// If config is missing or debug=false we default to "info".
|
||||
let log_level = detect_log_level_from_config();
|
||||
let _log_guard = ostp_client::logging::init_tracing(&log_level, "ostp-gui", env!("CARGO_PKG_VERSION"));
|
||||
// The GUI launch IS the daemon's startup, so clear the shared log here
|
||||
// (Windows-only inside init_tracing). The elevated TUN helper spawned later
|
||||
// passes truncate=false so it appends instead of wiping this session's log.
|
||||
let _log_guard = ostp_client::logging::init_tracing(&log_level, "ostp-gui", env!("CARGO_PKG_VERSION"), true);
|
||||
|
||||
tracing::info!("ostp-gui starting (log_level={})", log_level);
|
||||
|
||||
|
|
@ -28,7 +31,7 @@ fn main() {
|
|||
{
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let msg_w: Vec<u16> = OsStr::new(&format!("OSTP GUI crashed:\n\n{}\n\nSee ostp-gui.log for details.", msg))
|
||||
let msg_w: Vec<u16> = OsStr::new(&format!("OSTP GUI crashed:\n\n{}\n\nSee ostp.log for details.", msg))
|
||||
.encode_wide().chain(Some(0)).collect();
|
||||
let title_w: Vec<u16> = OsStr::new("OSTP GUI — Fatal Error").encode_wide().chain(Some(0)).collect();
|
||||
#[link(name = "user32")] extern "system" {
|
||||
|
|
|
|||
|
|
@ -302,6 +302,40 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div class="section-divider-mini"><span>Obfuscation</span></div>
|
||||
|
||||
<div class="toggle-row" style="border-top:none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">Junk Packets</span>
|
||||
<span class="toggle-hint">Random noise before connection (UoT only)</span>
|
||||
</div>
|
||||
<div class="toggle-with-gear">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="cs-junk-enabled" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
<button id="btn-junk-settings" class="gear-btn" title="Configure junk packets">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row" style="border-top:none;">
|
||||
<div class="toggle-text">
|
||||
<span class="toggle-name">TCP Fragmentation</span>
|
||||
<span class="toggle-hint">Split handshake into segments (UoT only)</span>
|
||||
</div>
|
||||
<div class="toggle-with-gear">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="cs-tcp-frag" />
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
</label>
|
||||
<button id="btn-frag-settings" class="gear-btn" title="Configure fragmentation">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- client-settings-card -->
|
||||
|
||||
<div class="app-version" id="app-version">OSTP GUI</div>
|
||||
|
|
@ -457,6 +491,58 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── JUNK PACKETS MODAL ──────────────────────────────────── -->
|
||||
<div id="junk-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content compact">
|
||||
<h3 class="modal-title">Junk Packets</h3>
|
||||
<p class="modal-text">Sends random garbage frames before the Noise handshake to confuse DPI pattern matching. Active in UoT/TCP mode only.</p>
|
||||
<div class="modal-row-2">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="cs-junk-pc-min">Count min</label>
|
||||
<input id="cs-junk-pc-min" class="field-input" type="number" min="0" max="100" placeholder="2" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="cs-junk-pc-max">Count max</label>
|
||||
<input id="cs-junk-pc-max" class="field-input" type="number" min="0" max="100" placeholder="5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-row-2">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="cs-junk-ps-min">Size min (bytes)</label>
|
||||
<input id="cs-junk-ps-min" class="field-input" type="number" min="1" placeholder="100" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="cs-junk-ps-max">Size max (bytes)</label>
|
||||
<input id="cs-junk-ps-max" class="field-input" type="number" min="1" placeholder="1000" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-junk-done" class="btn primary">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TCP FRAGMENTATION MODAL ──────────────────────────────── -->
|
||||
<div id="frag-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content compact">
|
||||
<h3 class="modal-title">TCP Fragmentation</h3>
|
||||
<p class="modal-text">Splits the initial handshake into tiny TCP segments with delays between them. Prevents DPI from reading the full handshake signature. Active in UoT/TCP mode only.</p>
|
||||
<div class="modal-row-2">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="cs-frag-chunk">Chunk size (bytes)</label>
|
||||
<input id="cs-frag-chunk" class="field-input" type="number" min="1" placeholder="2" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="cs-frag-sleep">Delay (ms)</label>
|
||||
<input id="cs-frag-sleep" class="field-input" type="number" min="0" placeholder="2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="btn-frag-done" class="btn primary">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,22 @@ const inShowSpeed = $('in-show-speed');
|
|||
const groupKillSwitch = $('group-kill-switch');
|
||||
const groupMuxSessions = $('group-mux-sessions');
|
||||
|
||||
const inJunkEnabled = $('cs-junk-enabled');
|
||||
const btnJunkSettings = $('btn-junk-settings');
|
||||
const junkModal = $('junk-modal');
|
||||
const inJunkPcMin = $('cs-junk-pc-min');
|
||||
const inJunkPcMax = $('cs-junk-pc-max');
|
||||
const inJunkPsMin = $('cs-junk-ps-min');
|
||||
const inJunkPsMax = $('cs-junk-ps-max');
|
||||
const btnJunkDone = $('btn-junk-done');
|
||||
|
||||
const inTcpFrag = $('cs-tcp-frag');
|
||||
const btnFragSettings = $('btn-frag-settings');
|
||||
const fragModal = $('frag-modal');
|
||||
const inFragChunk = $('cs-frag-chunk');
|
||||
const inFragSleep = $('cs-frag-sleep');
|
||||
const btnFragDone = $('btn-frag-done');
|
||||
|
||||
// ── UTILITIES ─────────────────────────────────────────────────────────
|
||||
function fmtBytes(b) {
|
||||
if (!b || b === 0) return '0 B';
|
||||
|
|
@ -307,11 +323,11 @@ function buildConfig() {
|
|||
debug: !!s.debug,
|
||||
transport: {
|
||||
mode: active.transport || 'udp',
|
||||
tcp_fragmentation: !!active.tcp_fragmentation,
|
||||
frag_chunk: active.frag_chunk || 2,
|
||||
frag_sleep: active.frag_sleep || 2,
|
||||
junk_pc: active.junk_pc || [2, 5],
|
||||
junk_ps: active.junk_ps || [100, 1000]
|
||||
tcp_fragmentation: s.tcpFrag || !!active.tcp_fragmentation,
|
||||
frag_chunk: s.tcpFrag ? (s.fragChunk || 2) : (active.frag_chunk || 2),
|
||||
frag_sleep: s.tcpFrag ? (!isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2) : (active.frag_sleep !== undefined ? active.frag_sleep : 2),
|
||||
junk_pc: s.junkEnabled ? [s.junkPcMin || 2, s.junkPcMax || 5] : (active.junk_pc || [2, 5]),
|
||||
junk_ps: s.junkEnabled ? [s.junkPsMin || 100, s.junkPsMax || 1000] : (active.junk_ps || [100, 1000])
|
||||
},
|
||||
tun: {
|
||||
enable: !!s.tun,
|
||||
|
|
@ -668,6 +684,14 @@ function loadSettingsIntoForm() {
|
|||
inDebug.checked = !!s.debug;
|
||||
inShowRtt.checked = s.showRtt !== false;
|
||||
inShowSpeed.checked = s.showSpeed !== false;
|
||||
inJunkEnabled.checked = !!s.junkEnabled;
|
||||
inJunkPcMin.value = s.junkPcMin || 2;
|
||||
inJunkPcMax.value = s.junkPcMax || 5;
|
||||
inJunkPsMin.value = s.junkPsMin || 100;
|
||||
inJunkPsMax.value = s.junkPsMax || 1000;
|
||||
inTcpFrag.checked = !!s.tcpFrag;
|
||||
inFragChunk.value = s.fragChunk || 2;
|
||||
inFragSleep.value = !isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2;
|
||||
updateClientVisibility();
|
||||
}
|
||||
|
||||
|
|
@ -688,6 +712,14 @@ function collectAndSaveSettings() {
|
|||
debug: inDebug.checked,
|
||||
showRtt: inShowRtt.checked,
|
||||
showSpeed: inShowSpeed.checked,
|
||||
junkEnabled: inJunkEnabled.checked,
|
||||
junkPcMin: parseInt(inJunkPcMin.value) || 2,
|
||||
junkPcMax: parseInt(inJunkPcMax.value) || 5,
|
||||
junkPsMin: parseInt(inJunkPsMin.value) || 100,
|
||||
junkPsMax: parseInt(inJunkPsMax.value) || 1000,
|
||||
tcpFrag: inTcpFrag.checked,
|
||||
fragChunk: parseInt(inFragChunk.value) || 2,
|
||||
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
|
||||
};
|
||||
saveClientSettings(s);
|
||||
updateClientVisibility();
|
||||
|
|
@ -881,9 +913,9 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); });
|
||||
|
||||
// Client settings — wire all inputs
|
||||
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed]
|
||||
[inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag]
|
||||
.forEach(el => el.addEventListener('change', collectAndSaveSettings));
|
||||
[inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs]
|
||||
[inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs, inJunkPcMin, inJunkPcMax, inJunkPsMin, inJunkPsMax, inFragChunk, inFragSleep]
|
||||
.forEach(el => {
|
||||
el.addEventListener('input', () => {
|
||||
clearTimeout(el._saveTimer);
|
||||
|
|
@ -891,6 +923,21 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Junk and Frag modals
|
||||
btnJunkSettings.addEventListener('click', () => junkModal.classList.remove('hidden'));
|
||||
btnJunkDone.addEventListener('click', () => {
|
||||
collectAndSaveSettings();
|
||||
junkModal.classList.add('hidden');
|
||||
});
|
||||
junkModal.addEventListener('click', e => { if (e.target === junkModal) junkModal.classList.add('hidden'); });
|
||||
|
||||
btnFragSettings.addEventListener('click', () => fragModal.classList.remove('hidden'));
|
||||
btnFragDone.addEventListener('click', () => {
|
||||
collectAndSaveSettings();
|
||||
fragModal.classList.add('hidden');
|
||||
});
|
||||
fragModal.addEventListener('click', e => { if (e.target === fragModal) fragModal.classList.add('hidden'); });
|
||||
|
||||
// ── Global Keyboard Shortcuts (TUI emulation) ─────────────────────
|
||||
window.addEventListener('keydown', async e => {
|
||||
// Ignore if typing in an input or textarea
|
||||
|
|
@ -922,6 +969,8 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
!profileModal.classList.contains('hidden') ||
|
||||
!shareModal.classList.contains('hidden') ||
|
||||
!wintunModal.classList.contains('hidden') ||
|
||||
!junkModal.classList.contains('hidden') ||
|
||||
!fragModal.classList.contains('hidden') ||
|
||||
!addMenu.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@
|
|||
:root {
|
||||
--c-bg: #080808;
|
||||
--c-surface: #111111;
|
||||
--c-card: rgba(255,255,255,0.04);
|
||||
--c-card-border: rgba(255,255,255,0.09);
|
||||
--c-card-hover: rgba(255,255,255,0.07);
|
||||
--c-card: rgba(var(--c-fg-rgb), 0.04);
|
||||
--c-card-border: rgba(var(--c-fg-rgb), 0.09);
|
||||
--c-card-hover: rgba(var(--c-fg-rgb), 0.07);
|
||||
|
||||
--c-fg-rgb: 255, 255, 255;
|
||||
/* Accent = white */
|
||||
--c-accent: #ffffff;
|
||||
--c-accent-dim: rgba(255,255,255,0.08);
|
||||
--c-accent-glow: rgba(255,255,255,0.18);
|
||||
--c-accent-dim: rgba(var(--c-fg-rgb),0.08);
|
||||
--c-accent-glow: rgba(var(--c-fg-rgb),0.18);
|
||||
|
||||
/* Green only for "connected" state */
|
||||
--c-green: #e8e8e8;
|
||||
|
|
@ -50,6 +51,7 @@
|
|||
--c-card: rgba(0,0,0,0.03);
|
||||
--c-card-border: rgba(0,0,0,0.10);
|
||||
--c-card-hover: rgba(0,0,0,0.05);
|
||||
--c-fg-rgb: 0, 0, 0;
|
||||
--c-accent: #18181b;
|
||||
--c-accent-dim: rgba(0,0,0,0.08);
|
||||
--c-accent-glow: rgba(0,0,0,0.14);
|
||||
|
|
@ -187,7 +189,7 @@ a { text-decoration: none; }
|
|||
transition: all var(--t-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.icon-btn:hover { border-color: rgba(255,255,255,0.18); color: var(--c-txt-1); background: rgba(255,255,255,0.07); }
|
||||
.icon-btn:hover { border-color: rgba(var(--c-fg-rgb),0.18); color: var(--c-txt-1); background: rgba(var(--c-fg-rgb),0.07); }
|
||||
.icon-btn:active { transform: scale(0.91); }
|
||||
.add-btn:hover { border-color: var(--c-accent); color: var(--c-accent); }
|
||||
|
||||
|
|
@ -223,19 +225,19 @@ a { text-decoration: none; }
|
|||
|
||||
.orbit-wrap.connecting .orbit {
|
||||
animation: orbit-spin 2.5s linear infinite;
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
border-color: rgba(var(--c-fg-rgb),0.12);
|
||||
opacity: 1;
|
||||
}
|
||||
.orbit-wrap.connecting .orbit-2 { animation-duration: 3.8s; animation-direction: reverse; border-color: rgba(255,255,255,0.07); }
|
||||
.orbit-wrap.connecting .orbit-3 { animation-duration: 5.5s; border-color: rgba(255,255,255,0.04); }
|
||||
.orbit-wrap.connecting .orbit-2 { animation-duration: 3.8s; animation-direction: reverse; border-color: rgba(var(--c-fg-rgb),0.07); }
|
||||
.orbit-wrap.connecting .orbit-3 { animation-duration: 5.5s; border-color: rgba(var(--c-fg-rgb),0.04); }
|
||||
|
||||
.orbit-wrap.connected .orbit {
|
||||
animation: orbit-spin 4s linear infinite;
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
border-color: rgba(var(--c-fg-rgb),0.14);
|
||||
opacity: 1;
|
||||
}
|
||||
.orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(255,255,255,0.08); }
|
||||
.orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(255,255,255,0.04); }
|
||||
.orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(var(--c-fg-rgb),0.08); }
|
||||
.orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(var(--c-fg-rgb),0.04); }
|
||||
|
||||
@keyframes orbit-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
|
|
@ -253,24 +255,24 @@ a { text-decoration: none; }
|
|||
color: var(--c-txt-3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all var(--t-slow);
|
||||
box-shadow: 0 0 0 8px rgba(255,255,255,0.015), 0 8px 36px rgba(0,0,0,0.7);
|
||||
box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.015), 0 8px 36px rgba(0,0,0,0.7);
|
||||
}
|
||||
.power-btn:hover {
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
border-color: rgba(var(--c-fg-rgb),0.4);
|
||||
color: var(--c-txt-1);
|
||||
box-shadow: 0 0 0 10px rgba(255,255,255,0.04), 0 0 40px rgba(255,255,255,0.08), 0 8px 36px rgba(0,0,0,0.6);
|
||||
box-shadow: 0 0 0 10px rgba(var(--c-fg-rgb),0.04), 0 0 40px rgba(var(--c-fg-rgb),0.08), 0 8px 36px rgba(0,0,0,0.6);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.power-btn:active { transform: scale(0.96); }
|
||||
.power-btn.connecting {
|
||||
border-color: rgba(255,255,255,0.5);
|
||||
border-color: rgba(var(--c-fg-rgb),0.5);
|
||||
color: var(--c-txt-1);
|
||||
animation: btn-breathe 2s infinite ease-in-out;
|
||||
}
|
||||
.power-btn.connected {
|
||||
border-color: rgba(255,255,255,0.8);
|
||||
border-color: rgba(var(--c-fg-rgb),0.8);
|
||||
color: var(--c-txt-1);
|
||||
box-shadow: 0 0 0 8px rgba(255,255,255,0.04), 0 0 50px rgba(255,255,255,0.12), 0 8px 32px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.04), 0 0 50px rgba(var(--c-fg-rgb),0.12), 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.power-btn.error {
|
||||
border-color: var(--c-red);
|
||||
|
|
@ -278,8 +280,8 @@ a { text-decoration: none; }
|
|||
}
|
||||
|
||||
@keyframes btn-breathe {
|
||||
0%,100% { box-shadow: 0 0 0 8px rgba(255,255,255,0.02), 0 0 20px rgba(255,255,255,0.05), 0 8px 32px rgba(0,0,0,0.6); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(255,255,255,0.06), 0 0 50px rgba(255,255,255,0.12), 0 8px 32px rgba(0,0,0,0.5); }
|
||||
0%,100% { box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.02), 0 0 20px rgba(var(--c-fg-rgb),0.05), 0 8px 32px rgba(0,0,0,0.6); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(var(--c-fg-rgb),0.06), 0 0 50px rgba(var(--c-fg-rgb),0.12), 0 8px 32px rgba(0,0,0,0.5); }
|
||||
}
|
||||
|
||||
.power-icon { display: flex; align-items: center; justify-content: center; transition: transform var(--t-med); }
|
||||
|
|
@ -294,7 +296,7 @@ a { text-decoration: none; }
|
|||
color: var(--c-txt-2);
|
||||
transition: color var(--t-med);
|
||||
}
|
||||
.status-label.is-connecting { color: rgba(255,255,255,0.7); }
|
||||
.status-label.is-connecting { color: rgba(var(--c-fg-rgb),0.7); }
|
||||
.status-label.is-connected { color: var(--c-txt-1); }
|
||||
.status-label.is-error { color: var(--c-red); }
|
||||
.status-sub { font-size: 0.72rem; color: var(--c-txt-2); opacity: 0.7; letter-spacing: 0.1px; }
|
||||
|
|
@ -334,10 +336,10 @@ a { text-decoration: none; }
|
|||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--r-full);
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
background: rgba(var(--c-fg-rgb),0.05);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.10);
|
||||
font-size: 0.82rem;
|
||||
color: rgba(255,255,255,0.6);
|
||||
color: rgba(var(--c-fg-rgb),0.6);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -347,8 +349,8 @@ a { text-decoration: none; }
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
background: rgba(var(--c-fg-rgb),0.03);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.07);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -381,7 +383,7 @@ a { text-decoration: none; }
|
|||
.live-stat-value.rtt-warn { color: var(--c-amber); }
|
||||
.live-stat-value.rtt-bad { color: var(--c-red); }
|
||||
|
||||
.live-stat-sep { width: 1px; height: 36px; background: rgba(255,255,255,0.07); flex-shrink: 0; }
|
||||
.live-stat-sep { width: 1px; height: 36px; background: rgba(var(--c-fg-rgb),0.07); flex-shrink: 0; }
|
||||
|
||||
/* ── Metrics bar ─────────────────────────────────────────────────────── */
|
||||
.metrics-bar {
|
||||
|
|
@ -389,21 +391,21 @@ a { text-decoration: none; }
|
|||
align-items: center;
|
||||
padding: 20px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255,255,255,0.025);
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
background: rgba(var(--c-fg-rgb),0.025);
|
||||
border-top: 1px solid rgba(var(--c-fg-rgb),0.06);
|
||||
margin: 0 -20px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
.metric { flex: 1; display: flex; align-items: center; justify-content: center; gap: 11px; }
|
||||
.metric-sep { width: 1px; height: 36px; background: rgba(255,255,255,0.08); flex-shrink: 0; }
|
||||
.metric-sep { width: 1px; height: 36px; background: rgba(var(--c-fg-rgb),0.08); flex-shrink: 0; }
|
||||
.metric-icon {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.down-icon { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); }
|
||||
.up-icon { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.35); }
|
||||
.down-icon { background: rgba(var(--c-fg-rgb),0.06); color: rgba(var(--c-fg-rgb),0.5); }
|
||||
.up-icon { background: rgba(var(--c-fg-rgb),0.04); color: rgba(var(--c-fg-rgb),0.35); }
|
||||
.metric-body { display: flex; flex-direction: column; gap: 1px; }
|
||||
.metric-label { font-size: 0.62rem; color: var(--c-txt-2); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; }
|
||||
.metric-value { font-size: 0.95rem; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--c-txt-1); font-variant-numeric: tabular-nums; }
|
||||
|
|
@ -442,7 +444,7 @@ a { text-decoration: none; }
|
|||
line-height: 1.6;
|
||||
}
|
||||
.profile-empty svg { flex-shrink: 0; }
|
||||
.profile-empty strong { color: rgba(255,255,255,0.5); }
|
||||
.profile-empty strong { color: rgba(var(--c-fg-rgb),0.5); }
|
||||
|
||||
/* ── Profile card ────────────────────────────────────────────────────── */
|
||||
.profile-card {
|
||||
|
|
@ -457,10 +459,10 @@ a { text-decoration: none; }
|
|||
transition: all var(--t-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
.profile-card:hover { border-color: rgba(255,255,255,0.14); background: var(--c-card-hover); }
|
||||
.profile-card:hover { border-color: rgba(var(--c-fg-rgb),0.14); background: var(--c-card-hover); }
|
||||
.profile-card.active {
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(var(--c-fg-rgb),0.4);
|
||||
background: rgba(var(--c-fg-rgb),0.06);
|
||||
}
|
||||
|
||||
.profile-radio {
|
||||
|
|
@ -507,7 +509,7 @@ a { text-decoration: none; }
|
|||
letter-spacing: 0.5px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
background: rgba(var(--c-fg-rgb),0.06);
|
||||
color: var(--c-txt-2);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -521,7 +523,7 @@ a { text-decoration: none; }
|
|||
color: var(--c-txt-2);
|
||||
transition: all var(--t-fast);
|
||||
}
|
||||
.profile-action-btn:hover { color: var(--c-txt-1); background: rgba(255,255,255,0.07); }
|
||||
.profile-action-btn:hover { color: var(--c-txt-1); background: rgba(var(--c-fg-rgb),0.07); }
|
||||
|
||||
/* ── Section dividers ────────────────────────────────────────────────── */
|
||||
.section-divider {
|
||||
|
|
@ -535,7 +537,7 @@ a { text-decoration: none; }
|
|||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-divider::after { content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06); }
|
||||
.section-divider::after { content: ''; flex: 1; height: 1px; background: rgba(var(--c-fg-rgb),0.06); }
|
||||
|
||||
.section-divider-mini {
|
||||
display: flex;
|
||||
|
|
@ -548,7 +550,7 @@ a { text-decoration: none; }
|
|||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-divider-mini::after { content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.04); }
|
||||
.section-divider-mini::after { content: ''; flex: 1; height: 1px; background: rgba(var(--c-fg-rgb),0.04); }
|
||||
|
||||
/* ── Client settings card ────────────────────────────────────────────── */
|
||||
.client-settings-card {
|
||||
|
|
@ -564,11 +566,11 @@ a { text-decoration: none; }
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 14px;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
border-top: 1px solid rgba(var(--c-fg-rgb),0.04);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.toggle-row:first-child { border-top: none; }
|
||||
.toggle-row:hover { background: rgba(255,255,255,0.025); }
|
||||
.toggle-row:hover { background: rgba(var(--c-fg-rgb),0.025); }
|
||||
.toggle-row.sub-row { padding-left: 30px; }
|
||||
|
||||
.toggle-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
|
@ -582,14 +584,14 @@ a { text-decoration: none; }
|
|||
display: block;
|
||||
width: 36px; height: 20px;
|
||||
border-radius: var(--r-full);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: rgba(var(--c-fg-rgb),0.08);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.1);
|
||||
transition: all var(--t-med);
|
||||
position: relative;
|
||||
}
|
||||
.toggle input:checked ~ .toggle-track {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-color: rgba(255,255,255,0.35);
|
||||
background: rgba(var(--c-fg-rgb),0.15);
|
||||
border-color: rgba(var(--c-fg-rgb),0.35);
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
|
|
@ -604,13 +606,29 @@ a { text-decoration: none; }
|
|||
background: var(--c-accent);
|
||||
}
|
||||
|
||||
.toggle-with-gear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.gear-btn {
|
||||
width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--r-xs);
|
||||
color: var(--c-txt-2);
|
||||
transition: all var(--t-fast);
|
||||
background: transparent;
|
||||
}
|
||||
.gear-btn:hover { color: var(--c-txt-1); background: rgba(var(--c-fg-rgb),0.07); }
|
||||
:root.light .gear-btn:hover { background: rgba(0,0,0,0.05); }
|
||||
|
||||
/* ── Inline field (label + input on one line) ────────────────────────── */
|
||||
.inline-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
border-top: 1px solid rgba(var(--c-fg-rgb),0.04);
|
||||
gap: 12px;
|
||||
}
|
||||
.inline-field.sub-row { padding-left: 30px; }
|
||||
|
|
@ -643,8 +661,8 @@ a { text-decoration: none; }
|
|||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(var(--c-fg-rgb),0.05);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.08);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 9px 11px;
|
||||
color: var(--c-txt-1);
|
||||
|
|
@ -653,8 +671,8 @@ a { text-decoration: none; }
|
|||
transition: border-color var(--t-fast), box-shadow var(--t-fast);
|
||||
}
|
||||
.field-input:focus {
|
||||
border-color: rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(255,255,255,0.04);
|
||||
border-color: rgba(var(--c-fg-rgb),0.3);
|
||||
box-shadow: 0 0 0 3px rgba(var(--c-fg-rgb),0.04);
|
||||
}
|
||||
.field-input::placeholder { color: var(--c-txt-3); }
|
||||
.field-input.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; }
|
||||
|
|
@ -703,7 +721,7 @@ textarea.field-input {
|
|||
color: var(--c-txt-2);
|
||||
border: 1px solid var(--c-card-border);
|
||||
}
|
||||
.btn.secondary:hover { border-color: rgba(255,255,255,0.2); color: var(--c-txt-1); }
|
||||
.btn.secondary:hover { border-color: rgba(var(--c-fg-rgb),0.2); color: var(--c-txt-1); }
|
||||
.btn.danger {
|
||||
background: rgba(255,95,95,0.12);
|
||||
color: var(--c-red);
|
||||
|
|
@ -718,7 +736,7 @@ textarea.field-input {
|
|||
top: 56px; right: 20px;
|
||||
z-index: 100;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.12);
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.8);
|
||||
|
|
@ -742,13 +760,13 @@ textarea.field-input {
|
|||
color: var(--c-txt-1);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
border-bottom: 1px solid rgba(var(--c-fg-rgb),0.05);
|
||||
transition: background var(--t-fast);
|
||||
min-width: 180px;
|
||||
text-align: left;
|
||||
}
|
||||
.add-menu-item:last-child { border-bottom: none; }
|
||||
.add-menu-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.add-menu-item:hover { background: rgba(var(--c-fg-rgb),0.06); }
|
||||
.add-menu-item svg { color: var(--c-txt-2); flex-shrink: 0; }
|
||||
|
||||
/* ── Modals ──────────────────────────────────────────────────────────── */
|
||||
|
|
@ -772,7 +790,7 @@ textarea.field-input {
|
|||
|
||||
.modal-content {
|
||||
background: #111111;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.1);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 20px;
|
||||
width: calc(100% - 40px);
|
||||
|
|
@ -810,7 +828,7 @@ textarea.field-input {
|
|||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--c-txt-1);
|
||||
background: rgba(255,255,255,0.06);
|
||||
background: rgba(var(--c-fg-rgb),0.06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
|
@ -842,7 +860,7 @@ textarea.field-input {
|
|||
left: 50%; transform: translateX(-50%);
|
||||
z-index: 500;
|
||||
background: rgba(24,24,24,0.96);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(var(--c-fg-rgb),0.1);
|
||||
color: var(--c-txt-1);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
name = "ostp-jni"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
[lib]
|
||||
name = "ostp_jni"
|
||||
|
|
|
|||
|
|
@ -305,15 +305,23 @@ impl Dispatcher {
|
|||
// Not an existing session — try each registered access key's derived obfuscation key
|
||||
let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
|
||||
|
||||
// Junk marker rotates per time window; check the current and previous
|
||||
// window so a client whose clock is up to ~1 window behind/ahead is still
|
||||
// recognised. Computed once per datagram, not per candidate key.
|
||||
let junk_window = ostp_core::crypto::current_junk_window();
|
||||
|
||||
for candidate_key in keys_snapshot {
|
||||
let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes());
|
||||
|
||||
// Junk frames carry this key's per-key derived marker (no global
|
||||
// constant → no universal DPI signature). Drop silently — the secrets
|
||||
// for this key are already derived here, so the check is free.
|
||||
if packet.len() >= 4 && packet[0..4] == secrets.junk_marker {
|
||||
// Junk frames carry this key's time-rotating marker (no global
|
||||
// constant, no static per-user signature). Drop silently.
|
||||
if packet.len() >= 4 {
|
||||
let m_now = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window);
|
||||
let m_prev = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window.wrapping_sub(1));
|
||||
if packet[0..4] == m_now || packet[0..4] == m_prev {
|
||||
return Ok(DispatchOutcome::Junk);
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the session_id using this key's obfuscation
|
||||
// The handshake mask is derived from the Noise payload at bytes [6..],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
name = "ostp-tun-helper"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "ostp-tun-helper"
|
||||
|
|
|
|||
|
|
@ -14,10 +14,8 @@ use portable_atomic::Ordering;
|
|||
fn log_to_file(msg: &str) {
|
||||
let msg = msg.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("ostp-helper.log")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("ostp-helper.log"));
|
||||
// Same shared ostp.log as everything else — not a separate ostp-helper.log.
|
||||
let path = ostp_client::logging::log_file_path();
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
|
||||
}
|
||||
|
|
@ -53,7 +51,10 @@ struct TunnelState {
|
|||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
ostp_client::logging::setup_panic_hook();
|
||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-helper", env!("CARGO_PKG_VERSION"));
|
||||
// The helper is a child of the GUI, which already truncated the shared log at
|
||||
// its own startup — pass false so the helper APPENDS instead of wiping the
|
||||
// GUI's session log.
|
||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-helper", env!("CARGO_PKG_VERSION"), false);
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OSTP — Stealth Transport Protocol</title>
|
||||
<meta name="description" content="Ospab Stealth Transport Protocol. An asynchronous networking framework designed for secure, resilient, and unidentifiable data transmission." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&family=Outfit:wght@300;500;700&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Syncopate:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-DnZZkzK0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CBAXD1xO.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -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"`).
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 90810f25f7af9e0a57bacce3d74ca3e46a6433e4
|
||||
509
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,12 +29,13 @@ 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")]
|
||||
format: String,
|
||||
/// Number of keys to generate
|
||||
// NOT short='c' — `--config` is a global arg (propagated into every
|
||||
// NOT short='c' - `--config` is a global arg (propagated into every
|
||||
// subcommand's scope), so a local '-c' here would collide with it.
|
||||
// Clap validates the whole command tree on the first parse() and
|
||||
// panics on a duplicate short flag, breaking the ENTIRE CLI.
|
||||
|
|
@ -54,7 +54,7 @@ enum Commands {
|
|||
Uninstall,
|
||||
/// Update OSTP: re-run the install script to fetch and install the latest version
|
||||
Update {
|
||||
/// Release branch to update from (stable, pre-release, nightly)
|
||||
/// Release branch to update from (stable, pre-release, alpha)
|
||||
#[arg(short = 'b', long, default_value = "stable")]
|
||||
branch: String,
|
||||
/// Exact release version to update to (e.g. 0.4.1 or 0.4.1-beta.3),
|
||||
|
|
@ -70,10 +70,15 @@ 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
|
||||
/// below, so the ~500 lines of existing command logic don't need to change —
|
||||
/// below, so the ~500 lines of existing command logic don't need to change -
|
||||
/// only how they get populated does.
|
||||
struct LegacyArgs {
|
||||
config: PathBuf,
|
||||
|
|
@ -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> {
|
||||
|
|
@ -110,14 +169,12 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
|||
let host = parsed.host_str().ok_or_else(|| anyhow!("Missing host in share link"))?;
|
||||
let port = parsed.port().ok_or_else(|| anyhow!("Missing port in share link"))?;
|
||||
let server = format!("{host}:{port}");
|
||||
let mut sni = String::new();
|
||||
let mut transport_mode = String::from("udp");
|
||||
let mut tun_enabled = false;
|
||||
let mut tun_dns = None;
|
||||
|
||||
for (k, v) in parsed.query_pairs() {
|
||||
match &*k {
|
||||
"sni" => sni = v.into_owned(),
|
||||
"type" => transport_mode = v.into_owned(),
|
||||
"tun" => tun_enabled = v == "true",
|
||||
"dns" => tun_dns = Some(v.into_owned()),
|
||||
|
|
@ -131,7 +188,6 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
|||
mtu: None,
|
||||
transport: Some(TransportConfigRaw {
|
||||
mode: Some(transport_mode),
|
||||
stealth_sni: Some(sni.clone()),
|
||||
tcp_fragmentation: None,
|
||||
}),
|
||||
socks5_bind: Some("127.0.0.1:1088".to_string()),
|
||||
|
|
@ -171,226 +227,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<()> {
|
||||
|
|
@ -399,7 +248,11 @@ async fn main() -> Result<()> {
|
|||
// where it does not apply.
|
||||
let _ = rlimit::increase_nofile_limit(1048576);
|
||||
ostp_client::logging::setup_panic_hook();
|
||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"));
|
||||
// Clear the shared log at startup only when THIS invocation is the daemon —
|
||||
// a one-shot command (`ostp gk`, `ostp check`, ...) must not wipe a running
|
||||
// daemon's log. (Truncation itself is additionally Windows-only.)
|
||||
let is_daemon = ostp_client::logging::invocation_is_daemon(std::env::args());
|
||||
let _log_guard = ostp_client::logging::init_tracing("info", "ostp-cli", env!("CARGO_PKG_VERSION"), is_daemon);
|
||||
|
||||
let res = run_app().await;
|
||||
if let Err(e) = res {
|
||||
|
|
@ -532,7 +385,7 @@ fn wizard_step(n: usize, total: usize, title: &str) {
|
|||
println!(" {} {}",
|
||||
format!("[{}/{}]", n, total).bold().yellow(),
|
||||
title.bold());
|
||||
println!(" {}", "─".repeat(50).dimmed());
|
||||
println!(" {}", "-".repeat(50).dimmed());
|
||||
}
|
||||
|
||||
fn wizard_box(lines: &[&str]) {
|
||||
|
|
@ -604,10 +457,10 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
"Press Enter to accept the value shown in [brackets].",
|
||||
]);
|
||||
|
||||
// ── Mode selection ────────────────────────────────────────────────
|
||||
// -- Mode selection ------------------------------------------------
|
||||
println!();
|
||||
println!(" {}", "Select operating mode:".bold());
|
||||
println!(" {}", "─".repeat(50).dimmed());
|
||||
println!(" {}", "-".repeat(50).dimmed());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
|
@ -638,7 +491,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
}
|
||||
|
||||
match mode_choice {
|
||||
// ── CLIENT ────────────────────────────────────────────────────
|
||||
// -- CLIENT ----------------------------------------------------
|
||||
"1" => {
|
||||
#[cfg(unix)] const TOTAL: usize = 5;
|
||||
#[cfg(windows)] const TOTAL: usize = 4;
|
||||
|
|
@ -647,15 +500,14 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
|
||||
// Try import from link first
|
||||
let use_link = wizard_yn("Do you have a share link (ostp://...)?", false);
|
||||
let (server, access_key, sni, transport_mode) = if use_link {
|
||||
let (server, access_key, transport_mode) = if use_link {
|
||||
let link_str = wizard_prompt("Paste link", "");
|
||||
let parsed = url::Url::parse(&link_str).unwrap();
|
||||
let mut p = parsed.query_pairs();
|
||||
let sni = p.find(|(k, _)| k == "sni").map(|(_, v)| v.to_string()).unwrap_or_default();
|
||||
let tm = p.find(|(k, _)| k == "type").map(|(_, v)| v.to_string()).unwrap_or("udp".to_string());
|
||||
(parsed.host_str().unwrap().to_string() + ":" + &parsed.port().unwrap_or(50000).to_string(), parsed.username().to_string(), sni, tm)
|
||||
(parsed.host_str().unwrap().to_string() + ":" + &parsed.port().unwrap_or(50000).to_string(), parsed.username().to_string(), tm)
|
||||
} else {
|
||||
("127.0.0.1:50000".to_string(), "".to_string(), "".to_string(), "udp".to_string())
|
||||
("127.0.0.1:50000".to_string(), "".to_string(), "udp".to_string())
|
||||
};
|
||||
|
||||
wizard_step(2, TOTAL, "Local proxy");
|
||||
|
|
@ -663,11 +515,11 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
|
||||
wizard_step(3, TOTAL, "VPN (TUN) mode");
|
||||
|
||||
// SSH warning on Linux — always
|
||||
// SSH warning on Linux - always
|
||||
#[cfg(unix)]
|
||||
{
|
||||
println!();
|
||||
println!(" ┌{}", "─".repeat(60));
|
||||
println!(" ┌{}", "-".repeat(60));
|
||||
println!(" │ {} {}",
|
||||
"WARNING:".red().bold(),
|
||||
"TUN mode captures ALL network traffic.".yellow());
|
||||
|
|
@ -679,7 +531,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
println!(" │");
|
||||
println!(" │ Make sure the VPN server is reachable before");
|
||||
println!(" │ enabling TUN, or your SSH session may be lost!");
|
||||
println!(" └{}", "─".repeat(60));
|
||||
println!(" └{}", "-".repeat(60));
|
||||
}
|
||||
|
||||
let tun_enable = wizard_yn("Enable TUN (full VPN) mode?", false);
|
||||
|
|
@ -699,7 +551,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
s.parse::<usize>().unwrap_or(5)
|
||||
} else { 1 };
|
||||
|
||||
// Daemon step — Linux only
|
||||
// Daemon step - Linux only
|
||||
#[cfg(unix)]
|
||||
{
|
||||
wizard_step(5, TOTAL, "Auto-start (systemd)");
|
||||
|
|
@ -708,7 +560,6 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
// Build and save config
|
||||
let key_for_gen = generate_secure_key("hex");
|
||||
let _ = key_for_gen;
|
||||
let _ = &sni;
|
||||
|
||||
let client_json = serde_json::json!({
|
||||
"mode": "client",
|
||||
|
|
@ -729,8 +580,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
"processes": []
|
||||
},
|
||||
"transport": {
|
||||
"mode": transport_mode,
|
||||
"stealth_sni": "www.microsoft.com"
|
||||
"mode": transport_mode
|
||||
},
|
||||
"mux": {
|
||||
"enabled": mux_enable,
|
||||
|
|
@ -759,12 +609,12 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
&format!("TUN mode: {}", if tun_enable { "enabled" } else { "disabled" }),
|
||||
"",
|
||||
"To start: ostp",
|
||||
"To check: ostp --check",
|
||||
"Proxy env: eval $(ostp --proxy-env)",
|
||||
"To check: ostp check",
|
||||
"Proxy env: eval $(ostp proxy-env)",
|
||||
]);
|
||||
}
|
||||
|
||||
// ── SERVER ────────────────────────────────────────────────────
|
||||
// -- SERVER ----------------------------------------------------
|
||||
"2" => {
|
||||
#[cfg(unix)] const TOTAL: usize = 4;
|
||||
#[cfg(windows)] const TOTAL: usize = 3;
|
||||
|
|
@ -832,12 +682,12 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
&format!("Keys: {}", key_count),
|
||||
"",
|
||||
"To start: ostp",
|
||||
"To check: ostp --check",
|
||||
"Share links: ostp --links",
|
||||
"To check: ostp check",
|
||||
"Share links: ostp links",
|
||||
]);
|
||||
}
|
||||
|
||||
// ── SERVER + PANEL (Linux only) ───────────────────────────────
|
||||
// -- SERVER + PANEL (Linux only) -------------------------------
|
||||
#[cfg(unix)]
|
||||
"3" => {
|
||||
const TOTAL: usize = 5;
|
||||
|
|
@ -940,7 +790,7 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
|||
]);
|
||||
}
|
||||
|
||||
// ── RELAY (Linux only) ────────────────────────────────────────
|
||||
// -- RELAY (Linux only) ----------------------------------------
|
||||
#[cfg(unix)]
|
||||
"4" => {
|
||||
const TOTAL: usize = 3;
|
||||
|
|
@ -1069,6 +919,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 +935,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,7 +947,11 @@ async fn run_app() -> Result<()> {
|
|||
return cmd_update(args.update_branch, args.target_version);
|
||||
}
|
||||
|
||||
// ── Setup wizard: explicit flag or first-time (no config) ────────
|
||||
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 +1036,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 +1058,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;
|
||||
}
|
||||
|
|
@ -1405,10 +1216,9 @@ async fn run_app() -> Result<()> {
|
|||
"processes": []
|
||||
}},
|
||||
|
||||
// Transport Mode: "udp" (default WebRTC masquerade) or "uot" (TCP UoT)
|
||||
// Transport Mode: "udp" (default) or "uot" (UDP over TCP, no mimicry)
|
||||
"transport": {{
|
||||
"mode": "udp",
|
||||
"stealth_sni": "www.microsoft.com"
|
||||
"mode": "udp"
|
||||
}},
|
||||
|
||||
"mux": {{
|
||||
|
|
@ -1453,9 +1263,9 @@ async fn run_app() -> Result<()> {
|
|||
anyhow::bail!(
|
||||
"Configuration file {:?} not found.\n\n\
|
||||
To generate a default configuration template, run:\n\
|
||||
\t./ostp --init server\n\
|
||||
\t./ostp init server\n\
|
||||
\tor\n\
|
||||
\t./ostp --init client\n\n\
|
||||
\t./ostp init client\n\n\
|
||||
Or specify a custom configuration file path using:\n\
|
||||
\t./ostp --config /path/to/your_config.json",
|
||||
args.config
|
||||
|
|
@ -1549,8 +1359,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 +1492,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")]
|
||||
|
|
@ -1720,7 +1610,7 @@ fn ensure_elevated_for_tun() -> Result<()> {
|
|||
};
|
||||
|
||||
// ShellExecuteW's return is a pseudo-HINSTANCE: > 32 means the call itself
|
||||
// "succeeded" — but that range INCLUDES ERROR_CANCELLED (1223), which is
|
||||
// "succeeded" - but that range INCLUDES ERROR_CANCELLED (1223), which is
|
||||
// exactly what Windows returns when the user clicks "No" on the UAC
|
||||
// prompt. The old check (`ret <= 32` only) treated a user-denied prompt
|
||||
// as success and silently exited without ever starting the tunnel.
|
||||
|
|
@ -1732,7 +1622,7 @@ fn ensure_elevated_for_tun() -> Result<()> {
|
|||
anyhow::bail!(
|
||||
"Failed to request UAC elevation (ShellExecuteW ret={}, GetLastError={}). \
|
||||
If this keeps happening, an unsigned binary can be silently blocked by \
|
||||
SmartScreen/antivirus during elevation — try running this as Administrator manually.",
|
||||
SmartScreen/antivirus during elevation - try running this as Administrator manually.",
|
||||
ret, win_err
|
||||
);
|
||||
}
|
||||
|
|
@ -1745,7 +1635,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
|||
println!("{} Starting client (mode={}, server={})", "[ostp]".cyan().bold(), mode_str.yellow(), client_cfg.server.cyan());
|
||||
|
||||
// TUN mode needs admin rights to create the WinTun adapter. This was
|
||||
// missing entirely before — the CLI would just try to create the
|
||||
// missing entirely before - the CLI would just try to create the
|
||||
// adapter unelevated and fail at the driver level with no UAC prompt
|
||||
// ever shown, which is what "UAC denied regardless of GUI or TUI"
|
||||
// actually was for this code path: TUI never asked for elevation at all.
|
||||
|
|
@ -1782,7 +1672,6 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
|||
},
|
||||
transport: ostp_client::config::TransportConfig {
|
||||
mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
|
||||
stealth_sni: client_cfg.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
|
||||
tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
||||
frag_chunk: 2,
|
||||
frag_sleep: 2,
|
||||
|
|
|
|||
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,241 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Cuts a new OSTP release and pushes the tag that triggers the matching
|
||||
GitHub Actions build (see .github/workflows/release.yml, which only
|
||||
triggers on "v*" tag pushes + workflow_dispatch — a bare branch push
|
||||
does NOT start a build).
|
||||
|
||||
.DESCRIPTION
|
||||
A release cycle has ONE fixed target version (e.g. "0.4.1") that stays in
|
||||
Cargo.toml/tauri.conf.json/package.json unchanged through every alpha and
|
||||
beta build — only a per-channel ITERATION counter increments, and that
|
||||
counter lives ONLY in the git tag, never in the manifests:
|
||||
|
||||
v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.100
|
||||
v0.4.1-beta.1 -> v0.4.1-beta.2 -> ... -> v0.4.1-beta.100
|
||||
v0.4.1 <- master: iteration dropped
|
||||
|
||||
This is deliberately NOT "0.4.1.5-alpha" (a 4th dot-separated component
|
||||
before the hyphen) — that is not valid semver, and Cargo's version parser
|
||||
rejects it outright. "0.4.1-alpha.5" (dot AFTER the hyphen, a semver
|
||||
pre-release identifier) is the only form that keeps Cargo.toml itself
|
||||
parseable, so that's the only place the iteration number is allowed to
|
||||
live: the git tag.
|
||||
|
||||
Promoting to beta/master first fast-forwards that branch to `alpha`
|
||||
(--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 alpha's latest, not a stale branch. Switching to a
|
||||
channel for the first time in a cycle resets THAT channel's iteration
|
||||
counter to 1 (a fresh promotion starts its own count; it doesn't inherit
|
||||
wherever alpha's counter happened to be).
|
||||
|
||||
Remembers {target_version, branch, alpha_iteration, beta_iteration} in
|
||||
.release-state.json at the repo root. Running with no arguments repeats
|
||||
last time's branch, bumping that channel's iteration by one — manifests
|
||||
are NOT touched (nothing to bump: the target version hasn't changed).
|
||||
-NewVersion starts a new target version line and resets both iteration
|
||||
counters to 0 — THIS is the one case that bumps every manifest.
|
||||
|
||||
.PARAMETER NewVersion
|
||||
Set a new target version (e.g. "0.4.2") instead of continuing the current
|
||||
one. Resets both alpha_iteration and beta_iteration to 0. Defaults the
|
||||
channel back to alpha unless -Branch is also given this run.
|
||||
|
||||
NOTE: this parameter is deliberately NOT named "Switch" — PowerShell's
|
||||
`switch` statement keyword is matched case-insensitively against variable
|
||||
names in scope, and a script parameter named exactly $Switch silently
|
||||
breaks every `switch (...) { ... }` expression later in the same script
|
||||
(it evaluates to nothing, no error). Confirmed by bisection: renaming the
|
||||
parameter is the only thing that fixes it. Do not rename this back.
|
||||
|
||||
.PARAMETER Branch
|
||||
Which branch/channel to release from: master, pre-release (beta), or alpha.
|
||||
Defaults to whatever was used last time (see .release-state.json).
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\gha.ps1
|
||||
Bumps the current channel's iteration by one and pushes v{target}-{channel}.{N}.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\gha.ps1 -NewVersion 0.4.2
|
||||
Starts a fresh 0.4.2 cycle: manifests -> 0.4.2, alpha iteration resets to 1,
|
||||
ships v0.4.2-alpha.1.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\gha.ps1 -Branch pre-release
|
||||
Promotes alpha -> pre-release (beta channel), resets beta_iteration to 1
|
||||
(or bumps it if already mid-beta), ships v{target}-beta.{N}.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\gha.ps1 -Branch master
|
||||
Promotes to master and ships the bare v{target} stable tag — no iteration.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$NewVersion,
|
||||
[ValidateSet('master', 'pre-release', 'alpha')]
|
||||
[string]$Branch
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
|
||||
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 ----------------------------------------------------
|
||||
$State = $null
|
||||
if (Test-Path $StateFile) {
|
||||
$State = Get-Content $StateFile -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
$PrevBranch = if ($State) { $State.branch } else { $null }
|
||||
$IsNewTarget = [bool]$NewVersion
|
||||
|
||||
$ResolvedBranch = if ($Branch) { $Branch } elseif ($PrevBranch) { $PrevBranch } else { "alpha" }
|
||||
|
||||
# -- Resolve the target version + per-channel iteration counters ------------
|
||||
if ($IsNewTarget) {
|
||||
if ($NewVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+$') { Fail "-NewVersion must be a bare X.Y.Z version, got '$NewVersion'." }
|
||||
$TargetVersion = $NewVersion
|
||||
$AlphaIter = 0
|
||||
$BetaIter = 0
|
||||
# A fresh target version starts a fresh cycle at the bottom of the chain,
|
||||
# unless the caller explicitly asked for a different branch this run.
|
||||
if (-not $Branch) { $ResolvedBranch = "alpha" }
|
||||
} else {
|
||||
# NOTE: a pre-migration state file (old schema: {version, branch, prefix})
|
||||
# has no target_version property at all — PowerShell silently returns
|
||||
# $null for a missing property on a PSCustomObject rather than erroring,
|
||||
# so this must check for it explicitly or $TargetVersion would end up
|
||||
# $null and corrupt every manifest below.
|
||||
$TargetVersion = if ($State -and $State.target_version) { $State.target_version } else {
|
||||
(Select-String -Path (Join-Path $RepoRoot "Cargo.toml") -Pattern '^version = "([0-9]+\.[0-9]+\.[0-9]+)"').Matches[0].Groups[1].Value
|
||||
}
|
||||
$AlphaIter = if ($State -and $State.alpha_iteration) { [int]$State.alpha_iteration } else { 0 }
|
||||
$BetaIter = if ($State -and $State.beta_iteration) { [int]$State.beta_iteration } else { 0 }
|
||||
|
||||
# Entering a channel that wasn't active last run (a promotion) starts
|
||||
# THAT channel's count fresh — it doesn't inherit alpha's iteration number.
|
||||
$BranchChanged = ($ResolvedBranch -ne $PrevBranch)
|
||||
if ($BranchChanged -and $ResolvedBranch -eq "pre-release") { $BetaIter = 0 }
|
||||
if ($BranchChanged -and $ResolvedBranch -eq "alpha") { $AlphaIter = 0 }
|
||||
}
|
||||
|
||||
$Channel = switch ($ResolvedBranch) { "alpha" { "alpha" }; "pre-release" { "beta" }; "master" { "stable" } }
|
||||
|
||||
if ($Channel -eq "alpha") { $AlphaIter++ }
|
||||
elseif ($Channel -eq "beta") { $BetaIter++ }
|
||||
$Iteration = if ($Channel -eq "alpha") { $AlphaIter } else { $BetaIter }
|
||||
|
||||
$Tag = if ($Channel -eq "stable") { "v$TargetVersion" } else { "v$TargetVersion-$Channel.$Iteration" }
|
||||
|
||||
Write-Step "Releasing $Tag on '$ResolvedBranch'"
|
||||
|
||||
# -- Checkout the target branch, promoting it from alpha 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 "alpha") {
|
||||
Write-Step "Fast-forwarding $ResolvedBranch to alpha (promotion)"
|
||||
git merge alpha --ff-only 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$msg = "'$ResolvedBranch' has diverged from alpha 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 manifests ONLY when the target version itself changes. A plain -----
|
||||
# -- alpha/beta iteration touches nothing but the state file's counter. -----
|
||||
if ($IsNewTarget -or -not $State) {
|
||||
Write-Step "Bumping target version -> $TargetVersion"
|
||||
|
||||
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 = `"$TargetVersion`""
|
||||
Set-VersionLine "ostp-gui/src-tauri/Cargo.toml" '(?m)^version = "[0-9]+\.[0-9]+\.[0-9]+"' "version = `"$TargetVersion`""
|
||||
Set-VersionLine "ostp-gui/src-tauri/tauri.conf.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`""
|
||||
Set-VersionLine "ostp-gui/package.json" '"version": "[0-9]+\.[0-9]+\.[0-9]+"' "`"version`": `"$TargetVersion`""
|
||||
|
||||
# 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` never touches.
|
||||
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." }
|
||||
}
|
||||
|
||||
# Flutter's build number (Android versionCode) must strictly increase on
|
||||
# every single build ever shipped — unlike the semantic version, it does NOT
|
||||
# stay fixed across alpha/beta iterations, so this runs every time, not just
|
||||
# on a target-version switch.
|
||||
$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: $TargetVersion+$nextBuild"
|
||||
[System.IO.File]::WriteAllText($pubspecPath, $pubspecText)
|
||||
} else {
|
||||
Fail "Version pattern not found in ostp-flutter/pubspec.yaml."
|
||||
}
|
||||
|
||||
# -- Persist the new state ---------------------------------------------------
|
||||
[PSCustomObject]@{
|
||||
target_version = $TargetVersion
|
||||
branch = $ResolvedBranch
|
||||
alpha_iteration = $AlphaIter
|
||||
beta_iteration = $BetaIter
|
||||
} | ConvertTo-Json | Set-Content $StateFile
|
||||
|
||||
# -- Commit -------------------------------------------------------------------
|
||||
$commitMsg = "chore: release $Tag 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. release.yml triggers ONLY on "v*" tag pushes (no branch trigger), -
|
||||
# -- so the tag push is what actually starts the build; the branch push is -
|
||||
# -- just so the promotion chain (alpha -> pre-release -> master) itself -
|
||||
# -- keeps moving forward for the next --ff-only. -
|
||||
Write-Step "Tagging $Tag and pushing $ResolvedBranch + tag"
|
||||
git tag $Tag
|
||||
git push origin $ResolvedBranch
|
||||
git push origin $Tag
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Watch the build: https://github.com/ospab/ostp/actions" -ForegroundColor Green
|
||||
|
|
@ -28,7 +28,7 @@ fi
|
|||
mkdir -p "$INSTALL_DIR"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
# ── Migration from legacy installations ──────────────────────────────
|
||||
# -- Migration from legacy installations ------------------------------
|
||||
|
||||
migrate_legacy() {
|
||||
local old_dir="$1"
|
||||
|
|
@ -68,7 +68,7 @@ if [ -L "$BIN_LINK" ] && [ ! -e "$BIN_LINK" ]; then
|
|||
rm -f "$BIN_LINK"
|
||||
fi
|
||||
|
||||
# ── Architecture detection ───────────────────────────────────────────
|
||||
# -- Architecture detection -------------------------------------------
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
|
|
@ -85,7 +85,7 @@ esac
|
|||
|
||||
echo "Platform: linux/$ARCH"
|
||||
|
||||
# ── Parse arguments ────────────────────────────────────────────────────
|
||||
# -- Parse arguments ----------------------------------------------------
|
||||
TARGET_VERSION=""
|
||||
TARGET_BRANCH="stable"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
|
@ -104,20 +104,20 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
# ── Download binary ──────────────────────────────────────────────────
|
||||
# -- Download binary --------------------------------------------------
|
||||
|
||||
if [ -n "$TARGET_VERSION" ]; then
|
||||
LATEST_RELEASE="$TARGET_VERSION"
|
||||
# Ensure it starts with 'v' if it's supposed to (only for real stable
|
||||
# semver tags — the nightly/pre-release channels use bare tag names).
|
||||
# semver tags - the alpha/pre-release channels use bare tag names).
|
||||
if [[ ! "$LATEST_RELEASE" =~ ^v ]] && [ "$TARGET_BRANCH" == "stable" ]; then
|
||||
LATEST_RELEASE="v$LATEST_RELEASE"
|
||||
fi
|
||||
echo "Fetching requested release $LATEST_RELEASE..."
|
||||
else
|
||||
if [ "$TARGET_BRANCH" == "nightly" ]; then
|
||||
echo "Fetching nightly release..."
|
||||
LATEST_RELEASE="nightly"
|
||||
if [ "$TARGET_BRANCH" == "alpha" ]; then
|
||||
echo "Fetching alpha release..."
|
||||
LATEST_RELEASE="alpha"
|
||||
elif [ "$TARGET_BRANCH" == "pre-release" ]; then
|
||||
echo "Fetching pre-release..."
|
||||
LATEST_RELEASE="pre-release"
|
||||
|
|
@ -166,64 +166,27 @@ else
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# ── Create global symlink ────────────────────────────────────────────
|
||||
# -- Create global symlink --------------------------------------------
|
||||
|
||||
ln -sf "$INSTALL_DIR/ostp" "$BIN_LINK"
|
||||
echo "Symlink created: $BIN_LINK -> $INSTALL_DIR/ostp"
|
||||
|
||||
# ── Update detection ─────────────────────────────────────────────────
|
||||
# -- Update detection -------------------------------------------------
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
echo "--------------------------------------------------------"
|
||||
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
|
||||
|
|
@ -268,7 +231,7 @@ EOF
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# ── First install: delegate to the built-in setup wizard ─────────────
|
||||
# -- First install: delegate to the built-in setup wizard -------------
|
||||
|
||||
echo ""
|
||||
echo "No configuration found. Launching setup wizard..."
|
||||
|
|
|
|||
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>()); }
|
||||