Compare commits

...

15 Commits

Author SHA1 Message Date
ospab db581ca391 fix(gui): fix UAC SmartScreen and scrolling UI layout 2026-07-08 22:45:03 +03:00
ospab a547ebff17 fix(ci): quote run-name — unquoted colon broke YAML parsing on every push
The run-name expression contains the GHA string literal 'Release build: {0}'
— an unquoted YAML plain scalar treats ": " as starting a nested mapping,
which invalidated the entire workflow file at parse time (before any job
runs). Every push since that line was introduced failed instantly with
"Invalid workflow file ... line 11", silently burning an Actions-minutes
run each time for nothing. Wrapping the whole expression in double quotes
fixes it — verified with `npx js-yaml` that the file now parses and the
run-name value round-trips intact.
2026-07-08 22:23:01 +03:00
ospab d065f6ceca chore: release 0.4.4-nightly on nightly 2026-07-08 19:13:55 +03:00
ospab d822f48891 refactor(config): one canonical config.json schema for client/server/relay
All three on-disk config.json shapes (client, server, relay) used to be
declared locally inside ostp/src/main.rs, invisible to any other consumer —
which is exactly how ostp_client::migrate ended up matching against loosely
typed serde_json::Value instead of a real schema, with no guarantee its
hand-built output actually matched what the CLI parser expected.

Moved every one of those definitions (AppMode, UnifiedConfig, ServerConfig,
RelayServerConfig, ClientFileConfig, TunConfig, ExcludeConfig, MuxConfig,
TransportConfigRaw, ApiConfig, FallbackCfg, ListenConfig, UserConfig) into
ostp_client::config — the same file that already held the runtime
ClientConfig/OstpConfig/etc. main.rs now imports them instead of
re-declaring them (`ClientFileConfig as ClientConfig` to avoid colliding
with the runtime ClientConfig, which stays separate on purpose: it's the
engine's internal shape — handshake/io timeouts and the like a user never
sets in config.json — built FROM one of these via the mapping in
run_client_directly, not the same thing).

ServerConfig.dns is now Option<serde_json::Value> rather than
Option<ostp_server::dns::DnsConfig> — ostp-client doesn't (and shouldn't)
depend on ostp-server just to name that type. main.rs, which already depends
on both crates, deserializes it right before handing it to run_server().

cmd_migrate now proves its output against this schema before ever writing
to disk (serde_json::from_value::<UnifiedConfig>(migrated)) — a migrator/
schema drift is now a hard error at migrate time, not a corrupted
config.json discovered later. Added a matching unit test
(every_migrated_output_matches_the_canonical_schema) that exercises this
same check on all three migration paths (modular, legacy-flat, server).
2026-07-08 18:58:34 +03:00
ospab 26665a826f feat(client): one authoritative config migrator, manual-only
- fix(cli): stop printing the startup banner ("ostp-cli vX.Y.Z | OS: ...")
  to stderr on every single command invocation. init_tracing() ran
  unconditionally before command dispatch, so `ostp -V`, `ostp gk`, etc. all
  showed it. It's still written to the log file (useful there), just no
  longer echoed via the stderr tracing layer for one-shot commands.

- feat(client): add ostp-client::migrate, the ONE place config migration
  runs. Previously there were three uncoordinated migration paths: a Python
  snippet embedded in scripts/install.sh (only touched server api.* fields,
  ran on every update), the old 0.3.x line's auto-migration on every hot
  reload (silent besides a log warning), and nothing at all for the current
  rebuild. Consolidated into one module covering every config shape that's
  actually existed:
    - v0.3.1-v0.3.21 modular (inbounds/outbounds/routing) -> current flat
      schema, including correctly resolving routing.default_outbound through
      a urltest/selector group to the real server, and reporting (not
      silently dropping) every additional server a multi-server config had.
    - pre-0.3.1 flat configs carrying now-dead fields (tun.wintun_path,
      tun.ipv4_address, transport.wss) -> dropped with an explicit reason,
      everything else passes through untouched.
    - server configs -> backfills api.* defaults and drops legacy api.token
      (ported straight from the install.sh Python, same behavior, correct
      place).
  6 unit tests cover all of the above against realistic fixtures. Wired up
  as `ostp migrate` (was missing from Commands entirely) — no other code
  path calls into this module, so a config's shape only ever changes when
  explicitly asked.

- feat(cli): `ostp import <url>` now asks the same TUN/mux/debug questions
  `ostp connect <url>` always did. Previously import just wrote flat
  defaults to disk with no way to turn any of that on short of hand-editing
  the resulting config.json afterward. Extracted the shared prompt into
  prompt_client_options() so both paths stay in sync.

- chore(install): remove the embedded Python config-migration snippet from
  install.sh; schema migration must never happen implicitly during an
  install/update. Points users at `ostp migrate` instead.
2026-07-08 18:45:04 +03:00
ospab 7b43e1dcf7 ci: prefix rolling-channel release tags with "v" for consistency
Stable releases were already "vX.Y.Z" (from the actual git tag pushed for
that channel), but nightly/beta tags were bare "X.Y.Z-nightly"/"X.Y.Z-beta"
— inconsistent with the v-prefixed convention used everywhere else. Every
channel's tag now starts with v: "vX.Y.Z-nightly", "vX.Y.Z-beta", "vX.Y.Z".
2026-07-08 18:20:45 +03:00
ospab b17e5499eb ci: fix run-name to say "channel" instead of a bare branch name
run-name showed "release version nightly" / "release version pre-release"
for every branch-push run — read exactly like the release TAG was bare
"nightly"/"pre-release" (the bug fixed earlier), when the actual GH Release
tag has been correctly versioned (e.g. "0.4.3-nightly") all along via
resolve-channel. run-name can't reference job outputs (it's evaluated before
any job runs), so it can't show the real computed tag directly — spell out
"channel" instead so the label can't be mistaken for the release tag again.
2026-07-08 18:18:11 +03:00
ospab ec947ec9d1 chore: release 0.4.3-nightly on nightly 2026-07-08 18:13:37 +03:00
ospab 0ec09d1311 ci: add scripts/gha.ps1 — versioned release cutter with channel memory
Replaces ad-hoc manual tag pushes (which is how the confusing v0.4.1-beta /
v0.4.2-beta / bare "nightly" / "pre-release" release mess happened) with one
script that always goes through the same path: bump every version manifest,
commit, and push in the way release.yml's resolve-channel job actually
expects (branch push for nightly/pre-release, a real "vX.Y.Z" tag for
master — never a hand-pushed "vX.Y.Z-beta"-style tag).

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

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

View File

@ -1,6 +1,20 @@
name: CI/CD
run-name: "CI/CD: release version ${{ github.ref_name }}"
# `run-name` is evaluated at workflow-start, BEFORE any job runs — it cannot
# see resolve-channel's computed tag_name (e.g. "0.4.3-nightly"), only the
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
# the bare branch name ("nightly"/"pre-release") for every run, which reads
# exactly like a literal release tag and caused real confusion — the actual
# release tag has been correct (versioned) all along; only this label lied
# about it. Spell out "channel" so nobody mistakes one for the other again.
# NOTE: this value MUST be quoted. The GHA string literal below contains
# "Release build: {0}" — an unquoted YAML plain scalar treats ": " (colon
# then space) as starting a nested mapping, which is exactly what broke every
# single push since this line was introduced: GitHub rejected the whole
# workflow file at parse time (before any job runs), silently burning an
# Actions-minutes-billed run per push for nothing.
run-name: "${{ startsWith(github.ref, 'refs/tags/') && format('Release build: {0}', github.ref_name) || format('Release build: {0} channel', github.ref_name) }}"
on:
push:
@ -10,6 +24,19 @@ on:
- nightly
- pre-release
workflow_dispatch:
inputs:
channel:
description: >-
Manually build+release just this rolling channel. Stable releases
are NEVER picked here on purpose — cut those only via a real
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
a "stable" release.
type: choice
required: true
default: nightly
options:
- nightly
- beta
permissions:
contents: write
@ -21,6 +48,55 @@ env:
RUST_BACKTRACE: short
jobs:
# Computes ONE channel + release tag for this whole run, so every build
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
# same release under the exact same tag, instead of repeating this logic
# (and risking it drifting out of sync) in five separate places.
#
# Tag shape:
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
# - push to `nightly` -> "{version}-nightly" (rolling, same tag every push)
# - push to `pre-release` -> "{version}-beta" (rolling, same tag every push)
# - workflow_dispatch -> forced by the `channel` input (nightly|beta only)
resolve-channel:
name: Resolve release channel
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.resolve.outputs.channel }}
tag_name: ${{ steps.resolve.outputs.tag_name }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- name: Resolve channel, version, and release tag
id: resolve
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
CHANNEL="stable"
TAG="${{ github.ref_name }}"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CHANNEL="${{ github.event.inputs.channel }}"
elif [ "${{ github.ref_name }}" = "nightly" ]; then
CHANNEL="nightly"
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
CHANNEL="beta"
else
CHANNEL="nightly"
fi
if [ "$CHANNEL" != "stable" ]; then
TAG="v${BASE_VERSION}-${CHANNEL}"
fi
echo "Resolved channel=$CHANNEL tag=$TAG (base version $BASE_VERSION)"
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
echo "prerelease=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
check-and-test:
name: Check & Test
runs-on: ubuntu-latest
@ -58,7 +134,7 @@ jobs:
publish-release-matrix:
name: Release for ${{ matrix.target }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@ -244,22 +320,19 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ${{ matrix.release_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows-gui:
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: windows-latest
strategy:
matrix:
@ -326,22 +399,19 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-windows-gui-${{ matrix.arch }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-linux-gui:
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest
strategy:
matrix:
@ -394,22 +464,19 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-macos-gui:
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: macos-latest
strategy:
matrix:
@ -459,22 +526,19 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android:
name: Build Android Client (Flutter) - ${{ matrix.arch }}
needs: check-and-test
needs: [check-and-test, resolve-channel]
runs-on: ubuntu-latest
strategy:
matrix:
@ -535,15 +599,12 @@ jobs:
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
# Version tags (v0.4.1, v0.4.1-beta.N) use their own name as the
# release; branch pushes (nightly/pre-release) roll a release named
# after the branch itself — no name remapping needed since
# github.ref_name is already the tag OR the branch name as-is.
tag_name: ${{ github.ref_name }}
# Any branch push is a rolling prerelease; for real version tags,
# a hyphenated suffix (-beta.N) marks it prerelease, a bare
# semver tag (v0.4.1) is a stable release.
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') || contains(github.ref_name, '-') }}
# Computed once in resolve-channel so every platform/job in this run
# lands on the exact same tag: "{version}-nightly" / "{version}-beta"
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
# real stable release.
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

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

View File

@ -1 +0,0 @@
127.0.0.1

5
.release-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"version": "0.4.4",
"branch": "nightly",
"prefix": "nightly"
}

View File

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

View File

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

12
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -293,3 +293,249 @@ impl ClientConfig {
})
}
}
// ═══════════════════════════════════════════════════════════════════════
// On-disk config.json shapes — client, server, and relay.
//
// This is the ONE place these are defined. They used to be declared locally
// inside ostp/src/main.rs (the CLI binary) with no other consumer able to
// see them, which is exactly how ostp-client::migrate ended up working
// against loosely-typed serde_json::Value instead of a real schema, and how
// the CLI, the migrator, and this crate's own hot-reload path could each
// silently drift out of sync with what a config.json actually looks like.
// main.rs now imports these instead of re-declaring them (see the `use
// ostp_client::config::{...}` at its top).
//
// These are DELIBERATELY separate from ClientConfig/OstpConfig/etc. above:
// this section is the friendly, minimal shape a user actually edits by
// hand; the types above are what the running engine needs internally
// (handshake/io timeouts, resolved addresses, ...) and are built FROM one
// of these via the mapping in ostp/src/main.rs::run_client_directly. Only
// `ClientConfig` collides by name with the runtime type above, so the
// on-disk one is `ClientFileConfig` — everything else keeps its natural name.
// ═══════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum AppMode {
Server(ServerConfig),
Client(ClientFileConfig),
Relay(RelayServerConfig),
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UnifiedConfig {
#[serde(flatten)]
pub mode: AppMode,
pub log_level: Option<String>,
}
impl UnifiedConfig {
pub fn validate(&self) -> Result<()> {
match &self.mode {
AppMode::Server(cfg) => {
if cfg.access_keys.is_empty() {
anyhow::bail!("Server configuration must contain at least one access_key.");
}
if let Some(outbound) = &cfg.outbound {
if outbound.enabled {
let action = outbound.default_action.as_deref().unwrap_or("direct");
if action == "direct" && outbound.rules.is_empty() {
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
}
}
}
}
AppMode::Client(cfg) => {
if cfg.access_key.is_empty() {
anyhow::bail!("Client configuration must contain an access_key.");
}
}
AppMode::Relay(cfg) => {
if cfg.upstream_tcp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
}
if cfg.upstream_api_url.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_api_url.");
}
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum UserConfig {
Detailed {
access_key: String,
name: Option<String>,
limit_bytes: Option<u64>,
},
KeyOnly(String),
}
impl UserConfig {
pub fn key(&self) -> String {
match self {
UserConfig::KeyOnly(k) => k.clone(),
UserConfig::Detailed { access_key, .. } => access_key.clone(),
}
}
pub fn name(&self) -> Option<String> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { name, .. } => name.clone(),
}
}
pub fn limit(&self) -> Option<u64> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { limit_bytes, .. } => *limit_bytes,
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub listen: ListenConfig,
pub access_keys: Vec<UserConfig>,
pub debug: Option<bool>,
pub outbound: Option<OutboundConfig>,
pub api: Option<ApiConfig>,
pub fallback: Option<FallbackCfg>,
pub transport: Option<TransportConfigRaw>,
// Left untyped: ostp-client does not (and should not) depend on
// ostp-server just to name its DnsConfig type. The CLI binary — which
// already depends on both crates — deserializes this into
// ostp_server::dns::DnsConfig right before handing it to run_server().
pub dns: Option<serde_json::Value>,
}
/// Relay-node config.json shape.
#[derive(Debug, Deserialize, Serialize)]
pub struct RelayServerConfig {
/// Listen address(es) (UDP + TCP UoT)
pub listen: ListenConfig,
/// Upstream address for TCP (UoT) traffic
pub upstream_tcp: String,
/// Upstream address for UDP traffic
pub upstream_udp: String,
/// Target server's API URL, for key sync
pub upstream_api_url: String,
/// Bearer token for the target server's API
#[serde(default)]
pub upstream_api_token: String,
/// Key sync interval in seconds (default 30)
#[serde(default = "default_sync_interval")]
pub sync_interval_secs: u64,
pub debug: Option<bool>,
}
fn default_sync_interval() -> u64 { 30 }
/// Supports both a single string "0.0.0.0:50000" and an array
/// ["0.0.0.0:50000", "[::]:50000"].
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum ListenConfig {
Single(String),
Multiple(Vec<String>),
}
impl ListenConfig {
pub fn addresses(&self) -> Vec<String> {
match self {
ListenConfig::Single(s) => vec![s.clone()],
ListenConfig::Multiple(v) => v.clone(),
}
}
pub fn primary(&self) -> String {
match self {
ListenConfig::Single(s) => s.clone(),
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiConfig {
pub enabled: Option<bool>,
pub bind: Option<String>,
pub token: Option<String>,
pub webpath: Option<String>,
pub username: Option<String>,
pub password_hash: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct FallbackCfg {
pub enabled: Option<bool>,
pub listen: Option<String>,
pub target: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ClientFileConfig {
pub server: String,
pub access_key: String,
pub mtu: Option<usize>,
pub socks5_bind: Option<String>,
pub tun: Option<TunConfig>,
pub debug: Option<bool>,
pub exclude: Option<ExcludeConfig>,
pub mux: Option<MuxConfig>,
pub transport: Option<TransportConfigRaw>,
pub gui: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TransportConfigRaw {
pub mode: Option<String>,
pub stealth_sni: Option<String>,
pub tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TunConfig {
pub enable: bool,
pub wintun_path: Option<String>,
pub ipv4_address: Option<String>,
pub dns: Option<String>,
pub kill_switch: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OutboundConfig {
pub enabled: bool,
pub protocol: String,
pub address: String,
pub port: u16,
#[serde(default)]
pub rules: Vec<OutboundRule>,
pub default_action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OutboundRule {
pub domain_suffix: Option<Vec<String>>,
pub ip_cidr: Option<Vec<String>>,
pub protocol: Option<String>,
pub action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ExcludeConfig {
pub domains: Option<Vec<String>>,
pub ips: Option<Vec<String>>,
pub processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct MuxConfig {
pub enabled: Option<bool>,
pub sessions: Option<usize>,
}

View File

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

View File

@ -71,7 +71,26 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
// Write the startup banner directly to the log file, bypassing the
// tracing subscriber entirely. Emitting it via tracing::info!() hits
// BOTH layers below (file AND stderr), so every one-shot CLI command
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
// terminal on every single invocation — pure noise for anything that
// isn't the long-running daemon. It's still genuinely useful for
// whoever's reading the log file later, so keep it there, just not on
// screen for commands that aren't the daemon.
let _ = writeln!(
file,
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
app_name,
version,
std::env::consts::OS,
std::env::consts::ARCH,
level,
path.display(),
);
let (file_writer, guard) = tracing_appender::non_blocking(file);
let fmt_layer = tracing_subscriber::fmt::layer()
@ -92,16 +111,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

542
ostp-client/src/migrate.rs Normal file
View File

@ -0,0 +1,542 @@
//! The ONE authoritative place that upgrades an old `config.json` to the
//! current schema. Reachable only via the explicit `ostp migrate` command —
//! nothing else in this codebase silently rewrites a user's config on their
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
//! log warning; that's exactly the kind of "invisible until something looks
//! wrong" behavior this module replaces).
//!
//! Every field this module cannot map forward is reported explicitly in
//! `MigrationReport.notes`, never silently dropped without a trace.
use serde_json::{json, Value};
#[derive(Debug, Default)]
pub struct MigrationReport {
/// Whether anything was actually different from the current schema.
pub changed: bool,
/// Human-readable line per field added, converted, or dropped.
pub notes: Vec<String>,
}
impl MigrationReport {
fn note(&mut self, msg: impl Into<String>) {
self.changed = true;
self.notes.push(msg.into());
}
}
/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs
/// from before that tag existed are sniffed structurally as a fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigKind {
Client,
Server,
Relay,
}
pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
match json.get("mode").and_then(|v| v.as_str()) {
Some("client") => return Some(ConfigKind::Client),
Some("server") => return Some(ConfigKind::Server),
Some("relay") => return Some(ConfigKind::Relay),
_ => {}
}
// No (or unrecognized) "mode" tag — this is an older config from before
// it was mandatory. Sniff by the fields that have been present on each
// shape since the earliest surviving config format.
if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() {
Some(ConfigKind::Relay)
} else if json.get("access_keys").is_some() || json.get("listen").is_some() {
Some(ConfigKind::Server)
} else if json.get("access_key").is_some() || json.get("server").is_some() {
Some(ConfigKind::Client)
} else {
None
}
}
/// Migrates a client config of any known past shape to the current flat
/// schema. Returns the migrated JSON and a report of every change made.
///
/// Known input shapes, oldest first:
/// - **v0.3.1v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays
/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this
/// line no longer supports multiple simultaneous servers); every other
/// `ostp` outbound is reported by tag+address so nothing vanishes
/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no
/// equivalent and are dropped (reported).
/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today
/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...)
/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail,
/// never user-meaningful data) and `transport.wss` (the WSS framing
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
/// explicit note; everything else maps 1:1, nothing to convert.
/// - **current flat schema**: no-op, `changed = false`.
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
let mut report = MigrationReport::default();
let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some();
let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some();
if has_inbounds && has_outbounds {
return migrate_client_from_modular(json, report);
}
// Flat shape already (current or pre-0.3.1) — normalize obsolete fields
// in place rather than rebuilding the whole document from scratch, so
// any field this module doesn't know about yet still survives untouched.
let mut out = json;
if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) {
for dead_field in ["wintun_path", "ipv4_address"] {
if tun.remove(dead_field).is_some() {
report.note(format!(
"Dropped tun.{dead_field} — internal driver detail from an older WinTun \
integration, not applicable to the current TUN implementation."
));
}
}
}
if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) {
if transport.remove("wss").is_some() {
report.note(
"Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \
(the project follows a zapret-like approach: no protocol mimicry, \
just packet-level obfuscation/manipulation, so there is no successor field)."
.to_string(),
);
}
}
(out, report)
}
fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) {
report.changed = true; // the shape itself is being replaced regardless of field-level detail
let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let routing = json.get("routing").cloned().unwrap_or(json!({}));
let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from);
// ── Pick the primary "ostp" outbound ────────────────────────────────
// Prefer the one routing.default_outbound points at (directly, or via a
// urltest/selector group that references it); otherwise take the first
// ostp outbound in file order. Every other ostp outbound is reported by
// tag+address, not silently discarded.
let ostp_outbounds: Vec<&Value> = outbounds
.iter()
.filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
.collect();
// default_outbound might name an ostp outbound directly, OR name a
// urltest/selector GROUP whose first member is the one to actually use —
// check both, since a plain `.or_else` here would never even attempt the
// group lookup while default_outbound is Some(_) (which it almost always
// is), silently falling through to "just take the first ostp outbound in
// file order" instead — exactly the kind of silent wrong answer this
// migrator exists to avoid.
let primary_tag: Option<String> = default_outbound.as_deref().and_then(|def_tag| {
if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) {
return Some(def_tag.to_string());
}
outbounds.iter().find_map(|o| {
let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector"));
let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag);
if is_group && tag_matches {
o.get("outbounds")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(String::from)
} else {
None
}
})
});
let primary = primary_tag
.as_deref()
.and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag)))
.copied()
.or_else(|| ostp_outbounds.first().copied());
let Some(primary) = primary else {
report.note(
"No 'ostp'-type outbound found in the old modular config — nothing to migrate \
the server connection from. Wrote a placeholder; you MUST fill in server/access_key \
by hand or re-import a share link."
.to_string(),
);
return (
json!({
"server": "127.0.0.1:50000",
"access_key": "",
}),
report,
);
};
for other in &ostp_outbounds {
if !std::ptr::eq(*other, primary) {
let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?");
let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?");
let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0);
report.note(format!(
"Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \
failover is no longer supported; only one server per config now. Kept the \
one from routing.default_outbound (or the first one if that wasn't set)."
));
}
}
let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000);
let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
let transport_type = primary
.get("transport")
.and_then(|t| t.get("type").or_else(|| t.get("mode")))
.and_then(|v| v.as_str())
.unwrap_or("udp")
.to_string();
let stealth_sni = primary
.get("transport")
.and_then(|t| t.get("stealth_sni"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tcp_fragmentation = primary
.get("transport")
.and_then(|t| t.get("tcp_fragmentation"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
// ── TUN + local proxy inbounds ───────────────────────────────────────
let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun"));
let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy"));
let tun_enable = tun_inbound.is_some();
let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64());
let socks5_bind = proxy_inbound
.map(|p| {
let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1");
let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088);
format!("{listen}:{port}")
})
.unwrap_or_else(|| "127.0.0.1:1088".to_string());
// ── Exclusions from routing.rules → direct ──────────────────────────
let mut ex_domains: Vec<String> = Vec::new();
let mut ex_ips: Vec<String> = Vec::new();
let mut ex_processes: Vec<String> = Vec::new();
if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) {
for rule in rules {
if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") {
continue; // only "route to direct" rules were ever exclusions in the old format
}
if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) {
ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
}
if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) {
ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
}
if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) {
ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
}
}
}
for other_rule_outbound in routing
.get("rules")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|r| r.get("outbound").and_then(|v| v.as_str()))
.filter(|o| *o != "direct")
{
report.note(format!(
"Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \
\"route to direct\" rules map to today's exclusions; anything else \
(custom per-domain outbound selection) has no equivalent anymore."
));
}
let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug");
let mut client = json!({
"server": server,
"port": port,
"access_key": access_key,
"socks5_bind": socks5_bind,
"debug": debug,
"tun": {
"enable": tun_enable,
"dns": null,
"kill_switch": false,
},
"exclude": {
"domains": ex_domains,
"ips": ex_ips,
"processes": ex_processes,
},
"mux": {
"enabled": mux_enabled,
"sessions": mux_sessions,
},
"transport": {
"mode": transport_type,
"stealth_sni": stealth_sni,
"tcp_fragmentation": tcp_fragmentation,
},
});
if let Some(mtu) = mtu {
client["mtu"] = json!(mtu);
}
if let Some(gui) = json.get("gui") {
client["gui"] = gui.clone();
}
(client, report)
}
/// Migrates a server config. The server shape has stayed structurally
/// identical since the earliest surviving version — this only backfills the
/// `api` section (added after some configs already existed) and drops the
/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used
/// to live in `scripts/install.sh` and only ran at install/update time.
pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
let mut report = MigrationReport::default();
let mut out = json;
let obj = match out.as_object_mut() {
Some(o) => o,
None => return (out, report),
};
let api = obj.entry("api").or_insert_with(|| json!({}));
if let Some(api_obj) = api.as_object_mut() {
let defaults: [(&str, Value); 5] = [
("enabled", json!(false)),
("bind", json!("0.0.0.0:9090")),
("webpath", json!("")),
("username", json!("")),
("password_hash", json!("")),
];
for (key, default) in defaults {
if !api_obj.contains_key(key) {
report.note(format!("Added api.{key} = {default} (missing default)"));
api_obj.insert(key.to_string(), default);
}
}
if api_obj.remove("token").is_some() {
report.note(
"Dropped legacy api.token — superseded by api.password_hash; \
set a new admin password with the management API or panel."
.to_string(),
);
}
}
(out, report)
}
#[cfg(test)]
mod tests {
use super::*;
/// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds,
/// a single ostp outbound, exclusion rules, mux) — mirrors the actual
/// shape from that tag, field for field.
#[test]
fn modular_single_server_preserves_every_field() {
let old = json!({
"version": "0.3.21",
"log": { "level": "debug" },
"inbounds": [
{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 },
{ "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 }
],
"outbounds": [
{
"type": "ostp", "tag": "proxy",
"server": "203.0.113.5", "port": 50000, "access_key": "sekrit123",
"transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true },
"multiplex": { "enabled": true, "sessions": 4 }
},
{ "type": "direct", "tag": "direct" },
{ "type": "block", "tag": "block" }
],
"routing": {
"rules": [
{ "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" },
{ "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" },
{ "process_name": ["steam.exe"], "outbound": "direct" }
],
"default_outbound": "proxy"
}
});
let (new, report) = migrate_client_json(old);
assert!(report.changed);
assert_eq!(new["server"], "203.0.113.5");
assert_eq!(new["port"], 50000);
assert_eq!(new["access_key"], "sekrit123");
assert_eq!(new["socks5_bind"], "127.0.0.1:1088");
assert_eq!(new["mtu"], 1350);
assert_eq!(new["debug"], true);
assert_eq!(new["tun"]["enable"], true);
assert_eq!(new["transport"]["mode"], "uot");
assert_eq!(new["transport"]["stealth_sni"], "vk.com");
assert_eq!(new["transport"]["tcp_fragmentation"], true);
assert_eq!(new["mux"]["enabled"], true);
assert_eq!(new["mux"]["sessions"], 4);
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
}
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
/// must keep the one routing.default_outbound points at and report every
/// other one by name/address rather than picking silently.
#[test]
fn modular_multi_server_keeps_default_and_reports_the_rest() {
let old = json!({
"inbounds": [],
"outbounds": [
{ "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" },
{ "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" },
{
"type": "urltest", "tag": "proxy",
"outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com"
}
],
"routing": { "rules": [], "default_outbound": "proxy" }
});
let (new, report) = migrate_client_json(old);
// urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked.
assert_eq!(new["server"], "2.2.2.2");
assert_eq!(new["access_key"], "k2");
assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1")));
}
/// Pre-0.3.1 flat config carrying fields that no longer exist
/// (tun.wintun_path, tun.ipv4_address, transport.wss) — those get
/// dropped with a note; every field that's still meaningful passes
/// through untouched, byte for byte.
#[test]
fn flat_legacy_drops_only_dead_fields() {
let old = json!({
"server": "198.51.100.9:50000",
"access_key": "oldkey",
"mtu": 1200,
"socks5_bind": "127.0.0.1:1090",
"tun": {
"enable": true,
"wintun_path": "C:\\Program Files\\wintun\\wintun.dll",
"ipv4_address": "10.0.0.2",
"dns": "1.1.1.1",
"kill_switch": true
},
"exclude": { "domains": ["a.com"], "ips": null, "processes": null },
"mux": { "enabled": false, "sessions": 1 },
"transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true }
});
let (new, report) = migrate_client_json(old);
assert!(report.changed);
// Untouched fields survive exactly as they were.
assert_eq!(new["server"], "198.51.100.9:50000");
assert_eq!(new["access_key"], "oldkey");
assert_eq!(new["mtu"], 1200);
assert_eq!(new["tun"]["enable"], true);
assert_eq!(new["tun"]["dns"], "1.1.1.1");
assert_eq!(new["tun"]["kill_switch"], true);
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
assert_eq!(new["transport"]["stealth_sni"], "bing.com");
// Dead fields are gone...
assert!(new["tun"].get("wintun_path").is_none());
assert!(new["tun"].get("ipv4_address").is_none());
assert!(new["transport"].get("wss").is_none());
// ...and their removal was reported, not silent.
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
assert!(report.notes.iter().any(|n| n.contains("wss")));
}
/// A config already in the current shape must be a true no-op: report
/// says nothing changed, and every field is untouched.
#[test]
fn current_flat_config_is_a_no_op() {
let current = json!({
"server": "example.com:50000",
"access_key": "k",
"tun": { "enable": false, "dns": null, "kill_switch": false },
"exclude": { "domains": [], "ips": [], "processes": [] },
"mux": { "enabled": false, "sessions": 1 },
"transport": { "mode": "udp", "stealth_sni": "", "tcp_fragmentation": false }
});
let (new, report) = migrate_client_json(current.clone());
assert!(!report.changed);
assert_eq!(new, current);
}
/// Every migrated output must actually deserialize into the ONE
/// canonical schema (`crate::config`) — this is the same check
/// `cmd_migrate` runs at runtime before ever touching a user's file,
/// exercised here directly so a schema/migrator drift fails a fast unit
/// test instead of surfacing as "your migrated config won't load".
#[test]
fn every_migrated_output_matches_the_canonical_schema() {
let modular = json!({
"inbounds": [{ "type": "tun", "tag": "tun-in", "mtu": 1350 }],
"outbounds": [
{ "type": "ostp", "tag": "proxy", "server": "1.2.3.4", "port": 50000, "access_key": "k" },
{ "type": "direct", "tag": "direct" }
],
"routing": { "rules": [], "default_outbound": "proxy" }
});
let (new, _) = migrate_client_json(modular);
serde_json::from_value::<crate::config::ClientFileConfig>(new)
.expect("modular->flat migration output must match ClientFileConfig");
let legacy_flat = json!({
"server": "1.2.3.4:50000",
"access_key": "k",
"tun": { "enable": true, "wintun_path": "x", "ipv4_address": "y" }
});
let (new, _) = migrate_client_json(legacy_flat);
serde_json::from_value::<crate::config::ClientFileConfig>(new)
.expect("legacy-flat migration output must match ClientFileConfig");
let server = json!({ "listen": "0.0.0.0:50000", "access_keys": ["k"] });
let (new, _) = migrate_server_json(server);
serde_json::from_value::<crate::config::ServerConfig>(new)
.expect("server migration output must match ServerConfig");
}
#[test]
fn server_config_backfills_api_defaults_and_drops_legacy_token() {
let old = json!({
"listen": "0.0.0.0:50000",
"access_keys": ["k1"],
"api": { "token": "old-plain-token" }
});
let (new, report) = migrate_server_json(old);
assert!(report.changed);
assert_eq!(new["api"]["enabled"], false);
assert_eq!(new["api"]["bind"], "0.0.0.0:9090");
assert!(new["api"].get("token").is_none());
assert!(report.notes.iter().any(|n| n.contains("api.token")));
}
#[test]
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server));
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -762,14 +762,40 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
let params_str = format!("--port {} --token-file \"{}\"", port, token_file.display());
let params_wstr: Vec<u16> = OsStr::new(&params_str).encode_wide().chain(Some(0)).collect();
#[link(name = "shell32")] extern "system" { fn ShellExecuteW(h: *mut std::ffi::c_void, op: *const u16, f: *const u16, p: *const u16, d: *const u16, s: i32) -> isize; }
#[link(name = "kernel32")] extern "system" { fn GetLastError() -> u32; }
// Use the GUI executable's directory as the working directory so dependencies are found
let cwd_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("."));
let dir_wstr: Vec<u16> = cwd_path.parent().unwrap_or(std::path::Path::new(".")).as_os_str().encode_wide().chain(Some(0)).collect();
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 0) };
// 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);
if ret <= 32 { anyhow::bail!("UAC denied or helper missing."); }
// Use SW_SHOWNORMAL (1) instead of SW_HIDE (0) because runas with SW_HIDE is automatically blocked by UAC
let ret = unsafe { ShellExecuteW(null_mut(), verb_wstr.as_ptr(), exe_wstr.as_ptr(), params_wstr.as_ptr(), dir_wstr.as_ptr(), 1) };
// ShellExecuteW's return is a pseudo-HINSTANCE: > 32 means the call itself
// "succeeded" — but that range INCLUDES ERROR_CANCELLED (1223), which is
// exactly what Windows returns when the user clicks "No" on the UAC prompt.
// The old `ret <= 32` check alone treated a user-denied prompt as success,
// silently starting nothing and reporting a single opaque "denied or
// missing" message that could not distinguish "no prompt ever shown"
// (missing exe, ret<=32) from "prompt shown and declined" (ret==1223) from
// any other Win32 failure — exactly the ambiguity blocking diagnosis here.
if ret == 1223 {
anyhow::bail!("UAC elevation was denied. TUN mode requires administrator privileges.");
}
if ret <= 32 {
let win_err = unsafe { GetLastError() };
anyhow::bail!(
"Failed to request UAC elevation for the TUN helper (ShellExecuteW ret={}, \
GetLastError={}, path={}). If this keeps happening with no prompt ever appearing, \
an unsigned binary can be silently blocked by SmartScreen/antivirus during \
elevation try running ostp-gui.exe as Administrator manually.",
ret, win_err, exe.display()
);
}
Ok(())
}

View File

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

View File

@ -280,10 +280,11 @@
</label>
</div>
</div>
</div> <!-- client-settings-card -->
<div class="app-version" id="app-version">OSTP GUI</div>
</div>
</div>
</div> <!-- settings-body -->
</div> <!-- settings-screen -->
<!-- ── ADD PROFILE DROPDOWN ─────────────────────────────── -->
<div id="add-menu" class="add-menu hidden">

View File

@ -84,7 +84,7 @@
/* ── Reset ───────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; background: var(--c-bg); overflow: hidden; user-select: none; }
html, body { width: 100%; height: 100%; background: var(--c-bg); user-select: none; }
button { cursor: pointer; font-family: inherit; border: none; background: none; }
input, textarea, select { font-family: inherit; }
a { text-decoration: none; }
@ -356,8 +356,12 @@ a { text-decoration: none; }
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 10px 18px;
padding: 10px 4px;
flex: 1;
min-width: 80px;
white-space: nowrap;
}
.live-stat-label {
font-size: 0.6rem;
@ -408,19 +412,13 @@ a { text-decoration: none; }
.settings-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.07) transparent;
overflow-y: scroll;
scrollbar-width: none;
padding-bottom: 20px;
position: relative;
z-index: 1;
}
.settings-body::-webkit-scrollbar { width: 3px; }
.settings-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.07); border-radius: 10px; }
.settings-body::-webkit-scrollbar { display: none; }
/* ── Profile list ────────────────────────────────────────────────────── */
.profile-list {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
use anyhow::{anyhow, Result};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use colored::Colorize;
@ -30,6 +29,7 @@ enum Commands {
mode: String,
},
/// Generate a new secure access key
#[command(name = "gk", alias = "generate-key")]
GenerateKey {
/// Format for generated key (hex, base64)
#[arg(long, default_value = "hex")]
@ -70,6 +70,11 @@ enum Commands {
ProxyEnv,
/// Output shell export commands to clear proxy (eval $(ostp proxy-env-clear))
ProxyEnvClear,
/// Upgrade the configuration file to the current schema. This is the
/// ONLY place config migration ever runs — never automatically at
/// startup or during install/update, so a config never changes shape
/// without you asking it to.
Migrate,
}
/// Bridges the new subcommand-based CLI onto the original flat-flag dispatch
@ -92,6 +97,60 @@ struct LegacyArgs {
import: Option<String>,
proxy_env: bool,
proxy_env_clear: bool,
migrate: bool,
}
/// Asks the same TUN/mux/debug questions regardless of how a share link
/// reached this config — connecting directly (`ostp connect <url>`) or
/// importing it to disk (`ostp import <url>`). Previously only the connect
/// path asked; `import` just wrote flat defaults with no way to turn any of
/// this on short of hand-editing the resulting config.json.
fn prompt_client_options(client_cfg: &mut ClientConfig) {
use std::io::Write;
let mut input = String::new();
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
if let Some(tun) = &mut client_cfg.tun {
tun.enable = true;
}
}
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
print!("How many sessions? [5]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
let mut sessions = 5;
if !input.trim().is_empty() {
if let Ok(s) = input.trim().parse() {
sessions = s;
}
}
if client_cfg.mux.is_none() {
client_cfg.mux = Some(MuxConfig {
enabled: Some(true),
sessions: Some(sessions),
});
} else if let Some(mux) = &mut client_cfg.mux {
mux.enabled = Some(true);
mux.sessions = Some(sessions);
}
}
print!("Enable debug mode? [y/N]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
client_cfg.debug = Some(true);
}
}
fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
@ -171,226 +230,19 @@ fn parse_outbound_action(value: Option<String>) -> ostp_server::OutboundAction {
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "mode", rename_all = "lowercase")]
enum AppMode {
Server(ServerConfig),
Client(ClientConfig),
Relay(RelayServerConfig),
}
#[derive(Debug, Deserialize, Serialize)]
struct UnifiedConfig {
#[serde(flatten)]
mode: AppMode,
log_level: Option<String>,
}
impl UnifiedConfig {
fn validate(&self) -> Result<()> {
match &self.mode {
AppMode::Server(cfg) => {
if cfg.access_keys.is_empty() {
anyhow::bail!("Server configuration must contain at least one access_key.");
}
if let Some(outbound) = &cfg.outbound {
if outbound.enabled {
let action = outbound.default_action.as_deref().unwrap_or("direct");
if action == "direct" && outbound.rules.is_empty() {
println!("\n[WARNING] Server outbound proxy is ENABLED, but default_action is 'direct' and there are no rules!");
println!(" This means ALL traffic will bypass the proxy and go out directly from the server IP.");
println!(" If you want all traffic to be proxied, change 'default_action' to 'proxy'.\n");
}
}
}
}
AppMode::Client(cfg) => {
if cfg.access_key.is_empty() {
anyhow::bail!("Client configuration must contain an access_key.");
}
}
AppMode::Relay(cfg) => {
if cfg.upstream_tcp.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_tcp address.");
}
if cfg.upstream_api_url.is_empty() {
anyhow::bail!("Relay configuration must specify upstream_api_url.");
}
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum UserConfig {
Detailed {
access_key: String,
name: Option<String>,
limit_bytes: Option<u64>,
},
KeyOnly(String),
}
impl UserConfig {
pub fn key(&self) -> String {
match self {
UserConfig::KeyOnly(k) => k.clone(),
UserConfig::Detailed { access_key, .. } => access_key.clone(),
}
}
pub fn name(&self) -> Option<String> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { name, .. } => name.clone(),
}
}
pub fn limit(&self) -> Option<u64> {
match self {
UserConfig::KeyOnly(_) => None,
UserConfig::Detailed { limit_bytes, .. } => limit_bytes.clone(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ServerConfig {
listen: ListenConfig,
access_keys: Vec<UserConfig>,
debug: Option<bool>,
outbound: Option<OutboundConfig>,
api: Option<ApiConfig>,
fallback: Option<FallbackCfg>,
transport: Option<TransportConfigRaw>,
dns: Option<ostp_server::dns::DnsConfig>,
}
/// Конфигурация Relay-узла в config.json
#[derive(Debug, Deserialize, Serialize)]
struct RelayServerConfig {
/// Адрес(а) прослушивания (UDP + TCP UoT)
listen: ListenConfig,
/// Адрес upstream для TCP (UoT) трафика
upstream_tcp: String,
/// Адрес upstream для UDP трафика
upstream_udp: String,
/// URL API целевого сервера для синхронизации ключей
upstream_api_url: String,
/// Bearer-токен для API целевого сервера
#[serde(default)]
upstream_api_token: String,
/// Интервал синхронизации ключей в секундах (по умолчанию 30)
#[serde(default = "default_sync_interval")]
sync_interval_secs: u64,
debug: Option<bool>,
}
fn default_sync_interval() -> u64 { 30 }
/// Supports both single string "0.0.0.0:50000" and array ["0.0.0.0:50000", "[::]:50000"]
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
enum ListenConfig {
Single(String),
Multiple(Vec<String>),
}
impl ListenConfig {
fn addresses(&self) -> Vec<String> {
match self {
ListenConfig::Single(s) => vec![s.clone()],
ListenConfig::Multiple(v) => v.clone(),
}
}
fn primary(&self) -> String {
match self {
ListenConfig::Single(s) => s.clone(),
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
struct ApiConfig {
enabled: Option<bool>,
bind: Option<String>,
token: Option<String>,
webpath: Option<String>,
username: Option<String>,
password_hash: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct FallbackCfg {
enabled: Option<bool>,
listen: Option<String>,
target: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ClientConfig {
server: String,
access_key: String,
mtu: Option<usize>,
socks5_bind: Option<String>,
tun: Option<TunConfig>,
debug: Option<bool>,
exclude: Option<ExcludeConfig>,
mux: Option<MuxConfig>,
transport: Option<TransportConfigRaw>,
gui: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TransportConfigRaw {
mode: Option<String>,
stealth_sni: Option<String>,
tcp_fragmentation: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TunConfig {
enable: bool,
wintun_path: Option<String>,
ipv4_address: Option<String>,
dns: Option<String>,
kill_switch: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundConfig {
enabled: bool,
protocol: String,
address: String,
port: u16,
#[serde(default)]
rules: Vec<OutboundRule>,
default_action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OutboundRule {
domain_suffix: Option<Vec<String>>,
ip_cidr: Option<Vec<String>>,
protocol: Option<String>,
action: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ExcludeConfig {
domains: Option<Vec<String>>,
ips: Option<Vec<String>>,
processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct MuxConfig {
enabled: Option<bool>,
sessions: Option<usize>,
}
// The on-disk config.json shapes (client/server/relay + all nested types)
// live in ostp_client::config now — this used to be ~220 lines of struct
// definitions duplicated here with no other consumer able to see them,
// which is exactly why ostp_client::migrate had to work against loosely
// typed JSON instead of a real schema. `ClientFileConfig` is aliased back to
// the bare `ClientConfig` name used throughout the rest of this file, so it
// doesn't collide with `ostp_client::config::ClientConfig` (the RUNTIME
// shape the engine actually uses — a different thing on purpose; see the
// doc comment on that struct).
use ostp_client::config::{
AppMode, ClientFileConfig as ClientConfig, MuxConfig, TransportConfigRaw, TunConfig,
UnifiedConfig,
};
#[tokio::main]
async fn main() -> Result<()> {
@ -1069,6 +921,7 @@ async fn run_app() -> Result<()> {
import: None,
proxy_env: false,
proxy_env_clear: false,
migrate: false,
};
if let Some(cmd) = raw_args.command {
@ -1084,6 +937,7 @@ async fn run_app() -> Result<()> {
Commands::Import { url } => { args.import = Some(url); }
Commands::ProxyEnv => { args.proxy_env = true; }
Commands::ProxyEnvClear => { args.proxy_env_clear = true; }
Commands::Migrate => { args.migrate = true; }
}
}
@ -1095,6 +949,10 @@ async fn run_app() -> Result<()> {
return cmd_update(args.update_branch, args.target_version);
}
if args.migrate {
return cmd_migrate(&args.config);
}
// ── Setup wizard: explicit flag or first-time (no config) ────────
if args.setup {
return run_setup_wizard(&args.config);
@ -1180,8 +1038,9 @@ async fn run_app() -> Result<()> {
if let Some(import_url) = args.import {
println!("{} Importing configuration from share link...", "[ostp]".cyan().bold());
let client_cfg = parse_ostp_link(&import_url)
let mut client_cfg = parse_ostp_link(&import_url)
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
prompt_client_options(&mut client_cfg);
let unified = UnifiedConfig {
mode: AppMode::Client(client_cfg),
log_level: Some("info".to_string()),
@ -1201,53 +1060,7 @@ async fn run_app() -> Result<()> {
println!("{} Connecting via share link...", "[ostp]".cyan().bold());
let mut client_cfg = parse_ostp_link(&url)
.map_err(|e| anyhow!("Share Link Error: {e}"))?;
// Interactive prompt for URL launch
use std::io::Write;
print!("{} Enable TUN (VPN) mode? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
if let Some(tun) = &mut client_cfg.tun {
tun.enable = true;
}
}
print!("{} Enable connection multiplexing (mux)? [y/N]: ", "?".blue().bold());
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
print!("How many sessions? [5]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
let mut sessions = 5;
if !input.trim().is_empty() {
if let Ok(s) = input.trim().parse() {
sessions = s;
}
}
if client_cfg.mux.is_none() {
client_cfg.mux = Some(MuxConfig {
enabled: Some(true),
sessions: Some(sessions),
});
} else if let Some(mux) = &mut client_cfg.mux {
mux.enabled = Some(true);
mux.sessions = Some(sessions);
}
}
print!("Enable debug mode? [y/N]: ");
std::io::stdout().flush().unwrap();
input.clear();
std::io::stdin().read_line(&mut input).unwrap();
if input.trim().eq_ignore_ascii_case("y") {
client_cfg.debug = Some(true);
}
prompt_client_options(&mut client_cfg);
return run_client_directly(client_cfg).await;
}
@ -1549,8 +1362,16 @@ async fn run_app() -> Result<()> {
})
}).collect::<Vec<_>>();
let host = get_or_ask_public_ip(&args.config);
// Build DNS config and set owndns flag in subscribe links if DNS enabled
let dns_cfg = server_cfg.dns;
// Build DNS config and set owndns flag in subscribe links if DNS enabled.
// Kept untyped (serde_json::Value) in the shared ServerConfig so
// ostp-client doesn't need a dependency on ostp-server just to
// name this type — deserialize it here instead, where both
// crates are already in scope.
let dns_cfg: Option<ostp_server::dns::DnsConfig> = server_cfg
.dns
.map(serde_json::from_value)
.transpose()
.map_err(|e| anyhow!("Invalid 'dns' section in server config: {e}"))?;
// Pass all listen addresses for multi-listener support
ostp_server::run_server(listen_addrs, Some(host), access_keys_meta, outbound, api_config, fallback_config, debug, dns_cfg, Some(args.config)).await?;
}
@ -1674,6 +1495,78 @@ fn cmd_update(_branch: String, _version: Option<String>) -> Result<()> {
anyhow::bail!("The 'update' command is only supported on Linux/Unix systems.");
}
/// The ONLY place config migration ever runs — see ostp_client::migrate for
/// why (and for the actual field-by-field mapping). Never called
/// automatically; only this explicit command touches an existing config's
/// shape.
fn cmd_migrate(config_path: &std::path::Path) -> Result<()> {
if !config_path.exists() {
anyhow::bail!("Configuration file not found at {:?}", config_path);
}
let raw_content = fs::read_to_string(config_path)?;
let mut stripped = json_comments::StripComments::new(raw_content.as_bytes());
let mut content_str = String::new();
{
use std::io::Read;
stripped.read_to_string(&mut content_str)?;
}
let parsed: serde_json::Value = serde_json::from_str(&content_str)
.map_err(|e| anyhow!("Failed to parse {:?} as JSON: {}", config_path, e))?;
let kind = ostp_client::migrate::detect_kind(&parsed)
.ok_or_else(|| anyhow!("Could not determine whether {:?} is a client, server, or relay config.", config_path))?;
let (migrated, report) = match kind {
ostp_client::migrate::ConfigKind::Client => {
let (mut v, r) = ostp_client::migrate::migrate_client_json(parsed);
if v.get("mode").is_none() { v["mode"] = serde_json::json!("client"); }
(v, r)
}
ostp_client::migrate::ConfigKind::Server => {
let (mut v, r) = ostp_client::migrate::migrate_server_json(parsed);
if v.get("mode").is_none() { v["mode"] = serde_json::json!("server"); }
(v, r)
}
ostp_client::migrate::ConfigKind::Relay => {
// The relay shape hasn't changed since it was introduced — nothing to migrate yet.
(parsed, ostp_client::migrate::MigrationReport::default())
}
};
if !report.changed {
println!("{} Config is already up to date, nothing to migrate.", "[ostp]".green().bold());
return Ok(());
}
// Prove the migrator's output actually matches the ONE canonical schema
// (ostp_client::config) before ever touching the user's file — this is
// what makes "single source of truth" a guarantee instead of just an
// intention: if migrate.rs's hand-built JSON ever drifts from what
// UnifiedConfig actually expects, this catches it here, not as a
// corrupted config.json on someone's server.
serde_json::from_value::<ostp_client::config::UnifiedConfig>(migrated.clone())
.map_err(|e| anyhow!(
"Internal error: the migrated config does not match the current schema ({e}). \
Nothing was written this is a bug in the migrator, please report it."
))?;
let backup_path = config_path.with_extension("json.bak");
fs::copy(config_path, &backup_path)?;
println!("{} Original config backed up to {:?}", "[ostp]".cyan().bold(), backup_path);
let new_content = serde_json::to_string_pretty(&migrated)?;
fs::write(config_path, new_content)?;
println!("{} Migrated {:?} — changes made:", "[ostp]".green().bold(), config_path);
for note in &report.notes {
println!(" - {note}");
}
println!("\n{} Run 'ostp check' to validate the migrated config.", "[ostp]".cyan().bold());
Ok(())
}
#[cfg(target_os = "windows")]
fn ensure_elevated_for_tun() -> Result<()> {
#[link(name = "shell32")]

View File

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

201
scripts/gha.ps1 Normal file
View File

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

View File

@ -178,52 +178,15 @@ if [ -f "$CONFIG_FILE" ]; then
echo "Existing configuration found at $CONFIG_FILE."
echo "Binary updated to ${LATEST_RELEASE:-latest}."
# ── Config migration: add new fields, preserve existing values ──
echo "Checking for new config fields..."
python3 << 'PYEOF'
import json, sys
CONFIG = '/etc/ostp/config.json'
with open(CONFIG) as f:
raw = f.read()
lines = [l for l in raw.split('\n') if not l.strip().startswith('//')]
cfg = json.loads('\n'.join(lines))
changed = False
# Ensure api section has all modern fields
if cfg.get('mode') == 'server':
if 'api' not in cfg:
cfg['api'] = {}
changed = True
api_defaults = {
'enabled': False,
'bind': '0.0.0.0:9090',
'webpath': '',
'username': '',
'password_hash': '',
}
for k, v in api_defaults.items():
if k not in cfg['api']:
cfg['api'][k] = v
changed = True
print(f'[migration] Added api.{k} = {json.dumps(v)}')
# Remove legacy "token" field if present
if 'token' in cfg['api']:
del cfg['api']['token']
changed = True
print('[migration] Removed legacy api.token field')
if changed:
with open(CONFIG, 'w') as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
print('[ok] Config migrated: new fields added, existing data preserved.')
else:
print('[ok] Config is up to date, no migration needed.')
PYEOF
# Config SCHEMA migration does NOT happen here (or anywhere automatic) —
# it used to be an ad-hoc Python snippet embedded right in this script,
# silently rewriting config.json on every update. That's exactly the kind
# of surprise this project no longer does: the ONE place a config's shape
# is ever changed is the explicit `ostp migrate` command (see
# ostp-client::migrate), which backs up the original file first and
# prints exactly what it changed. If your config predates this install,
# run it yourself:
echo "If this config is from an older OSTP version, run 'ostp migrate' to upgrade it."
# Update systemd service to use new paths
if [ -f "/etc/systemd/system/ostp.service" ]; then

View File

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

View File

@ -1 +0,0 @@


View File

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