Compare commits
139 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
cf14a4243c | |
|
|
66368c9d0f | |
|
|
bc61b47817 | |
|
|
5a33ed69c4 | |
|
|
c5e703c144 | |
|
|
05f25155bd | |
|
|
e6e0a7b28c | |
|
|
8f0ffd08c0 | |
|
|
8a1426ecf5 | |
|
|
e483af541f | |
|
|
df1a14d15c | |
|
|
d915efc715 | |
|
|
b673219894 | |
|
|
a1c146aff3 | |
|
|
365b4ccbf5 | |
|
|
4a3fb8b944 | |
|
|
f789167a22 | |
|
|
108bab6a90 | |
|
|
f7e9215331 | |
|
|
ebfc751471 | |
|
|
3cda1a9bd4 | |
|
|
77a45d7642 | |
|
|
6abae68f35 | |
|
|
cb57347d51 | |
|
|
32c36afc3b | |
|
|
a8aba8f4b8 | |
|
|
2ede607027 | |
|
|
0c69617725 | |
|
|
88e0634f09 | |
|
|
7473278cc2 | |
|
|
77e42b77f7 | |
|
|
e7a4f2b4a4 | |
|
|
6bc646c8a5 | |
|
|
d9fe749cd4 | |
|
|
cdfd2babc0 | |
|
|
2092e22a7c | |
|
|
5278f58903 | |
|
|
340819745a | |
|
|
e31c4b2268 | |
|
|
e46c863ef0 | |
|
|
cddd623ad0 | |
|
|
9a891310f9 | |
|
|
d9686c9344 | |
|
|
dbf923fb16 | |
|
|
51b947e6ff | |
|
|
f01ed4ec25 | |
|
|
c2a1a53b4d | |
|
|
cd12b01bc3 | |
|
|
de5cee103b | |
|
|
c523b083cb | |
|
|
c6a130673d | |
|
|
c756e02b63 | |
|
|
70a669d3c6 | |
|
|
66a1e97840 | |
|
|
b6bdd53066 | |
|
|
1c291d9c88 | |
|
|
2660a37249 | |
|
|
108ab8468b | |
|
|
5fcc0ba7f4 | |
|
|
7e3ada8d4d | |
|
|
4fd4f7d435 | |
|
|
b166f13d59 | |
|
|
a69ffae750 | |
|
|
90a919df59 | |
|
|
4ac2e79e14 | |
|
|
a9509a235d | |
|
|
5754689e09 | |
|
|
b5735fe8c2 | |
|
|
f904695760 | |
|
|
29554a71f1 | |
|
|
271a39c664 | |
|
|
44f9067222 | |
|
|
dee0288f2a | |
|
|
10ec253fa0 | |
|
|
cb59a5343f | |
|
|
b7bd8c20a5 | |
|
|
d725a4440b | |
|
|
caab8698ba | |
|
|
3e9e8845f1 | |
|
|
e52087ee8e | |
|
|
2092f6c716 | |
|
|
223f02287a | |
|
|
1d1a1ea5af | |
|
|
1b3390a3cf | |
|
|
7d9e5faeec | |
|
|
eda2a0eba7 | |
|
|
22c2d5edd8 | |
|
|
fa7ec2cd9a | |
|
|
794ea5251b | |
|
|
c6d506e6c0 | |
|
|
61091b6d56 | |
|
|
aa1c4ccd52 | |
|
|
5ab6833eab | |
|
|
5dc3a60017 | |
|
|
a33e5d3874 | |
|
|
1b536e9cf3 | |
|
|
5883f5105b | |
|
|
e21acc2ee1 | |
|
|
1568db3323 | |
|
|
edb2d8e229 | |
|
|
d609a3e883 | |
|
|
43914055b3 | |
|
|
3df5d5fccf | |
|
|
3d531ee0d9 | |
|
|
2819e2b3c2 | |
|
|
244d3ad374 | |
|
|
b89c6b0950 | |
|
|
992c212c76 | |
|
|
6361a87072 | |
|
|
fbc37e9d39 | |
|
|
db581ca391 | |
|
|
a547ebff17 | |
|
|
d065f6ceca | |
|
|
d822f48891 | |
|
|
26665a826f | |
|
|
7b43e1dcf7 | |
|
|
b17e5499eb | |
|
|
ec947ec9d1 | |
|
|
0ec09d1311 | |
|
|
f81610f939 | |
|
|
114011df5a | |
|
|
f96daaf57d | |
|
|
6929d42736 | |
|
|
5e0ff4a7ef | |
|
|
c330a0abe3 | |
|
|
b2ee9eb010 | |
|
|
8ca411a64b | |
|
|
dbd4ebc4e3 | |
|
|
acab38c551 | |
|
|
e1bf18e653 | |
|
|
39127d30f3 | |
|
|
5c9ec89821 | |
|
|
4f7f1ca838 | |
|
|
2a9b099b24 | |
|
|
db65e3367f | |
|
|
89a5eea20d | |
|
|
38f2d9e659 | |
|
|
7704e0bdb1 | |
|
|
92d6b06d75 |
|
|
@ -1,23 +1,107 @@
|
||||||
name: CI/CD
|
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-alpha"), only the
|
||||||
|
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
|
||||||
|
# the bare branch name ("alpha"/"beta") 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/') && (contains(github.ref_name, 'beta') && format('CI/CD: beta version {0}', github.ref_name) || contains(github.ref_name, 'alpha') && format('CI/CD: alpha version {0}', github.ref_name) || format('CI/CD: release version {0}', github.ref_name)) || format('CI/CD: {0} channel build', github.ref_name) }}"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
channel:
|
||||||
|
description: >-
|
||||||
|
Manually build+release just this rolling channel. Stable releases
|
||||||
|
are NEVER picked here on purpose - cut those only via a real
|
||||||
|
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
|
||||||
|
a "stable" release.
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: alpha
|
||||||
|
options:
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
# ── Global defaults ─────────────────────────────────────────────────────────
|
# -- Global defaults ---------------------------------------------------------
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Computes ONE channel + release tag for this whole run, so every build
|
||||||
|
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
|
||||||
|
# same release under the exact same tag, instead of repeating this logic
|
||||||
|
# (and risking it drifting out of sync) in five separate places.
|
||||||
|
#
|
||||||
|
# Tag shape:
|
||||||
|
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
|
||||||
|
# - push to `alpha` -> "{version}-alpha" (rolling, same tag every push)
|
||||||
|
# - push to `beta` -> "{version}-beta" (rolling, same tag every push)
|
||||||
|
# - workflow_dispatch -> forced by the `channel` input (alpha|beta only)
|
||||||
|
resolve-channel:
|
||||||
|
name: Resolve release channel
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
channel: ${{ steps.resolve.outputs.channel }}
|
||||||
|
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||||
|
prerelease: ${{ steps.resolve.outputs.prerelease }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve channel, version, and release tag
|
||||||
|
id: resolve
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||||
|
|
||||||
|
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||||
|
# A pushed tag is authoritative — use it AS-IS (never recompute it
|
||||||
|
# from Cargo.toml, or the release would upload to a different tag than
|
||||||
|
# the one that triggered this run). The channel, and thus prerelease,
|
||||||
|
# is decided by the tag's suffix: v0.4.7-beta / v0.4.7-alpha are
|
||||||
|
# prereleases; a bare vX.Y.Z is the only thing that becomes stable.
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
case "$TAG" in
|
||||||
|
*-alpha*) CHANNEL="alpha" ;;
|
||||||
|
*-beta*) CHANNEL="beta" ;;
|
||||||
|
*) CHANNEL="stable" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
# No tag (workflow_dispatch, or a legacy branch push): pick the
|
||||||
|
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
|
elif [ "${{ github.ref_name }}" = "beta" ]; then
|
||||||
|
CHANNEL="beta"
|
||||||
|
else
|
||||||
|
CHANNEL="alpha"
|
||||||
|
fi
|
||||||
|
TAG="v${BASE_VERSION}-${CHANNEL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Resolved channel=$CHANNEL tag=$TAG (base version $BASE_VERSION)"
|
||||||
|
echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "prerelease=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
check-and-test:
|
check-and-test:
|
||||||
name: Check & Test
|
name: Check & Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -55,13 +139,13 @@ jobs:
|
||||||
|
|
||||||
publish-release-matrix:
|
publish-release-matrix:
|
||||||
name: Release for ${{ matrix.target }}
|
name: Release for ${{ matrix.target }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# ── Windows ──────────────────────────────────────────────────────
|
# -- Windows ------------------------------------------------------
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
artifact_name: ostp.exe
|
artifact_name: ostp.exe
|
||||||
|
|
@ -80,7 +164,7 @@ jobs:
|
||||||
release_name: ostp-windows-arm64.zip
|
release_name: ostp-windows-arm64.zip
|
||||||
wintun_arch: arm64
|
wintun_arch: arm64
|
||||||
|
|
||||||
# ── macOS ─────────────────────────────────────────────────────────
|
# -- macOS ---------------------------------------------------------
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
artifact_name: ostp
|
artifact_name: ostp
|
||||||
|
|
@ -91,7 +175,7 @@ jobs:
|
||||||
artifact_name: ostp
|
artifact_name: ostp
|
||||||
release_name: ostp-darwin-arm64.tar.gz
|
release_name: ostp-darwin-arm64.tar.gz
|
||||||
|
|
||||||
# ── Linux native ──────────────────────────────────────────────────
|
# -- Linux native --------------------------------------------------
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: x86_64-unknown-linux-musl
|
target: x86_64-unknown-linux-musl
|
||||||
artifact_name: ostp
|
artifact_name: ostp
|
||||||
|
|
@ -103,7 +187,7 @@ jobs:
|
||||||
release_name: ostp-linux-386.tar.gz
|
release_name: ostp-linux-386.tar.gz
|
||||||
use_cross: true
|
use_cross: true
|
||||||
|
|
||||||
# ── Linux cross ───────────────────────────────────────────────────
|
# -- Linux cross ---------------------------------------------------
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
target: aarch64-unknown-linux-musl
|
target: aarch64-unknown-linux-musl
|
||||||
artifact_name: ostp
|
artifact_name: ostp
|
||||||
|
|
@ -141,25 +225,31 @@ jobs:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# ── Frontend Build ─────────────────────────────────────────────────────
|
# -- Frontend Build -----------------------------------------------------
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: Build Web Panel
|
- name: Build Web Panel (skip if no source; use committed dist/)
|
||||||
working-directory: ostp-control
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npm install
|
mkdir -p ostp-control/dist
|
||||||
npm run build
|
cd ostp-control
|
||||||
|
if [ -f package.json ]; then
|
||||||
|
npm install && npm run build
|
||||||
|
else
|
||||||
|
echo "ostp-control has no package.json - using committed dist/"
|
||||||
|
[ -f dist/index.html ] || echo '<!doctype html><title>OSTP</title>' > dist/index.html
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Rust toolchain ─────────────────────────────────────────────────────
|
# -- Rust toolchain -----------------------------------------------------
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ matrix.toolchain || 'stable' }}
|
toolchain: ${{ matrix.toolchain || 'stable' }}
|
||||||
targets: ${{ !matrix.use_cross && matrix.target || '' }}
|
targets: ${{ !matrix.use_cross && matrix.target || '' }}
|
||||||
|
|
||||||
# ── Cargo cache (shared per target) ───────────────────────────────────
|
# -- Cargo cache (shared per target) -----------------------------------
|
||||||
- name: Restore Cargo cache
|
- name: Restore Cargo cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -172,18 +262,18 @@ jobs:
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
cargo-${{ matrix.target }}-
|
cargo-${{ matrix.target }}-
|
||||||
|
|
||||||
# ── MUSL tools for native Linux musl builds ────────────────────────────
|
# -- MUSL tools for native Linux musl builds ----------------------------
|
||||||
- name: Install musl-tools
|
- name: Install musl-tools
|
||||||
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
|
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
|
||||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||||
|
|
||||||
# ── Native build ───────────────────────────────────────────────────────
|
# -- Native build -------------------------------------------------------
|
||||||
- name: Build (native)
|
- name: Build (native)
|
||||||
if: ${{ !matrix.use_cross }}
|
if: ${{ !matrix.use_cross }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: cargo build --release --target ${{ matrix.target }} --bin ostp
|
run: cargo build --release --target ${{ matrix.target }} --bin ostp
|
||||||
|
|
||||||
# ── Cross build ────────────────────────────────────────────────────────
|
# -- Cross build --------------------------------------------------------
|
||||||
- name: Restore cross binary cache
|
- name: Restore cross binary cache
|
||||||
if: ${{ matrix.use_cross }}
|
if: ${{ matrix.use_cross }}
|
||||||
id: cross-cache
|
id: cross-cache
|
||||||
|
|
@ -194,13 +284,21 @@ jobs:
|
||||||
|
|
||||||
- name: Install cross (if not cached)
|
- name: Install cross (if not cached)
|
||||||
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
||||||
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
# cross-rs's own source (not ours, not a dependency of ours) uses a
|
||||||
|
# macro-at-end-of-block pattern that trips rustc's
|
||||||
|
# semicolon_in_expressions_from_macros lint on current toolchains -
|
||||||
|
# harmless in cross's actual behavior, but `cargo install` compiles
|
||||||
|
# the installed package as the "local" crate, so dependency lint
|
||||||
|
# capping doesn't shield it. --cap-lints=warn is the standard escape
|
||||||
|
# hatch for building a third-party tool against a newer compiler than
|
||||||
|
# its own lint config assumed; it doesn't touch our own build.
|
||||||
|
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
||||||
|
|
||||||
- name: Build (cross)
|
- name: Build (cross)
|
||||||
if: ${{ matrix.use_cross }}
|
if: ${{ matrix.use_cross }}
|
||||||
run: cross build --release --target ${{ matrix.target }} --bin ostp
|
run: cross build --release --target ${{ matrix.target }} --bin ostp
|
||||||
|
|
||||||
# ── Driver dependencies ────────────────────────────────────────────────
|
# -- Driver dependencies ------------------------------------------------
|
||||||
- name: Download wintun (Windows)
|
- name: Download wintun (Windows)
|
||||||
if: ${{ matrix.os == 'windows-latest' }}
|
if: ${{ matrix.os == 'windows-latest' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -212,7 +310,7 @@ jobs:
|
||||||
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/"
|
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/"
|
||||||
Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
|
Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
|
||||||
|
|
||||||
# ── Package ────────────────────────────────────────────────────────────
|
# -- Package ------------------------------------------------------------
|
||||||
- name: Package (Windows)
|
- name: Package (Windows)
|
||||||
if: ${{ matrix.os == 'windows-latest' }}
|
if: ${{ matrix.os == 'windows-latest' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -231,18 +329,23 @@ jobs:
|
||||||
FILES="${{ matrix.artifact_name }}"
|
FILES="${{ matrix.artifact_name }}"
|
||||||
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
|
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
|
||||||
|
|
||||||
# ── Upload ─────────────────────────────────────────────────────────────
|
# -- Upload -------------------------------------------------------------
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
|
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||||
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
|
# real stable release.
|
||||||
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
files: ${{ matrix.release_name }}
|
files: ${{ matrix.release_name }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-windows-gui:
|
build-windows-gui:
|
||||||
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -275,7 +378,15 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
# Without a prefix fallback this cache NEVER restored on a release:
|
||||||
|
# cutting a release rewrites every Cargo.lock (version bump), which
|
||||||
|
# changes hashFiles(), which misses the exact key — so each release
|
||||||
|
# rebuilt every dependency from scratch. That is why the GUI jobs ran
|
||||||
|
# 2-4x longer than the plain release targets, which had this all along.
|
||||||
|
restore-keys: |
|
||||||
|
cargo-windows-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -307,16 +418,21 @@ jobs:
|
||||||
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
|
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||||
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
|
# real stable release.
|
||||||
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
files: ostp-windows-gui-${{ matrix.arch }}.zip
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-linux-gui:
|
build-linux-gui:
|
||||||
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -352,31 +468,46 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-linux-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
|
# TUN mode shells out to this helper, elevated via pkexec. Only the
|
||||||
|
# Windows job used to build it, so the Linux package shipped without
|
||||||
|
# it and TUN could never start.
|
||||||
|
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
|
||||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Package Portable Tarball
|
- name: Package Portable Tarball
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir ostp-linux-gui-${{ matrix.arch }}
|
mkdir ostp-linux-gui-${{ matrix.arch }}
|
||||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
||||||
|
# The GUI looks for the helper next to its own executable first.
|
||||||
|
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
|
||||||
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
|
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||||
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
|
# real stable release.
|
||||||
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
files: ostp-linux-gui-${{ matrix.arch }}.tar.gz
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-macos-gui:
|
build-macos-gui:
|
||||||
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -409,7 +540,10 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-macos-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
|
|
@ -424,16 +558,21 @@ jobs:
|
||||||
tar -czf ostp-macos-gui-${{ matrix.arch }}.tar.gz ostp-macos-gui-${{ matrix.arch }}
|
tar -czf ostp-macos-gui-${{ matrix.arch }}.tar.gz ostp-macos-gui-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
|
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||||
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
|
# real stable release.
|
||||||
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
files: ostp-macos-gui-${{ matrix.arch }}.tar.gz
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
||||||
needs: check-and-test
|
needs: [check-and-test, resolve-channel]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -444,7 +583,6 @@ jobs:
|
||||||
- arch: armeabi-v7a
|
- arch: armeabi-v7a
|
||||||
rust_target: armv7-linux-androideabi
|
rust_target: armv7-linux-androideabi
|
||||||
flutter_target: android-arm
|
flutter_target: android-arm
|
||||||
tun2socks_arch: linux-armv7
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -470,32 +608,117 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ndk-version: r26b
|
ndk-version: r26b
|
||||||
|
|
||||||
- name: Install cargo-ndk
|
# The Android jobs had no Rust caching at all, so every release recompiled
|
||||||
run: cargo install cargo-ndk
|
# the whole ostp-jni dependency graph from scratch — the main reason these
|
||||||
|
# were among the slowest jobs in the matrix.
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
target/
|
||||||
|
key: cargo-android-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-android-${{ matrix.arch }}-
|
||||||
|
|
||||||
|
# cargo-ndk was built from source on every run. Cache the binary the same
|
||||||
|
# way the cross-compilation jobs already cache `cross`.
|
||||||
|
- name: Restore cargo-ndk binary cache
|
||||||
|
id: cargo-ndk-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-ndk
|
||||||
|
key: cargo-ndk-bin-${{ runner.os }}-v1
|
||||||
|
|
||||||
|
- name: Install cargo-ndk (if not cached)
|
||||||
|
if: steps.cargo-ndk-cache.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install cargo-ndk --locked
|
||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ostp-flutter
|
working-directory: ostp-flutter
|
||||||
|
env:
|
||||||
|
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
# 1. Compile JNI
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. Materialise the upload keystore from secrets. Android keys an app
|
||||||
|
# by applicationId + signing key and refuses to update across a key
|
||||||
|
# change, so every published build MUST use this one key. Releases
|
||||||
|
# used to fall through to the per-machine debug keystore, which on
|
||||||
|
# ephemeral CI runners meant a different random key every build -
|
||||||
|
# hence "App not installed" on upgrade.
|
||||||
|
if [ -z "${OSTP_KEYSTORE_B64:-}" ]; then
|
||||||
|
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set. Refusing to publish a"
|
||||||
|
echo "::error::debug-signed APK: users could not update over it and the key is"
|
||||||
|
echo "::error::not reproducible. See docs for the one-time keystore setup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export OSTP_KEYSTORE_PATH="$RUNNER_TEMP/ostp-upload.jks"
|
||||||
|
# Strip any stray CR/LF before decoding: the secret is pasted from a
|
||||||
|
# shell whose line endings we don't control, and a single trailing \r
|
||||||
|
# is enough to corrupt the decode.
|
||||||
|
printf '%s' "$OSTP_KEYSTORE_B64" | tr -d '\r\n' | base64 -d > "$OSTP_KEYSTORE_PATH"
|
||||||
|
|
||||||
|
# Verify the keystore opens BEFORE spending four minutes on Gradle only
|
||||||
|
# to fail at the packaging step. The size/SHA-256 are safe to print (a
|
||||||
|
# hash reveals nothing) and let the operator compare against the local
|
||||||
|
# file to tell a transport problem apart from a wrong password.
|
||||||
|
echo "keystore: $(stat -c%s "$OSTP_KEYSTORE_PATH") bytes, sha256 $(sha256sum "$OSTP_KEYSTORE_PATH" | cut -d' ' -f1)"
|
||||||
|
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||||
|
-storepass "$OSTP_KEYSTORE_PASSWORD" >/dev/null 2>&1; then
|
||||||
|
echo "::error::The keystore did not open with ANDROID_KEYSTORE_PASSWORD."
|
||||||
|
echo "::error::If the SHA-256 above matches your local ostp-upload.jks, the file"
|
||||||
|
echo "::error::arrived intact and the password secret itself is wrong - note that"
|
||||||
|
echo "::error::PowerShell expands \$ inside double quotes, so a password containing"
|
||||||
|
echo "::error::one gets mangled unless it was set with single quotes."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||||
|
-storepass "$OSTP_KEYSTORE_PASSWORD" -alias "$OSTP_KEY_ALIAS" >/dev/null 2>&1; then
|
||||||
|
echo "::error::Keystore opened, but it has no key under ANDROID_KEY_ALIAS."
|
||||||
|
echo "::error::Aliases present in the keystore:"
|
||||||
|
keytool -list -keystore "$OSTP_KEYSTORE_PATH" -storepass "$OSTP_KEYSTORE_PASSWORD" \
|
||||||
|
| grep -i "PrivateKeyEntry" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Compile JNI
|
||||||
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
||||||
|
|
||||||
cd ../ostp-jni
|
cd ../ostp-jni
|
||||||
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
||||||
cd ../ostp-flutter
|
cd ../ostp-flutter
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 3. Build Flutter APK
|
# 3. Build Flutter APK
|
||||||
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
||||||
|
|
||||||
# 4. Copy to output
|
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
|
# than shipping another un-updatable build.
|
||||||
|
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
|
||||||
|
| grep -qi "CN=Android Debug"; then
|
||||||
|
echo "::error::APK is signed with the Android debug certificate - aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Copy to output
|
||||||
|
cp "$APK" ostp-android-${{ matrix.arch }}.apk
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
# Computed once in resolve-channel so every platform/job in this run
|
||||||
|
# lands on the exact same tag: "{version}-alpha" / "{version}-beta"
|
||||||
|
# for rolling channel pushes, or the pushed "vX.Y.Z" tag as-is for a
|
||||||
|
# real stable release.
|
||||||
|
tag_name: ${{ needs.resolve-channel.outputs.tag_name }}
|
||||||
|
prerelease: ${{ needs.resolve-channel.outputs.prerelease }}
|
||||||
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
# Binaries & libraries
|
# Binaries & libraries
|
||||||
*.exe
|
*.exe
|
||||||
|
|
@ -25,6 +26,17 @@ test_route.ps1
|
||||||
config.json
|
config.json
|
||||||
wintun.dll
|
wintun.dll
|
||||||
|
|
||||||
|
# Android signing keys. The upload keystore is the ONE key every published APK
|
||||||
|
# must be signed with (Android refuses to update an app across a key change),
|
||||||
|
# so losing or leaking it is unrecoverable — it can never be committed.
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
key.properties
|
||||||
|
|
||||||
|
# Server runtime cache (public IP autodetect) — must never be committed,
|
||||||
|
# it's regenerated locally and leaks whatever host it ran on last.
|
||||||
|
.ostp_public_ip
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|
@ -34,5 +46,14 @@ turn-harvesting-idea.md
|
||||||
|
|
||||||
# Private tooling (closed-source)
|
# Private tooling (closed-source)
|
||||||
ostp-prober/
|
ostp-prober/
|
||||||
|
ostp-lab/
|
||||||
|
|
||||||
ostp-brain/
|
ostp-brain/
|
||||||
|
|
||||||
|
# Management panel built assets (built separately; dummy dist created for rust-embed build)
|
||||||
|
ostp-control/
|
||||||
|
|
||||||
|
.agents/
|
||||||
|
netstack-smoltcp/
|
||||||
|
dnstt/
|
||||||
|
ostp-web/
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
127.0.0.1
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"target_version": "0.4.4",
|
||||||
|
"branch": "master",
|
||||||
|
"alpha_iteration": 0,
|
||||||
|
"beta_iteration": 0
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,12 @@ By contributing to this project, you agree to abide by our code of conduct and l
|
||||||
|
|
||||||
1. [Development Setup](#development-setup)
|
1. [Development Setup](#development-setup)
|
||||||
2. [Project Structure](#project-structure)
|
2. [Project Structure](#project-structure)
|
||||||
3. [Development Workflow](#development-workflow)
|
3. [Branch Strategy](#branch-strategy)
|
||||||
4. [Coding Guidelines](#coding-guidelines)
|
4. [Development Workflow](#development-workflow)
|
||||||
5. [Submitting Pull Requests](#submitting-pull-requests)
|
5. [Commit Message Conventions](#commit-message-conventions)
|
||||||
6. [Security Vulnerabilities](#security-vulnerabilities)
|
6. [Coding Guidelines](#coding-guidelines)
|
||||||
|
7. [Submitting Pull Requests](#submitting-pull-requests)
|
||||||
|
8. [Security Vulnerabilities](#security-vulnerabilities)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -33,20 +35,19 @@ To build and test OSTP locally, you will need:
|
||||||
cd ostp
|
cd ostp
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Build the control panel frontend**:
|
2. **Build the entire Cargo workspace**:
|
||||||
```bash
|
|
||||||
cd ostp-control
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Build the entire Cargo workspace**:
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
cargo build
|
||||||
```
|
```
|
||||||
|
`ostp-control` (the web panel) is only needed if you're working on it
|
||||||
|
specifically - the server build embeds a dummy `dist/` via `rust-embed`
|
||||||
|
otherwise, so this step is not required for day-to-day core/client/server
|
||||||
|
work. If you *are* touching the panel:
|
||||||
|
```bash
|
||||||
|
cd ostp-control && npm install && npm run build && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
4. **Run tests**:
|
3. **Run tests**:
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
@ -58,7 +59,7 @@ To build and test OSTP locally, you will need:
|
||||||
The repository is organized as a Cargo workspace containing the following crates:
|
The repository is organized as a Cargo workspace containing the following crates:
|
||||||
|
|
||||||
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Core protocol logic, including packet formatting, serialization, selective ACK/NACK (ARQ) state machine, and the Noise protocol (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`) handshake.
|
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Core protocol logic, including packet formatting, serialization, selective ACK/NACK (ARQ) state machine, and the Noise protocol (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`) handshake.
|
||||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Client implementations, including SOCKS5/HTTP local proxies, `tun2socks` integration, native TUN interface routing, and split-tunneling bypass mechanisms.
|
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Client implementations, including SOCKS5/HTTP local proxies, the native OSTP TUN interface routing, and split-tunneling bypass mechanisms.
|
||||||
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Server logic, session dispatcher, anti-probing fallback server proxying, access key database, and the REST API for control panel communication.
|
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Server logic, session dispatcher, anti-probing fallback server proxying, access key database, and the REST API for control panel communication.
|
||||||
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): A modern web dashboard for server administration (user management, real-time metrics, bandwidth limits).
|
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): A modern web dashboard for server administration (user management, real-time metrics, bandwidth limits).
|
||||||
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Tauri-based desktop GUI application for Windows and Linux.
|
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Tauri-based desktop GUI application for Windows and Linux.
|
||||||
|
|
@ -66,11 +67,28 @@ The repository is organized as a Cargo workspace containing the following crates
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
The repository runs three long-lived branches, in increasing order of stability:
|
||||||
|
|
||||||
|
| Branch | Role |
|
||||||
|
|---|---|
|
||||||
|
| `alpha` | Active development. All feature work and fixes land here first. |
|
||||||
|
| `beta` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
|
||||||
|
| `master` | Fast-forwarded from `beta` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
|
||||||
|
|
||||||
|
`beta` 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 `beta` or `master`.
|
||||||
|
|
||||||
|
**Contributor PRs target `alpha`**, not `master`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
|
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
|
||||||
2. **Fork the repository** and create a new branch from `master`:
|
2. **Fork the repository** and create a new branch from `alpha`:
|
||||||
```bash
|
```bash
|
||||||
|
git checkout alpha
|
||||||
git checkout -b feat/your-feature-name
|
git checkout -b feat/your-feature-name
|
||||||
```
|
```
|
||||||
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
|
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
|
||||||
|
|
@ -89,6 +107,32 @@ The repository is organized as a Cargo workspace containing the following crates
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Commit Message Conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short, imperative summary>
|
||||||
|
|
||||||
|
<optional body - explain WHY, not what; the diff already shows what changed>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Type** - one of: `feat` (new capability), `fix` (bug fix), `docs`, `refactor` (no behavior change), `perf`, `test`, `chore` (deps/tooling/version bumps), `ci`, `security`.
|
||||||
|
- **Scope** (optional) - the crate or area touched: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs`, etc. e.g. `fix(client): ...`.
|
||||||
|
- **Summary** - imperative mood ("add", not "added"/"adds"), no trailing period, ideally under ~70 characters.
|
||||||
|
- **Body** - only when the *why* isn't obvious from the diff: a prior bug this fixes, a constraint that shaped the approach, a tradeoff you made. Don't restate what the diff already shows. Wrap at ~72 columns.
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(server): drop junk frames by per-key marker instead of a global one
|
||||||
|
|
||||||
|
A fixed 4-byte marker on every junk packet is itself a DPI signature any
|
||||||
|
observer can filter on across every OSTP deployment. Derive the marker
|
||||||
|
from the access key (HKDF, same scheme as obfuscation_key/psk) so it's
|
||||||
|
per-user and indistinguishable from the packet's own random payload.
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple unrelated changes belong in separate commits, not one bundled commit - it keeps `git bisect` and review useful. Squash-merge is fine for a PR with a few "fix typo" / "address review" commits, but don't squash logically distinct changes together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
|
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
|
||||||
|
|
@ -104,7 +148,7 @@ The repository is organized as a Cargo workspace containing the following crates
|
||||||
```bash
|
```bash
|
||||||
git push origin feat/your-feature-name
|
git push origin feat/your-feature-name
|
||||||
```
|
```
|
||||||
2. Open a Pull Request (PR) targeting the `master` branch.
|
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `beta`, never direct PRs).
|
||||||
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
||||||
4. Verify that GitHub Actions CI runs successfully on your PR.
|
4. Verify that GitHub Actions CI runs successfully on your PR.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@
|
||||||
|
|
||||||
1. [Подготовка окружения](#подготовка-окружения)
|
1. [Подготовка окружения](#подготовка-окружения)
|
||||||
2. [Структура проекта](#структура-проекта)
|
2. [Структура проекта](#структура-проекта)
|
||||||
3. [Процесс разработки](#процесс-разработки)
|
3. [Стратегия веток](#стратегия-веток)
|
||||||
4. [Правила оформления кода](#правила-оформления-кода)
|
4. [Процесс разработки](#процесс-разработки)
|
||||||
5. [Создание Pull Request](#создание-pull-request)
|
5. [Оформление коммитов](#оформление-коммитов)
|
||||||
6. [Уязвимости безопасности](#уязвимости-безопасности)
|
6. [Правила оформления кода](#правила-оформления-кода)
|
||||||
|
7. [Создание Pull Request](#создание-pull-request)
|
||||||
|
8. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -33,20 +35,19 @@
|
||||||
cd ostp
|
cd ostp
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Соберите веб-интерфейс панели управления**:
|
2. **Соберите весь Cargo-workspace**:
|
||||||
```bash
|
|
||||||
cd ostp-control
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Соберите весь Cargo-workspace**:
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
cargo build
|
||||||
```
|
```
|
||||||
|
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
|
||||||
|
ней - в остальных случаях сервер собирается с пустым `dist/` через
|
||||||
|
`rust-embed`, и этот шаг не нужен для повседневной работы над
|
||||||
|
core/client/server. Если вы всё же трогаете панель:
|
||||||
|
```bash
|
||||||
|
cd ostp-control && npm install && npm run build && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
4. **Запустите тесты**:
|
3. **Запустите тесты**:
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
@ -58,7 +59,7 @@
|
||||||
Репозиторий представляет собой единый Cargo-workspace со следующими компонентами:
|
Репозиторий представляет собой единый Cargo-workspace со следующими компонентами:
|
||||||
|
|
||||||
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Базовая логика протокола: форматирование пакетов, сериализация, конечный автомат выборочного подтверждения (ARQ/ACK/NACK) и рукопожатие Noise (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`).
|
* [`ostp-core/`](file:///d:/ospab-projects/ostp/ostp-core): Базовая логика протокола: форматирование пакетов, сериализация, конечный автомат выборочного подтверждения (ARQ/ACK/NACK) и рукопожатие Noise (`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`).
|
||||||
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Клиентская часть: локальные SOCKS5/HTTP прокси-серверы, интеграция с драйвером `wintun` / `tun2socks` и реализация раздельного туннелирования для прямого обхода трафика.
|
* [`ostp-client/`](file:///d:/ospab-projects/ostp/ostp-client): Клиентская часть: локальные SOCKS5/HTTP прокси-серверы, нативный OSTP TUN-интерфейс (через драйвер `wintun`) и реализация раздельного туннелирования для прямого обхода трафика.
|
||||||
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Серверная часть: диспетчеризация сессий, маскировка под классические веб-серверы при активном сканировании, база данных ключей доступа и REST API панели управления.
|
* [`ostp-server/`](file:///d:/ospab-projects/ostp/ostp-server): Серверная часть: диспетчеризация сессий, маскировка под классические веб-серверы при активном сканировании, база данных ключей доступа и REST API панели управления.
|
||||||
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): Панель администратора (пользователи, статистика трафика в реальном времени, лимиты скорости и объема данных).
|
* [`ostp-control/`](file:///d:/ospab-projects/ostp/ostp-control): Панель администратора (пользователи, статистика трафика в реальном времени, лимиты скорости и объема данных).
|
||||||
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Настольное приложение-клиент для Windows и Linux на платформе Tauri.
|
* [`ostp-gui/`](file:///d:/ospab-projects/ostp/ostp-gui): Настольное приложение-клиент для Windows и Linux на платформе Tauri.
|
||||||
|
|
@ -66,11 +67,28 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Стратегия веток
|
||||||
|
|
||||||
|
В репозитории три долгоживущие ветки, по возрастанию стабильности:
|
||||||
|
|
||||||
|
| Ветка | Роль |
|
||||||
|
|---|---|
|
||||||
|
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
||||||
|
| `beta` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||||
|
| `master` | Перематывается вперёд от `beta`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||||
|
|
||||||
|
В `beta` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `beta` или `master`.
|
||||||
|
|
||||||
|
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Процесс разработки
|
## Процесс разработки
|
||||||
|
|
||||||
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
|
1. **Проверьте существующие задачи** или откройте новую тему (Issue) для обсуждения предлагаемых изменений.
|
||||||
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
|
2. **Сделайте fork репозитория** и создайте новую ветку от `alpha`:
|
||||||
```bash
|
```bash
|
||||||
|
git checkout alpha
|
||||||
git checkout -b feat/имя-вашей-фичи
|
git checkout -b feat/имя-вашей-фичи
|
||||||
```
|
```
|
||||||
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
|
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
|
||||||
|
|
@ -89,6 +107,33 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Оформление коммитов
|
||||||
|
|
||||||
|
```
|
||||||
|
<тип>(<область>): <краткое описание в повелительном наклонении>
|
||||||
|
|
||||||
|
<опционально: тело - объясняет ПОЧЕМУ, а не что; диф и так показывает что изменилось>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Тип** - один из: `feat` (новая функциональность), `fix` (исправление бага), `docs`, `refactor` (без изменения поведения), `perf`, `test`, `chore` (зависимости/тулинг/версии), `ci`, `security`.
|
||||||
|
- **Область** (опционально) - крейт или часть проекта: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs` и т.д., например `fix(client): ...`.
|
||||||
|
- **Краткое описание** - повелительное наклонение ("добавь", а не "добавил"/"добавляет"), без точки в конце, желательно до ~70 символов.
|
||||||
|
- **Тело** - только когда причина не очевидна из дифа: какой баг это чинит, какое ограничение определило подход, на какой trade-off вы пошли. Не пересказывайте то, что и так видно в дифе. Перенос строк на ~72 символах.
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(server): отбрасывать junk-фреймы по маркеру для каждого ключа, а не глобальному
|
||||||
|
|
||||||
|
Фиксированный 4-байтовый маркер на каждом junk-пакете сам по себе - сигнатура
|
||||||
|
DPI, по которой можно фильтровать любого наблюдателя во всех деплойментах OSTP
|
||||||
|
сразу. Выводим маркер из access_key (HKDF, та же схема что у
|
||||||
|
obfuscation_key/psk), чтобы он был индивидуальным для ключа и неотличимым от
|
||||||
|
случайной полезной нагрузки пакета.
|
||||||
|
```
|
||||||
|
|
||||||
|
Несколько несвязанных изменений - это несколько отдельных коммитов, а не один сборный. Это сохраняет пользу от `git bisect` и код-ревью. Squash-merge подходит для PR с парой коммитов вроде "fix typo" / "address review", но не сквошьте вместе логически разные изменения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Правила оформления кода
|
## Правила оформления кода
|
||||||
|
|
||||||
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
|
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
|
||||||
|
|
@ -104,7 +149,7 @@
|
||||||
```bash
|
```bash
|
||||||
git push origin feat/имя-вашей-фичи
|
git push origin feat/имя-вашей-фичи
|
||||||
```
|
```
|
||||||
2. Создайте Pull Request (PR) в ветку `master` основного репозитория.
|
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `beta`, PR туда не принимаются напрямую).
|
||||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1316,7 +1316,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "netstack-smoltcp"
|
name = "netstack-smoltcp"
|
||||||
version = "0.2.2"
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c38f66cdd673ff0e760752f27c6d34a7e3a140f0b1eea9efae3c46d8867c83d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"etherparse",
|
"etherparse",
|
||||||
"futures",
|
"futures",
|
||||||
|
|
@ -1384,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1395,8 +1397,10 @@ dependencies = [
|
||||||
"ostp-core",
|
"ostp-core",
|
||||||
"ostp-server",
|
"ostp-server",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"rlimit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -1405,16 +1409,14 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chacha20poly1305",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
|
||||||
"hmac",
|
"hmac",
|
||||||
"json_comments",
|
"json_comments",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1434,12 +1436,11 @@ dependencies = [
|
||||||
"tun",
|
"tun",
|
||||||
"webpki-roots 0.26.11",
|
"webpki-roots 0.26.11",
|
||||||
"winapi",
|
"winapi",
|
||||||
"x25519-dalek",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1473,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1496,6 +1497,7 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
"simple-dns",
|
"simple-dns",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -1505,7 +1507,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1517,7 +1519,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -1856,6 +1858,15 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rlimit"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-embed"
|
name = "rust-embed"
|
||||||
version = "8.11.0"
|
version = "8.11.0"
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,18 @@ resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "BSL 1.1"
|
license = "AGPL-3.0"
|
||||||
version = "0.2.98"
|
version = "0.4.4"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
bytes = "1.6"
|
bytes = "1.6"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
snow = "0.9"
|
snow = { version = "0.9", features = ["risky-raw-split"] }
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
portable-atomic = "1.10"
|
portable-atomic = "1.10"
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
netstack-smoltcp = { path = "netstack-smoltcp" }
|
|
||||||
|
|
|
||||||
701
LICENSE
|
|
@ -1,74 +1,661 @@
|
||||||
Business Source License 1.1
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Parameters
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Licensor: Ospab Foundation (represented by Syralev Georgiy)
|
Preamble
|
||||||
Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all
|
|
||||||
associated workspace crates, utilities, and documents.
|
|
||||||
Additional Use Grant: The Licensor hereby grants you the right to copy,
|
|
||||||
modify, create derivative works, redistribute, and
|
|
||||||
make non-production and non-commercial use of the
|
|
||||||
Licensed Work. You are also permitted to use the
|
|
||||||
Licensed Work in production for personal, private
|
|
||||||
utility and non-profit organizations.
|
|
||||||
Change Date: May 14, 2030
|
|
||||||
Change License: MIT License (as defined below)
|
|
||||||
|
|
||||||
-----------------------------------------------------------------------------------
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
Terms
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
1. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
When we speak of free software, we are referring to freedom, not
|
||||||
redistribute, and make use of the Licensed Work only as permitted by the
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
Additional Use Grant.
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
2. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
Developers that use our General Public Licenses protect your rights
|
||||||
redistribute, and make use of the Licensed Work under the terms of the Change
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
License on and after the Change Date.
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
3. To the extent that any term of this License (including the Additional Use Grant
|
A secondary benefit of defending all users' freedom is that
|
||||||
and the Change License) is in conflict with the Terms of this License, these
|
improvements made in alternate versions of the program, if they
|
||||||
Terms shall take precedence.
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
4. Every copy of the Licensed Work and any derivative work must include this
|
The GNU Affero General Public License is designed specifically to
|
||||||
License and all other copyright, trademark, and proprietary notices included
|
ensure that, in such cases, the modified source code becomes available
|
||||||
with the Licensed Work.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
5. Any use of the Licensed Work that is not permitted by this License is a breach
|
An older license, called the Affero General Public License and
|
||||||
of this License and may terminate your rights under this License.
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED
|
The precise terms and conditions for copying, distribution and
|
||||||
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR
|
modification follow.
|
||||||
WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY
|
|
||||||
OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE,
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
|
|
||||||
|
|
||||||
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
|
TERMS AND CONDITIONS
|
||||||
WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL,
|
|
||||||
CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE
|
|
||||||
USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE
|
|
||||||
POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
-----------------------------------------------------------------------------------
|
0. Definitions.
|
||||||
|
|
||||||
Change License Text (MIT License)
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
Copyright (c) 2026 Syralev Georgiy (Ospab Foundation)
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
in the Software without restriction, including without limitation the rights
|
"recipients" may be individuals or organizations.
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
copies or substantial portions of the Software.
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
A "covered work" means either the unmodified Program or a work based
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
on the Program.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
To "propagate" a work means to do anything with it that, without
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
permission, would make you directly or secondarily liable for
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
infringement under applicable copyright law, except executing it on a
|
||||||
SOFTWARE.
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
|
||||||
122
README.md
|
|
@ -1,16 +1,16 @@
|
||||||
# OSTP — Ospab Stealth Transport Protocol
|
# OSTP - Ospab Stealth Transport Protocol
|
||||||
|
|
||||||
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
[Русский язык](README.ru.md) · [Wiki](https://github.com/ospab/ostp/wiki) · [Contributing](CONTRIBUTING.md) · [Releases](https://github.com/ospab/ostp/releases)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
> A fast, custom encrypted transport protocol written in Rust.
|
> A fast, custom encrypted transport protocol written in Rust.
|
||||||
|
|
||||||
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
|
**OSTP** (Ospab Stealth Transport Protocol) is a high-performance transport protocol. It implements a custom ARQ transport over UDP, as well as a UoT (UDP-over-TCP) mode. Every byte on the wire - including packet headers - is cryptographically indistinguishable from random noise, making it highly resistant to Deep Packet Inspection (DPI).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -35,16 +35,16 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **Full Traffic Obfuscation** | Every packet — including headers — is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
| **Full Traffic Obfuscation** | Every packet - including headers - is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
|
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
|
||||||
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
||||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption — tracked by session-ID, not IP. |
|
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption - tracked by session-ID, not IP. |
|
||||||
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
|
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
|
||||||
| **Fallback Server** | TCP fallback proxy to a web server — makes OSTP indistinguishable from nginx during active probing. |
|
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
|
||||||
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
||||||
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
||||||
| **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel that completely hides traffic. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
||||||
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
||||||
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
||||||
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
||||||
|
|
@ -56,35 +56,42 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
flowchart LR
|
||||||
subgraph Client ["Client"]
|
%% Styles
|
||||||
A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||||
TUN[TUN Interface] -->|IP Packets| B
|
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||||
|
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||||
|
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||||
|
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||||
|
|
||||||
subgraph OSTPCoreClient ["OSTP Core Protocol"]
|
subgraph Local["💻 Client Device"]
|
||||||
B --> C{Protocol Machine}
|
Apps["Web Browser / Apps"]:::userApp
|
||||||
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
Socks["SOCKS5 / HTTP Proxy"]:::ostpCore
|
||||||
D -->|Obfuscated UDP Payload| E((UDP Socket))
|
Tun["Global TUN (VPN)"]:::ostpCore
|
||||||
end
|
Client["OSTP Client Protocol Engine\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||||
|
|
||||||
|
Apps -->|TCP/UDP| Socks
|
||||||
|
Apps -->|IP Packets| Tun
|
||||||
|
Socks --> Client
|
||||||
|
Tun --> Client
|
||||||
end
|
end
|
||||||
|
|
||||||
E <==>|Encrypted & Obfuscated UDP Tunnel| F
|
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
|
||||||
|
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
|
||||||
subgraph Server ["Server"]
|
|
||||||
F((UDP Socket)) --> G{Dispatcher}
|
|
||||||
|
|
||||||
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
|
||||||
G -->|Auth & Decrypt| H[Session & State Guard]
|
|
||||||
H -->|TCP Stream| I[Relay Loop]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
subgraph Remote["🖥️ Remote VPS (Server)"]
|
||||||
FB -->|Forward| NGINX[nginx / Caddy]
|
Server["OSTP Server Protocol Engine\n(Authentication & Decryption)"]:::ostpCore
|
||||||
|
Relay["Connection Multiplexer"]:::ostpCore
|
||||||
|
Fallback["Fake Website\n(Nginx/Caddy)"]:::fallback
|
||||||
|
Target["Open Internet\n(YouTube, Google, etc)"]:::external
|
||||||
|
|
||||||
H -->|Stats & Traffic| API[Management API]
|
Server -->|Decrypted Traffic| Relay
|
||||||
|
Server -->|Active Probe / Scanner| Fallback
|
||||||
I -->|Outbound| WWW((Internet))
|
Relay -->|Clear Traffic| Target
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Client <==> Tunnel <==> Server
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -95,15 +102,15 @@ graph TD
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your VPS (server):
|
# On your VPS (server):
|
||||||
./ostp --init server
|
./ostp init server
|
||||||
|
|
||||||
# On your machine (client):
|
# On your machine (client):
|
||||||
./ostp --init client
|
./ostp init client
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Edit config
|
### 2. Edit config
|
||||||
|
|
||||||
**Server** — set your access keys:
|
**Server** - set your access keys:
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"mode": "server",
|
"mode": "server",
|
||||||
|
|
@ -114,14 +121,14 @@ graph TD
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Client** — point to your server:
|
**Client** - point to your server:
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"mode": "client",
|
"mode": "client",
|
||||||
"server": "YOUR_SERVER_IP:50000",
|
"server": "YOUR_SERVER_IP:50000",
|
||||||
"access_key": "YOUR_SECRET_KEY",
|
"access_key": "YOUR_SECRET_KEY",
|
||||||
"socks5_bind": "127.0.0.1:1088",
|
"socks5_bind": "127.0.0.1:1088",
|
||||||
"transport": { "mode": "udp", "stealth_sni": "vk.com" },
|
"transport": { "mode": "udp" },
|
||||||
"tun": { "enable": false, "dns": "1.1.1.1" }
|
"tun": { "enable": false, "dns": "1.1.1.1" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -131,14 +138,14 @@ graph TD
|
||||||
```bash
|
```bash
|
||||||
./ostp # Uses config.json in current directory
|
./ostp # Uses config.json in current directory
|
||||||
./ostp --config /path/to.json # Custom config path
|
./ostp --config /path/to.json # Custom config path
|
||||||
./ostp --check # Validate config without running
|
./ostp check # Validate config without running
|
||||||
./ostp --generate-key # Generate a new access key
|
./ostp gk # Generate a new access key
|
||||||
./ostp --links # Print client share links
|
./ostp links # Print client share links
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Connect via share link (one-liner)
|
### 4. Connect via share link (one-liner)
|
||||||
```bash
|
```bash
|
||||||
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
|
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|
@ -171,21 +178,33 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
```
|
```
|
||||||
ostp [OPTIONS] [URL]
|
ostp [--config <PATH>] [COMMAND]
|
||||||
|
|
||||||
Options:
|
Commands:
|
||||||
--config <PATH> Config file path (default: config.json)
|
run Run the daemon using the config file (default when no command is given)
|
||||||
--init <MODE> Generate template config (server/client)
|
connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
|
||||||
--check Validate configuration and exit
|
setup Interactive setup wizard
|
||||||
-g, --generate-key Generate a secure access key
|
init <MODE> Generate a template config (server/client/relay)
|
||||||
-c, --count <N> Number of keys to generate (default: 1)
|
check Validate the configuration file and exit
|
||||||
|
gk Generate a secure access key (alias: generate-key)
|
||||||
--format <FMT> Key format: hex, base64 (default: hex)
|
--format <FMT> Key format: hex, base64 (default: hex)
|
||||||
--links Print client share links from server config
|
-n, --count <N> Number of keys to generate (default: 1)
|
||||||
|
links Print client share links from the server config
|
||||||
|
import <URL> Import a share link into the config file
|
||||||
|
update Update OSTP to the latest release
|
||||||
|
-b, --branch <NAME> Release channel: stable, beta, alpha (default: stable)
|
||||||
|
-v, --version <VER> Update to an exact version instead of the channel's latest
|
||||||
|
migrate Force-migrate the configuration file to the current format
|
||||||
|
proxy-env Print shell export commands for the local SOCKS proxy
|
||||||
|
proxy-env-clear Print shell export commands to unset it
|
||||||
|
uninstall Stop the service and remove the binary and config
|
||||||
|
|
||||||
Arguments:
|
Global options:
|
||||||
[URL] Connect via share link: ostp://KEY@HOST:PORT
|
--config <PATH> Config file path (default: config.json)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every subcommand also accepts `-h`/`--help` for its own option list.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Protocol Summary
|
## Protocol Summary
|
||||||
|
|
@ -218,7 +237,7 @@ cargo test -p ostp-core -p ostp-server
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **[Wiki](https://github.com/ospab/ostp/wiki)** — Full documentation
|
- **[Wiki](https://github.com/ospab/ostp/wiki)** - Full documentation
|
||||||
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
|
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
|
||||||
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
|
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
|
||||||
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||||
|
|
@ -230,8 +249,7 @@ cargo test -p ostp-core -p ostp-server
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Business Source License 1.1. Free for personal and non-commercial use.
|
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
|
||||||
Converts to MIT License on May 14, 2030.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
101
README.ru.md
|
|
@ -1,16 +1,16 @@
|
||||||
# OSTP — Ospab Stealth Transport Protocol
|
# OSTP - Ospab Stealth Transport Protocol
|
||||||
|
|
||||||
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
> Быстрый кастомный зашифрованный транспортный протокол на Rust.
|
> Быстрый кастомный зашифрованный транспортный протокол на Rust.
|
||||||
|
|
||||||
**OSTP** (Ospab Stealth Transport Protocol) — кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
|
**OSTP** (Ospab Stealth Transport Protocol) - кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -19,12 +19,12 @@
|
||||||
| Возможность | Описание |
|
| Возможность | Описание |
|
||||||
|-------------|----------|
|
|-------------|----------|
|
||||||
| **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. Session ID и nonce маскируются HMAC-ключами, уникальными для каждого пакета. |
|
| **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. Session ID и nonce маскируются HMAC-ключами, уникальными для каждого пакета. |
|
||||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
||||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
|
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
|
||||||
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
||||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии — сервер отслеживает session-ID, а не IP-адрес. |
|
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
|
||||||
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
||||||
| **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, который полностью скрывает трафик. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
||||||
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
||||||
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
||||||
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
||||||
|
|
@ -35,33 +35,42 @@
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
flowchart LR
|
||||||
subgraph Client ["Клиент"]
|
%% Styles
|
||||||
A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||||
TUN[TUN Интерфейс] -->|IP Пакеты| B
|
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||||
|
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||||
|
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||||
|
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||||
|
|
||||||
subgraph OSTPCoreClient ["OSTP Core Протокол"]
|
subgraph Local["💻 Устройство клиента"]
|
||||||
B --> C{Protocol Machine}
|
Apps["Браузер / Приложения"]:::userApp
|
||||||
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
Socks["SOCKS5 / HTTP Прокси"]:::ostpCore
|
||||||
D -->|Обфусцированный UDP| E((UDP Сокет))
|
Tun["Global TUN (VPN)"]:::ostpCore
|
||||||
end
|
Client["OSTP Клиент\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||||
|
|
||||||
|
Apps -->|TCP/UDP| Socks
|
||||||
|
Apps -->|IP Пакеты| Tun
|
||||||
|
Socks --> Client
|
||||||
|
Tun --> Client
|
||||||
end
|
end
|
||||||
|
|
||||||
E <==>|Зашифрованный UDP Туннель| F
|
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
|
||||||
|
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
|
||||||
subgraph Server ["Сервер"]
|
|
||||||
F((UDP Сокет)) --> G{Dispatcher}
|
|
||||||
|
|
||||||
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
|
||||||
G -->|Auth & Decrypt| H[Session & State Guard]
|
|
||||||
H -->|TCP Поток| I[Relay Loop]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
subgraph Remote["🖥️ Удаленный сервер (VPS)"]
|
||||||
FB -->|Перенаправление| NGINX[nginx / Caddy]
|
Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore
|
||||||
|
Relay["Мультиплексор соединений"]:::ostpCore
|
||||||
|
Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback
|
||||||
|
Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external
|
||||||
|
|
||||||
I -->|Outbound| WWW((Интернет))
|
Server -->|Расшифрованный трафик| Relay
|
||||||
|
Server -->|Сканеры цензоров| Fallback
|
||||||
|
Relay -->|Чистый трафик| Target
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Client <==> Tunnel <==> Server
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -84,8 +93,8 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
||||||
|
|
||||||
Создать конфиг по умолчанию:
|
Создать конфиг по умолчанию:
|
||||||
```bash
|
```bash
|
||||||
./ostp --init server # VPS
|
./ostp init server # VPS
|
||||||
./ostp --init client # Локальная машина
|
./ostp init client # Локальная машина
|
||||||
```
|
```
|
||||||
|
|
||||||
### Сервер (`config.json`)
|
### Сервер (`config.json`)
|
||||||
|
|
@ -116,8 +125,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
||||||
"debug": false,
|
"debug": false,
|
||||||
// Настройки транспорта (udp или uot)
|
// Настройки транспорта (udp или uot)
|
||||||
"transport": {
|
"transport": {
|
||||||
"mode": "udp",
|
"mode": "udp"
|
||||||
"stealth_sni": "vk.com"
|
|
||||||
},
|
},
|
||||||
// TUN-режим (полносистемный VPN)
|
// TUN-режим (полносистемный VPN)
|
||||||
"tun": {
|
"tun": {
|
||||||
|
|
@ -156,6 +164,36 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
||||||
./ostp
|
./ostp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Справка по командам
|
||||||
|
|
||||||
|
```
|
||||||
|
ostp [--config <PATH>] [КОМАНДА]
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
run Запустить демон по конфигу (по умолчанию, если команда не указана)
|
||||||
|
connect <URL> Подключиться по share-ссылке: ostp://KEY@HOST:PORT
|
||||||
|
setup Интерактивный мастер настройки
|
||||||
|
init <MODE> Сгенерировать шаблон конфига (server/client/relay)
|
||||||
|
check Проверить конфиг и выйти
|
||||||
|
gk Сгенерировать access-key (алиас: generate-key)
|
||||||
|
--format <FMT> Формат ключа: hex, base64 (по умолчанию hex)
|
||||||
|
-n, --count <N> Количество ключей (по умолчанию 1)
|
||||||
|
links Вывести client-share-ссылки из серверного конфига
|
||||||
|
import <URL> Импортировать share-ссылку в конфиг
|
||||||
|
update Обновить OSTP до актуального релиза
|
||||||
|
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
|
||||||
|
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||||
|
migrate Принудительно мигрировать конфиг к текущему формату
|
||||||
|
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||||
|
proxy-env-clear Вывести shell-команды для их отмены
|
||||||
|
uninstall Остановить сервис и удалить бинарник с конфигом
|
||||||
|
|
||||||
|
Глобальные опции:
|
||||||
|
--config <PATH> Путь к конфигу (по умолчанию config.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
У каждой подкоманды есть своя справка через `-h`/`--help`.
|
||||||
|
|
||||||
### TUN-режим (Windows)
|
### TUN-режим (Windows)
|
||||||
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
||||||
|
|
||||||
|
|
@ -204,5 +242,4 @@ cross build --release --target x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Business Source License 1.1. Бесплатно для личного и некоммерческого использования.
|
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст - в файле [LICENSE](LICENSE).
|
||||||
Переходит в MIT License 14 мая 2030 года.
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Чистая переборка на базе v0.2.98
|
||||||
|
|
||||||
|
База: `v0.2.98` (commit `31d0020`) — последняя версия, которая **стабильно работает**.
|
||||||
|
Ветка: `clean-rebuild`. Всё, что появилось после (0.3.1 … 0.3.21), переносим
|
||||||
|
**выборочно и с чистой головой**, а не копируем рефактор целиком.
|
||||||
|
|
||||||
|
Принцип: 0.3.1 принёс «модульный multi-server рефактор» + лавину фич — и вместе с
|
||||||
|
ними нестабильность. Берём только проверенное и нужное.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решения (зафиксировано пользователем)
|
||||||
|
- **Junk-пакеты + TCP-фрагментация — ОСТАВЛЯЕМ** (нравятся). НО починить вредную
|
||||||
|
часть: junk по UDP не должен выглядеть для сервера как `Unauthorized probe`
|
||||||
|
(rate-limit/гейт на сервере), иначе флуд лога и риск самобана клиента. Фича
|
||||||
|
остаётся — чиним поведение, а не выпиливаем. Тонкая настройка — §E.
|
||||||
|
- **Версия переборки — 0.4.0** (решено; 0.3.x сожжены в pre-release).
|
||||||
|
- **WSS и Reality (TLS-мимикрия) — ВЫКИНУТЬ.** Путь проекта — **zapret-like**:
|
||||||
|
обфускация/DPI-evasion на уровне пакетов (junk, фрагментация, обфускация), а НЕ
|
||||||
|
мимикрия под TLS. Reality с нуля тяжела и не вписывается.
|
||||||
|
- **Multi-server — НЕ НУЖЕН.** Режем до одного сервера → уходит urltest-группа и
|
||||||
|
половина сложности 0.3.1.
|
||||||
|
- **Конфиг — ПЛОСКИЙ по сути, но оформлен красиво/секционно как сейчас** (решено).
|
||||||
|
Сохраняем читаемую секционную структуру (server / transport / tun / dns / exclude
|
||||||
|
и т.п.), но **выпиливаем модульную машинерию**: массивы `inbounds[]`/`outbounds[]`,
|
||||||
|
`routing.rules[]` с тегами, `default_outbound`, urltest, мульти-сервер. Один сервер
|
||||||
|
на конфиг. Исключения = плоский список внутри секции `exclude`.
|
||||||
|
- **Профили — ОСТАВЛЯЕМ, single-select, в UI-слое** (решено). Профиль = сохранённый
|
||||||
|
конфиг одного сервера; активен ровно один (radio). Список/выбор/share живут во
|
||||||
|
фронте (prefs GUI / Flutter); **ядро о профилях не знает** — на «Подключить» из
|
||||||
|
выбранного профиля генерится плоский конфиг на один сервер. Никаких чекбоксов/
|
||||||
|
мульти-актив/urltest.
|
||||||
|
- **Derived-secrets — ОСТАВЛЯЕМ, но ОБЯЗАТЕЛЬНО проверить, что он РЕАЛЬНО работает:**
|
||||||
|
старый клиент НЕ должен подключаться к новому серверу. В прошлой реализации это
|
||||||
|
НЕ соблюдалось (старый клиент → новый сервер подключался) — значит сервер всё ещё
|
||||||
|
принимал старый формат handshake / obfuscation-key. Это **баг**, закрыть в первую
|
||||||
|
очередь: сервер обязан отвергать всё, что не прошло derived-secrets.
|
||||||
|
- **Лицензия — AGPLv3.**
|
||||||
|
- **Брендинг — ОСТАВЛЯЕМ**: тёмная тема + орёл на фоне (watermark/логотип).
|
||||||
|
- **Стелс-философия (north-star): zapret-like** — «нет узнаваемого заголовка +
|
||||||
|
манипуляции пакетами» (обфускация, junk, фрагментация, DNS/UoT-транспорты), а НЕ
|
||||||
|
«притворись известным протоколом» (Reality/WSS — выкинуты).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Корневая причина нестабильности 0.3.x
|
||||||
|
**Модульный multi-server рефактор (0.3.1)** — `580faf6`, `8ed66f9`, `67f9c06`.
|
||||||
|
Сменил формат конфига (inbounds/outbounds/routing/urltest), session-модель,
|
||||||
|
hot-reload. Источник большинства багов (мёртвые маршруты, фейк-коннект,
|
||||||
|
рассинхрон конфига). **НЕ копировать целиком.** Если multi-server реально нужен —
|
||||||
|
добавлять минимально и поверх рабочей одно-серверной модели 0.2.98.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. ВЫКИНУТЬ / не переносить
|
||||||
|
1. **WSS-фрейминг и Reality (TLS-мимикрия)** — оба выкинуть. Путь zapret-like, а не
|
||||||
|
маскировка под TLS-сайт; Reality (`reality.rs`) к тому же сложно сделать корректно
|
||||||
|
с нуля. Удалить из базы 0.2.98 целиком.
|
||||||
|
2. **Multi-server / urltest-группа** — не нужен. Один сервер на конфиг.
|
||||||
|
3. Остатки **tun2socks** на Android (`libtun2socks.so`, `tun2socks-arm64`,
|
||||||
|
`tun_child`, `t2sBinPath`) — давно мёртвый код, только раздувает APK. Не тащить.
|
||||||
|
|
||||||
|
> ⚠️ Junk-пакеты и TCP-фрагментация **ОСТАЮТСЯ** (см. Решения и §E) — это уже не
|
||||||
|
> «мусор». Но junk по UDP нужно сделать так, чтобы сервер его не считал
|
||||||
|
> `Unauthorized probe` (rate-limit/гейт), иначе лог-флуд и риск самобана.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. ОБЯЗАТЕЛЬНО перенести (фиксы стабильности)
|
||||||
|
- **fd limits / EMFILE** — `922cf0b`.
|
||||||
|
- **Lifecycle хелпера**: принудительный `std::process::exit` после остановки, чтобы
|
||||||
|
не оставался зомби-процесс, держащий адаптер `ostp_tun` и дефолтный маршрут — `b6e78c1`.
|
||||||
|
- **Bypass-маршрут сервера через `route.exe` по шлюзу** (а не legacy
|
||||||
|
`CreateIpForwardEntry`, который падал с err 160 из-за рассинхрона индексов
|
||||||
|
интерфейсов) — `b6e78c1`.
|
||||||
|
- **IPC хелпера** (ChaCha20Poly1305 + hex) + **единый формат логов** — `ee38b15`.
|
||||||
|
- **Closing-state fix** + `sent_history` на `BTreeMap` (O(log n) NACK) — `47d44fa`.
|
||||||
|
- **Handshake timeout fixes** — `d65af35`, `6eb7b36` (ждать ответ до отправки данных).
|
||||||
|
- **Buffer / UDP handler** — `b5e830a`.
|
||||||
|
- **Логи**: UoT и unauthorized-probe → debug; rate-limit probe-лога — `1151726`, `fc339b3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Протокол / крипто — решить и перенести
|
||||||
|
- **Derived secrets handshake** — `f8f27d3`. PSK и obfuscation-key выводятся из
|
||||||
|
access-key через HKDF; handshake-payload = `[timestamp][session_id][access_key]`;
|
||||||
|
параметры паддинга деривируются; timestamp anti-replay (±300с).
|
||||||
|
⚠️ **Ломает совместимость с 0.2.98 wire** (старый клиент не подключится).
|
||||||
|
Безопаснее старого (raw-PSK + нулевой obfuscation-key). **РЕШЕНИЕ:** переносим ли
|
||||||
|
(тогда нужен ребилд всех клиентов) — ДА, скорее всего, но осознанно.
|
||||||
|
- **l4_protocol** для server outbound — `2997bfd`, `ad3a8cb`, `aae9d22`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Транспорты — перенести аккуратно (большие куски)
|
||||||
|
- **DNS transport (dnstt)** как fallback — `3f1adbc`, `3ced4a1`, `d031b15`,
|
||||||
|
`10c1772`, `b31da29`. Полезно против блокировок, но объёмно и со своей
|
||||||
|
фрагментацией/reassembly. Переносить отдельным изолированным модулем.
|
||||||
|
- UoT (UDP-over-TCP) — уже есть в 0.2.98, проверить что не сломан.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Тонкая настройка junk/фрагментации (как в AmneziaWG)
|
||||||
|
Junk и фрагментацию **оставляем** (Решения), а это — их параметризация. Главное
|
||||||
|
условие: **координация клиент↔сервер**, иначе junk превращается в probe-флуд.
|
||||||
|
- `Jc` — кол-во junk-пакетов, `Jmin`/`Jmax` — размеры; **сервер знает и молча отбрасывает**.
|
||||||
|
- `S1`/`S2` — размеры init/response подгоняются.
|
||||||
|
- Магические заголовки/сигнатуры пакетов (`H1..H4`).
|
||||||
|
Реализовать как явные настраиваемые поля (не хардкод). Сервер ОБЯЗАН их понимать.
|
||||||
|
Сам факт junk/frag — в базе; это «желание» — сделать их настраиваемыми. Можно потом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. GUI (desktop) — перенести нужное, без хаоса
|
||||||
|
- Профили на странице **настроек** (пусто + «Create a new profile» + «+» когда нет
|
||||||
|
профиля; «+» → меню «из ссылки / вручную»). Главный экран не усложнять.
|
||||||
|
- **Share** профиля: QR (генерить локально, ключ наружу не отдавать — крейт `qrcode`)
|
||||||
|
+ копируемая `ostp://` ссылка.
|
||||||
|
- **Метрики**: байты считать в TUN-инбаунде; rtt брать из round-trip handshake
|
||||||
|
(а не отдельным TCP-probe).
|
||||||
|
- **Health/состояние**: «connected» по реальной достижимости сервера на ПРАВИЛЬНОМ
|
||||||
|
порту (не хардкод :443), а не по факту «процесс запустился».
|
||||||
|
- **routing**: всегда задавать `default_outbound: "proxy"`; ключи правил —
|
||||||
|
`domain_suffix` / `ip_cidr` / `process_name` (не `domains/ips/processes`).
|
||||||
|
- Смена сервера = полный **stop+start**, а не hot-reload (иначе остаётся старый сервер).
|
||||||
|
- Никаких непрогарженных `addEventListener` на удалённые элементы (краш init).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Мобилка (Flutter + JNI) — перенести нужное
|
||||||
|
- **routing**: тот же `default_outbound` + правильные ключи правил
|
||||||
|
(без них трафик шёл мимо туннеля — реальный IP).
|
||||||
|
- **Байты на Android**: считать в обеих задачах fd-пути (read=upload, write=download).
|
||||||
|
- **rtt**: из handshake (health-probe сокет на Android не protected → до сервера не доходит).
|
||||||
|
- **Смена сети (WiFi↔LTE)**: реальный reconnect (сейчас `notifyNetworkChanged` — no-op).
|
||||||
|
- **fd ownership**: НЕ двойное закрытие (Rust `OwnedFd` + Kotlin `close()`) → `detachFd()`.
|
||||||
|
- **Share** профиля: QR (`qr_flutter`) + ссылка.
|
||||||
|
- Выкинуть tun2socks (см. §A.3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Инфра / лицензия / брендинг
|
||||||
|
- Лицензия: **AGPLv3** ✅ (зафиксировано). В 0.2.98 был BSL 1.1 → заменить (`9ce9e6d`).
|
||||||
|
- **Брендинг — ОСТАВЛЯЕМ** ✅: тёмная тема + орёл на фоне (watermark/логотип) в GUI.
|
||||||
|
Перенести из текущего `ostp-gui` (assets/logo.svg, тёмная палитра) в чистую переборку.
|
||||||
|
- Панель/license-check: open-source без license-check — `5782107`, `99ff76d` (если нужно).
|
||||||
|
- Версионирование/CI build-script — `774d926` и пр.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инвентаризация базы 0.2.98 (что уже есть / что портировать)
|
||||||
|
- **Есть в 0.2.98**: WSS (→ удалить), Reality/`reality.rs` (→ удалить),
|
||||||
|
инфра derived-secrets (`derive_all_secrets`, `obfuscation_key`) — но клиент юзал
|
||||||
|
dummy-ключи до `f8f27d3`.
|
||||||
|
- **Нет в 0.2.98 — портировать из пост-0.2.98 кода**: junk-пакеты, TCP-фрагментация,
|
||||||
|
DNS-transport (dnstt), фикс derived-secrets `f8f27d3`, все фиксы §B, GUI/мобилка §F/§G.
|
||||||
|
|
||||||
|
## Что легко упустить (решить до старта)
|
||||||
|
1. **Версия переборки — 0.4.0** (решено). Сожжённые 0.3.x не переиспользуем.
|
||||||
|
2. **Серверный конфиг — тоже плоский** и согласован с клиентским. Сервер обязан
|
||||||
|
поддерживать всё оставленное: derived-secrets (и **отвергать** старый формат),
|
||||||
|
корректную обработку junk (не probe-флуд), UoT, DNS-transport, management API.
|
||||||
|
3. **Версия/магический байт протокола ДО крипто-слоя.** Сейчас нельзя отличить старый
|
||||||
|
handshake от нового — отсюда баг «старый клиент → новый сервер подключился».
|
||||||
|
Добавить версию в wire → будущие изменения управляемы, сервер чётко режет
|
||||||
|
несовместимое. Это системный фикс проблемы derived-secrets.
|
||||||
|
4. **Клиент и сервер обновляются ВМЕСТЕ** — derived-secrets ломает совместимость,
|
||||||
|
смешивать старое и новое нельзя. Координировать выкладку.
|
||||||
|
5. **Reality — выкинуть** (решено; в базе 0.2.98 есть `reality.rs` → удалить целиком).
|
||||||
|
6. **Verify-loop = критерий «готово».** Каждая фича проверяется реальным тестом, не
|
||||||
|
«на словах»: connect → `curl` показывает IP **сервера**; старый клиент к новому
|
||||||
|
серверу **не** подключается; смена сети на мобилке восстанавливает туннель.
|
||||||
|
|
||||||
|
## Порядок переборки (предложение, 1 сессия)
|
||||||
|
1. §B (фиксы стабильности) — на чистый 0.2.98.
|
||||||
|
2. §C (derived-secrets) — и СРАЗУ проверить: старый клиент к новому серверу НЕ
|
||||||
|
подключается (в прошлый раз был баг — подключался).
|
||||||
|
3. **Junk + TCP-фрагментация** — перенести (оставляем), но junk по UDP не должен
|
||||||
|
читаться сервером как `Unauthorized probe` (rate-limit/гейт на сервере).
|
||||||
|
4. §F/§G по минимуму (routing, метрики, состояние, share) **+ брендинг** (тёмная
|
||||||
|
тема, орёл на фоне).
|
||||||
|
5. §D (DNS transport) — если нужно.
|
||||||
|
6. ВЫКИНУТЬ: **WSS, multi-server, tun2socks** (§A). §E (тюнинг junk) — позже.
|
||||||
|
7. Конфиг: плоский по сути, секционно-оформленный, один сервер (РЕШЕНО — без
|
||||||
|
inbounds/outbounds/routing-движка).
|
||||||
|
|
@ -5,40 +5,38 @@ Traditional tunneling protocols (such as TLS, OpenVPN, and WireGuard) exhibit di
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Obfuscation Key Derivation
|
## Secret Derivation
|
||||||
|
|
||||||
To dynamically mask protocol data, an 8-byte obfuscation key is statically derived from the shared `access_key` configured on both the client and the server:
|
Every protocol secret — the obfuscation key, the Noise PSK, the handshake padding range, and the per-key junk marker (see below) — is derived from the shared `access_key` via a single HKDF-SHA256 pass, domain-separated by a trailing info byte per output:
|
||||||
|
|
||||||
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
|
```
|
||||||
|
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
|
||||||
|
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 bytes)
|
||||||
|
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 bytes)
|
||||||
|
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 bytes)
|
||||||
|
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
This key is established pre-session and is never transmitted across the wire in any capacity.
|
The wire protocol version is mixed into the IKM, not sent as a plaintext byte: peers on a different protocol version derive an entirely different `obfuscation_key`, so they simply cannot deobfuscate each other's packets and are rejected as unauthorized — a hard version gate with no recognizable marker ever appearing on the wire. No secret is ever transmitted; both sides derive the same values independently from the shared access key.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dynamic In-Place Masking Algorithm
|
## Dynamic In-Place Masking Algorithm
|
||||||
|
|
||||||
OSTP datagrams are processed "in-place" immediately prior to transmission and right after arrival. Two distinct mathematical modes are utilized based on the current handshake phase:
|
OSTP datagrams are masked "in-place" immediately prior to transmission and right after arrival. The mask itself is **derived from the packet's own ciphertext**, not from a fixed keystream or a counter, so it changes with every packet automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
|
||||||
|
```
|
||||||
|
|
||||||
### 1. Handshake Phase Mode (`is_handshake = true`)
|
### 1. Handshake Phase Mode (`is_handshake = true`)
|
||||||
During connection initiation (Noise Handshake), the wire packet consists of a 4-byte `session_id` prefixed to the Noise payload. To mask the fixed session ID:
|
The wire packet is `[4-byte session_id][2-byte noise_len][Noise payload]`. The mask is computed over the Noise payload (`raw[6..]`), and its first 6 bytes are XORed onto `session_id || noise_len`.
|
||||||
|
|
||||||
* **Masking**: The first 4 bytes are XORed with the first 4 bytes of the derived obfuscation key:
|
|
||||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
|
||||||
* **De-masking**: A repeated XOR with the identical key bytes recovers the original `session_id`.
|
|
||||||
|
|
||||||
### 2. Data Transmission Mode (`is_handshake = false`)
|
### 2. Data Transmission Mode (`is_handshake = false`)
|
||||||
Post-handshake, the wire layout contains:
|
The wire packet is `[4-byte session_id][8-byte nonce][AEAD ciphertext]`. The mask is computed over the AEAD ciphertext, and its first 12 bytes are XORed onto `session_id || nonce`.
|
||||||
`[4-byte session_id]` + `[8-byte nonce]` + `[AEAD Ciphertext]`
|
|
||||||
|
|
||||||
To completely randomize metadata, a two-tiered dynamic XOR masking process is applied:
|
#### Impact of the Scheme
|
||||||
|
Because the mask is keyed on both the shared secret and the packet's own ciphertext, no two packets — even consecutive ones from the same session — share a keystream, without needing an explicit counter-based scheme. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
|
||||||
1. **Nonce Masking**: The 8-byte `nonce` (sequence counter) is XORed with the full 8-byte static key:
|
|
||||||
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
|
|
||||||
2. **Session ID Masking**: The 4-byte `session_id` is masked using high dynamic entropy — the lower 32 bits of the **original (unmasked)** `nonce` value:
|
|
||||||
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
|
|
||||||
|
|
||||||
#### Impact of the Scheme:
|
|
||||||
Because the `nonce` increments strictly with each outgoing datagram, the session ID's masking keystream continuously changes. This breaks all packet header correlations and eliminates repeating byte patterns, rendering statistical fingerprinting futile.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -50,6 +48,13 @@ The `AdaptivePadder` calculates dynamic dummy byte quantities to append to the p
|
||||||
- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams.
|
- **Dynamic Distributions**: The padding algorithms emulate length profiles commonly seen in whitelisted HTTPS or real-time video streams.
|
||||||
- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths.
|
- **Encrypted Overheads**: The appended padding resides within the AEAD cipher scope. Consequently, passive observers cannot distinguish padding bytes from useful application payload, hiding the true message boundary lengths.
|
||||||
|
|
||||||
## XTLS-Reality Impersonation
|
---
|
||||||
|
|
||||||
OSTP provides a custom, dependency-free implementation of the XTLS-Reality protocol. It fully simulates a TLS 1.3 handshake (with realistic ClientHello profiles) to bypass advanced DPI filters. Post-handshake, it utilizes ChaCha20Poly1305 to seamlessly encrypt and tunnel the inner HTTP/WSS connections.
|
## Junk Packets & TCP Fragmentation
|
||||||
|
|
||||||
|
OSTP does not try to impersonate a known protocol (TLS, HTTP, or otherwise) — a fingerprint-matching filter can always be updated to catch an impersonation attempt. Instead it follows a **zapret-like** approach: no recognizable header at all, plus active manipulation of packet boundaries, so there is nothing distinctive to fingerprint in the first place.
|
||||||
|
|
||||||
|
- **Junk packets**: before the handshake, the client sends a configurable number (`junk_pc`) of random-size (`junk_ps`) filler datagrams. Each carries a 4-byte marker **derived from the access key** (the `junk_marker` above) rather than a fixed constant — a fixed marker would itself be a universal signature any observer could filter on across every OSTP deployment. The server derives the same per-key marker while trying candidate keys and drops matching junk silently, before it ever reaches the "unauthorized probe" logging path.
|
||||||
|
- **TCP fragmentation** (UoT/TCP transport only): the first packet (the handshake) is split into small chunks (`frag_chunk` bytes) with short delays (`frag_sleep` ms) between writes, so DPI that inspects only the first TCP segment never sees a complete handshake to fingerprint.
|
||||||
|
|
||||||
|
Both are configurable per-profile; neither is sent over plain UDP transport, where a standalone junk datagram would look exactly like a random one-off probe to the server.
|
||||||
|
|
@ -90,11 +90,22 @@ Because the `Nonce` is unique per packet, the mask is cryptographically independ
|
||||||
|
|
||||||
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
|
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
|
||||||
|
|
||||||
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via SHA-256.
|
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via HKDF-SHA-256.
|
||||||
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
|
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
|
||||||
3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels.
|
3. Ephemeral Curve25519 key exchange (`ee`) is evaluated, and the two directional transport keys are taken from Noise's `Split()` over the final chaining key `ck`.
|
||||||
|
|
||||||
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window.
|
> **Forward secrecy.** The transport keys are derived from the chaining key
|
||||||
|
> `ck`, which absorbs the ephemeral `ee` Diffie-Hellman result. They are **not**
|
||||||
|
> derived from the Noise handshake hash `h` — `h` only ever absorbs public
|
||||||
|
> transcript data (ephemeral public keys and on-wire ciphertexts) and never the
|
||||||
|
> DH secret, so keys derived from it would give an access-key holder the ability
|
||||||
|
> to decrypt any recorded session. Deriving from `ck` binds each session to its
|
||||||
|
> ephemeral private keys, which are discarded after the handshake: an adversary
|
||||||
|
> who later compromises the PSK still cannot decrypt past traffic. This is a
|
||||||
|
> wire-breaking property gated by the internal protocol version (currently 5);
|
||||||
|
> peers on an older version derive different keys and cannot interoperate.
|
||||||
|
|
||||||
|
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a ±300-second (5-minute) synchronization window and additionally records accepted handshakes in an anti-replay set for that window.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -126,4 +137,5 @@ The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to
|
||||||
|
|
||||||
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
|
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
|
||||||
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
|
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
|
||||||
|
* **Handshake-trial CPU DoS:** Because there is no cleartext key identifier on the wire (a deliberate stealth property), a datagram from an unknown source must be trial-decrypted against every registered key. Servers MUST bound this work: OSTP caches each key's derived secrets and time-windowed junk markers (so a trial is a cheap comparison plus one AEAD attempt per key, not a fresh HKDF/HMAC), and gates the trial path behind a global token bucket (default 100/s) so a spoofed-source flood cannot force unbounded per-packet crypto. The established-session fast path and IP-roaming path are not subject to this bucket.
|
||||||
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).
|
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@
|
||||||
// Адрес следующего узла в цепочке — UDP
|
// Адрес следующего узла в цепочке — UDP
|
||||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||||
|
|
||||||
// URL API конечного (целевого) сервера для синхронизации access_keys
|
// URL API конечного (целевого) сервера для синхронизации access_keys.
|
||||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
|
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
|
||||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
|
//
|
||||||
|
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
|
||||||
|
// сервера). Management API смонтирован ВНУТРИ этого пути — именно он скрывает
|
||||||
|
// панель от сканеров, — поэтому голый host:port попадает в несуществующий
|
||||||
|
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
|
||||||
|
// Это тот же адрес, по которому вы открываете веб-панель.
|
||||||
|
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
|
||||||
|
|
||||||
// Bearer-токен для доступа к API целевого сервера
|
// Bearer-токен для доступа к API целевого сервера
|
||||||
// Должен совпадать с api.token в конфиге target-сервера
|
// Должен совпадать с api.token в конфиге target-сервера
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,60 @@
|
||||||
# Маскирование энтропии сигналов OSTP
|
# Обфускация трафика OSTP
|
||||||
|
|
||||||
## Философия структуры канала
|
## Философия
|
||||||
|
|
||||||
Традиционные сетевые протоколы промышленного сбора данных могут обладать фиксированными заголовками, что при анализе статистического распределения байт ведет к предвзятости выборок и искажению телеметрического профиля. Задача механизмов энтропийного маскирования OSTP — достижение **равномерного вероятностного распределения значений байт**, начиная с самого первого пакета. Это делает сигналы шины данных абсолютно однородными и устойчивыми к корреляционному анализу и структурному мониторингу сетевых контроллеров.
|
Классические туннельные протоколы (TLS, OpenVPN, WireGuard) имеют узнаваемые сигнатуры в хэндшейке или статичные заголовки пакетов. Механизм обфускации OSTP спроектирован так, чтобы **начиная с первого байта** трафик был максимально похож на случайный шум — и для DPI-систем был неотличим от него.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Производная сигнатурная матрица (Keystream Initialization Vector)
|
## Деривация секретов
|
||||||
|
|
||||||
Для стабилизации битового распределения используется 8-байтовый вектор, вычисляемый на базе глобального идентификатора регистрации узла (`access_key`):
|
Все секреты протокола — ключ обфускации, PSK Noise-хэндшейка, диапазон паддинга хэндшейка и маркер junk-пакетов (см. ниже) — выводятся из общего `access_key` одним проходом HKDF-SHA256, с разделением по доменам через последний байт `info`:
|
||||||
|
|
||||||
$$\text{Key} = \text{SHA-256}(\text{access\_key})[0..8]$$
|
```
|
||||||
|
PRK = HKDF-Extract(salt = SHA-256(access_key)[0..16], IKM = access_key || PROTOCOL_VERSION)
|
||||||
|
obfuscation_key = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x01, 8 байт)
|
||||||
|
psk = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x02, 32 байта)
|
||||||
|
handshake_pad = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x03, 2 байта)
|
||||||
|
junk_marker = HKDF-Expand(PRK, info = SHA-256(access_key)[16..] || 0x04, 4 байта)
|
||||||
|
```
|
||||||
|
|
||||||
Данная последовательность фиксируется на передающем и принимающем узлах и не передается через внешние сетевые шлюзы.
|
Версия протокола подмешивается в IKM, а не передаётся открытым байтом на проводе: пиры с разной версией протокола выведут разный `obfuscation_key` и просто не смогут деобфусцировать пакеты друг друга — жёсткий version gate без единого узнаваемого маркера на проводе. Ни один секрет никогда не передаётся — обе стороны независимо выводят одинаковые значения из общего access_key.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Алгоритм динамического маскирования пакетов (In-place Masking)
|
## Алгоритм динамического маскирования
|
||||||
|
|
||||||
Пакетные структуры OSTP проходят низкоуровневую предобработку непосредственно перед выдачей в канальный уровень (Layer 3) и при получении. В зависимости от фазы жизненного цикла сессии связи выделяют две модели:
|
Датаграммы OSTP маскируются "на месте" прямо перед отправкой и сразу после получения. Сама маска **выводится из шифротекста самого пакета**, а не из статичного потока ключа или счётчика — поэтому она меняется от пакета к пакету автоматически:
|
||||||
|
|
||||||
### 1. Этап начального согласования среды (`is_handshake = true`)
|
```
|
||||||
В период инициализации канала передачи пакет структурирован как 4-байтовое поле логического адреса порта `session_id` и криптографический блок согласования среды. Для подавления статических компонент ID порта применяется процедура обратимого битового сложения:
|
mask = HMAC-SHA256(key = obfuscation_key, message = ciphertext[0..min(32, len)])
|
||||||
|
```
|
||||||
|
|
||||||
* **Обработка**: Первые 4 байта вектора пакета проходят побитовую операцию XOR с первыми 4 байтами сигнатурной матрицы:
|
### 1. Фаза хэндшейка (`is_handshake = true`)
|
||||||
$$\text{raw}[i] = \text{raw}[i] \oplus \text{Key}[i \pmod 8], \quad i \in [0..3]$$
|
Пакет на проводе — `[4 байта session_id][2 байта noise_len][Noise-полезная нагрузка]`. Маска считается по Noise-полезной нагрузке (`raw[6..]`), и её первые 6 байт накладываются XOR'ом на `session_id || noise_len`.
|
||||||
* **Восстановление**: Обратное наложение сигнатурной матрицы возвращает корректное значение логического идентификатора.
|
|
||||||
|
|
||||||
### 2. Этап высокоскоростного переноса данных (`is_handshake = false`)
|
### 2. Фаза передачи данных (`is_handshake = false`)
|
||||||
После перевода сессии в состояние активности кадр передачи принимает следующий вид:
|
Пакет на проводе — `[4 байта session_id][8 байт nonce][AEAD-шифротекст]`. Маска считается по шифротексту, и её первые 12 байт накладываются XOR'ом на `session_id || nonce`.
|
||||||
`[4 байта session_id]` + `[8 байт nonce]` + `[Полезная нагрузка блока]`
|
|
||||||
|
|
||||||
Для максимизации дифференциальной энтропии применяется двухступенчатое динамическое взвешивание:
|
#### Эффект схемы
|
||||||
|
Поскольку маска зависит одновременно от общего секрета и от содержимого шифротекста конкретного пакета, никакие два пакета — даже два подряд идущих в одной сессии — не используют одинаковый ключевой поток, и для этого не нужна явная схема на основе счётчика. Это полностью убирает корреляции между заголовками пакетов и повторяющиеся байтовые паттерны, делая статистический фингерпринтинг бесполезным.
|
||||||
1. **Коррекция счетчика цикла (Nonce Correction)**: 8-байтовое значение инкрементного счетчика пакета подвергается побитовому сложению с вектором матрицы:
|
|
||||||
$$\text{nonce\_bytes}[i] = \text{nonce\_bytes}[i] \oplus \text{Key}[i], \quad i \in [0..7]$$
|
|
||||||
2. **Маскирование ID сессии**: 4-байтовое поле логического адреса маскируется с помощью переменной высокочастотной энтропии — младших 32 бит **исходного** показателя системного счетчика пакетов:
|
|
||||||
$$\text{session\_id\_bytes}[i] = \text{session\_id\_bytes}[i] \oplus \text{real\_nonce\_low32\_bytes}[i], \quad i \in [0..3]$$
|
|
||||||
|
|
||||||
#### Статистическая устойчивость:
|
|
||||||
Благодаря инкрементации счетчика на каждом цикле отправки, маскирующий поток (keystream) для поля `session_id` постоянно видоизменяется. Это полностью нивелирует фиксированные битовые паттерны во всем спектре UDP-датаграмм и исключает появление повторяющихся префиксов.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Выравнивание блоков по границам регистров (Adaptive Alignment)
|
## Статистический паддинг
|
||||||
|
|
||||||
Дополнительно к маскировке заголовков, протокол OSTP исключает возможность анализа поведения системы на основе длин пакетов данных. Модуль адаптивного заполнения (`AdaptivePadder`) рассчитывает оптимальный размер буфера выравнивания (`padding`), интегрируемый в структуру пакета до момента активации шифрующего каскада:
|
Помимо маскирования заголовков, OSTP защищается от анализа длин пакетов (Traffic Length Analysis). `AdaptivePadder` вычисляет случайный размер мусорных байт, добавляемых к полезной нагрузке ещё до шифрования:
|
||||||
|
|
||||||
- **Стратегия заполнения буферов**: Механизм анализирует текущую длину выборки телеметрии и производит масштабирование до типичных кратных длин промышленных сетей передачи данных и буферов потоковых агрегаторов.
|
- **Динамическое распределение**: длины паддинга подобраны так, чтобы напоминать профили длин обычного HTTPS-трафика или видеопотоков.
|
||||||
- **Изоляция выравнивания**: Данные заполнения помещаются внутрь защищенной области кадра. Внешние анализаторы топологии сети не способны определить внутренние границы между телеметрической нагрузкой и служебными полями выравнивания, видя только монолитный блок данных.
|
- **Внутри шифротекста**: добавленный паддинг находится внутри области AEAD-шифрования — пассивный наблюдатель не может отличить паддинг от полезной нагрузки и не видит настоящую границу сообщения.
|
||||||
|
|
||||||
## XTLS-Reality (Имитация TLS 1.3)
|
---
|
||||||
|
|
||||||
OSTP предоставляет собственную реализацию протокола XTLS-Reality без сторонних зависимостей. Протокол полностью имитирует рукопожатие TLS 1.3 (с реалистичным профилем ClientHello) для обхода продвинутых DPI фильтров. После успешного рукопожатия применяется ChaCha20Poly1305 для бесшовного шифрования и туннелирования внутренних HTTP/WSS соединений.
|
## Junk-пакеты и TCP-фрагментация
|
||||||
|
|
||||||
|
OSTP не пытается притворяться известным протоколом (TLS, HTTP и т.п.) — фильтр по сигнатуре всегда можно обновить под конкретную имитацию. Вместо этого используется подход **в духе zapret**: никакого узнаваемого заголовка вообще, плюс активная манипуляция границами пакетов — фингерпринтить попросту нечего.
|
||||||
|
|
||||||
|
- **Junk-пакеты**: перед хэндшейком клиент отправляет настраиваемое количество (`junk_pc`) мусорных датаграмм случайного размера (`junk_ps`). Каждая несёт 4-байтовый маркер, **выведенный из access_key** (тот самый `junk_marker` выше), а не фиксированную константу — константный маркер сам по себе стал бы универсальной сигнатурой для любого наблюдателя сразу по всем серверам OSTP. Сервер, перебирая кандидатов-ключей, выводит тот же маркер и тихо отбрасывает junk, не доходя до логирования «unauthorized probe».
|
||||||
|
- **TCP-фрагментация** (только для транспорта UoT/TCP): первый пакет (хэндшейк) режется на мелкие куски (`frag_chunk` байт) с небольшими задержками (`frag_sleep` мс) между записями — DPI, анализирующий только первый TCP-сегмент, никогда не видит цельный хэндшейк для фингерпринтинга.
|
||||||
|
|
||||||
|
Обе фичи настраиваются per-профиль; ни одна не применяется поверх обычного UDP-транспорта, где отдельная junk-датаграмма выглядела бы для сервера точь-в-точь как случайный одиночный проб.
|
||||||
|
|
@ -90,11 +90,23 @@ OSTP поддерживает **внутреннее криптографиче
|
||||||
|
|
||||||
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
||||||
|
|
||||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256.
|
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256.
|
||||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
|
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия.
|
||||||
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
|
3. Выполняется эфемерный обмен ключами Curve25519 (`ee`), и два однонаправленных транспортных ключа берутся из `Split()` протокола Noise над финальным chaining key `ck`.
|
||||||
|
|
||||||
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд).
|
> **Прямая секретность (Forward Secrecy).** Транспортные ключи выводятся из
|
||||||
|
> chaining key `ck`, который вбирает результат эфемерного обмена Диффи-Хеллмана
|
||||||
|
> `ee`. Они **не** выводятся из handshake hash `h` протокола Noise: `h` вбирает
|
||||||
|
> только публичные данные транскрипта (эфемерные публичные ключи и шифртексты с
|
||||||
|
> провода) и никогда — сам DH-секрет, поэтому ключи, выведенные из `h`, дали бы
|
||||||
|
> держателю PSK возможность расшифровать любую записанную сессию. Вывод из `ck`
|
||||||
|
> привязывает каждую сессию к её эфемерным приватным ключам, которые
|
||||||
|
> уничтожаются после рукопожатия: злоумышленник, скомпрометировавший PSK позже,
|
||||||
|
> всё равно не сможет расшифровать прошлый трафик. Это свойство ломает
|
||||||
|
> совместимость и защищено внутренней версией протокола (сейчас 5): узлы более
|
||||||
|
> старой версии выводят другие ключи и не могут взаимодействовать.
|
||||||
|
|
||||||
|
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд, 5 минут) и дополнительно фиксирует принятые рукопожатия в множестве защиты от повтора на время этого окна.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -119,4 +131,5 @@ OSTP обеспечивает надежную доставку поверх UDP
|
||||||
|
|
||||||
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
|
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
|
||||||
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
||||||
|
* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают.
|
||||||
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 769 KiB |
|
|
@ -1 +0,0 @@
|
||||||
{"v":1}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"git": {
|
|
||||||
"sha1": "702f6dfe124c5e4d343cfd3ca5a3efe0446cf6f0"
|
|
||||||
},
|
|
||||||
"path_in_vcs": ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
name: Setup Android NDK and Rust compiler ENV
|
|
||||||
description: Setup an Android_NDK_HOME environment by downloading and Rust compiler environment.
|
|
||||||
inputs:
|
|
||||||
rust-target:
|
|
||||||
description: Rust target to build
|
|
||||||
required: true
|
|
||||||
sdk-version:
|
|
||||||
description: Exact SDK version to use
|
|
||||||
default: "33"
|
|
||||||
ndk-version:
|
|
||||||
description: Exact NDK version to use
|
|
||||||
default: "25"
|
|
||||||
ndk-platform:
|
|
||||||
description: Which host platform to use
|
|
||||||
default: "linux"
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Download Android NDK
|
|
||||||
run: curl --http1.1 -O https://dl.google.com/android/repository/android-ndk-r${{ inputs.ndk-version }}-${{ inputs.ndk-platform }}.zip
|
|
||||||
shell: bash
|
|
||||||
- name: Extract Android NDK
|
|
||||||
run: unzip -q android-ndk-r${{ inputs.ndk-version }}-${{ inputs.ndk-platform }}.zip
|
|
||||||
shell: bash
|
|
||||||
- name: Set Rust compiler ENV
|
|
||||||
run: |
|
|
||||||
ndk_home=${{ github.workspace }}/android-ndk-r${{ inputs.ndk-version }}
|
|
||||||
platform=$(ls ${ndk_home}/toolchains/llvm/prebuilt/ | head -1)
|
|
||||||
ndk_tool=${ndk_home}/toolchains/llvm/prebuilt/${platform}/bin
|
|
||||||
envvar_suffix=$(echo ${{ inputs.rust-target }} | sed "s/-/_/g")
|
|
||||||
upper_suffix=$(echo ${envvar_suffix} | tr '[:lower:]' '[:upper:]')
|
|
||||||
tool_prefix=${{ inputs.rust-target }}${{ inputs.sdk-version }}
|
|
||||||
echo "ANDROID_NDK_HOME=${ndk_home}" >> $GITHUB_ENV
|
|
||||||
echo "CC_${envvar_suffix}=${ndk_tool}/${tool_prefix}-clang" >> $GITHUB_ENV
|
|
||||||
echo "AR_${envvar_suffix}=${ndk_tool}/llvm-ar" >> $GITHUB_ENV
|
|
||||||
echo "CARGO_TARGET_${upper_suffix}_LINKER=${ndk_tool}/${tool_prefix}-clang" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- build: linux-amd64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
- build: android-arm64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: aarch64-linux-android
|
|
||||||
no_run: --no-run
|
|
||||||
- build: android-amd64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-linux-android
|
|
||||||
no_run: --no-run
|
|
||||||
- build: macos-amd64
|
|
||||||
os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- build: macos-arm64
|
|
||||||
os: macos-14
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
- build: ios-arm64
|
|
||||||
os: macos-latest
|
|
||||||
target: aarch64-apple-ios
|
|
||||||
no_run: --no-run
|
|
||||||
- build: windows-amd64
|
|
||||||
os: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
- build: windows-arm64
|
|
||||||
os: windows-latest
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
no_run: --no-run
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Rust (rustup)
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
rustup toolchain install stable --no-self-update --profile minimal --target ${{ matrix.target }}
|
|
||||||
rustup default stable
|
|
||||||
shell: bash
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Setup android environment
|
|
||||||
if: contains(matrix.build, 'android')
|
|
||||||
uses: ./.github/actions/ndk-dev-rs
|
|
||||||
with:
|
|
||||||
rust-target: ${{ matrix.target }}
|
|
||||||
- run: cargo test ${{ matrix.no_run }} --workspace --target ${{ matrix.target }}
|
|
||||||
- run: cargo test ${{ matrix.no_run }} --workspace --target ${{ matrix.target }} --release
|
|
||||||
|
|
||||||
msrv_n_clippy:
|
|
||||||
name: MSRV & Clippy & Rustfmt
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- run: cargo fmt -- --check
|
|
||||||
- run: cargo clippy --all-features -- -D warnings
|
|
||||||
- run: cargo check --lib -p netstack-smoltcp
|
|
||||||
- run: cargo check --lib -p netstack-smoltcp --all-features
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Publish to crates.io
|
|
||||||
run: |
|
|
||||||
cargo publish
|
|
||||||
env:
|
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/target
|
|
||||||
/Cargo.lock
|
|
||||||
|
|
||||||
.idea
|
|
||||||
.VSCodeCounter/
|
|
||||||
.vscode
|
|
||||||
.DS_Store
|
|
||||||
*.iml
|
|
||||||
**/*.log
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
|
||||||
#
|
|
||||||
# When uploading crates to the registry Cargo will automatically
|
|
||||||
# "normalize" Cargo.toml files for maximal compatibility
|
|
||||||
# with all versions of Cargo and also rewrite `path` dependencies
|
|
||||||
# to registry (e.g., crates.io) dependencies.
|
|
||||||
#
|
|
||||||
# If you are reading this file be aware that the original Cargo.toml
|
|
||||||
# will likely look very different (and much more reasonable).
|
|
||||||
# See Cargo.toml.orig for the original contents.
|
|
||||||
|
|
||||||
[package]
|
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.75.0"
|
|
||||||
name = "netstack-smoltcp"
|
|
||||||
version = "0.2.2"
|
|
||||||
authors = ["cavivie <cavivie@gmail.com>"]
|
|
||||||
build = false
|
|
||||||
autolib = false
|
|
||||||
autobins = false
|
|
||||||
autoexamples = false
|
|
||||||
autotests = false
|
|
||||||
autobenches = false
|
|
||||||
description = """
|
|
||||||
A netstack for the special purpose of turning packets from/to a TUN interface
|
|
||||||
into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
|
|
||||||
"""
|
|
||||||
homepage = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
documentation = "https://docs.rs/netstack-smoltcp"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = [
|
|
||||||
"netstack",
|
|
||||||
"smoltcp",
|
|
||||||
"network",
|
|
||||||
"ip",
|
|
||||||
"tun",
|
|
||||||
]
|
|
||||||
categories = ["network-programming"]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
repository = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "netstack_smoltcp"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "forward"
|
|
||||||
path = "examples/forward.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "forward-offload-linux"
|
|
||||||
path = "examples/forward-offload-linux.rs"
|
|
||||||
|
|
||||||
[[test]]
|
|
||||||
name = "regression"
|
|
||||||
path = "tests/regression.rs"
|
|
||||||
|
|
||||||
[dependencies.etherparse]
|
|
||||||
version = "0.16"
|
|
||||||
|
|
||||||
[dependencies.futures]
|
|
||||||
version = "0.3"
|
|
||||||
|
|
||||||
[dependencies.rand]
|
|
||||||
version = "0.8"
|
|
||||||
|
|
||||||
[dependencies.smoltcp]
|
|
||||||
version = "0.12"
|
|
||||||
features = [
|
|
||||||
"std",
|
|
||||||
"log",
|
|
||||||
"medium-ip",
|
|
||||||
"proto-ipv4",
|
|
||||||
"proto-ipv6",
|
|
||||||
"socket-icmp",
|
|
||||||
"socket-udp",
|
|
||||||
"socket-tcp",
|
|
||||||
]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dependencies.spin]
|
|
||||||
version = "0.9"
|
|
||||||
|
|
||||||
[dependencies.tokio]
|
|
||||||
version = "1"
|
|
||||||
features = [
|
|
||||||
"sync",
|
|
||||||
"time",
|
|
||||||
"rt",
|
|
||||||
"macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.tokio-util]
|
|
||||||
version = "0.7.10"
|
|
||||||
|
|
||||||
[dependencies.tracing]
|
|
||||||
version = "0.1"
|
|
||||||
features = ["std"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dev-dependencies.socket2]
|
|
||||||
version = "0.5.6"
|
|
||||||
|
|
||||||
[dev-dependencies.socket2-ext]
|
|
||||||
version = "0.1"
|
|
||||||
|
|
||||||
[dev-dependencies.structopt]
|
|
||||||
version = "0.3"
|
|
||||||
|
|
||||||
[dev-dependencies.tokio]
|
|
||||||
version = "1"
|
|
||||||
features = [
|
|
||||||
"rt",
|
|
||||||
"macros",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"io-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dev-dependencies.tracing]
|
|
||||||
version = "0.1"
|
|
||||||
features = ["std"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dev-dependencies.tracing-subscriber]
|
|
||||||
version = "0.3.18"
|
|
||||||
|
|
||||||
[dev-dependencies.tun-rs]
|
|
||||||
version = "2"
|
|
||||||
features = [
|
|
||||||
"async",
|
|
||||||
"async_framed",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dev-dependencies.tun2]
|
|
||||||
version = "3"
|
|
||||||
features = ["async"]
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "netstack-smoltcp"
|
|
||||||
version = "0.2.2"
|
|
||||||
edition = "2021"
|
|
||||||
authors = ["cavivie <cavivie@gmail.com>"]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
repository = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
homepage = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
documentation = "https://docs.rs/netstack-smoltcp"
|
|
||||||
keywords = ["netstack", "smoltcp", "network", "ip", "tun"]
|
|
||||||
categories = ["network-programming"]
|
|
||||||
description = """
|
|
||||||
A netstack for the special purpose of turning packets from/to a TUN interface
|
|
||||||
into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
|
|
||||||
"""
|
|
||||||
rust-version = "1.75.0"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
|
||||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"] }
|
|
||||||
tokio-util = "0.7.10"
|
|
||||||
etherparse = "0.16"
|
|
||||||
futures = "0.3"
|
|
||||||
rand = "0.8"
|
|
||||||
spin = "0.9"
|
|
||||||
smoltcp = { version = "0.12", default-features = false, features = [
|
|
||||||
"std",
|
|
||||||
"log",
|
|
||||||
"medium-ip",
|
|
||||||
"proto-ipv4",
|
|
||||||
"proto-ipv6",
|
|
||||||
"socket-icmp",
|
|
||||||
"socket-udp",
|
|
||||||
"socket-tcp",
|
|
||||||
] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tun2 = { version = "3", features = ["async"] }
|
|
||||||
# has better performance on linux than tun2
|
|
||||||
tun-rs = { version = "2", features = ["async", "async_framed"] }
|
|
||||||
tokio = { version = "1", features = [
|
|
||||||
"rt",
|
|
||||||
"macros",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"io-util",
|
|
||||||
] }
|
|
||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
|
||||||
tracing-subscriber = "0.3.18"
|
|
||||||
structopt = "0.3"
|
|
||||||
socket2 = "0.5.6"
|
|
||||||
socket2-ext = { version = "0.1" }
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
Copyright (c) 2024 cavivie and netstack-smoltcp Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any
|
|
||||||
person obtaining a copy of this software and associated
|
|
||||||
documentation files (the "Software"), to deal in the
|
|
||||||
Software without restriction, including without
|
|
||||||
limitation the rights to use, copy, modify, merge,
|
|
||||||
publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software
|
|
||||||
is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice
|
|
||||||
shall be included in all copies or substantial portions
|
|
||||||
of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
||||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
|
||||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
|
||||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
|
||||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
||||||
DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# Netstack Smoltcp
|
|
||||||
|
|
||||||
A netstack for the special purpose of turning packets from/to a TUN interface into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
|
|
||||||
|
|
||||||
[![Crates.io][crates-badge]][crates-url]
|
|
||||||
[![MIT licensed][mit-badge]][mit-url]
|
|
||||||
[![Apache licensed, Version 2.0][apache-badge]][apache-url]
|
|
||||||
[![Build Status][actions-badge]][actions-url]
|
|
||||||
|
|
||||||
[crates-badge]: https://img.shields.io/crates/v/netstack-smoltcp.svg
|
|
||||||
[crates-url]: https://crates.io/crates/netstack-smoltcp
|
|
||||||
[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
|
|
||||||
[mit-url]: https://github.com/automesh-network/netstack-smoltcp/blob/master/LICENSE-MIT
|
|
||||||
[apache-badge]: https://img.shields.io/badge/license-APACHE2.0-blue.svg
|
|
||||||
[apache-url]: https://github.com/automesh-network/netstack-smoltcp/blob/master/LICENSE-APACHE
|
|
||||||
[actions-badge]: https://github.com/automesh-network/netstack-smoltcp/workflows/CI/badge.svg
|
|
||||||
[actions-url]: https://github.com/automesh-network/netstack-smoltcp/actions?query=workflow%3ACI+branch%3Amain
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Supports Future Send and non-Send, mostly pepole use Send.
|
|
||||||
- Supports ICMP protocol drive by TCP runner to use ICMP ping.
|
|
||||||
- Supports filtering packets by source and destination IP addresses.
|
|
||||||
- Can read IP packets from netstack, write IP packets to netstack.
|
|
||||||
- Can receive TcpStream from TcpListener exposed from netstack.
|
|
||||||
- Can receive UDP datagram from UdpSocket exposed from netstack.
|
|
||||||
- Implements popular future streaming traits and asynchronous IO traits:
|
|
||||||
* TcpListener implements futures Stream/Sink trait
|
|
||||||
* TcpStream implements tokio AsyncRead/AsyncWrite trait
|
|
||||||
* UdpSocket(ReadHalf/WriteHalf) implements futures Stream/Sink trait.
|
|
||||||
|
|
||||||
## Platforms
|
|
||||||
|
|
||||||
This crate provides lightweight netstack support for Linux, iOS, macOS, Android and Windows.
|
|
||||||
Currently, it works on most targets, but mainly tested the popular platforms which includes:
|
|
||||||
- linux-amd64: x86_64-unknown-linux-gnu
|
|
||||||
- android-arm64: aarch64-linux-android
|
|
||||||
- android-amd64: x86_64-linux-android
|
|
||||||
- macos-amd64: x86_64-apple-darwin
|
|
||||||
- macos-arm64: aarch64-apple-darwin
|
|
||||||
- ios-arm64: aarch64-apple-ios
|
|
||||||
- windows-amd64: x86_64-pc-windows-msvc
|
|
||||||
- windows-arm64: aarch64-pc-windows-msvc
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// let device = tun2::create_as_async(&cfg)?;
|
|
||||||
// let framed = device.into_framed();
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, tcp_listener) = netstack_smoltcp::StackBuilder::default()
|
|
||||||
.stack_buffer_size(512)
|
|
||||||
.tcp_buffer_size(4096)
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_tcp(true)
|
|
||||||
.enable_icmp(true)
|
|
||||||
.mtu(9000) // virtual device usually benefits from larger MTU
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let mut udp_socket = udp_socket.unwrap(); // udp enabled
|
|
||||||
let mut tcp_listener = tcp_listener.unwrap(); // tcp/icmp enabled
|
|
||||||
if let Some(runner) = runner {
|
|
||||||
tokio::spawn(runner);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
let (mut tun_sink, mut tun_stream) = framed.split();
|
|
||||||
|
|
||||||
// Reads packet from stack and sends to TUN.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(pkt) = stack_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
tun_sink.send(pkt).await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reads packet from TUN and sends to stack.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(pkt) = tun_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
stack_sink.send(pkt).await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extracts TCP connections from stack and sends them to the dispatcher.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
handle_inbound_stream(tcp_listener).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Receive and send UDP packets between netstack and NAT manager. The NAT
|
|
||||||
// manager would maintain UDP sessions and send them to the dispatcher.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
handle_inbound_datagram(udp_socket).await;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
Typically, `netstack-smoltcp` will be used with an tun device, so a careful choice of TUN crate matters.
|
|
||||||
|
|
||||||
[tun-rs](https://github.com/tun-rs/tun-rs) have better performance on **Linux** than [rust-tun](https://github.com/meh/rust-tun/) due to GSO/GRO which allow you to process the packets in batches.
|
|
||||||
|
|
||||||
`bash scripts/bench-offload.sh` could tell that `tun-rs` boosts the performance by 4x. Try it out on your Linux machine!
|
|
||||||
|
|
||||||
The example for using `tun-rs` with `netstack-smoltcp` could be found at [forward-offload-linux.rs](examples/forward-offload-linux.rs)
|
|
||||||
|
|
||||||
For further tuning, refer to `tun-rs`'s detailed [README](https://github.com/tun-rs/tun-rs/blob/main/README.md)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under either of
|
|
||||||
|
|
||||||
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
|
|
||||||
https://www.apache.org/licenses/LICENSE-2.0)
|
|
||||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
|
||||||
https://opensource.org/licenses/MIT)
|
|
||||||
|
|
||||||
at your option.
|
|
||||||
|
|
||||||
### Contribution
|
|
||||||
|
|
||||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
|
||||||
for inclusion in netstack-smoltcp by you, as defined in the Apache-2.0 license,
|
|
||||||
shall be dual licensed as above, without any additional terms or conditions.
|
|
||||||
|
|
||||||
## Inspired By
|
|
||||||
|
|
||||||
Special thanks to these amazing projects that inspired netstack-smoltcp (in no particular order):
|
|
||||||
- [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust/)
|
|
||||||
- [netstack-lwip](https://github.com/eycorsican/netstack-lwip/)
|
|
||||||
- [rust-tun-active](https://github.com/tun2proxy/rust-tun)
|
|
||||||
- [rust-tun](https://github.com/meh/rust-tun/)
|
|
||||||
- [tun-rs](https://github.com/tun-rs/tun-rs)
|
|
||||||
- [smoltcp](https://github.com/smoltcp-rs/smoltcp)
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod inner {
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use netstack_smoltcp::{StackBuilder, TcpListener, UdpSocket};
|
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
|
||||||
use structopt::StructOpt;
|
|
||||||
use tokio::net::{TcpSocket, TcpStream};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
use tun_rs::{DeviceBuilder, IDEAL_BATCH_SIZE, VIRTIO_NET_HDR_LEN};
|
|
||||||
|
|
||||||
// Patched forward example: tun2 → tun-rs with Linux GRO/GSO offload.
|
|
||||||
// For further reading, check out https://blog.cloudflare.com/virtual-networking-101-understanding-tap
|
|
||||||
//
|
|
||||||
// Key changes vs forward.rs:
|
|
||||||
// 1. Use tun-rs DeviceBuilder with .offload(true) on Linux (enables
|
|
||||||
// IFF_VNET_HDR + TUN_F_CSUM/TSO4/TSO6/USO4/USO6).
|
|
||||||
// 2. TX (stack → TUN): prepend 10-byte zero virtio_net_hdr (GSO_NONE)
|
|
||||||
// so the kernel accepts the write when IFF_VNET_HDR is set.
|
|
||||||
// 3. RX (TUN → stack): use recv_multiple() for batch GSO splitting;
|
|
||||||
// buffers sized to 1600 to fit smoltcp's 1504-byte MTU segments.
|
|
||||||
#[derive(Debug, StructOpt)]
|
|
||||||
#[structopt(name = "forward", about = "Simply forward tun tcp/udp traffic.")]
|
|
||||||
struct Opt {
|
|
||||||
/// Outbound interface to bind forwarded connections to.
|
|
||||||
#[structopt(short = "i", long = "interface")]
|
|
||||||
interface: String,
|
|
||||||
/// Name of the TUN device.
|
|
||||||
#[structopt(short = "n", long = "name", default_value = "utun8")]
|
|
||||||
name: String,
|
|
||||||
/// Tracing log level.
|
|
||||||
#[structopt(long = "log-level", default_value = "debug")]
|
|
||||||
log_level: tracing::Level,
|
|
||||||
/// Use current-thread Tokio runtime (default: multi-thread).
|
|
||||||
#[structopt(long = "current-thread")]
|
|
||||||
current_thread: bool,
|
|
||||||
/// Use spawn_local instead of spawn.
|
|
||||||
#[structopt(long = "local-task")]
|
|
||||||
local_task: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn main() {
|
|
||||||
let opt = Opt::from_args();
|
|
||||||
let rt = if opt.current_thread {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
} else {
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
|
||||||
}
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
rt.block_on(main_exec(opt));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main_exec(opt: Opt) {
|
|
||||||
macro_rules! tokio_spawn {
|
|
||||||
($fut:expr) => {
|
|
||||||
if opt.local_task {
|
|
||||||
tokio::task::spawn_local($fut)
|
|
||||||
} else {
|
|
||||||
tokio::task::spawn($fut)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
tracing::subscriber::set_global_default(
|
|
||||||
tracing_subscriber::FmtSubscriber::builder()
|
|
||||||
.with_max_level(opt.log_level)
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Build TUN device with GRO/GSO offload on Linux.
|
|
||||||
let builder = DeviceBuilder::new()
|
|
||||||
.name(opt.name)
|
|
||||||
.ipv4("10.10.10.2", 24, Some("10.10.10.1"))
|
|
||||||
.mtu(9000);
|
|
||||||
let builder = builder.offload(true);
|
|
||||||
let dev = Arc::new(builder.build_async().unwrap());
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, tcp_listener) = StackBuilder::default()
|
|
||||||
.enable_tcp(true)
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_icmp(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let udp_socket = udp_socket.unwrap();
|
|
||||||
let tcp_listener = tcp_listener.unwrap();
|
|
||||||
if let Some(runner) = runner {
|
|
||||||
tokio_spawn!(runner);
|
|
||||||
}
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
|
|
||||||
let mut futs = vec![];
|
|
||||||
|
|
||||||
// stack → TUN
|
|
||||||
// With IFF_VNET_HDR every write must start with a virtio_net_hdr.
|
|
||||||
// We use all-zero (gso_type = GSO_NONE, flags = 0): plain packet,
|
|
||||||
// checksum already valid (smoltcp always computes checksums itself).
|
|
||||||
let dev1 = dev.clone();
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
while let Some(pkt) = stack_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
let result = {
|
|
||||||
let mut buf = vec![0u8; VIRTIO_NET_HDR_LEN + pkt.len()];
|
|
||||||
buf[VIRTIO_NET_HDR_LEN..].copy_from_slice(&pkt);
|
|
||||||
dev1.send(&buf).await
|
|
||||||
};
|
|
||||||
if let Err(e) = result {
|
|
||||||
warn!("failed to send packet to TUN: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// TUN → stack
|
|
||||||
// recv_multiple() does one read() syscall and returns N individual IP
|
|
||||||
// packets after splitting any incoming GRO super-packet.
|
|
||||||
// Buffer size 1600 > smoltcp MTU (1504) to avoid an out-of-bounds panic
|
|
||||||
// when the kernel segments at MSS=1464 with 40-byte IP+TCP headers.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
let mut orig = vec![0u8; VIRTIO_NET_HDR_LEN + 65535];
|
|
||||||
let mut bufs = vec![vec![0u8; 1600]; IDEAL_BATCH_SIZE];
|
|
||||||
let mut sizes = vec![0usize; IDEAL_BATCH_SIZE];
|
|
||||||
while let Ok(n) = dev.recv_multiple(&mut orig, &mut bufs, &mut sizes, 0).await {
|
|
||||||
for i in 0..n {
|
|
||||||
let pkt = &bufs[i][..sizes[i]];
|
|
||||||
if let Err(e) = stack_sink.send(pkt.to_vec()).await {
|
|
||||||
warn!("failed to send packet to stack: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
futs.push(tokio_spawn!({
|
|
||||||
let iface = opt.interface.clone();
|
|
||||||
async move {
|
|
||||||
handle_inbound_stream(tcp_listener, iface).await;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
handle_inbound_datagram(udp_socket, opt.interface).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
futures::future::join_all(futs).await.iter().for_each(|r| {
|
|
||||||
if let Err(e) = r {
|
|
||||||
error!("{:?}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_inbound_stream(mut tcp_listener: TcpListener, interface: String) {
|
|
||||||
while let Some((mut stream, local, remote)) = tcp_listener.next().await {
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
info!("tcp: {:?} => {:?}", local, remote);
|
|
||||||
match new_tcp_stream(remote, &interface).await {
|
|
||||||
Ok(mut r) => {
|
|
||||||
if let Err(e) = tokio::io::copy_bidirectional(&mut stream, &mut r).await {
|
|
||||||
warn!(
|
|
||||||
"failed to copy tcp stream {:?}=>{:?}: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to open tcp stream {:?}=>{:?}: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_inbound_datagram(udp_socket: UdpSocket, interface: String) {
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let (mut read_half, mut write_half) = udp_socket.split();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some((data, local, remote)) = rx.recv().await {
|
|
||||||
let _ = write_half.send((data, remote, local)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
while let Some((data, local, remote)) = read_half.next().await {
|
|
||||||
let tx = tx.clone();
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match new_udp_packet(remote, &interface).await {
|
|
||||||
Ok(sock) => {
|
|
||||||
let _ = sock.send(&data).await;
|
|
||||||
loop {
|
|
||||||
let mut buf = vec![0; 1024];
|
|
||||||
match sock.recv_from(&mut buf).await {
|
|
||||||
Ok((n, _)) => {
|
|
||||||
let _ = tx.send((buf[..n].to_vec(), local, remote));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("udp recv {:?}: {:?}", remote, e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("failed to open udp socket {:?}: {:?}", remote, e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_tcp_stream(addr: SocketAddr, iface: &str) -> std::io::Result<TcpStream> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let s = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
|
|
||||||
s.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
s.set_keepalive(true)?;
|
|
||||||
s.set_nodelay(true)?;
|
|
||||||
s.set_nonblocking(true)?;
|
|
||||||
Ok(TcpSocket::from_std_stream(s.into()).connect(addr).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_udp_packet(
|
|
||||||
addr: SocketAddr,
|
|
||||||
iface: &str,
|
|
||||||
) -> std::io::Result<tokio::net::UdpSocket> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let s = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?;
|
|
||||||
s.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
s.set_nonblocking(true)?;
|
|
||||||
let sock = tokio::net::UdpSocket::from_std(s.into())?;
|
|
||||||
sock.connect(addr).await?;
|
|
||||||
Ok(sock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
mod inner {
|
|
||||||
pub(super) fn main() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
inner::main();
|
|
||||||
}
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
use std::net::{IpAddr, SocketAddr};
|
|
||||||
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use netstack_smoltcp::{StackBuilder, TcpListener, UdpSocket};
|
|
||||||
use structopt::StructOpt;
|
|
||||||
use tokio::net::{TcpSocket, TcpStream};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
// to run this example, you should set the policy routing **after the start of the main program**
|
|
||||||
//
|
|
||||||
// linux:
|
|
||||||
// with bind device:
|
|
||||||
// `curl 1.1.1.1 --interface utun8`
|
|
||||||
// with default route:
|
|
||||||
// `bash scripts/route-linux.sh add`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
// with single route:
|
|
||||||
// `ip rule add to 1.1.1.1 table 200`
|
|
||||||
// `ip route add default dev utun8 table 200`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
//
|
|
||||||
// macos:
|
|
||||||
// with default route:
|
|
||||||
// `bash scripts/route-macos.sh add`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
//
|
|
||||||
// windows:
|
|
||||||
// with default route:
|
|
||||||
// tun2 set default route automatically, won't set agian
|
|
||||||
// # `powershell.exe scripts/route-windows.ps1 add`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
//
|
|
||||||
// currently, the example only supports the TCP stream, and the UDP packet will be dropped.
|
|
||||||
|
|
||||||
#[derive(Debug, StructOpt)]
|
|
||||||
#[structopt(name = "forward", about = "Simply forward tun tcp/udp traffic.")]
|
|
||||||
struct Opt {
|
|
||||||
/// Default binding interface, default by guessed.
|
|
||||||
/// Specify but doesn't exist, no device is bound.
|
|
||||||
#[structopt(short = "i", long = "interface")]
|
|
||||||
interface: String,
|
|
||||||
|
|
||||||
/// name of the tun device, default to rtun8.
|
|
||||||
#[structopt(short = "n", long = "name", default_value = "utun8")]
|
|
||||||
name: String,
|
|
||||||
|
|
||||||
/// Tracing subscriber log level.
|
|
||||||
#[structopt(long = "log-level", default_value = "debug")]
|
|
||||||
log_level: tracing::Level,
|
|
||||||
|
|
||||||
/// Tokio current-thread runtime, default to multi-thread.
|
|
||||||
#[structopt(long = "current-thread")]
|
|
||||||
current_thread: bool,
|
|
||||||
|
|
||||||
/// Tokio task spawn_local, default to spwan.
|
|
||||||
#[structopt(long = "local-task")]
|
|
||||||
local_task: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let opt = Opt::from_args();
|
|
||||||
|
|
||||||
let rt = if opt.current_thread {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
} else {
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
|
||||||
}
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rt.block_on(main_exec(opt));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main_exec(opt: Opt) {
|
|
||||||
macro_rules! tokio_spawn {
|
|
||||||
($fut: expr) => {
|
|
||||||
if opt.local_task {
|
|
||||||
tokio::task::spawn_local($fut)
|
|
||||||
} else {
|
|
||||||
tokio::task::spawn($fut)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::subscriber::set_global_default(
|
|
||||||
tracing_subscriber::FmtSubscriber::builder()
|
|
||||||
.with_max_level(opt.log_level)
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cfg = tun2::Configuration::default();
|
|
||||||
cfg.layer(tun2::Layer::L3);
|
|
||||||
let fd = -1;
|
|
||||||
if fd >= 0 {
|
|
||||||
cfg.raw_fd(fd);
|
|
||||||
} else {
|
|
||||||
cfg.tun_name(&opt.name)
|
|
||||||
.address("10.10.10.2")
|
|
||||||
.destination("10.10.10.1")
|
|
||||||
.mtu(tun2::DEFAULT_MTU);
|
|
||||||
#[cfg(not(any(target_arch = "mips", target_arch = "mips64",)))]
|
|
||||||
{
|
|
||||||
cfg.netmask("255.255.255.0");
|
|
||||||
}
|
|
||||||
cfg.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = tun2::create_as_async(&cfg).unwrap();
|
|
||||||
let mut builder = StackBuilder::default()
|
|
||||||
.enable_tcp(true)
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_icmp(true)
|
|
||||||
.mtu(9000);
|
|
||||||
if let Some(device_broadcast) = get_device_broadcast(&device) {
|
|
||||||
builder = builder
|
|
||||||
// .add_ip_filter(Box::new(move |src, dst| *src != device_broadcast && *dst != device_broadcast));
|
|
||||||
.add_ip_filter_fn(move |src, dst| *src != device_broadcast && *dst != device_broadcast);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, tcp_listener) = builder.build().unwrap();
|
|
||||||
let udp_socket = udp_socket.unwrap(); // udp enabled
|
|
||||||
let tcp_listener = tcp_listener.unwrap(); // tcp enabled or icmp enabled
|
|
||||||
|
|
||||||
if let Some(runner) = runner {
|
|
||||||
tokio_spawn!(runner);
|
|
||||||
}
|
|
||||||
|
|
||||||
let framed = device.into_framed();
|
|
||||||
let (mut tun_sink, mut tun_stream) = framed.split();
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
|
|
||||||
let mut futs = vec![];
|
|
||||||
|
|
||||||
// Reads packet from stack and sends to TUN.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
while let Some(pkt) = stack_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
match tun_sink.send(pkt).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => warn!("failed to send packet to TUN, err: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Reads packet from TUN and sends to stack.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
while let Some(pkt) = tun_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
match stack_sink.send(pkt).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => warn!("failed to send packet to stack, err: {:?}", e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Extracts TCP connections from stack and sends them to the dispatcher.
|
|
||||||
futs.push(tokio_spawn!({
|
|
||||||
let interface = opt.interface.clone();
|
|
||||||
async move {
|
|
||||||
handle_inbound_stream(tcp_listener, interface).await;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Receive and send UDP packets between netstack and NAT manager. The NAT
|
|
||||||
// manager would maintain UDP sessions and send them to the dispatcher.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
handle_inbound_datagram(udp_socket, opt.interface).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
futures::future::join_all(futs)
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.for_each(|res| {
|
|
||||||
if let Err(e) = res {
|
|
||||||
error!("error: {:?}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// simply forward tcp stream
|
|
||||||
async fn handle_inbound_stream(mut tcp_listener: TcpListener, interface: String) {
|
|
||||||
while let Some((mut stream, local, remote)) = tcp_listener.next().await {
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
info!("new tcp connection: {:?} => {:?}", local, remote);
|
|
||||||
match new_tcp_stream(remote, &interface).await {
|
|
||||||
Ok(mut remote_stream) => {
|
|
||||||
// pipe between two tcp stream
|
|
||||||
match tokio::io::copy_bidirectional(&mut stream, &mut remote_stream).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to copy tcp stream {:?}=>{:?}, err: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to new tcp stream {:?}=>{:?}, err: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// simply forward udp datagram
|
|
||||||
async fn handle_inbound_datagram(udp_socket: UdpSocket, interface: String) {
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let (mut read_half, mut write_half) = udp_socket.split();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some((data, local, remote)) = rx.recv().await {
|
|
||||||
let _ = write_half.send((data, remote, local)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
while let Some((data, local, remote)) = read_half.next().await {
|
|
||||||
let tx = tx.clone();
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
info!("new udp datagram: {:?} => {:?}", local, remote);
|
|
||||||
match new_udp_packet(remote, &interface).await {
|
|
||||||
Ok(remote_socket) => {
|
|
||||||
// pipe between two udp sockets
|
|
||||||
let _ = remote_socket.send(&data).await;
|
|
||||||
loop {
|
|
||||||
let mut buf = vec![0; 1024];
|
|
||||||
match remote_socket.recv_from(&mut buf).await {
|
|
||||||
Ok((len, _)) => {
|
|
||||||
let _ = tx.send((buf[..len].to_vec(), local, remote));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"failed to recv udp datagram {:?}<->{:?}: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to new udp socket {:?}=>{:?}, err: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_tcp_stream<'a>(addr: SocketAddr, iface: &str) -> std::io::Result<TcpStream> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
|
|
||||||
socket.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
socket.set_keepalive(true)?;
|
|
||||||
socket.set_nodelay(true)?;
|
|
||||||
socket.set_nonblocking(true)?;
|
|
||||||
|
|
||||||
let stream = TcpSocket::from_std_stream(socket.into())
|
|
||||||
.connect(addr)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_udp_packet(addr: SocketAddr, iface: &str) -> std::io::Result<tokio::net::UdpSocket> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?;
|
|
||||||
socket.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
socket.set_nonblocking(true)?;
|
|
||||||
|
|
||||||
let socket = tokio::net::UdpSocket::from_std(socket.into());
|
|
||||||
if let Ok(ref socket) = socket {
|
|
||||||
socket.connect(addr).await?;
|
|
||||||
}
|
|
||||||
socket
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_device_broadcast(device: &tun2::AsyncDevice) -> Option<std::net::Ipv4Addr> {
|
|
||||||
use tun2::AbstractDevice;
|
|
||||||
|
|
||||||
let mtu = device.mtu().unwrap_or(tun2::DEFAULT_MTU);
|
|
||||||
|
|
||||||
let address = match device.address() {
|
|
||||||
Ok(a) => match a {
|
|
||||||
IpAddr::V4(v4) => v4,
|
|
||||||
IpAddr::V6(_) => return None,
|
|
||||||
},
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let netmask = match device.netmask() {
|
|
||||||
Ok(n) => match n {
|
|
||||||
IpAddr::V4(v4) => v4,
|
|
||||||
IpAddr::V6(_) => return None,
|
|
||||||
},
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match smoltcp::wire::Ipv4Cidr::from_netmask(address, netmask) {
|
|
||||||
Ok(address_net) => match address_net.broadcast() {
|
|
||||||
Some(broadcast) => {
|
|
||||||
info!(
|
|
||||||
"tun device network: {} (address: {}, netmask: {}, broadcast: {}, mtu: {})",
|
|
||||||
address_net, address, netmask, broadcast, mtu,
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(broadcast)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
error!("invalid tun address {}, netmask {}", address, netmask);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
"invalid tun address {}, netmask {}, error: {}",
|
|
||||||
address, netmask, err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# bench-offload.sh
|
|
||||||
#
|
|
||||||
# Benchmarks netstack-smoltcp's forward examples with 2-stream iperf3.
|
|
||||||
# Compares:
|
|
||||||
# - examples/forward (tun2, no GRO/GSO offload)
|
|
||||||
# - examples/forward-offload-linux (tun-rs, Linux GRO/GSO offload via IFF_VNET_HDR)
|
|
||||||
#
|
|
||||||
# Setup: creates a veth pair + network namespace; iperf3 server runs inside
|
|
||||||
# the namespace, the forward proxy bridges traffic through a TUN device.
|
|
||||||
#
|
|
||||||
# Requirements: cargo, iperf3, ip (iproute2), root/CAP_NET_ADMIN
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# sudo bash scripts/bench-offload.sh
|
|
||||||
#
|
|
||||||
# Run from the root of the netstack-smoltcp repository.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── config ────────────────────────────────────────────────────────────────────
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
NS=bench
|
|
||||||
VETH_HOST=veth-host
|
|
||||||
VETH_NS=veth-bench
|
|
||||||
HOST_IP=172.19.0.1
|
|
||||||
NS_IP=172.19.0.2
|
|
||||||
PREFIX=24
|
|
||||||
TUN_NAME=utun8
|
|
||||||
TUN_IP=10.10.10.2
|
|
||||||
IPERF_PORT=5201
|
|
||||||
DURATION=15
|
|
||||||
STREAMS=2
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
|
||||||
require() { command -v "$1" &>/dev/null || die "'$1' not found"; }
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
pkill -f "forward-" 2>/dev/null || true
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
|
|
||||||
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
|
|
||||||
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
|
|
||||||
ip link del "$VETH_HOST" 2>/dev/null || true
|
|
||||||
ip netns del "$NS" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# ── preflight ─────────────────────────────────────────────────────────────────
|
|
||||||
require cargo
|
|
||||||
require iperf3
|
|
||||||
require ip
|
|
||||||
[[ $EUID -eq 0 ]] || die "run as root (needs CAP_NET_ADMIN for TUN + netns)"
|
|
||||||
[[ -f "$REPO_DIR/Cargo.toml" ]] || die "run from the netstack-smoltcp repo root"
|
|
||||||
grep -q 'name = "netstack-smoltcp"' "$REPO_DIR/Cargo.toml" \
|
|
||||||
|| die "Cargo.toml does not look like netstack-smoltcp"
|
|
||||||
|
|
||||||
# ── network setup ─────────────────────────────────────────────────────────────
|
|
||||||
echo "[net] setting up namespace '$NS' and veth pair..."
|
|
||||||
cleanup 2>/dev/null || true
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
ip netns add "$NS"
|
|
||||||
ip link add "$VETH_HOST" type veth peer name "$VETH_NS"
|
|
||||||
ip link set "$VETH_NS" netns "$NS"
|
|
||||||
ip addr add "${HOST_IP}/${PREFIX}" dev "$VETH_HOST"
|
|
||||||
ip link set "$VETH_HOST" up
|
|
||||||
ip netns exec "$NS" ip addr add "${NS_IP}/${PREFIX}" dev "$VETH_NS"
|
|
||||||
ip netns exec "$NS" ip link set "$VETH_NS" up
|
|
||||||
ip netns exec "$NS" ip link set lo up
|
|
||||||
echo "[net] ${HOST_IP} <──veth──> ${NS_IP} (ns:${NS})"
|
|
||||||
|
|
||||||
# ── build: forward (tun2, no offload) ────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "[build] examples/forward (tun2, no GRO/GSO offload)..."
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
cargo build --example forward --release --quiet
|
|
||||||
cp target/release/examples/forward /tmp/forward-tun2
|
|
||||||
)
|
|
||||||
echo "[build] done → /tmp/forward-tun2"
|
|
||||||
|
|
||||||
# ── build: forward-offload-linux (tun-rs, GRO/GSO offload) ───────────────────
|
|
||||||
echo ""
|
|
||||||
echo "[build] examples/forward-offload-linux (tun-rs, GRO/GSO offload)..."
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
cargo build --example forward-offload-linux --release --quiet
|
|
||||||
cp target/release/examples/forward-offload-linux /tmp/forward-tun-rs
|
|
||||||
)
|
|
||||||
echo "[build] done → /tmp/forward-tun-rs"
|
|
||||||
|
|
||||||
# ── benchmark runner ──────────────────────────────────────────────────────────
|
|
||||||
run_bench() {
|
|
||||||
local label="$1" binary="$2"
|
|
||||||
|
|
||||||
# clean any leftover state
|
|
||||||
pkill -f "forward-" 2>/dev/null || true
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
|
|
||||||
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
|
|
||||||
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
|
|
||||||
sleep 0.8
|
|
||||||
|
|
||||||
# start iperf3 server inside namespace
|
|
||||||
ip netns exec "$NS" iperf3 -s -p "$IPERF_PORT" -D \
|
|
||||||
--logfile /tmp/iperf3-bench-server.log
|
|
||||||
|
|
||||||
# start proxy
|
|
||||||
"$binary" -i "$VETH_HOST" -n "$TUN_NAME" --log-level warn &
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
ip link show "$TUN_NAME" &>/dev/null \
|
|
||||||
|| { echo " [!] TUN not up, skipping"; return 1; }
|
|
||||||
|
|
||||||
# route iperf3 traffic through TUN (more-specific /32 overrides /24 via veth)
|
|
||||||
ip route add "${NS_IP}/32" dev "$TUN_NAME"
|
|
||||||
|
|
||||||
echo " running iperf3: ${STREAMS} streams × ${DURATION}s …"
|
|
||||||
local out
|
|
||||||
out=$(iperf3 -c "$NS_IP" -p "$IPERF_PORT" \
|
|
||||||
-t "$DURATION" -P "$STREAMS" 2>&1)
|
|
||||||
|
|
||||||
local sender receiver
|
|
||||||
sender=$(echo "$out" | grep "SUM.*sender" | awk '{print $6, $7}')
|
|
||||||
receiver=$(echo "$out" | grep "SUM.*receiver" | awk '{print $6, $7}')
|
|
||||||
|
|
||||||
if [[ -z "$sender" ]]; then
|
|
||||||
echo " result: FAILED"
|
|
||||||
echo "$out" | tail -5 | sed 's/^/ /'
|
|
||||||
else
|
|
||||||
printf " sender: %s\n" "$sender"
|
|
||||||
printf " receiver: %s\n" "$receiver"
|
|
||||||
fi
|
|
||||||
|
|
||||||
pkill -f "forward-" 2>/dev/null || true
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
|
|
||||||
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
|
|
||||||
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
|
|
||||||
sleep 0.8
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── direct baseline ───────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " BASELINE: direct veth (no TUN, no proxy)"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true; sleep 0.3
|
|
||||||
ip netns exec "$NS" iperf3 -s -p "$IPERF_PORT" -D \
|
|
||||||
--logfile /tmp/iperf3-bench-server.log; sleep 0.3
|
|
||||||
echo " running iperf3: ${STREAMS} streams × ${DURATION}s …"
|
|
||||||
baseline_out=$(iperf3 -c "$NS_IP" -p "$IPERF_PORT" \
|
|
||||||
-t "$DURATION" -P "$STREAMS" 2>&1)
|
|
||||||
echo "$baseline_out" | grep "SUM.*sender" | awk '{printf " sender: %s %s\n", $6, $7}'
|
|
||||||
echo "$baseline_out" | grep "SUM.*receiver" | awk '{printf " receiver: %s %s\n", $6, $7}'
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true; sleep 0.5
|
|
||||||
|
|
||||||
# ── tun2 ─────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " tun2 (main branch — no GRO/GSO offload)"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
run_bench "tun2" /tmp/forward-tun2
|
|
||||||
|
|
||||||
# ── tun-rs + offload ──────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " tun-rs (patched — GRO/GSO offload via IFF_VNET_HDR)"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
run_bench "tun-rs+offload" /tmp/forward-tun-rs
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " done."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#__author__: cavivie
|
|
||||||
|
|
||||||
DEFAULT_TUN_NAME="utun8"
|
|
||||||
|
|
||||||
function do_route() {
|
|
||||||
local route_op="${1}"
|
|
||||||
local tun_name="${2:-$DEFAULT_TUN_NAME}"
|
|
||||||
ip route ${route_op} 0.0.0.0/1 dev ${tun_name}
|
|
||||||
ip route ${route_op} 128.0.0.0/1 dev ${tun_name}
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(){
|
|
||||||
echo "Usage:
|
|
||||||
route add add tun routes to system route table
|
|
||||||
route del delete routes from system route table
|
|
||||||
route help display all usages of the shell script"
|
|
||||||
}
|
|
||||||
|
|
||||||
# START MAIN-OPTIONS
|
|
||||||
case $1 in
|
|
||||||
add) do_route add $2;;
|
|
||||||
del) do_route delete $2;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
# END MAIN-OPTIONS
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#__author__: cavivie
|
|
||||||
|
|
||||||
DEFAULT_TUN_ADDR="10.10.10.2/24"
|
|
||||||
DEFAULT_TUN_DEST="10.10.10.1"
|
|
||||||
|
|
||||||
function do_route() {
|
|
||||||
local route_op="${1}"
|
|
||||||
local tun_addr="${2:-$DEFAULT_TUN_ADDR}"
|
|
||||||
local tun_dest="${3:-$DEFAULT_TUN_DEST}"
|
|
||||||
sudo route ${route_op} -net 1.0.0.0/8 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 2.0.0.0/7 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 4.0.0.0/6 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 8.0.0.0/5 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 16.0.0.0/4 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 32.0.0.0/3 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 64.0.0.0/2 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 128.0.0.0/1 ${tun_dest}
|
|
||||||
# tun2 do like this automatically
|
|
||||||
sudo route ${route_op} -net ${tun_addr} ${tun_dest}
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(){
|
|
||||||
echo "Usage:
|
|
||||||
route add add tun routes to system route table
|
|
||||||
route del delete routes from system route table
|
|
||||||
route help display all usages of the shell script"
|
|
||||||
}
|
|
||||||
|
|
||||||
# START MAIN-OPTIONS
|
|
||||||
case $1 in
|
|
||||||
add) do_route add $2 $3;;
|
|
||||||
del) do_route delete $2 $3;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
# END MAIN-OPTIONS
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#__author__: cavivie
|
|
||||||
|
|
||||||
param(
|
|
||||||
[string]$Cmd = "help",
|
|
||||||
[string]$TunName = "utun8",
|
|
||||||
[string]$TunGateway = "10.10.10.1"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# START MAIN-OPTIONS
|
|
||||||
switch ($Cmd) {
|
|
||||||
"add" {
|
|
||||||
# tun2 do like this automatically
|
|
||||||
New-NetRoute -DestinationPrefix "0.0.0.0/1" -InterfaceAlias $TunName -NextHop "$TunGateway"
|
|
||||||
New-NetRoute -DestinationPrefix "128.0.0.0/1" -InterfaceAlias $TunName -NextHop "$TunGateway"
|
|
||||||
}
|
|
||||||
"del" {
|
|
||||||
# tun2 do like this automatically
|
|
||||||
Get-NetRoute -DestinationPrefix "0.0.0.0/1" -InterfaceAlias $TunName | Remove-NetRoute
|
|
||||||
Get-NetRoute -DestinationPrefix "128.0.0.0/1" -InterfaceAlias $TunName | Remove-NetRoute
|
|
||||||
}
|
|
||||||
default {
|
|
||||||
Write-Host "Usage:
|
|
||||||
route add add tun routes to system route table
|
|
||||||
route del delete routes from system route table
|
|
||||||
route help display all usages of the shell script"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# END MAIN-OPTIONS
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use smoltcp::{
|
|
||||||
phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc::{unbounded_channel, Permit, Sender, UnboundedReceiver, UnboundedSender};
|
|
||||||
|
|
||||||
use crate::packet::AnyIpPktFrame;
|
|
||||||
|
|
||||||
pub(super) struct VirtualDevice {
|
|
||||||
in_buf_avail: Arc<AtomicBool>,
|
|
||||||
in_buf: UnboundedReceiver<Vec<u8>>,
|
|
||||||
out_buf: Sender<AnyIpPktFrame>,
|
|
||||||
mtu: usize,
|
|
||||||
cached_packet: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VirtualDevice {
|
|
||||||
pub(super) fn new(
|
|
||||||
iface_egress_tx: Sender<AnyIpPktFrame>,
|
|
||||||
mtu: usize,
|
|
||||||
) -> (Self, UnboundedSender<Vec<u8>>, Arc<AtomicBool>) {
|
|
||||||
let iface_ingress_tx_avail = Arc::new(AtomicBool::new(false));
|
|
||||||
let (iface_ingress_tx, iface_ingress_rx) = unbounded_channel();
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
in_buf_avail: iface_ingress_tx_avail.clone(),
|
|
||||||
in_buf: iface_ingress_rx,
|
|
||||||
out_buf: iface_egress_tx,
|
|
||||||
mtu,
|
|
||||||
cached_packet: None,
|
|
||||||
},
|
|
||||||
iface_ingress_tx,
|
|
||||||
iface_ingress_tx_avail,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Device for VirtualDevice {
|
|
||||||
type RxToken<'a> = VirtualRxToken;
|
|
||||||
type TxToken<'a> = VirtualTxToken<'a>;
|
|
||||||
|
|
||||||
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
|
||||||
let buffer = if let Some(buf) = self.cached_packet.take() {
|
|
||||||
buf
|
|
||||||
} else {
|
|
||||||
let Ok(buf) = self.in_buf.try_recv() else {
|
|
||||||
self.in_buf_avail.store(false, Ordering::Release);
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
buf
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(permit) = self.out_buf.try_reserve() else {
|
|
||||||
self.cached_packet = Some(buffer);
|
|
||||||
self.in_buf_avail.store(false, Ordering::Release);
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
Some((Self::RxToken { buffer }, Self::TxToken { permit }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
|
|
||||||
match self.out_buf.try_reserve() {
|
|
||||||
Ok(permit) => Some(Self::TxToken { permit }),
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capabilities(&self) -> DeviceCapabilities {
|
|
||||||
let mut capabilities = DeviceCapabilities::default();
|
|
||||||
capabilities.medium = Medium::Ip;
|
|
||||||
capabilities.max_transmission_unit = self.mtu;
|
|
||||||
capabilities
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct VirtualRxToken {
|
|
||||||
buffer: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RxToken for VirtualRxToken {
|
|
||||||
fn consume<R, F>(self, f: F) -> R
|
|
||||||
where
|
|
||||||
F: FnOnce(&[u8]) -> R,
|
|
||||||
{
|
|
||||||
f(&self.buffer[..])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct VirtualTxToken<'a> {
|
|
||||||
permit: Permit<'a, Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> TxToken for VirtualTxToken<'a> {
|
|
||||||
fn consume<R, F>(self, len: usize, f: F) -> R
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut [u8]) -> R,
|
|
||||||
{
|
|
||||||
let mut buffer = vec![0u8; len];
|
|
||||||
let result = f(&mut buffer);
|
|
||||||
self.permit.send(buffer);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
pub type IpFilter<'a> = Box<dyn Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'a>;
|
|
||||||
|
|
||||||
pub struct IpFilters<'a> {
|
|
||||||
filters: Vec<IpFilter<'a>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Default for IpFilters<'a> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> IpFilters<'a> {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
filters: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_non_broadcast() -> Self {
|
|
||||||
macro_rules! non_broadcast {
|
|
||||||
($addr:ident) => {
|
|
||||||
match $addr {
|
|
||||||
IpAddr::V4(a) => !(a.is_broadcast() || a.is_multicast() || a.is_unspecified()),
|
|
||||||
IpAddr::V6(a) => !(a.is_multicast() || a.is_unspecified()),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
filters: vec![Box::new(|src, dst| {
|
|
||||||
non_broadcast!(src) && non_broadcast!(dst)
|
|
||||||
})],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(&mut self, filter: IpFilter<'a>) {
|
|
||||||
self.filters.push(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_fn<F>(&mut self, filter: F)
|
|
||||||
where
|
|
||||||
F: Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'a,
|
|
||||||
{
|
|
||||||
self.filters.push(Box::new(filter));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_all<I: IntoIterator<Item = IpFilter<'a>>>(&mut self, filters: I) {
|
|
||||||
self.filters.extend(filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_allowed(&self, src: &IpAddr, dst: &IpAddr) -> bool {
|
|
||||||
self.filters.iter().all(|filter| filter(src, dst))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
mod device;
|
|
||||||
|
|
||||||
mod runner;
|
|
||||||
pub use runner::Runner;
|
|
||||||
|
|
||||||
mod packet;
|
|
||||||
pub use packet::AnyIpPktFrame;
|
|
||||||
|
|
||||||
mod filter;
|
|
||||||
pub use filter::{IpFilter, IpFilters};
|
|
||||||
|
|
||||||
pub mod udp;
|
|
||||||
pub use udp::UdpSocket;
|
|
||||||
|
|
||||||
pub mod tcp;
|
|
||||||
pub use tcp::{TcpListener, TcpStream};
|
|
||||||
|
|
||||||
pub mod stack;
|
|
||||||
pub use stack::{Stack, StackBuilder};
|
|
||||||
|
|
||||||
/// Re-export
|
|
||||||
pub use smoltcp;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
use smoltcp::wire::{IpProtocol, IpVersion, Ipv4Packet, Ipv6Packet};
|
|
||||||
|
|
||||||
pub type AnyIpPktFrame = Vec<u8>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(super) enum IpPacket<T: AsRef<[u8]>> {
|
|
||||||
Ipv4(Ipv4Packet<T>),
|
|
||||||
Ipv6(Ipv6Packet<T>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: AsRef<[u8]> + Copy> IpPacket<T> {
|
|
||||||
pub fn new_checked(packet: T) -> smoltcp::wire::Result<IpPacket<T>> {
|
|
||||||
let buffer = packet.as_ref();
|
|
||||||
match IpVersion::of_packet(buffer)? {
|
|
||||||
IpVersion::Ipv4 => Ok(IpPacket::Ipv4(Ipv4Packet::new_checked(packet)?)),
|
|
||||||
IpVersion::Ipv6 => Ok(IpPacket::Ipv6(Ipv6Packet::new_checked(packet)?)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn src_addr(&self) -> IpAddr {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => IpAddr::from(packet.src_addr()),
|
|
||||||
IpPacket::Ipv6(ref packet) => IpAddr::from(packet.src_addr()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dst_addr(&self) -> IpAddr {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => IpAddr::from(packet.dst_addr()),
|
|
||||||
IpPacket::Ipv6(ref packet) => IpAddr::from(packet.dst_addr()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn protocol(&self) -> IpProtocol {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => packet.next_header(),
|
|
||||||
IpPacket::Ipv6(ref packet) => packet.next_header(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T: AsRef<[u8]> + ?Sized> IpPacket<&'a T> {
|
|
||||||
/// Return a pointer to the payload.
|
|
||||||
#[inline]
|
|
||||||
pub fn payload(&self) -> &'a [u8] {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => packet.payload(),
|
|
||||||
IpPacket::Ipv6(ref packet) => packet.payload(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
use std::{
|
|
||||||
future::{Future, IntoFuture},
|
|
||||||
pin::Pin,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// BoxFuture acts the same as the [BoxFuture in crate futures utils],
|
|
||||||
/// which is an owned dynamically typed Future for use in cases where you
|
|
||||||
/// can’t statically type your result or need to add some indirection.
|
|
||||||
/// But the difference of this structure is that it will conditionally
|
|
||||||
/// implement Send according to the properties of type T, which does not
|
|
||||||
/// require two sets of API interfaces in single-threaded and multi-threaded.
|
|
||||||
///
|
|
||||||
/// [BoxFuture in crate futures utils]: https://docs.rs/futures-util/latest/futures_util/future/type.BoxFuture.html
|
|
||||||
pub struct BoxFuture<'a, T>(Pin<Box<dyn Future<Output = T> + Send + 'a>>);
|
|
||||||
|
|
||||||
impl<'a, T> BoxFuture<'a, T> {
|
|
||||||
pub fn new<F>(f: F) -> BoxFuture<'a, T>
|
|
||||||
where
|
|
||||||
F: IntoFuture<Output = T> + Send + 'a,
|
|
||||||
F::IntoFuture: Send + 'a,
|
|
||||||
{
|
|
||||||
BoxFuture(Box::pin(f.into_future()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn wrap(f: Pin<Box<dyn Future<Output = T> + Send + 'a>>) -> BoxFuture<'a, T> {
|
|
||||||
BoxFuture(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
impl<T> Future for BoxFuture<'_, T> {
|
|
||||||
type Output = T;
|
|
||||||
|
|
||||||
fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
|
|
||||||
self.0.as_mut().poll(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Runner = BoxFuture<'static, std::io::Result<()>>;
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
use std::{
|
|
||||||
net::IpAddr,
|
|
||||||
pin::Pin,
|
|
||||||
task::{ready, Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
use futures::{Sink, Stream};
|
|
||||||
use smoltcp::wire::IpProtocol;
|
|
||||||
use tokio::sync::mpsc::{channel, Receiver};
|
|
||||||
use tokio_util::sync::PollSender;
|
|
||||||
use tracing::{debug, trace};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
filter::{IpFilter, IpFilters},
|
|
||||||
packet::{AnyIpPktFrame, IpPacket},
|
|
||||||
runner::Runner,
|
|
||||||
tcp::TcpListener,
|
|
||||||
udp::UdpSocket,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct StackBuilder {
|
|
||||||
enable_udp: bool,
|
|
||||||
enable_tcp: bool,
|
|
||||||
enable_icmp: bool,
|
|
||||||
stack_buffer_size: usize,
|
|
||||||
udp_buffer_size: usize,
|
|
||||||
tcp_buffer_size: usize,
|
|
||||||
mtu: usize,
|
|
||||||
ip_filters: IpFilters<'static>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StackBuilder {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enable_udp: false,
|
|
||||||
enable_tcp: false,
|
|
||||||
enable_icmp: false,
|
|
||||||
stack_buffer_size: 1024,
|
|
||||||
udp_buffer_size: 512,
|
|
||||||
tcp_buffer_size: 512,
|
|
||||||
mtu: 1504, // 1500 for Ethernet + 4 for VLAN
|
|
||||||
ip_filters: IpFilters::with_non_broadcast(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
impl StackBuilder {
|
|
||||||
pub fn enable_udp(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_udp = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enable_tcp(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_tcp = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enable_icmp(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_icmp = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stack_buffer_size(mut self, size: usize) -> Self {
|
|
||||||
self.stack_buffer_size = size;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn udp_buffer_size(mut self, size: usize) -> Self {
|
|
||||||
self.udp_buffer_size = size;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tcp_buffer_size(mut self, size: usize) -> Self {
|
|
||||||
self.tcp_buffer_size = size;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_ip_filters(mut self, filters: IpFilters<'static>) -> Self {
|
|
||||||
self.ip_filters = filters;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ip_filter(mut self, filter: IpFilter<'static>) -> Self {
|
|
||||||
self.ip_filters.add(filter);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ip_filter_fn<F>(mut self, filter: F) -> Self
|
|
||||||
where
|
|
||||||
F: Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
self.ip_filters.add_fn(filter);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mtu(mut self, mtu: usize) -> Self {
|
|
||||||
self.mtu = mtu;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub fn build(
|
|
||||||
self,
|
|
||||||
) -> std::io::Result<(
|
|
||||||
Stack,
|
|
||||||
Option<Runner>,
|
|
||||||
Option<UdpSocket>,
|
|
||||||
Option<TcpListener>,
|
|
||||||
)> {
|
|
||||||
let (stack_tx, stack_rx) = channel(self.stack_buffer_size);
|
|
||||||
|
|
||||||
let (udp_tx, udp_rx) = if self.enable_udp {
|
|
||||||
let (udp_tx, udp_rx) = channel(self.udp_buffer_size);
|
|
||||||
(Some(PollSender::new(udp_tx)), Some(udp_rx))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (tcp_tx, tcp_rx) = if self.enable_tcp {
|
|
||||||
let (tcp_tx, tcp_rx) = channel(self.tcp_buffer_size);
|
|
||||||
(Some(PollSender::new(tcp_tx)), Some(tcp_rx))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ICMP is handled by TCP's Interface.
|
|
||||||
// smoltcp's interface will always send replies to EchoRequest
|
|
||||||
if self.enable_icmp && !self.enable_tcp {
|
|
||||||
use std::io::{Error, ErrorKind::InvalidInput};
|
|
||||||
return Err(Error::new(InvalidInput, "ICMP requires TCP"));
|
|
||||||
}
|
|
||||||
let icmp_tx = if self.enable_icmp {
|
|
||||||
tcp_tx.clone()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let udp_socket = udp_rx.map(|udp_rx| UdpSocket::new(udp_rx, stack_tx.clone()));
|
|
||||||
|
|
||||||
let (tcp_runner, tcp_listener) = if let Some(tcp_rx) = tcp_rx {
|
|
||||||
let (tcp_runner, tcp_listener) = TcpListener::new(tcp_rx, stack_tx, self.mtu)?;
|
|
||||||
(Some(tcp_runner), Some(tcp_listener))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let stack = Stack {
|
|
||||||
ip_filters: self.ip_filters,
|
|
||||||
stack_rx,
|
|
||||||
sink_buf: None,
|
|
||||||
udp_tx,
|
|
||||||
tcp_tx,
|
|
||||||
icmp_tx,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((stack, tcp_runner, udp_socket, tcp_listener))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Stack {
|
|
||||||
ip_filters: IpFilters<'static>,
|
|
||||||
sink_buf: Option<(AnyIpPktFrame, IpProtocol)>,
|
|
||||||
udp_tx: Option<PollSender<AnyIpPktFrame>>,
|
|
||||||
tcp_tx: Option<PollSender<AnyIpPktFrame>>,
|
|
||||||
icmp_tx: Option<PollSender<AnyIpPktFrame>>,
|
|
||||||
stack_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stack {
|
|
||||||
fn poll_send(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
|
|
||||||
let (item, proto) = match self.sink_buf.take() {
|
|
||||||
Some(val) => val,
|
|
||||||
None => return Poll::Ready(Ok(())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tx = match proto {
|
|
||||||
IpProtocol::Tcp => self.tcp_tx.as_mut(),
|
|
||||||
IpProtocol::Udp => self.udp_tx.as_mut(),
|
|
||||||
IpProtocol::Icmp | IpProtocol::Icmpv6 => self.icmp_tx.as_mut(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(tx) = tx else {
|
|
||||||
return Poll::Ready(Ok(()));
|
|
||||||
};
|
|
||||||
|
|
||||||
match tx.poll_reserve(cx) {
|
|
||||||
Poll::Pending => {
|
|
||||||
self.sink_buf = Some((item, proto));
|
|
||||||
Poll::Pending
|
|
||||||
}
|
|
||||||
Poll::Ready(Err(_)) => Poll::Ready(Err(channel_closed_err("channel is closed"))),
|
|
||||||
Poll::Ready(Ok(_)) => match tx.send_item(item) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(_) => Poll::Ready(Err(channel_closed_err("channel is closed"))),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recv from stack.
|
|
||||||
impl Stream for Stack {
|
|
||||||
type Item = std::io::Result<AnyIpPktFrame>;
|
|
||||||
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
||||||
match self.stack_rx.poll_recv(cx) {
|
|
||||||
Poll::Ready(Some(pkt)) => Poll::Ready(Some(Ok(pkt))),
|
|
||||||
Poll::Ready(None) => Poll::Ready(None),
|
|
||||||
Poll::Pending => Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to stack.
|
|
||||||
impl Sink<AnyIpPktFrame> for Stack {
|
|
||||||
type Error = std::io::Error;
|
|
||||||
|
|
||||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
// If a buffered item exists, try to flush it first. This also properly
|
|
||||||
// registers the waker via poll_reserve so we get woken when the channel
|
|
||||||
// has capacity. Without this, returning Pending here with _cx unused
|
|
||||||
// means the task never gets rescheduled.
|
|
||||||
if self.sink_buf.is_some() {
|
|
||||||
ready!(self.poll_send(cx))?;
|
|
||||||
}
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_send(mut self: Pin<&mut Self>, item: AnyIpPktFrame) -> Result<(), Self::Error> {
|
|
||||||
if item.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
use std::io::{Error, ErrorKind::InvalidInput};
|
|
||||||
let packet = IpPacket::new_checked(item.as_slice())
|
|
||||||
.map_err(|err| Error::new(InvalidInput, format!("invalid IP packet: {err}")))?;
|
|
||||||
|
|
||||||
let src_ip = packet.src_addr();
|
|
||||||
let dst_ip = packet.dst_addr();
|
|
||||||
|
|
||||||
let addr_allowed = self.ip_filters.is_allowed(&src_ip, &dst_ip);
|
|
||||||
if !addr_allowed {
|
|
||||||
trace!("IP packet {src_ip} -> {dst_ip} (allowed? {addr_allowed}) throwing away",);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let protocol = packet.protocol();
|
|
||||||
if matches!(
|
|
||||||
protocol,
|
|
||||||
IpProtocol::Tcp | IpProtocol::Udp | IpProtocol::Icmp | IpProtocol::Icmpv6
|
|
||||||
) {
|
|
||||||
self.sink_buf.replace((item, protocol));
|
|
||||||
} else {
|
|
||||||
debug!("tun IP packet ignored (protocol: {:?})", protocol);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
self.poll_send(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_close(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
_cx: &mut Context<'_>,
|
|
||||||
) -> Poll<Result<(), Self::Error>> {
|
|
||||||
self.stack_rx.close();
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channel_closed_err<E>(err: E) -> std::io::Error
|
|
||||||
where
|
|
||||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
||||||
{
|
|
||||||
std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)
|
|
||||||
}
|
|
||||||
|
|
@ -1,564 +0,0 @@
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
net::SocketAddr,
|
|
||||||
pin::Pin,
|
|
||||||
sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
},
|
|
||||||
task::{Context, Poll, Waker},
|
|
||||||
};
|
|
||||||
|
|
||||||
use futures::Stream;
|
|
||||||
use smoltcp::{
|
|
||||||
iface::{Config as InterfaceConfig, Interface, SocketHandle, SocketSet},
|
|
||||||
phy::Device,
|
|
||||||
socket::tcp::{Socket as TcpSocket, SocketBuffer as TcpSocketBuffer, State as TcpState},
|
|
||||||
storage::RingBuffer,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
wire::{HardwareAddress, IpAddress, IpCidr, IpProtocol, Ipv4Address, Ipv6Address, TcpPacket},
|
|
||||||
};
|
|
||||||
use spin::Mutex as SpinMutex;
|
|
||||||
use tokio::{
|
|
||||||
io::{AsyncRead, AsyncWrite, ReadBuf},
|
|
||||||
sync::{
|
|
||||||
mpsc::{channel, Receiver, Sender, UnboundedSender},
|
|
||||||
Notify,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tracing::{error, trace};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
device::VirtualDevice,
|
|
||||||
packet::{AnyIpPktFrame, IpPacket},
|
|
||||||
Runner,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reduced buffer sizes to 16KB to prevent excessive memory overhead (was 0x3FFF * 20 = 327KB per buffer)
|
|
||||||
const DEFAULT_TCP_SEND_BUFFER_SIZE: u32 = 16384;
|
|
||||||
const DEFAULT_TCP_RECV_BUFFER_SIZE: u32 = 16384;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
|
||||||
enum TcpSocketState {
|
|
||||||
Normal,
|
|
||||||
Close,
|
|
||||||
Closing,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TcpSocketControl {
|
|
||||||
send_buffer: RingBuffer<'static, u8>,
|
|
||||||
send_waker: Option<Waker>,
|
|
||||||
recv_buffer: RingBuffer<'static, u8>,
|
|
||||||
recv_waker: Option<Waker>,
|
|
||||||
recv_state: TcpSocketState,
|
|
||||||
send_state: TcpSocketState,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TcpSocketCreation {
|
|
||||||
control: SharedControl,
|
|
||||||
socket: TcpSocket<'static>,
|
|
||||||
}
|
|
||||||
|
|
||||||
type SharedNotify = Arc<Notify>;
|
|
||||||
type SharedControl = Arc<SpinMutex<TcpSocketControl>>;
|
|
||||||
|
|
||||||
struct TcpListenerRunner;
|
|
||||||
|
|
||||||
impl TcpListenerRunner {
|
|
||||||
fn create(
|
|
||||||
device: VirtualDevice,
|
|
||||||
iface: Interface,
|
|
||||||
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
|
||||||
tcp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stream_tx: Sender<TcpStream>,
|
|
||||||
sockets: HashMap<SocketHandle, SharedControl>,
|
|
||||||
) -> Runner {
|
|
||||||
Runner::new(async move {
|
|
||||||
let notify = Arc::new(Notify::new());
|
|
||||||
let (socket_tx, socket_rx) = channel::<TcpSocketCreation>(1024);
|
|
||||||
let res = tokio::select! {
|
|
||||||
v = Self::handle_packet(notify.clone(), iface_ingress_tx, iface_ingress_tx_avail.clone(), tcp_rx, stream_tx, socket_tx) => v,
|
|
||||||
v = Self::handle_socket(notify, device, iface, iface_ingress_tx_avail, sockets, socket_rx) => v,
|
|
||||||
};
|
|
||||||
res?;
|
|
||||||
trace!("VirtDevice::poll thread exited");
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_packet(
|
|
||||||
notify: SharedNotify,
|
|
||||||
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
|
||||||
mut tcp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stream_tx: Sender<TcpStream>,
|
|
||||||
socket_tx: Sender<TcpSocketCreation>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
while let Some(frame) = tcp_rx.recv().await {
|
|
||||||
let packet = match IpPacket::new_checked(frame.as_slice()) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid TCP IP packet: {:?}", err,);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Specially handle icmp packet by TCP interface.
|
|
||||||
if matches!(packet.protocol(), IpProtocol::Icmp | IpProtocol::Icmpv6) {
|
|
||||||
iface_ingress_tx
|
|
||||||
.send(frame)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
|
||||||
iface_ingress_tx_avail.store(true, Ordering::Release);
|
|
||||||
notify.notify_one();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_ip = packet.src_addr();
|
|
||||||
let dst_ip = packet.dst_addr();
|
|
||||||
let payload = packet.payload();
|
|
||||||
|
|
||||||
let packet = match TcpPacket::new_checked(payload) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid TCP err: {err}, src_ip: {src_ip}, dst_ip: {dst_ip}, payload: {payload:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let src_port = packet.src_port();
|
|
||||||
let dst_port = packet.dst_port();
|
|
||||||
|
|
||||||
let src_addr = SocketAddr::new(src_ip, src_port);
|
|
||||||
let dst_addr = SocketAddr::new(dst_ip, dst_port);
|
|
||||||
|
|
||||||
// TCP first handshake packet, create a new Connection
|
|
||||||
if packet.syn() && !packet.ack() {
|
|
||||||
let mut socket = TcpSocket::new(
|
|
||||||
TcpSocketBuffer::new(vec![0u8; DEFAULT_TCP_RECV_BUFFER_SIZE as usize]),
|
|
||||||
TcpSocketBuffer::new(vec![0u8; DEFAULT_TCP_SEND_BUFFER_SIZE as usize]),
|
|
||||||
);
|
|
||||||
socket.set_keep_alive(Some(Duration::from_secs(28)));
|
|
||||||
// FIXME: It should follow system's setting. 7200 is Linux's default.
|
|
||||||
socket.set_timeout(Some(Duration::from_secs(7200)));
|
|
||||||
// NO ACK delay
|
|
||||||
// socket.set_ack_delay(None);
|
|
||||||
|
|
||||||
if let Err(err) = socket.listen(dst_addr) {
|
|
||||||
error!("listen error: {:?}", err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
trace!("created TCP connection for {} <-> {}", src_addr, dst_addr);
|
|
||||||
|
|
||||||
let control = Arc::new(SpinMutex::new(TcpSocketControl {
|
|
||||||
send_buffer: RingBuffer::new(vec![0u8; DEFAULT_TCP_SEND_BUFFER_SIZE as usize]),
|
|
||||||
send_waker: None,
|
|
||||||
recv_buffer: RingBuffer::new(vec![0u8; DEFAULT_TCP_RECV_BUFFER_SIZE as usize]),
|
|
||||||
recv_waker: None,
|
|
||||||
recv_state: TcpSocketState::Normal,
|
|
||||||
send_state: TcpSocketState::Normal,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if let Err(_) = stream_tx.try_send(TcpStream {
|
|
||||||
src_addr,
|
|
||||||
dst_addr,
|
|
||||||
notify: notify.clone(),
|
|
||||||
control: control.clone(),
|
|
||||||
}) {
|
|
||||||
error!("stream_tx full or dropped, dropping SYN from {}", src_addr);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(_) = socket_tx.try_send(TcpSocketCreation { control, socket }) {
|
|
||||||
error!("socket_tx full or dropped, dropping SYN from {}", src_addr);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pipeline tcp stream packet
|
|
||||||
iface_ingress_tx
|
|
||||||
.send(frame)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
|
||||||
iface_ingress_tx_avail.store(true, Ordering::Release);
|
|
||||||
notify.notify_one();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(
|
|
||||||
notify: SharedNotify,
|
|
||||||
mut device: VirtualDevice,
|
|
||||||
mut iface: Interface,
|
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
|
||||||
mut sockets: HashMap<SocketHandle, SharedControl>,
|
|
||||||
mut socket_rx: Receiver<TcpSocketCreation>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
let mut socket_set = SocketSet::new(vec![]);
|
|
||||||
loop {
|
|
||||||
while let Ok(TcpSocketCreation { control, socket }) = socket_rx.try_recv() {
|
|
||||||
let handle = socket_set.add(socket);
|
|
||||||
sockets.insert(handle, control);
|
|
||||||
}
|
|
||||||
|
|
||||||
let before_poll = Instant::now();
|
|
||||||
let updated_sockets = iface.poll(before_poll, &mut device, &mut socket_set);
|
|
||||||
if matches!(
|
|
||||||
updated_sockets,
|
|
||||||
smoltcp::iface::PollResult::SocketStateChanged
|
|
||||||
) {
|
|
||||||
trace!("VirtDevice::poll costed {}", Instant::now() - before_poll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all the sockets' status
|
|
||||||
let mut sockets_to_remove = Vec::new();
|
|
||||||
|
|
||||||
for (socket_handle, control) in sockets.iter() {
|
|
||||||
let socket_handle = *socket_handle;
|
|
||||||
let socket = socket_set.get_mut::<TcpSocket>(socket_handle);
|
|
||||||
let mut control = control.lock();
|
|
||||||
|
|
||||||
// Remove the socket only when it is in the closed state.
|
|
||||||
if socket.state() == TcpState::Closed {
|
|
||||||
sockets_to_remove.push(socket_handle);
|
|
||||||
|
|
||||||
control.send_state = TcpSocketState::Closed;
|
|
||||||
control.recv_state = TcpSocketState::Closed;
|
|
||||||
|
|
||||||
if let Some(waker) = control.send_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
if let Some(waker) = control.recv_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
|
|
||||||
trace!("closed TCP connection");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHUT_WR — only close once the send_buffer has been fully
|
|
||||||
// drained into the smoltcp socket. Closing earlier transitions
|
|
||||||
// the socket to FIN_WAIT_1, making can_send() return false, so
|
|
||||||
// the send loop below never runs and the remaining data is lost.
|
|
||||||
if matches!(control.send_state, TcpSocketState::Close)
|
|
||||||
&& control.send_buffer.is_empty()
|
|
||||||
{
|
|
||||||
trace!("closing TCP Write Half, {:?}", socket.state());
|
|
||||||
|
|
||||||
socket.close();
|
|
||||||
control.send_state = TcpSocketState::Closing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if readable
|
|
||||||
let mut wake_receiver = false;
|
|
||||||
while socket.can_recv() && !control.recv_buffer.is_full() {
|
|
||||||
let result = socket.recv(|buffer| {
|
|
||||||
let n = control.recv_buffer.enqueue_slice(buffer);
|
|
||||||
(n, ())
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(..) => wake_receiver = true,
|
|
||||||
Err(err) => {
|
|
||||||
error!("socket recv error: {:?}, {:?}", err, socket.state());
|
|
||||||
|
|
||||||
// Don't know why. Abort the connection.
|
|
||||||
socket.abort();
|
|
||||||
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Normal) {
|
|
||||||
control.recv_state = TcpSocketState::Closed;
|
|
||||||
}
|
|
||||||
wake_receiver = true;
|
|
||||||
|
|
||||||
// The socket will be recycled in the next poll.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If socket is not in ESTABLISH, FIN-WAIT-1, FIN-WAIT-2,
|
|
||||||
// the local client have closed our receiver.
|
|
||||||
let states = [
|
|
||||||
TcpState::Listen,
|
|
||||||
TcpState::SynReceived,
|
|
||||||
TcpState::Established,
|
|
||||||
TcpState::FinWait1,
|
|
||||||
TcpState::FinWait2,
|
|
||||||
];
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Normal)
|
|
||||||
&& !socket.may_recv()
|
|
||||||
&& !states.contains(&socket.state())
|
|
||||||
{
|
|
||||||
trace!("closed TCP Read Half, {:?}", socket.state());
|
|
||||||
|
|
||||||
// Let TcpStream::poll_read returns EOF.
|
|
||||||
control.recv_state = TcpSocketState::Closed;
|
|
||||||
wake_receiver = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if wake_receiver && control.recv_waker.is_some() {
|
|
||||||
if let Some(waker) = control.recv_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if writable
|
|
||||||
let mut wake_sender = false;
|
|
||||||
while socket.can_send() && !control.send_buffer.is_empty() {
|
|
||||||
let result = socket.send(|buffer| {
|
|
||||||
let n = control.send_buffer.dequeue_slice(buffer);
|
|
||||||
(n, ())
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(..) => wake_sender = true,
|
|
||||||
Err(err) => {
|
|
||||||
error!("socket send error: {:?}, {:?}", err, socket.state());
|
|
||||||
|
|
||||||
// Don't know why. Abort the connection.
|
|
||||||
socket.abort();
|
|
||||||
|
|
||||||
if matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
control.send_state = TcpSocketState::Closed;
|
|
||||||
}
|
|
||||||
wake_sender = true;
|
|
||||||
|
|
||||||
// The socket will be recycled in the next poll.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if wake_sender && control.send_waker.is_some() {
|
|
||||||
if let Some(waker) = control.send_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for socket_handle in sockets_to_remove {
|
|
||||||
sockets.remove(&socket_handle);
|
|
||||||
socket_set.remove(socket_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !iface_ingress_tx_avail.load(Ordering::Acquire) {
|
|
||||||
let next_duration = iface
|
|
||||||
.poll_delay(before_poll, &socket_set)
|
|
||||||
.unwrap_or(Duration::from_millis(5));
|
|
||||||
if next_duration != Duration::ZERO {
|
|
||||||
let _ = tokio::time::timeout(
|
|
||||||
tokio::time::Duration::from(next_duration),
|
|
||||||
notify.notified(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TcpListener {
|
|
||||||
stream_rx: Receiver<TcpStream>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TcpListener {
|
|
||||||
pub(super) fn new(
|
|
||||||
tcp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stack_tx: Sender<AnyIpPktFrame>,
|
|
||||||
mtu: usize,
|
|
||||||
) -> std::io::Result<(Runner, Self)> {
|
|
||||||
let (mut device, iface_ingress_tx, iface_ingress_tx_avail) =
|
|
||||||
VirtualDevice::new(stack_tx, mtu);
|
|
||||||
let iface = Self::create_interface(&mut device)?;
|
|
||||||
|
|
||||||
let (stream_tx, stream_rx) = channel(1024);
|
|
||||||
|
|
||||||
let runner = TcpListenerRunner::create(
|
|
||||||
device,
|
|
||||||
iface,
|
|
||||||
iface_ingress_tx,
|
|
||||||
iface_ingress_tx_avail,
|
|
||||||
tcp_rx,
|
|
||||||
stream_tx,
|
|
||||||
HashMap::new(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((runner, Self { stream_rx }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_interface<D>(device: &mut D) -> std::io::Result<Interface>
|
|
||||||
where
|
|
||||||
D: Device + ?Sized,
|
|
||||||
{
|
|
||||||
let mut iface_config = InterfaceConfig::new(HardwareAddress::Ip);
|
|
||||||
iface_config.random_seed = rand::random();
|
|
||||||
let mut iface = Interface::new(iface_config, device, Instant::now());
|
|
||||||
iface.update_ip_addrs(|ip_addrs| {
|
|
||||||
ip_addrs
|
|
||||||
.push(IpCidr::new(IpAddress::v4(0, 0, 0, 1), 0))
|
|
||||||
.expect("iface IPv4");
|
|
||||||
ip_addrs
|
|
||||||
.push(IpCidr::new(IpAddress::v6(0, 0, 0, 0, 0, 0, 0, 1), 0))
|
|
||||||
.expect("iface IPv6");
|
|
||||||
});
|
|
||||||
iface
|
|
||||||
.routes_mut()
|
|
||||||
.add_default_ipv4_route(Ipv4Address::new(0, 0, 0, 1))
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, e))?;
|
|
||||||
iface
|
|
||||||
.routes_mut()
|
|
||||||
.add_default_ipv6_route(Ipv6Address::new(0, 0, 0, 0, 0, 0, 0, 1))
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, e))?;
|
|
||||||
iface.set_any_ip(true);
|
|
||||||
Ok(iface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream for TcpListener {
|
|
||||||
type Item = (TcpStream, SocketAddr, SocketAddr);
|
|
||||||
|
|
||||||
fn poll_next(
|
|
||||||
mut self: std::pin::Pin<&mut Self>,
|
|
||||||
cx: &mut std::task::Context<'_>,
|
|
||||||
) -> std::task::Poll<Option<Self::Item>> {
|
|
||||||
self.stream_rx.poll_recv(cx).map(|stream| {
|
|
||||||
stream.map(|stream| {
|
|
||||||
let local_addr = *stream.local_addr();
|
|
||||||
let remote_addr: SocketAddr = *stream.remote_addr();
|
|
||||||
(stream, local_addr, remote_addr)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TcpStream {
|
|
||||||
src_addr: SocketAddr,
|
|
||||||
dst_addr: SocketAddr,
|
|
||||||
notify: SharedNotify,
|
|
||||||
control: SharedControl,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TcpStream {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Normal) {
|
|
||||||
control.recv_state = TcpSocketState::Close;
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
control.send_state = TcpSocketState::Close;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.notify.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TcpStream {
|
|
||||||
pub fn local_addr(&self) -> &SocketAddr {
|
|
||||||
&self.src_addr
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remote_addr(&self) -> &SocketAddr {
|
|
||||||
&self.dst_addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncRead for TcpStream {
|
|
||||||
fn poll_read(
|
|
||||||
self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
buf: &mut ReadBuf<'_>,
|
|
||||||
) -> Poll<std::io::Result<()>> {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
// Read from buffer
|
|
||||||
if control.recv_buffer.is_empty() {
|
|
||||||
// If socket is already closed / half closed, just return EOF directly.
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Closed) {
|
|
||||||
return Ok(()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing could be read. Wait for notify.
|
|
||||||
if let Some(old_waker) = control.recv_waker.replace(cx.waker().clone()) {
|
|
||||||
if !old_waker.will_wake(cx.waker()) {
|
|
||||||
old_waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
let recv_buf = buf.initialize_unfilled();
|
|
||||||
let n = control.recv_buffer.dequeue_slice(recv_buf);
|
|
||||||
buf.advance(n);
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
self.notify.notify_one();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(()).into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncWrite for TcpStream {
|
|
||||||
fn poll_write(
|
|
||||||
self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
buf: &[u8],
|
|
||||||
) -> Poll<std::io::Result<usize>> {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
// If state == Close | Closing | Closed, the TCP stream WR half is closed.
|
|
||||||
if !matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
return Err(std::io::ErrorKind::BrokenPipe.into()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to buffer
|
|
||||||
|
|
||||||
if control.send_buffer.is_full() {
|
|
||||||
if let Some(old_waker) = control.send_waker.replace(cx.waker().clone()) {
|
|
||||||
if !old_waker.will_wake(cx.waker()) {
|
|
||||||
old_waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = control.send_buffer.enqueue_slice(buf);
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
self.notify.notify_one();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(n).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
Ok(()).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
if matches!(control.send_state, TcpSocketState::Closed | TcpSocketState::Closing) {
|
|
||||||
return Ok(()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHUT_WR
|
|
||||||
if matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
control.send_state = TcpSocketState::Close;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(old_waker) = control.send_waker.replace(cx.waker().clone()) {
|
|
||||||
if !old_waker.will_wake(cx.waker()) {
|
|
||||||
old_waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.notify.notify_one();
|
|
||||||
|
|
||||||
Poll::Pending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
use std::{
|
|
||||||
net::SocketAddr,
|
|
||||||
pin::Pin,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
use etherparse::PacketBuilder;
|
|
||||||
use futures::{ready, Sink, SinkExt, Stream};
|
|
||||||
use smoltcp::wire::UdpPacket;
|
|
||||||
use tokio::sync::mpsc::{Receiver, Sender};
|
|
||||||
use tokio_util::sync::PollSender;
|
|
||||||
use tracing::{error, trace};
|
|
||||||
|
|
||||||
use crate::packet::{AnyIpPktFrame, IpPacket};
|
|
||||||
|
|
||||||
pub type UdpMsg = (
|
|
||||||
Vec<u8>, /* payload */
|
|
||||||
SocketAddr, /* local */
|
|
||||||
SocketAddr, /* remote */
|
|
||||||
);
|
|
||||||
|
|
||||||
pub struct UdpSocket {
|
|
||||||
udp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stack_tx: PollSender<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UdpSocket {
|
|
||||||
pub(super) fn new(udp_rx: Receiver<AnyIpPktFrame>, stack_tx: Sender<AnyIpPktFrame>) -> Self {
|
|
||||||
Self {
|
|
||||||
udp_rx,
|
|
||||||
stack_tx: PollSender::new(stack_tx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn split(self) -> (ReadHalf, WriteHalf) {
|
|
||||||
(
|
|
||||||
ReadHalf {
|
|
||||||
udp_rx: self.udp_rx,
|
|
||||||
},
|
|
||||||
WriteHalf {
|
|
||||||
stack_tx: self.stack_tx,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReadHalf {
|
|
||||||
udp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct WriteHalf {
|
|
||||||
stack_tx: PollSender<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream for ReadHalf {
|
|
||||||
type Item = UdpMsg;
|
|
||||||
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
|
|
||||||
loop {
|
|
||||||
match ready!(self.udp_rx.poll_recv(cx)) {
|
|
||||||
Some(frame) => {
|
|
||||||
let packet = match IpPacket::new_checked(frame.as_slice()) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid IP packet: {}", err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let src_ip = packet.src_addr();
|
|
||||||
let dst_ip = packet.dst_addr();
|
|
||||||
let payload = packet.payload();
|
|
||||||
|
|
||||||
let packet = match UdpPacket::new_checked(payload) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid err: {err}, src_ip: {src_ip}, dst_ip: {dst_ip}, payload: {payload:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let src_port = packet.src_port();
|
|
||||||
let dst_port = packet.dst_port();
|
|
||||||
|
|
||||||
let src_addr = SocketAddr::new(src_ip, src_port);
|
|
||||||
let dst_addr = SocketAddr::new(dst_ip, dst_port);
|
|
||||||
|
|
||||||
trace!("created UDP socket for {} <-> {}", src_addr, dst_addr);
|
|
||||||
|
|
||||||
return Poll::Ready(Some((packet.payload().to_vec(), src_addr, dst_addr)));
|
|
||||||
}
|
|
||||||
None => return Poll::Ready(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sink<UdpMsg> for WriteHalf {
|
|
||||||
type Error = std::io::Error;
|
|
||||||
|
|
||||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
match ready!(self.stack_tx.poll_ready_unpin(cx)) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(err) => Poll::Ready(Err(std::io::Error::other(err))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_send(mut self: Pin<&mut Self>, item: UdpMsg) -> Result<(), Self::Error> {
|
|
||||||
use std::io::{Error, ErrorKind::InvalidData};
|
|
||||||
let (data, src_addr, dst_addr) = item;
|
|
||||||
|
|
||||||
if data.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let builder = match (src_addr, dst_addr) {
|
|
||||||
(SocketAddr::V4(src), SocketAddr::V4(dst)) => {
|
|
||||||
PacketBuilder::ipv4(src.ip().octets(), dst.ip().octets(), 20)
|
|
||||||
.udp(src_addr.port(), dst_addr.port())
|
|
||||||
}
|
|
||||||
(SocketAddr::V6(src), SocketAddr::V6(dst)) => {
|
|
||||||
PacketBuilder::ipv6(src.ip().octets(), dst.ip().octets(), 20)
|
|
||||||
.udp(src_addr.port(), dst_addr.port())
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Error::new(InvalidData, "src or destination type unmatch"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ip_packet_writer = Vec::with_capacity(builder.size(data.len()));
|
|
||||||
builder
|
|
||||||
.write(&mut ip_packet_writer, &data)
|
|
||||||
.map_err(|err| Error::other(format!("PacketBuilder::write: {err}")))?;
|
|
||||||
|
|
||||||
match self.stack_tx.start_send_unpin(ip_packet_writer) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(err) => Err(Error::other(format!("send error: {err}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
use std::io::Error;
|
|
||||||
match ready!(self.stack_tx.poll_flush_unpin(cx)) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(err) => Poll::Ready(Err(Error::other(format!("flush error: {err}")))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
use std::io::Error;
|
|
||||||
match ready!(self.stack_tx.poll_close_unpin(cx)) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(err) => Poll::Ready(Err(Error::other(format!("close error: {err}")))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
//! Regression tests that reproduce the bugs found in the static analysis.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use etherparse::{IpNumber, Ipv4Header, UdpHeader};
|
|
||||||
use futures::SinkExt;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
use netstack_smoltcp::StackBuilder;
|
|
||||||
|
|
||||||
fn make_udp_ipv4(
|
|
||||||
src_ip: [u8; 4],
|
|
||||||
src_port: u16,
|
|
||||||
dst_ip: [u8; 4],
|
|
||||||
dst_port: u16,
|
|
||||||
payload: &[u8],
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let udp_hdr = UdpHeader::with_ipv4_checksum(
|
|
||||||
src_port,
|
|
||||||
dst_port,
|
|
||||||
&Ipv4Header::new(
|
|
||||||
(UdpHeader::LEN + payload.len()) as u16,
|
|
||||||
64,
|
|
||||||
IpNumber::UDP,
|
|
||||||
src_ip,
|
|
||||||
dst_ip,
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ip_hdr = Ipv4Header::new(
|
|
||||||
(UdpHeader::LEN + payload.len()) as u16,
|
|
||||||
64,
|
|
||||||
IpNumber::UDP,
|
|
||||||
src_ip,
|
|
||||||
dst_ip,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(Ipv4Header::MIN_LEN + UdpHeader::LEN + payload.len());
|
|
||||||
ip_hdr.write(&mut buf).unwrap();
|
|
||||||
udp_hdr.write(&mut buf).unwrap();
|
|
||||||
buf.extend_from_slice(payload);
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// before(include) a15e0b72bfc72cb032e67138070da01e325d66f8
|
|
||||||
/// sink_buf is used in `Stack` to hold a slot for sending any pkt
|
|
||||||
///
|
|
||||||
/// the original assumption is that the `poll_ready` -> `start_send` -> `poll_flush`
|
|
||||||
/// are called sequentially so the slot could be reused and will never get blocked.
|
|
||||||
///
|
|
||||||
/// but once the user calls `send_all` on `Stack`, which will not immediate flush the pkt(call `poll_flush`),
|
|
||||||
/// then `sink_buf` is could be Some(pkt), then it will trigger `Poll::Pending` branch in `Stack::poll_ready`,
|
|
||||||
/// who did not register the waker correctly, so it will got hanged forever.
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn bug1_poll_ready_waker_registered_via_send_all() {
|
|
||||||
let (mut stack, _runner, _udp_socket, _tcp) = StackBuilder::default()
|
|
||||||
.enable_udp(true)
|
|
||||||
.udp_buffer_size(64)
|
|
||||||
.stack_buffer_size(64)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let pkt1 = make_udp_ipv4([1, 2, 3, 4], 1111, [5, 6, 7, 8], 9999, b"first");
|
|
||||||
let pkt2 = make_udp_ipv4([1, 2, 3, 4], 1111, [5, 6, 7, 8], 9999, b"second");
|
|
||||||
|
|
||||||
let mut stream = futures::stream::iter([Ok(pkt1), Ok(pkt2)]);
|
|
||||||
|
|
||||||
let result = timeout(Duration::from_secs(1), stack.send_all(&mut stream)).await;
|
|
||||||
// should be ok after the fix
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
@ -29,7 +29,4 @@ tun = { version = "0.8.9", features = ["async"] }
|
||||||
netstack-smoltcp = "0.2.2"
|
netstack-smoltcp = "0.2.2"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
libc = "0.2.186"
|
libc = "0.2.186"
|
||||||
x25519-dalek = "2.0.1"
|
|
||||||
chacha20poly1305.workspace = true
|
|
||||||
hex = "0.4.3"
|
|
||||||
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }
|
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,25 @@ use ostp_core::{NoiseRole, OstpEvent, PaddingStrategy, ProtocolAction, ProtocolC
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
use tokio::time::{interval, timeout, Instant};
|
use tokio::time::{interval, timeout, Instant, MissedTickBehavior};
|
||||||
|
|
||||||
use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
||||||
use crate::config::ClientConfig;
|
use crate::config::ClientConfig;
|
||||||
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||||
|
|
||||||
|
/// Per-address ceiling on the UoT/TCP connect attempt. Long enough that a
|
||||||
|
/// genuinely slow mobile path still completes its handshake, short enough that
|
||||||
|
/// a blackholed address (typically IPv6 advertised without a working route)
|
||||||
|
/// costs seconds instead of the kernel's full SYN-retry budget before the next
|
||||||
|
/// candidate address is tried.
|
||||||
|
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
|
/// How long to keep retrying a resume-triggered reconnect before handing the
|
||||||
|
/// problem back to the ordinary stall path. That path is what releases the
|
||||||
|
/// system proxy, so this is really a bound on how long the machine may be left
|
||||||
|
/// with no working internet at all after waking.
|
||||||
|
const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45);
|
||||||
|
|
||||||
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
pub fn set_socket_protector<F>(f: F)
|
pub fn set_socket_protector<F>(f: F)
|
||||||
|
|
@ -46,6 +59,56 @@ async fn send_datagram(socket: &crate::transport::Transport, frame: &Bytes, _web
|
||||||
struct SessionState {
|
struct SessionState {
|
||||||
socket: crate::transport::Transport,
|
socket: crate::transport::Transport,
|
||||||
machine: ProtocolMachine,
|
machine: ProtocolMachine,
|
||||||
|
/// Handle to this session's spawned receiver task. Held so the task is
|
||||||
|
/// aborted when the session is dropped (e.g. replaced on reconnect).
|
||||||
|
/// Otherwise, on a dead connection the task blocks forever in recv() while
|
||||||
|
/// keeping the old socket alive — leaking a task + socket on every
|
||||||
|
/// reconnect, which piles up across sleep/resume cycles.
|
||||||
|
rx_task: tokio::task::AbortHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SessionState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.rx_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the per-session receiver loop that reads inbound datagrams from the
|
||||||
|
/// transport and forwards them to the bridge, returning an AbortHandle so the
|
||||||
|
/// task is torn down when its `SessionState` is dropped. Consolidates the three
|
||||||
|
/// previously-duplicated inline copies (initial connect, network-change, and
|
||||||
|
/// keepalive reconnect).
|
||||||
|
fn spawn_session_receiver(
|
||||||
|
socket: crate::transport::Transport,
|
||||||
|
session_index: usize,
|
||||||
|
udp_tx: mpsc::Sender<(usize, Bytes)>,
|
||||||
|
) -> tokio::task::AbortHandle {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0_u8; 65535];
|
||||||
|
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
|
||||||
|
loop {
|
||||||
|
match socket.recv(&mut buf).await {
|
||||||
|
Ok(n) => {
|
||||||
|
let inbound = Bytes::copy_from_slice(&buf[..n]);
|
||||||
|
if udp_tx.send((session_index, inbound)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if is_uot {
|
||||||
|
// TCP transport is dead; exit so the bridge sees the
|
||||||
|
// channel close and reconnects.
|
||||||
|
tracing::debug!("UoT session {} disconnected: {}", session_index, e);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.abort_handle()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Bridge {
|
pub struct Bridge {
|
||||||
|
|
@ -65,8 +128,11 @@ pub struct Bridge {
|
||||||
pub mux_sessions: usize,
|
pub mux_sessions: usize,
|
||||||
|
|
||||||
pub transport_mode: String,
|
pub transport_mode: String,
|
||||||
pub stealth_sni: String,
|
pub tcp_fragmentation: bool,
|
||||||
pub wss: bool,
|
pub frag_chunk: usize,
|
||||||
|
pub frag_sleep: u64,
|
||||||
|
pub junk_pc: [usize; 2],
|
||||||
|
pub junk_ps: [usize; 2],
|
||||||
pub mtu: usize,
|
pub mtu: usize,
|
||||||
pub kill_switch: bool,
|
pub kill_switch: bool,
|
||||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||||
|
|
@ -77,6 +143,21 @@ pub struct Bridge {
|
||||||
last_rtt_ms: f64,
|
last_rtt_ms: f64,
|
||||||
last_sample_at: Instant,
|
last_sample_at: Instant,
|
||||||
last_valid_recv: Instant,
|
last_valid_recv: Instant,
|
||||||
|
/// Set when a suspend/resume is detected, cleared once a reconnect actually
|
||||||
|
/// succeeds. Waking is precisely when the network is least likely to be
|
||||||
|
/// ready — Wi-Fi has not reassociated yet — so a single attempt fired
|
||||||
|
/// milliseconds after resume usually fails, and a one-shot forced reconnect
|
||||||
|
/// then fell back to the ordinary 25s stall heuristic. That heuristic keys
|
||||||
|
/// off a monotonic clock which does not advance while the machine is
|
||||||
|
/// asleep, so it could take a further 25s of real uptime to fire, or not
|
||||||
|
/// fire at all. Retrying until success removes the dependency on either.
|
||||||
|
forced_reconnect_pending: bool,
|
||||||
|
last_forced_reconnect_try: Instant,
|
||||||
|
/// Wall-clock start of the current resume-reconnect campaign, used to bound
|
||||||
|
/// it. Wall clock rather than Instant because the monotonic clock does not
|
||||||
|
/// advance across suspend on Windows, so it cannot measure anything that
|
||||||
|
/// begins at wake.
|
||||||
|
forced_reconnect_started: Option<SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bridge {
|
impl Bridge {
|
||||||
|
|
@ -98,8 +179,11 @@ impl Bridge {
|
||||||
mux_sessions: config.multiplex.sessions.max(1),
|
mux_sessions: config.multiplex.sessions.max(1),
|
||||||
|
|
||||||
transport_mode: config.transport.mode.clone(),
|
transport_mode: config.transport.mode.clone(),
|
||||||
stealth_sni: config.transport.stealth_sni.clone(),
|
tcp_fragmentation: config.transport.tcp_fragmentation,
|
||||||
wss: config.transport.wss,
|
frag_chunk: config.transport.frag_chunk,
|
||||||
|
frag_sleep: config.transport.frag_sleep,
|
||||||
|
junk_pc: config.transport.junk_pc,
|
||||||
|
junk_ps: config.transport.junk_ps,
|
||||||
mtu: config.ostp.mtu,
|
mtu: config.ostp.mtu,
|
||||||
kill_switch: config.kill_switch,
|
kill_switch: config.kill_switch,
|
||||||
reload_tx: None,
|
reload_tx: None,
|
||||||
|
|
@ -110,6 +194,9 @@ impl Bridge {
|
||||||
last_rtt_ms: 0.0,
|
last_rtt_ms: 0.0,
|
||||||
last_sample_at: Instant::now(),
|
last_sample_at: Instant::now(),
|
||||||
last_valid_recv: Instant::now(),
|
last_valid_recv: Instant::now(),
|
||||||
|
forced_reconnect_pending: false,
|
||||||
|
last_forced_reconnect_try: Instant::now(),
|
||||||
|
forced_reconnect_started: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +212,21 @@ impl Bridge {
|
||||||
let mut metrics_tick = interval(Duration::from_millis(500));
|
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 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 mut retransmit_tick = tokio::time::interval(Duration::from_millis(10));
|
||||||
|
// CRITICAL for suspend/resume: the default MissedTickBehavior is `Burst`,
|
||||||
|
// which after a laptop sleep or a phone backgrounding the app fires ALL
|
||||||
|
// the ticks that "should" have happened during the gap back-to-back. For
|
||||||
|
// the 10ms retransmit tick that is tens of thousands of instant ticks on
|
||||||
|
// resume — a CPU storm that hangs the bridge and manifests as the app
|
||||||
|
// freezing or getting stuck "Connecting". Skip missed ticks instead.
|
||||||
|
metrics_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
keepalive_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
retransmit_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
|
||||||
|
// Wall-clock anchor for suspend/resume detection. tokio's timers run on a
|
||||||
|
// monotonic clock; comparing it against wall-clock lets us notice that
|
||||||
|
// the machine slept (or the app was frozen in the background) and force
|
||||||
|
// one clean reconnect instead of trying to resume a long-dead session.
|
||||||
|
let mut last_wall_check = SystemTime::now();
|
||||||
let init_msg = if self.mode == "tun" {
|
let init_msg = if self.mode == "tun" {
|
||||||
"Bridge initialized (TUN mode)".to_string()
|
"Bridge initialized (TUN mode)".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -160,18 +262,89 @@ impl Bridge {
|
||||||
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
|
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() => {
|
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 {
|
if !self.handle_bridge_cmd(cmd, &mut bridge_rx, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = metrics_tick.tick() => {
|
_ = metrics_tick.tick() => {
|
||||||
|
// Suspend/resume detection: the wall clock jumps forward on
|
||||||
|
// wake even when the monotonic timer clock does not, so a
|
||||||
|
// large gap here means the machine slept / the app was frozen.
|
||||||
|
// The session is almost certainly dead (the server evicts
|
||||||
|
// idle sessions after 10 min), so force one clean reconnect
|
||||||
|
// rather than waiting on stale-session heuristics.
|
||||||
|
let wall_gap = last_wall_check.elapsed().unwrap_or_default();
|
||||||
|
last_wall_check = SystemTime::now();
|
||||||
|
if self.running && wall_gap > Duration::from_secs(15) {
|
||||||
|
let _ = tx.send(UiEvent::Log(format!(
|
||||||
|
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
|
||||||
|
))).await;
|
||||||
|
self.forced_reconnect_pending = true;
|
||||||
|
self.forced_reconnect_started = Some(SystemTime::now());
|
||||||
|
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give up if resume reconnects keep failing. Retrying forever
|
||||||
|
// looks harmless but is not: the system proxy stays pointed at
|
||||||
|
// our local listener the whole time, so the machine has NO
|
||||||
|
// working internet — not merely no tunnel — while the UI sits
|
||||||
|
// on "connecting". Handing the retry to the ordinary keepalive
|
||||||
|
// path restores the proxy through its hard-timeout branch,
|
||||||
|
// which force=true deliberately skips.
|
||||||
|
//
|
||||||
|
// Measured on the wall clock: Instant does not advance across
|
||||||
|
// suspend on Windows (QPC stops), so a monotonic deadline can
|
||||||
|
// not bound anything that starts at wake.
|
||||||
|
if self.forced_reconnect_pending {
|
||||||
|
let pending_for = self
|
||||||
|
.forced_reconnect_started
|
||||||
|
.and_then(|t| t.elapsed().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if pending_for > RESUME_RECONNECT_GIVE_UP {
|
||||||
|
self.forced_reconnect_pending = false;
|
||||||
|
self.forced_reconnect_started = None;
|
||||||
|
let _ = tx.send(UiEvent::Log(format!(
|
||||||
|
"Reconnect after suspend failed for {}s — releasing the system \
|
||||||
|
proxy so normal traffic works; will keep retrying in the \
|
||||||
|
background",
|
||||||
|
pending_for.as_secs()
|
||||||
|
))).await;
|
||||||
|
// Make the ordinary stall path fire on the next
|
||||||
|
// keepalive tick: it is the one that tears the proxy
|
||||||
|
// back down (or, with kill switch on, deliberately
|
||||||
|
// keeps blocking).
|
||||||
|
self.last_valid_recv = Instant::now()
|
||||||
|
.checked_sub(Duration::from_secs(3600))
|
||||||
|
.unwrap_or_else(Instant::now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep retrying a resume-triggered reconnect until one lands.
|
||||||
|
// The first attempt fires within half a second of waking, when
|
||||||
|
// the NIC is typically still reassociating, so treating it as
|
||||||
|
// one-shot left the tunnel dead until some other timer noticed.
|
||||||
|
if self.running
|
||||||
|
&& self.forced_reconnect_pending
|
||||||
|
&& self.last_forced_reconnect_try.elapsed() >= Duration::from_secs(3)
|
||||||
|
{
|
||||||
|
self.last_forced_reconnect_try = Instant::now();
|
||||||
|
self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||||
|
// handle_keepalive refreshes last_valid_recv only when a
|
||||||
|
// session was actually established, so this is a real
|
||||||
|
// success check rather than "we tried".
|
||||||
|
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
|
||||||
|
self.forced_reconnect_pending = false;
|
||||||
|
self.forced_reconnect_started = None;
|
||||||
|
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.running {
|
if self.running {
|
||||||
self.emit_metrics(&tx).await;
|
self.emit_metrics(&tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = keepalive_tick.tick() => {
|
_ = keepalive_tick.tick() => {
|
||||||
if self.running {
|
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;
|
self.handle_keepalive(false, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = retransmit_tick.tick() => {
|
_ = retransmit_tick.tick() => {
|
||||||
|
|
@ -180,7 +353,20 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
|
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))
|
// Upper bound matches MAX_CWND_PACKETS in ostp-core's congestion
|
||||||
|
// controller. The old 16384 ceiling let ~20 MB sit in flight,
|
||||||
|
// which on a mobile uplink is minutes of buffered queue rather
|
||||||
|
// than throughput — the app kept handing over data long after
|
||||||
|
// the path had stopped draining it.
|
||||||
|
// Two independent gates. cwnd bounds how much may be in
|
||||||
|
// flight; pacing bounds how FAST it is released. Without the
|
||||||
|
// second, a full window goes out back-to-back and lands in
|
||||||
|
// the bottleneck's buffer as standing queue rather than
|
||||||
|
// throughput — the thing that produced multi-second RTT.
|
||||||
|
s.iter().any(|ses| {
|
||||||
|
ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 1024)
|
||||||
|
&& ses.machine.can_pace_packet()
|
||||||
|
})
|
||||||
}).unwrap_or(true) => {
|
}).unwrap_or(true) => {
|
||||||
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
||||||
}
|
}
|
||||||
|
|
@ -203,8 +389,8 @@ impl Bridge {
|
||||||
) {
|
) {
|
||||||
match udp_msg {
|
match udp_msg {
|
||||||
Some((session_index, inbound)) => {
|
Some((session_index, inbound)) => {
|
||||||
|
// Raw byte counter — every datagram that reached the socket counts.
|
||||||
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
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 let Some(sessions) = sessions_opt.as_mut() {
|
||||||
if session_index < sessions.len() {
|
if session_index < sessions.len() {
|
||||||
let session = &mut sessions[session_index];
|
let session = &mut sessions[session_index];
|
||||||
|
|
@ -217,6 +403,22 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only NOW, after the datagram actually authenticated and
|
||||||
|
// decrypted, does it count as a sign of life. This used to
|
||||||
|
// be set above, before any validation — so a datagram that
|
||||||
|
// failed to decrypt still reset the stall detector on its
|
||||||
|
// way to the `return` above. Anything arriving at this port
|
||||||
|
// (frames from a session the server already evicted, stale
|
||||||
|
// retransmits, or plain garbage from an off-path source that
|
||||||
|
// knows the ip:port) kept the client convinced the tunnel
|
||||||
|
// was healthy: the 25s background reconnect in
|
||||||
|
// handle_keepalive never fired and the tunnel sat dead at
|
||||||
|
// 0 b/s until the user reconnected by hand. It also made
|
||||||
|
// `is_healthy` (see emit_metrics) lie in the UI, and handed
|
||||||
|
// any off-path sender a trivial way to pin a client in a
|
||||||
|
// dead session indefinitely.
|
||||||
|
self.last_valid_recv = Instant::now();
|
||||||
|
|
||||||
let mut actions_queue = std::collections::VecDeque::new();
|
let mut actions_queue = std::collections::VecDeque::new();
|
||||||
actions_queue.push_back(initial_action);
|
actions_queue.push_back(initial_action);
|
||||||
|
|
||||||
|
|
@ -289,6 +491,7 @@ impl Bridge {
|
||||||
async fn handle_bridge_cmd(
|
async fn handle_bridge_cmd(
|
||||||
&mut self,
|
&mut self,
|
||||||
cmd: Option<BridgeCommand>,
|
cmd: Option<BridgeCommand>,
|
||||||
|
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
|
||||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||||
|
|
@ -325,35 +528,9 @@ impl Bridge {
|
||||||
match self.perform_handshake_with_id(&tx, session_id).await {
|
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||||
Ok((sock, mach, rtt)) => {
|
Ok((sock, mach, rtt)) => {
|
||||||
let session_index = sessions.len();
|
let session_index = sessions.len();
|
||||||
let socket_clone = sock.clone();
|
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||||
let udp_tx_clone = udp_tx.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||||
let mut buf = vec![0_u8; 65535];
|
|
||||||
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
|
|
||||||
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) => {
|
|
||||||
if is_uot {
|
|
||||||
// TCP is dead — drop sender to signal bridge via channel close
|
|
||||||
tracing::warn!("UoT session {} disconnected: {}", session_index, e);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sessions.push(SessionState { socket: sock, machine: mach });
|
|
||||||
rtt_sum += rtt;
|
rtt_sum += rtt;
|
||||||
successful_sessions += 1;
|
successful_sessions += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +583,32 @@ impl Bridge {
|
||||||
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
||||||
}
|
}
|
||||||
Some(BridgeCommand::NetworkChanged) => {
|
Some(BridgeCommand::NetworkChanged) => {
|
||||||
|
// A real network handoff (Wi-Fi <-> cellular) commonly fires
|
||||||
|
// onLost + onAvailable within milliseconds of each other on
|
||||||
|
// Android, queuing several NetworkChanged commands back to
|
||||||
|
// back. Each reconnect below is a full sequential handshake
|
||||||
|
// (up to ~1.2s x 4 attempts x mux_sessions) run synchronously
|
||||||
|
// in this select-loop iteration, so without coalescing, the
|
||||||
|
// first attempt often races the OS's own network switch and
|
||||||
|
// fails on the now-dead interface, then the SECOND queued
|
||||||
|
// NetworkChanged only starts its own full reconnect after
|
||||||
|
// that first one finishes - multiplying a sub-second handoff
|
||||||
|
// into many seconds of extra outage. Drain same-kind repeats
|
||||||
|
// so a burst collapses into one reconnect on the freshest
|
||||||
|
// signal; a different command found while draining is
|
||||||
|
// handled immediately rather than dropped.
|
||||||
|
while let Ok(next) = bridge_rx.try_recv() {
|
||||||
|
if !matches!(next, BridgeCommand::NetworkChanged) {
|
||||||
|
let more = Box::pin(self.handle_bridge_cmd(
|
||||||
|
Some(next), bridge_rx, sessions_opt, udp_rx_opt, proxy_guard, stream_map, tx, proxy_tx,
|
||||||
|
)).await;
|
||||||
|
if !more {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.running {
|
if self.running {
|
||||||
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
||||||
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
||||||
|
|
@ -422,31 +625,8 @@ impl Bridge {
|
||||||
match self.perform_handshake_with_id(&tx, session_id).await {
|
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||||
Ok((sock, mach, rtt)) => {
|
Ok((sock, mach, rtt)) => {
|
||||||
let session_index = new_sessions.len();
|
let session_index = new_sessions.len();
|
||||||
let socket_clone = sock.clone();
|
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||||
let udp_tx_clone = udp_tx.clone();
|
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buf = vec![0_u8; 65535];
|
|
||||||
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
|
|
||||||
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) => {
|
|
||||||
if is_uot {
|
|
||||||
tracing::warn!("UoT network-change session {} disconnected: {}", session_index, e);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
new_sessions.push(SessionState { socket: sock, machine: mach });
|
|
||||||
rtt_sum += rtt;
|
rtt_sum += rtt;
|
||||||
successful_sessions += 1;
|
successful_sessions += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -517,6 +697,7 @@ impl Bridge {
|
||||||
|
|
||||||
async fn handle_keepalive(
|
async fn handle_keepalive(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
force: bool,
|
||||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||||
|
|
@ -525,9 +706,12 @@ impl Bridge {
|
||||||
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
|
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
|
||||||
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
|
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
|
||||||
) {
|
) {
|
||||||
if self.last_valid_recv.elapsed().as_secs() > 25 {
|
if force || self.last_valid_recv.elapsed().as_secs() > 25 {
|
||||||
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
||||||
if elapsed > 180 {
|
// On a forced (post-resume) reconnect the monotonic clock may not
|
||||||
|
// have advanced, so `elapsed` can be small — never treat a forced
|
||||||
|
// reconnect as a hard timeout; we specifically want to re-establish.
|
||||||
|
if !force && elapsed > 180 {
|
||||||
if self.kill_switch {
|
if self.kill_switch {
|
||||||
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
|
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -558,34 +742,9 @@ impl Bridge {
|
||||||
match self.perform_handshake_with_id(&tx, session_id).await {
|
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||||
Ok((sock, mach, rtt)) => {
|
Ok((sock, mach, rtt)) => {
|
||||||
let session_index = new_sessions.len();
|
let session_index = new_sessions.len();
|
||||||
let socket_clone = sock.clone();
|
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||||
let udp_tx_clone = udp_tx.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||||
let mut buf = vec![0_u8; 65535];
|
|
||||||
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
|
|
||||||
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) => {
|
|
||||||
if is_uot {
|
|
||||||
tracing::warn!("UoT reconnect session {} disconnected: {}", session_index, e);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("UDP socket recv error (reconnect session {}): {}", session_index, e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
new_sessions.push(SessionState { socket: sock, machine: mach });
|
|
||||||
rtt_sum += rtt;
|
rtt_sum += rtt;
|
||||||
successful_sessions += 1;
|
successful_sessions += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -861,7 +1020,21 @@ impl Bridge {
|
||||||
Ok(addrs) => addrs.collect(),
|
Ok(addrs) => addrs.collect(),
|
||||||
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
||||||
};
|
};
|
||||||
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
|
// IPv4 first. Addresses are tried strictly in order, each burning its
|
||||||
|
// full retry budget before the next is touched, so this ordering decides
|
||||||
|
// how long a bad family stalls the whole connect. Mobile carriers
|
||||||
|
// routinely hand out IPv6 with no working route and BLACKHOLE it rather
|
||||||
|
// than rejecting, so every IPv6 candidate costs the full timeout budget
|
||||||
|
// — with several AAAA records the working IPv4 address was not reached
|
||||||
|
// for tens of seconds. (The same ordering bug was already fixed on the
|
||||||
|
// server's outbound path and in the UoT connect.)
|
||||||
|
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 1 } else { 0 });
|
||||||
|
|
||||||
|
// NAT64 is a fallback for IPv6-only networks. Retrying it per failing
|
||||||
|
// address multiplied an already-long connect: each attempt re-runs a DNS
|
||||||
|
// lookup and another full round of handshake retries, for a path that
|
||||||
|
// either works for the whole network or for none of it.
|
||||||
|
let mut nat64_attempted = false;
|
||||||
|
|
||||||
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
||||||
|
|
||||||
|
|
@ -874,7 +1047,8 @@ impl Bridge {
|
||||||
let socket = match self.try_connect_transport(target_ip, port).await {
|
let socket = match self.try_connect_transport(target_ip, port).await {
|
||||||
Ok(sock) => sock,
|
Ok(sock) => sock,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||||
|
nat64_attempted = true;
|
||||||
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
||||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||||
|
|
@ -955,7 +1129,8 @@ impl Bridge {
|
||||||
let (final_socket, size) = if success {
|
let (final_socket, size) = if success {
|
||||||
(socket, size)
|
(socket, size)
|
||||||
} else {
|
} else {
|
||||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||||
|
nat64_attempted = true;
|
||||||
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
||||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||||
|
|
@ -1025,8 +1200,11 @@ impl Bridge {
|
||||||
self.mux_enabled = cfg.multiplex.enabled;
|
self.mux_enabled = cfg.multiplex.enabled;
|
||||||
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
||||||
self.transport_mode = cfg.transport.mode.clone();
|
self.transport_mode = cfg.transport.mode.clone();
|
||||||
self.stealth_sni = cfg.transport.stealth_sni.clone();
|
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
|
||||||
self.wss = cfg.transport.wss; // Fix: wss was not updated on hot-reload
|
self.frag_chunk = cfg.transport.frag_chunk.max(1);
|
||||||
|
self.frag_sleep = cfg.transport.frag_sleep;
|
||||||
|
self.junk_pc = cfg.transport.junk_pc;
|
||||||
|
self.junk_ps = cfg.transport.junk_ps;
|
||||||
self.mtu = cfg.ostp.mtu;
|
self.mtu = cfg.ostp.mtu;
|
||||||
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
||||||
self.kill_switch = cfg.kill_switch;
|
self.kill_switch = cfg.kill_switch;
|
||||||
|
|
@ -1039,22 +1217,106 @@ impl Bridge {
|
||||||
) -> Result<crate::transport::Transport> {
|
) -> Result<crate::transport::Transport> {
|
||||||
let mode = self.transport_mode.to_lowercase();
|
let mode = self.transport_mode.to_lowercase();
|
||||||
if mode == "uot" || mode == "tcp" {
|
if mode == "uot" || mode == "tcp" {
|
||||||
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
// Bound the TCP connect. Without this it inherits the kernel's SYN
|
||||||
|
// retry budget, which is tens of seconds (and can reach ~2 minutes).
|
||||||
|
// That is exactly what made UoT appear to hang on mobile: callers
|
||||||
|
// resolve every address for the server and try IPv6 first (see the
|
||||||
|
// sort in perform_handshake_with_id), and a mobile network that
|
||||||
|
// advertises IPv6 without a working route blackholes the SYN rather
|
||||||
|
// than rejecting it — so the client sat through the full retry
|
||||||
|
// budget before it ever reached the IPv4 address that would have
|
||||||
|
// connected immediately. UDP never showed this because connect() on
|
||||||
|
// a UDP socket only sets the default peer and returns at once.
|
||||||
|
let stream = tokio::time::timeout(
|
||||||
|
UOT_CONNECT_TIMEOUT,
|
||||||
|
tokio::net::TcpStream::connect((target_ip, port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"TCP connect to {target_ip}:{port} timed out after {:?}",
|
||||||
|
UOT_CONNECT_TIMEOUT
|
||||||
|
)
|
||||||
|
})??;
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
let (mut read_half, mut write_half) = stream.into_split();
|
let (mut read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
|
let tcp_fragmentation = self.tcp_fragmentation;
|
||||||
|
let frag_chunk = self.frag_chunk;
|
||||||
|
let frag_sleep = self.frag_sleep;
|
||||||
|
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
||||||
|
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
||||||
|
// Time-rotating per-key junk marker — NOT a global constant and NOT
|
||||||
|
// even a static per-user value: it changes every window, so junk
|
||||||
|
// carries no fixed DPI signature on the wire. All frames in this
|
||||||
|
// burst are sent within milliseconds, so one window applies to all.
|
||||||
|
let junk_marker = ostp_core::crypto::derive_junk_marker(
|
||||||
|
&self.access_key,
|
||||||
|
ostp_core::crypto::current_junk_window(),
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
// Build all junk frames up front so ThreadRng isn't held across an
|
||||||
|
// await point (keeps this future Send).
|
||||||
|
let junk_frames: Vec<Vec<u8>> = {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let min_c = junk_pc_min;
|
||||||
|
let max_c = junk_pc_max.max(min_c);
|
||||||
|
let num_junk = rng.gen_range(min_c..=max_c);
|
||||||
|
(0..num_junk)
|
||||||
|
.map(|_| {
|
||||||
|
let min_s = junk_ps_min.max(1);
|
||||||
|
let max_s = junk_ps_max.max(min_s);
|
||||||
|
let junk_len = rng.gen_range(min_s..=max_s);
|
||||||
|
let mut frame = Vec::with_capacity(2 + junk_len);
|
||||||
|
frame.extend_from_slice(&(junk_len as u16).to_be_bytes());
|
||||||
|
let start = frame.len();
|
||||||
|
frame.resize(start + junk_len, 0);
|
||||||
|
rng.fill(&mut frame[start..]);
|
||||||
|
// Stamp this key's derived junk marker so the server drops it silently.
|
||||||
|
if junk_len >= 4 {
|
||||||
|
frame[start..start+4].copy_from_slice(&junk_marker);
|
||||||
|
}
|
||||||
|
frame
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for frame in junk_frames {
|
||||||
|
if write_half.write_all(&frame).await.is_err() { break; }
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
||||||
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
||||||
|
|
||||||
// Task to write from rx_out to tcp stream
|
// Writer: length-prefix each frame. With tcp_fragmentation on, split
|
||||||
|
// the FIRST real frame (the handshake — junk above was written
|
||||||
|
// directly, so it doesn't count) into tiny TCP segments with short
|
||||||
|
// gaps so DPI can't reassemble/classify the handshake from one read.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut first_packet = true;
|
||||||
while let Some(data) = rx_out.recv().await {
|
while let Some(data) = rx_out.recv().await {
|
||||||
let mut len_buf = [0u8; 2];
|
let len_buf = (data.len() as u16).to_be_bytes();
|
||||||
len_buf.copy_from_slice(&(data.len() as u16).to_be_bytes());
|
if first_packet && tcp_fragmentation {
|
||||||
|
first_packet = false;
|
||||||
|
if write_half.write_all(&len_buf[0..1]).await.is_err() { break; }
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
if write_half.write_all(&len_buf[1..2]).await.is_err() { break; }
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
let mut broke = false;
|
||||||
|
for chunk in data.chunks(frag_chunk) {
|
||||||
|
if write_half.write_all(chunk).await.is_err() { broke = true; break; }
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(frag_sleep)).await;
|
||||||
|
}
|
||||||
|
if broke { break; }
|
||||||
|
} else {
|
||||||
if write_half.write_all(&len_buf).await.is_err() { break; }
|
if write_half.write_all(&len_buf).await.is_err() { break; }
|
||||||
if write_half.write_all(&data).await.is_err() { break; }
|
if write_half.write_all(&data).await.is_err() { break; }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task to read from tcp stream to tx_in
|
// Task to read from tcp stream to tx_in
|
||||||
|
|
@ -1112,8 +1374,19 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
||||||
|
// Well-known prefix (RFC 6052), used if discovery doesn't answer in time.
|
||||||
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
|
// Bound the discovery lookup. This runs on exactly the networks that are
|
||||||
|
// already misbehaving, where the resolver can hang for tens of seconds
|
||||||
|
// before giving up — unbounded, it was a large part of why connecting over
|
||||||
|
// a broken mobile network took minutes. Falling back to the well-known
|
||||||
|
// prefix is strictly better than waiting.
|
||||||
|
let discovery = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
tokio::net::lookup_host("ipv4only.arpa:80"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Ok(Ok(addrs)) = discovery {
|
||||||
for addr in addrs {
|
for addr in addrs {
|
||||||
if let std::net::SocketAddr::V6(v6) = addr {
|
if let std::net::SocketAddr::V6(v6) = addr {
|
||||||
let octets = v6.ip().octets();
|
let octets = v6.ip().octets();
|
||||||
|
|
|
||||||
|
|
@ -70,28 +70,45 @@ pub struct LocalProxyConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport layer configuration.
|
/// Transport layer configuration.
|
||||||
/// `mode` = "udp" (default) or "uot" (UDP over TCP with xHTTP stealth).
|
/// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry —
|
||||||
|
/// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TransportConfig {
|
pub struct TransportConfig {
|
||||||
/// "udp" or "uot"
|
/// "udp" or "uot"
|
||||||
#[serde(default = "default_transport_mode")]
|
#[serde(default = "default_transport_mode")]
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
/// TLS SNI and HTTP Host for stealth routing
|
/// Split the first UoT/TCP packet (handshake) into tiny TCP segments to
|
||||||
#[serde(default)]
|
/// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
|
||||||
pub stealth_sni: String,
|
pub tcp_fragmentation: bool,
|
||||||
/// Enable strict RFC 6455 WebSocket framing
|
/// TCP chunk size (bytes)
|
||||||
#[serde(default)]
|
#[serde(default = "default_frag_chunk")]
|
||||||
pub wss: bool,
|
pub frag_chunk: usize,
|
||||||
|
/// TCP sleep duration between chunks (ms)
|
||||||
|
#[serde(default = "default_frag_sleep")]
|
||||||
|
pub frag_sleep: u64,
|
||||||
|
/// [min, max] junk packet count
|
||||||
|
#[serde(default = "default_junk_count")]
|
||||||
|
pub junk_pc: [usize; 2],
|
||||||
|
/// [min, max] junk packet size in bytes
|
||||||
|
#[serde(default = "default_junk_size")]
|
||||||
|
pub junk_ps: [usize; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_transport_mode() -> String { "udp".to_string() }
|
fn default_transport_mode() -> String { "udp".to_string() }
|
||||||
|
fn default_frag_chunk() -> usize { 2 }
|
||||||
|
fn default_frag_sleep() -> u64 { 2 }
|
||||||
|
fn default_junk_count() -> [usize; 2] { [2, 5] }
|
||||||
|
fn default_junk_size() -> [usize; 2] { [100, 1000] }
|
||||||
|
|
||||||
impl Default for TransportConfig {
|
impl Default for TransportConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode: default_transport_mode(),
|
mode: default_transport_mode(),
|
||||||
stealth_sni: String::new(),
|
tcp_fragmentation: false,
|
||||||
wss: false,
|
frag_chunk: default_frag_chunk(),
|
||||||
|
frag_sleep: default_frag_sleep(),
|
||||||
|
junk_pc: default_junk_count(),
|
||||||
|
junk_ps: default_junk_size(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,8 +189,11 @@ struct RawUnifiedConfig {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawTransportSection {
|
struct RawTransportSection {
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
stealth_sni: Option<String>,
|
tcp_fragmentation: Option<bool>,
|
||||||
wss: Option<bool>,
|
frag_chunk: Option<usize>,
|
||||||
|
frag_sleep: Option<u64>,
|
||||||
|
junk_pc: Option<[usize; 2]>,
|
||||||
|
junk_ps: Option<[usize; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -246,8 +266,11 @@ impl ClientConfig {
|
||||||
},
|
},
|
||||||
transport: TransportConfig {
|
transport: TransportConfig {
|
||||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
||||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),
|
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
||||||
wss: raw.transport.as_ref().and_then(|t| t.wss).unwrap_or(false),
|
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk),
|
||||||
|
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
|
||||||
|
junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count),
|
||||||
|
junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size),
|
||||||
},
|
},
|
||||||
exclusions: ExclusionConfig {
|
exclusions: ExclusionConfig {
|
||||||
domains: exclusions.domains.unwrap_or_default(),
|
domains: exclusions.domains.unwrap_or_default(),
|
||||||
|
|
@ -265,3 +288,251 @@ 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,
|
||||||
|
// ── Deprecated ──────────────────────────────────────────────────────────
|
||||||
|
// The relay used to authenticate clients itself and pulled the access-key
|
||||||
|
// list from the target server's management API to do it. It no longer does:
|
||||||
|
// sessions are authenticated end-to-end by the target server, and a relay
|
||||||
|
// that re-checks credentials only adds a weaker second gate plus a copy of
|
||||||
|
// the key list on a machine that does not need one. These are kept solely
|
||||||
|
// so existing relay configs still parse; they are ignored.
|
||||||
|
#[serde(default)]
|
||||||
|
pub upstream_api_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upstream_api_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_interval_secs: u64,
|
||||||
|
pub debug: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supports both a single string "0.0.0.0:50000" and an array
|
||||||
|
/// ["0.0.0.0:50000", "[::]:50000"].
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ListenConfig {
|
||||||
|
Single(String),
|
||||||
|
Multiple(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListenConfig {
|
||||||
|
pub fn addresses(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
ListenConfig::Single(s) => vec![s.clone()],
|
||||||
|
ListenConfig::Multiple(v) => v.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn primary(&self) -> String {
|
||||||
|
match self {
|
||||||
|
ListenConfig::Single(s) => s.clone(),
|
||||||
|
ListenConfig::Multiple(v) => v.first().cloned().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ApiConfig {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub bind: Option<String>,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub webpath: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct FallbackCfg {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub listen: Option<String>,
|
||||||
|
pub target: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ClientFileConfig {
|
||||||
|
pub server: String,
|
||||||
|
pub access_key: String,
|
||||||
|
pub mtu: Option<usize>,
|
||||||
|
pub socks5_bind: Option<String>,
|
||||||
|
pub tun: Option<TunConfig>,
|
||||||
|
pub debug: Option<bool>,
|
||||||
|
pub exclude: Option<ExcludeConfig>,
|
||||||
|
pub mux: Option<MuxConfig>,
|
||||||
|
pub transport: Option<TransportConfigRaw>,
|
||||||
|
pub gui: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct TransportConfigRaw {
|
||||||
|
pub mode: Option<String>,
|
||||||
|
pub tcp_fragmentation: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct TunConfig {
|
||||||
|
pub enable: bool,
|
||||||
|
pub wintun_path: Option<String>,
|
||||||
|
pub ipv4_address: Option<String>,
|
||||||
|
pub dns: Option<String>,
|
||||||
|
pub kill_switch: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct OutboundConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub protocol: String,
|
||||||
|
pub address: String,
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rules: Vec<OutboundRule>,
|
||||||
|
pub default_action: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct OutboundRule {
|
||||||
|
pub domain_suffix: Option<Vec<String>>,
|
||||||
|
pub ip_cidr: Option<Vec<String>>,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub action: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ExcludeConfig {
|
||||||
|
pub domains: Option<Vec<String>>,
|
||||||
|
pub ips: Option<Vec<String>>,
|
||||||
|
pub processes: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct MuxConfig {
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub sessions: Option<usize>,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod migrate;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod sysproxy;
|
pub mod sysproxy;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,53 @@ use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
|
/// The single canonical log file for the whole core. Every process (CLI daemon,
|
||||||
|
/// GUI, TUN helper) and every subsystem (tracing, the core event logger, the
|
||||||
|
/// helper IPC, panics) writes here — no more per-binary / per-subsystem sprawl
|
||||||
|
/// (`ostp-cli.log` + `ostp-core.log` + `ostp-helper.log` + `ostp-crash.log`).
|
||||||
|
pub const LOG_FILE_NAME: &str = "ostp.log";
|
||||||
|
|
||||||
|
/// Absolute path to the shared log file, next to the running executable.
|
||||||
|
pub fn log_file_path() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join(LOG_FILE_NAME)))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(LOG_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this invocation is the long-running daemon (a client/server run),
|
||||||
|
/// as opposed to a one-shot subcommand (`gk`, `check`, `init`, `-V`, ...).
|
||||||
|
///
|
||||||
|
/// Used to gate log truncation: only the daemon clears the log at startup, so a
|
||||||
|
/// one-shot command run while a daemon is live can never wipe the daemon's log.
|
||||||
|
/// A daemon invocation is simply one that carries none of the one-shot tokens
|
||||||
|
/// (`ostp`, `ostp run`, `ostp connect <url>` → daemon; everything else → one-shot).
|
||||||
|
pub fn invocation_is_daemon<I: IntoIterator<Item = String>>(args: I) -> bool {
|
||||||
|
const ONE_SHOT: &[&str] = &[
|
||||||
|
"gk", "generate-key", "check", "init", "setup", "links", "import",
|
||||||
|
"update", "migrate", "prober", "proxy-env", "proxy-env-clear",
|
||||||
|
"uninstall", "-V", "--version", "-h", "--help", "help",
|
||||||
|
];
|
||||||
|
!args
|
||||||
|
.into_iter()
|
||||||
|
.skip(1) // program name
|
||||||
|
.any(|a| ONE_SHOT.contains(&a.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a single timestamped line to the shared log file. Used by the manual
|
||||||
|
/// writers (core event logger, TUN helper IPC) so their output lands in the same
|
||||||
|
/// `ostp.log` as the tracing subscriber instead of a separate file.
|
||||||
|
pub fn append_line(msg: &str) {
|
||||||
|
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||||
|
let _ = writeln!(
|
||||||
|
file,
|
||||||
|
"[{}] {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn setup_panic_hook() {
|
pub fn setup_panic_hook() {
|
||||||
std::panic::set_hook(Box::new(|info| {
|
std::panic::set_hook(Box::new(|info| {
|
||||||
let payload = info.payload();
|
let payload = info.payload();
|
||||||
|
|
@ -29,19 +76,16 @@ pub fn setup_panic_hook() {
|
||||||
eprintln!("{}", crash_msg);
|
eprintln!("{}", crash_msg);
|
||||||
tracing::error!("{}", crash_msg);
|
tracing::error!("{}", crash_msg);
|
||||||
|
|
||||||
let path = std::env::current_exe()
|
// Crashes land in the same shared log file (append — a crash must never
|
||||||
.ok()
|
// truncate, and the tracing worker may already be dead so we write direct).
|
||||||
.and_then(|p| p.parent().map(|d| d.join("ostp-crash.log")))
|
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||||
.unwrap_or_else(|| PathBuf::from("ostp-crash.log"));
|
|
||||||
|
|
||||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
|
||||||
let _ = file.write_all(crash_msg.as_bytes());
|
let _ = file.write_all(crash_msg.as_bytes());
|
||||||
let _ = file.write_all(b"\n===================================================\n");
|
let _ = file.write_all(b"\n===================================================\n");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialises tracing and writes to `<app_name>.log` next to the executable.
|
/// Initialises tracing and writes to the shared `ostp.log` next to the executable.
|
||||||
///
|
///
|
||||||
/// The `level` parameter controls the minimum log level:
|
/// The `level` parameter controls the minimum log level:
|
||||||
/// - `"error"` — only errors
|
/// - `"error"` — only errors
|
||||||
|
|
@ -51,7 +95,17 @@ pub fn setup_panic_hook() {
|
||||||
/// - `"trace"` — all messages including very verbose internal state
|
/// - `"trace"` — all messages including very verbose internal state
|
||||||
///
|
///
|
||||||
/// The environment variable `RUST_LOG` overrides this value if set.
|
/// The environment variable `RUST_LOG` overrides this value if set.
|
||||||
pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
///
|
||||||
|
/// `truncate`: clear the log at startup. Honoured **only on Windows** — Linux
|
||||||
|
/// servers keep their history (OS-rotated). Pass `true` only from the daemon's
|
||||||
|
/// own entrypoint; one-shot commands and child processes (the TUN helper) pass
|
||||||
|
/// `false` so they append instead of wiping a running daemon's log.
|
||||||
|
pub fn init_tracing(
|
||||||
|
level: &str,
|
||||||
|
app_name: &str,
|
||||||
|
version: &str,
|
||||||
|
truncate: bool,
|
||||||
|
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||||
// RUST_LOG overrides the config-derived level
|
// RUST_LOG overrides the config-derived level
|
||||||
let env_filter = EnvFilter::try_from_default_env()
|
let env_filter = EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
|
|
@ -66,12 +120,39 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let path = std::env::current_exe()
|
let path = log_file_path();
|
||||||
.ok()
|
|
||||||
.and_then(|p| p.parent().map(|d| d.join(format!("{}.log", app_name))))
|
let mut open_opts = OpenOptions::new();
|
||||||
.unwrap_or_else(|| PathBuf::from(format!("{}.log", app_name)));
|
open_opts.create(true);
|
||||||
|
// Truncate-on-startup is Windows-only and daemon-only. Everywhere else append:
|
||||||
|
// Linux keeps server history, and one-shot commands / the TUN helper must not
|
||||||
|
// wipe a running daemon's log.
|
||||||
|
if truncate && cfg!(windows) {
|
||||||
|
open_opts.write(true).truncate(true);
|
||||||
|
} else {
|
||||||
|
open_opts.append(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut file) = open_opts.open(&path) {
|
||||||
|
// Write the startup banner directly to the log file, bypassing the
|
||||||
|
// tracing subscriber entirely. Emitting it via tracing::info!() hits
|
||||||
|
// BOTH layers below (file AND stderr), so every one-shot CLI command
|
||||||
|
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
|
||||||
|
// terminal on every single invocation — pure noise for anything that
|
||||||
|
// isn't the long-running daemon. It's still genuinely useful for
|
||||||
|
// whoever's reading the log file later, so keep it there, just not on
|
||||||
|
// screen for commands that aren't the daemon.
|
||||||
|
let _ = writeln!(
|
||||||
|
file,
|
||||||
|
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
|
||||||
|
app_name,
|
||||||
|
version,
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH,
|
||||||
|
level,
|
||||||
|
path.display(),
|
||||||
|
);
|
||||||
|
|
||||||
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
|
|
||||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||||
|
|
||||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||||
|
|
@ -92,16 +173,6 @@ pub fn init_tracing(level: &str, app_name: &str, version: &str) -> Option<tracin
|
||||||
.with(stderr_layer)
|
.with(stderr_layer)
|
||||||
.try_init();
|
.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)
|
Some(guard)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: stderr only
|
// Fallback: stderr only
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,559 @@
|
||||||
|
//! The ONE authoritative place that upgrades an old `config.json` to the
|
||||||
|
//! current schema. Reachable only via the explicit `ostp migrate` command —
|
||||||
|
//! nothing else in this codebase silently rewrites a user's config on their
|
||||||
|
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
|
||||||
|
//! log warning; that's exactly the kind of "invisible until something looks
|
||||||
|
//! wrong" behavior this module replaces).
|
||||||
|
//!
|
||||||
|
//! Every field this module cannot map forward is reported explicitly in
|
||||||
|
//! `MigrationReport.notes`, never silently dropped without a trace.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MigrationReport {
|
||||||
|
/// Whether anything was actually different from the current schema.
|
||||||
|
pub changed: bool,
|
||||||
|
/// Human-readable line per field added, converted, or dropped.
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigrationReport {
|
||||||
|
fn note(&mut self, msg: impl Into<String>) {
|
||||||
|
self.changed = true;
|
||||||
|
self.notes.push(msg.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs
|
||||||
|
/// from before that tag existed are sniffed structurally as a fallback.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConfigKind {
|
||||||
|
Client,
|
||||||
|
Server,
|
||||||
|
Relay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
|
||||||
|
match json.get("mode").and_then(|v| v.as_str()) {
|
||||||
|
Some("client") => return Some(ConfigKind::Client),
|
||||||
|
Some("server") => return Some(ConfigKind::Server),
|
||||||
|
Some("relay") => return Some(ConfigKind::Relay),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// No (or unrecognized) "mode" tag — this is an older config from before
|
||||||
|
// it was mandatory. Sniff by the fields that have been present on each
|
||||||
|
// shape since the earliest surviving config format.
|
||||||
|
if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() {
|
||||||
|
Some(ConfigKind::Relay)
|
||||||
|
} else if json.get("access_keys").is_some() || json.get("listen").is_some() {
|
||||||
|
Some(ConfigKind::Server)
|
||||||
|
} else if json.get("access_key").is_some() || json.get("server").is_some() {
|
||||||
|
Some(ConfigKind::Client)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrates a client config of any known past shape to the current flat
|
||||||
|
/// schema. Returns the migrated JSON and a report of every change made.
|
||||||
|
///
|
||||||
|
/// Known input shapes, oldest first:
|
||||||
|
/// - **v0.3.1–v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays
|
||||||
|
/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this
|
||||||
|
/// line no longer supports multiple simultaneous servers); every other
|
||||||
|
/// `ostp` outbound is reported by tag+address so nothing vanishes
|
||||||
|
/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no
|
||||||
|
/// equivalent and are dropped (reported).
|
||||||
|
/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today
|
||||||
|
/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...)
|
||||||
|
/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail,
|
||||||
|
/// never user-meaningful data) and `transport.wss` (the WSS framing
|
||||||
|
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
|
||||||
|
/// explicit note; everything else maps 1:1, nothing to convert.
|
||||||
|
/// - **configs carrying a leftover `transport.stealth_sni`**: dropped with a
|
||||||
|
/// note, same reasoning as `wss` — it never fed into anything on the wire
|
||||||
|
/// (no TLS/HTTP mimicry exists in this project), so there is no successor
|
||||||
|
/// field. Not tied to a specific version: it lingered in the schema well
|
||||||
|
/// past when the mimicry work it was meant for got removed.
|
||||||
|
/// - **current flat schema**: no-op, `changed = false`.
|
||||||
|
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
|
||||||
|
let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some();
|
||||||
|
let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some();
|
||||||
|
|
||||||
|
if has_inbounds && has_outbounds {
|
||||||
|
return migrate_client_from_modular(json, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat shape already (current or pre-0.3.1) — normalize obsolete fields
|
||||||
|
// in place rather than rebuilding the whole document from scratch, so
|
||||||
|
// any field this module doesn't know about yet still survives untouched.
|
||||||
|
let mut out = json;
|
||||||
|
|
||||||
|
if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) {
|
||||||
|
for dead_field in ["wintun_path", "ipv4_address"] {
|
||||||
|
if tun.remove(dead_field).is_some() {
|
||||||
|
report.note(format!(
|
||||||
|
"Dropped tun.{dead_field} — internal driver detail from an older WinTun \
|
||||||
|
integration, not applicable to the current TUN implementation."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) {
|
||||||
|
if transport.remove("wss").is_some() {
|
||||||
|
report.note(
|
||||||
|
"Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \
|
||||||
|
(the project follows a zapret-like approach: no protocol mimicry, \
|
||||||
|
just packet-level obfuscation/manipulation, so there is no successor field)."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if transport.remove("stealth_sni").is_some() {
|
||||||
|
report.note(
|
||||||
|
"Dropped transport.stealth_sni — never actually used to construct any wire \
|
||||||
|
bytes (no TLS/HTTP mimicry exists in this project — same zapret-like \
|
||||||
|
reasoning as transport.wss), so it was unused config plumbing with no effect."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(out, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) {
|
||||||
|
report.changed = true; // the shape itself is being replaced regardless of field-level detail
|
||||||
|
|
||||||
|
let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||||
|
let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||||
|
let routing = json.get("routing").cloned().unwrap_or(json!({}));
|
||||||
|
let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
|
||||||
|
// ── Pick the primary "ostp" outbound ────────────────────────────────
|
||||||
|
// Prefer the one routing.default_outbound points at (directly, or via a
|
||||||
|
// urltest/selector group that references it); otherwise take the first
|
||||||
|
// ostp outbound in file order. Every other ostp outbound is reported by
|
||||||
|
// tag+address, not silently discarded.
|
||||||
|
let ostp_outbounds: Vec<&Value> = outbounds
|
||||||
|
.iter()
|
||||||
|
.filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// default_outbound might name an ostp outbound directly, OR name a
|
||||||
|
// urltest/selector GROUP whose first member is the one to actually use —
|
||||||
|
// check both, since a plain `.or_else` here would never even attempt the
|
||||||
|
// group lookup while default_outbound is Some(_) (which it almost always
|
||||||
|
// is), silently falling through to "just take the first ostp outbound in
|
||||||
|
// file order" instead — exactly the kind of silent wrong answer this
|
||||||
|
// migrator exists to avoid.
|
||||||
|
let primary_tag: Option<String> = default_outbound.as_deref().and_then(|def_tag| {
|
||||||
|
if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) {
|
||||||
|
return Some(def_tag.to_string());
|
||||||
|
}
|
||||||
|
outbounds.iter().find_map(|o| {
|
||||||
|
let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector"));
|
||||||
|
let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag);
|
||||||
|
if is_group && tag_matches {
|
||||||
|
o.get("outbounds")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.and_then(|arr| arr.first())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let primary = primary_tag
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag)))
|
||||||
|
.copied()
|
||||||
|
.or_else(|| ostp_outbounds.first().copied());
|
||||||
|
|
||||||
|
let Some(primary) = primary else {
|
||||||
|
report.note(
|
||||||
|
"No 'ostp'-type outbound found in the old modular config — nothing to migrate \
|
||||||
|
the server connection from. Wrote a placeholder; you MUST fill in server/access_key \
|
||||||
|
by hand or re-import a share link."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
json!({
|
||||||
|
"server": "127.0.0.1:50000",
|
||||||
|
"access_key": "",
|
||||||
|
}),
|
||||||
|
report,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
for other in &ostp_outbounds {
|
||||||
|
if !std::ptr::eq(*other, primary) {
|
||||||
|
let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?");
|
||||||
|
let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?");
|
||||||
|
let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||||
|
report.note(format!(
|
||||||
|
"Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \
|
||||||
|
failover is no longer supported; only one server per config now. Kept the \
|
||||||
|
one from routing.default_outbound (or the first one if that wasn't set)."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
|
||||||
|
let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000);
|
||||||
|
let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let transport_type = primary
|
||||||
|
.get("transport")
|
||||||
|
.and_then(|t| t.get("type").or_else(|| t.get("mode")))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("udp")
|
||||||
|
.to_string();
|
||||||
|
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
|
||||||
|
if !sni.is_empty() {
|
||||||
|
report.note(format!(
|
||||||
|
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
|
||||||
|
any wire bytes; unused config plumbing with no successor field."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tcp_fragmentation = primary
|
||||||
|
.get("transport")
|
||||||
|
.and_then(|t| t.get("tcp_fragmentation"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
|
||||||
|
|
||||||
|
// ── TUN + local proxy inbounds ───────────────────────────────────────
|
||||||
|
let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun"));
|
||||||
|
let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy"));
|
||||||
|
|
||||||
|
let tun_enable = tun_inbound.is_some();
|
||||||
|
let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64());
|
||||||
|
|
||||||
|
let socks5_bind = proxy_inbound
|
||||||
|
.map(|p| {
|
||||||
|
let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1");
|
||||||
|
let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088);
|
||||||
|
format!("{listen}:{port}")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "127.0.0.1:1088".to_string());
|
||||||
|
|
||||||
|
// ── Exclusions from routing.rules → direct ──────────────────────────
|
||||||
|
let mut ex_domains: Vec<String> = Vec::new();
|
||||||
|
let mut ex_ips: Vec<String> = Vec::new();
|
||||||
|
let mut ex_processes: Vec<String> = Vec::new();
|
||||||
|
if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) {
|
||||||
|
for rule in rules {
|
||||||
|
if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") {
|
||||||
|
continue; // only "route to direct" rules were ever exclusions in the old format
|
||||||
|
}
|
||||||
|
if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) {
|
||||||
|
ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||||
|
}
|
||||||
|
if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) {
|
||||||
|
ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||||
|
}
|
||||||
|
if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) {
|
||||||
|
ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for other_rule_outbound in routing
|
||||||
|
.get("rules")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|r| r.get("outbound").and_then(|v| v.as_str()))
|
||||||
|
.filter(|o| *o != "direct")
|
||||||
|
{
|
||||||
|
report.note(format!(
|
||||||
|
"Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \
|
||||||
|
\"route to direct\" rules map to today's exclusions; anything else \
|
||||||
|
(custom per-domain outbound selection) has no equivalent anymore."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug");
|
||||||
|
|
||||||
|
let mut client = json!({
|
||||||
|
"server": server,
|
||||||
|
"port": port,
|
||||||
|
"access_key": access_key,
|
||||||
|
"socks5_bind": socks5_bind,
|
||||||
|
"debug": debug,
|
||||||
|
"tun": {
|
||||||
|
"enable": tun_enable,
|
||||||
|
"dns": null,
|
||||||
|
"kill_switch": false,
|
||||||
|
},
|
||||||
|
"exclude": {
|
||||||
|
"domains": ex_domains,
|
||||||
|
"ips": ex_ips,
|
||||||
|
"processes": ex_processes,
|
||||||
|
},
|
||||||
|
"mux": {
|
||||||
|
"enabled": mux_enabled,
|
||||||
|
"sessions": mux_sessions,
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"mode": transport_type,
|
||||||
|
"tcp_fragmentation": tcp_fragmentation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if let Some(mtu) = mtu {
|
||||||
|
client["mtu"] = json!(mtu);
|
||||||
|
}
|
||||||
|
if let Some(gui) = json.get("gui") {
|
||||||
|
client["gui"] = gui.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
(client, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrates a server config. The server shape has stayed structurally
|
||||||
|
/// identical since the earliest surviving version — this only backfills the
|
||||||
|
/// `api` section (added after some configs already existed) and drops the
|
||||||
|
/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used
|
||||||
|
/// to live in `scripts/install.sh` and only ran at install/update time.
|
||||||
|
pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
|
||||||
|
let mut report = MigrationReport::default();
|
||||||
|
let mut out = json;
|
||||||
|
|
||||||
|
let obj = match out.as_object_mut() {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return (out, report),
|
||||||
|
};
|
||||||
|
|
||||||
|
let api = obj.entry("api").or_insert_with(|| json!({}));
|
||||||
|
if let Some(api_obj) = api.as_object_mut() {
|
||||||
|
let defaults: [(&str, Value); 5] = [
|
||||||
|
("enabled", json!(false)),
|
||||||
|
("bind", json!("0.0.0.0:9090")),
|
||||||
|
("webpath", json!("")),
|
||||||
|
("username", json!("")),
|
||||||
|
("password_hash", json!("")),
|
||||||
|
];
|
||||||
|
for (key, default) in defaults {
|
||||||
|
if !api_obj.contains_key(key) {
|
||||||
|
report.note(format!("Added api.{key} = {default} (missing default)"));
|
||||||
|
api_obj.insert(key.to_string(), default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api_obj.remove("token").is_some() {
|
||||||
|
report.note(
|
||||||
|
"Dropped legacy api.token — superseded by api.password_hash; \
|
||||||
|
set a new admin password with the management API or panel."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(out, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds,
|
||||||
|
/// a single ostp outbound, exclusion rules, mux) — mirrors the actual
|
||||||
|
/// shape from that tag, field for field.
|
||||||
|
#[test]
|
||||||
|
fn modular_single_server_preserves_every_field() {
|
||||||
|
let old = json!({
|
||||||
|
"version": "0.3.21",
|
||||||
|
"log": { "level": "debug" },
|
||||||
|
"inbounds": [
|
||||||
|
{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 },
|
||||||
|
{ "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 }
|
||||||
|
],
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "ostp", "tag": "proxy",
|
||||||
|
"server": "203.0.113.5", "port": 50000, "access_key": "sekrit123",
|
||||||
|
"transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true },
|
||||||
|
"multiplex": { "enabled": true, "sessions": 4 }
|
||||||
|
},
|
||||||
|
{ "type": "direct", "tag": "direct" },
|
||||||
|
{ "type": "block", "tag": "block" }
|
||||||
|
],
|
||||||
|
"routing": {
|
||||||
|
"rules": [
|
||||||
|
{ "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" },
|
||||||
|
{ "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" },
|
||||||
|
{ "process_name": ["steam.exe"], "outbound": "direct" }
|
||||||
|
],
|
||||||
|
"default_outbound": "proxy"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (new, report) = migrate_client_json(old);
|
||||||
|
assert!(report.changed);
|
||||||
|
assert_eq!(new["server"], "203.0.113.5");
|
||||||
|
assert_eq!(new["port"], 50000);
|
||||||
|
assert_eq!(new["access_key"], "sekrit123");
|
||||||
|
assert_eq!(new["socks5_bind"], "127.0.0.1:1088");
|
||||||
|
assert_eq!(new["mtu"], 1350);
|
||||||
|
assert_eq!(new["debug"], true);
|
||||||
|
assert_eq!(new["tun"]["enable"], true);
|
||||||
|
assert_eq!(new["transport"]["mode"], "uot");
|
||||||
|
assert_eq!(new["transport"]["tcp_fragmentation"], true);
|
||||||
|
assert_eq!(new["mux"]["enabled"], true);
|
||||||
|
assert_eq!(new["mux"]["sessions"], 4);
|
||||||
|
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
|
||||||
|
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
|
||||||
|
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
|
||||||
|
// stealth_sni never fed into any wire bytes — dropped, not carried forward.
|
||||||
|
assert!(new["transport"].get("stealth_sni").is_none());
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("stealth_sni") && n.contains("vk.com")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
|
||||||
|
/// must keep the one routing.default_outbound points at and report every
|
||||||
|
/// other one by name/address rather than picking silently.
|
||||||
|
#[test]
|
||||||
|
fn modular_multi_server_keeps_default_and_reports_the_rest() {
|
||||||
|
let old = json!({
|
||||||
|
"inbounds": [],
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" },
|
||||||
|
{ "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" },
|
||||||
|
{
|
||||||
|
"type": "urltest", "tag": "proxy",
|
||||||
|
"outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||||
|
});
|
||||||
|
|
||||||
|
let (new, report) = migrate_client_json(old);
|
||||||
|
// urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked.
|
||||||
|
assert_eq!(new["server"], "2.2.2.2");
|
||||||
|
assert_eq!(new["access_key"], "k2");
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-0.3.1 flat config carrying fields that no longer exist
|
||||||
|
/// (tun.wintun_path, tun.ipv4_address, transport.wss, transport.stealth_sni)
|
||||||
|
/// — those get dropped with a note; every field that's still meaningful
|
||||||
|
/// passes through untouched, byte for byte.
|
||||||
|
#[test]
|
||||||
|
fn flat_legacy_drops_only_dead_fields() {
|
||||||
|
let old = json!({
|
||||||
|
"server": "198.51.100.9:50000",
|
||||||
|
"access_key": "oldkey",
|
||||||
|
"mtu": 1200,
|
||||||
|
"socks5_bind": "127.0.0.1:1090",
|
||||||
|
"tun": {
|
||||||
|
"enable": true,
|
||||||
|
"wintun_path": "C:\\Program Files\\wintun\\wintun.dll",
|
||||||
|
"ipv4_address": "10.0.0.2",
|
||||||
|
"dns": "1.1.1.1",
|
||||||
|
"kill_switch": true
|
||||||
|
},
|
||||||
|
"exclude": { "domains": ["a.com"], "ips": null, "processes": null },
|
||||||
|
"mux": { "enabled": false, "sessions": 1 },
|
||||||
|
"transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true }
|
||||||
|
});
|
||||||
|
|
||||||
|
let (new, report) = migrate_client_json(old);
|
||||||
|
assert!(report.changed);
|
||||||
|
// Untouched fields survive exactly as they were.
|
||||||
|
assert_eq!(new["server"], "198.51.100.9:50000");
|
||||||
|
assert_eq!(new["access_key"], "oldkey");
|
||||||
|
assert_eq!(new["mtu"], 1200);
|
||||||
|
assert_eq!(new["tun"]["enable"], true);
|
||||||
|
assert_eq!(new["tun"]["dns"], "1.1.1.1");
|
||||||
|
assert_eq!(new["tun"]["kill_switch"], true);
|
||||||
|
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
|
||||||
|
// Dead fields are gone...
|
||||||
|
assert!(new["tun"].get("wintun_path").is_none());
|
||||||
|
assert!(new["tun"].get("ipv4_address").is_none());
|
||||||
|
assert!(new["transport"].get("wss").is_none());
|
||||||
|
assert!(new["transport"].get("stealth_sni").is_none());
|
||||||
|
// ...and their removal was reported, not silent.
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("wss")));
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("stealth_sni")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A config already in the current shape must be a true no-op: report
|
||||||
|
/// says nothing changed, and every field is untouched.
|
||||||
|
#[test]
|
||||||
|
fn current_flat_config_is_a_no_op() {
|
||||||
|
let current = json!({
|
||||||
|
"server": "example.com:50000",
|
||||||
|
"access_key": "k",
|
||||||
|
"tun": { "enable": false, "dns": null, "kill_switch": false },
|
||||||
|
"exclude": { "domains": [], "ips": [], "processes": [] },
|
||||||
|
"mux": { "enabled": false, "sessions": 1 },
|
||||||
|
"transport": { "mode": "udp", "tcp_fragmentation": false }
|
||||||
|
});
|
||||||
|
let (new, report) = migrate_client_json(current.clone());
|
||||||
|
assert!(!report.changed);
|
||||||
|
assert_eq!(new, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every migrated output must actually deserialize into the ONE
|
||||||
|
/// canonical schema (`crate::config`) — this is the same check
|
||||||
|
/// `cmd_migrate` runs at runtime before ever touching a user's file,
|
||||||
|
/// exercised here directly so a schema/migrator drift fails a fast unit
|
||||||
|
/// test instead of surfacing as "your migrated config won't load".
|
||||||
|
#[test]
|
||||||
|
fn every_migrated_output_matches_the_canonical_schema() {
|
||||||
|
let modular = json!({
|
||||||
|
"inbounds": [{ "type": "tun", "tag": "tun-in", "mtu": 1350 }],
|
||||||
|
"outbounds": [
|
||||||
|
{ "type": "ostp", "tag": "proxy", "server": "1.2.3.4", "port": 50000, "access_key": "k" },
|
||||||
|
{ "type": "direct", "tag": "direct" }
|
||||||
|
],
|
||||||
|
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||||
|
});
|
||||||
|
let (new, _) = migrate_client_json(modular);
|
||||||
|
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||||
|
.expect("modular->flat migration output must match ClientFileConfig");
|
||||||
|
|
||||||
|
let legacy_flat = json!({
|
||||||
|
"server": "1.2.3.4:50000",
|
||||||
|
"access_key": "k",
|
||||||
|
"tun": { "enable": true, "wintun_path": "x", "ipv4_address": "y" }
|
||||||
|
});
|
||||||
|
let (new, _) = migrate_client_json(legacy_flat);
|
||||||
|
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||||
|
.expect("legacy-flat migration output must match ClientFileConfig");
|
||||||
|
|
||||||
|
let server = json!({ "listen": "0.0.0.0:50000", "access_keys": ["k"] });
|
||||||
|
let (new, _) = migrate_server_json(server);
|
||||||
|
serde_json::from_value::<crate::config::ServerConfig>(new)
|
||||||
|
.expect("server migration output must match ServerConfig");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_config_backfills_api_defaults_and_drops_legacy_token() {
|
||||||
|
let old = json!({
|
||||||
|
"listen": "0.0.0.0:50000",
|
||||||
|
"access_keys": ["k1"],
|
||||||
|
"api": { "token": "old-plain-token" }
|
||||||
|
});
|
||||||
|
let (new, report) = migrate_server_json(old);
|
||||||
|
assert!(report.changed);
|
||||||
|
assert_eq!(new["api"]["enabled"], false);
|
||||||
|
assert_eq!(new["api"]["bind"], "0.0.0.0:9090");
|
||||||
|
assert!(new["api"].get("token").is_none());
|
||||||
|
assert!(report.notes.iter().any(|n| n.contains("api.token")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
|
||||||
|
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
|
||||||
|
assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server));
|
||||||
|
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
|
||||||
|
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,9 @@ use std::fs::OpenOptions;
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
fn log_to_core_file(msg: &str) {
|
fn log_to_core_file(msg: &str) {
|
||||||
let path = std::env::current_exe()
|
// Writes into the single shared ostp.log (same file as the tracing appender),
|
||||||
.ok()
|
// not a separate ostp-core.log — see logging::LOG_FILE_NAME.
|
||||||
.and_then(|p| p.parent().map(|d| d.join("ostp-core.log")))
|
let path = crate::logging::log_file_path();
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("ostp-core.log"));
|
|
||||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||||
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
|
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +182,64 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
||||||
run_client_core(config, metrics, shutdown_rx, None).await
|
run_client_core(config, metrics, shutdown_rx, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the client with auto-reconnect: any subsystem ending — a network
|
||||||
|
/// change stranding the TUN adapter/UDP socket on a dead interface, the OSTP
|
||||||
|
/// protocol connection dropping in a way the inner Bridge-level retry (see
|
||||||
|
/// `UiEvent::TunnelStopped` below) couldn't recover from, or a proxy/TUN task
|
||||||
|
/// crashing outright — triggers a full clean restart (fresh DNS resolution,
|
||||||
|
/// fresh Bridge, fresh TUN/proxy) with exponential backoff, instead of the
|
||||||
|
/// client just dying. Only an explicit shutdown request stops this loop.
|
||||||
pub async fn run_client_core(
|
pub async fn run_client_core(
|
||||||
|
config: crate::config::ClientConfig,
|
||||||
|
metrics: Arc<BridgeMetrics>,
|
||||||
|
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||||
|
config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use portable_atomic::Ordering;
|
||||||
|
|
||||||
|
const BACKOFF_SCHEDULE_SECS: [u64; 6] = [1, 2, 5, 10, 20, 30];
|
||||||
|
// A run that stayed up at least this long counts as "was actually
|
||||||
|
// connected", so a later drop restarts the backoff from the top instead
|
||||||
|
// of inheriting a long delay from a previous flaky stretch.
|
||||||
|
const STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
let mut backoff_idx = 0usize;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if *shutdown_rx_ext.borrow() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt_start = std::time::Instant::now();
|
||||||
|
let result = run_client_once(config.clone(), metrics.clone(), shutdown_rx_ext.clone(), config_rx.clone()).await;
|
||||||
|
|
||||||
|
if *shutdown_rx_ext.borrow() {
|
||||||
|
// Shutdown was requested during (or right after) this attempt — honor it, don't retry.
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
tracing::warn!("client run ended unexpectedly, will auto-reconnect: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt_start.elapsed() >= STABLE_UPTIME {
|
||||||
|
backoff_idx = 0;
|
||||||
|
}
|
||||||
|
let delay = BACKOFF_SCHEDULE_SECS[backoff_idx.min(BACKOFF_SCHEDULE_SECS.len() - 1)];
|
||||||
|
backoff_idx += 1;
|
||||||
|
|
||||||
|
// Reflect the retry wait as "connecting" rather than "disconnected".
|
||||||
|
metrics.connection_state.store(1, Ordering::Relaxed);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(delay)) => {}
|
||||||
|
_ = shutdown_rx_ext.changed() => {
|
||||||
|
if *shutdown_rx_ext.borrow() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_client_once(
|
||||||
mut config: crate::config::ClientConfig,
|
mut config: crate::config::ClientConfig,
|
||||||
metrics: Arc<BridgeMetrics>,
|
metrics: Arc<BridgeMetrics>,
|
||||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||||
|
|
@ -250,10 +306,6 @@ pub async fn run_client_core(
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
if config.mode == "tun" && !config.exclusions.processes.is_empty() {
|
|
||||||
println!("[ostp] Process exclusions are not supported in TUN mode");
|
|
||||||
}
|
|
||||||
|
|
||||||
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256);
|
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(256);
|
||||||
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
let (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ fn refresh_wininet() {
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
pub fn enable_system_proxy(proxy_addr: &str) {
|
pub fn enable_system_proxy(proxy_addr: &str) {
|
||||||
let parts: Vec<&str> = proxy_addr.split(':').collect();
|
let parts: Vec<&str> = proxy_addr.split(':').collect();
|
||||||
let host = parts.get(0).unwrap_or(&"127.0.0.1");
|
let host = parts.first().unwrap_or(&"127.0.0.1");
|
||||||
let port = parts.get(1).unwrap_or(&"1088");
|
let port = parts.get(1).unwrap_or(&"1088");
|
||||||
|
|
||||||
let is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
let is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||||
|
|
@ -235,7 +235,7 @@ pub fn enable_system_proxy(proxy_addr: &str) {
|
||||||
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
||||||
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
||||||
println!("configure your system proxy. To route traffic from this terminal, run:");
|
println!("configure your system proxy. To route traffic from this terminal, run:");
|
||||||
println!("\n eval $(ostp --proxy-env)\n");
|
println!("\n eval $(ostp proxy-env)\n");
|
||||||
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
||||||
println!("===================================================================\n");
|
println!("===================================================================\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,394 +0,0 @@
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
|
||||||
use anyhow::{Result, Context};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use hmac::Hmac;
|
|
||||||
use sha2::Sha256;
|
|
||||||
use base64::Engine;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::task::{Context as TaskContext, Poll};
|
|
||||||
use x25519_dalek::PublicKey;
|
|
||||||
use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce};
|
|
||||||
|
|
||||||
use ostp_core::crypto::reality::{build_client_hello, derive_keys, generate_session_id, generate_x25519_keypair, REALITY_SERVER_HANDSHAKE_RECORDS};
|
|
||||||
use ostp_core::framing::wss::{encode_wss_frame, decode_wss_frame, WssFrameResult};
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
pub async fn connect_xhttp(
|
|
||||||
target_ip: IpAddr,
|
|
||||||
port: u16,
|
|
||||||
sni: &str,
|
|
||||||
access_key: &[u8],
|
|
||||||
reality_enabled: bool,
|
|
||||||
wss: bool,
|
|
||||||
reality_pbk: &str,
|
|
||||||
reality_sid: &str,
|
|
||||||
) -> Result<(mpsc::Sender<Bytes>, Arc<tokio::sync::Mutex<mpsc::Receiver<Bytes>>>)> {
|
|
||||||
let addr = std::net::SocketAddr::new(target_ip, port);
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let mut tcp_stream = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
tokio::net::TcpStream::connect(addr),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("TCP connect timeout to {}", addr))?
|
|
||||||
.with_context(|| format!("failed to connect to {}", addr))?;
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
let mut tcp_stream = {
|
|
||||||
let domain = if target_ip.is_ipv6() { socket2::Domain::IPV6 } else { socket2::Domain::IPV4 };
|
|
||||||
let sock = socket2::Socket::new(domain, socket2::Type::STREAM, Some(socket2::Protocol::TCP))?;
|
|
||||||
|
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
crate::bridge::protect_socket(sock.as_raw_fd());
|
|
||||||
|
|
||||||
sock.set_nonblocking(true)?;
|
|
||||||
let tcp_socket = tokio::net::TcpSocket::from_std_stream(sock.into());
|
|
||||||
tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
tcp_socket.connect(addr),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("TCP connect timeout to {}", addr))?
|
|
||||||
.with_context(|| format!("failed to connect to {}", addr))?
|
|
||||||
};
|
|
||||||
|
|
||||||
tcp_stream.set_nodelay(true)?;
|
|
||||||
|
|
||||||
if reality_enabled {
|
|
||||||
let pbk_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(reality_pbk)
|
|
||||||
.context("invalid reality_pbk base64")?;
|
|
||||||
if pbk_bytes.len() != 32 {
|
|
||||||
anyhow::bail!("reality_pbk must be 32 bytes");
|
|
||||||
}
|
|
||||||
let pbk = PublicKey::from(<[u8; 32]>::try_from(pbk_bytes.as_slice()).unwrap());
|
|
||||||
|
|
||||||
let sid_bytes_vec = hex::decode(reality_sid).context("invalid reality_sid hex")?;
|
|
||||||
if sid_bytes_vec.len() != 8 {
|
|
||||||
anyhow::bail!("reality_sid must be 8 bytes");
|
|
||||||
}
|
|
||||||
let sid: [u8; 8] = sid_bytes_vec.try_into().unwrap();
|
|
||||||
|
|
||||||
let (c_priv, c_pub) = generate_x25519_keypair();
|
|
||||||
let shared_secret = c_priv.diffie_hellman(&pbk);
|
|
||||||
let (auth_key, data_key) = derive_keys(shared_secret.as_bytes());
|
|
||||||
|
|
||||||
let session_id = generate_session_id(&auth_key, &sid);
|
|
||||||
let client_hello = build_client_hello(if sni.is_empty() { "www.microsoft.com" } else { sni }, &session_id, &c_pub);
|
|
||||||
|
|
||||||
tcp_stream.write_all(&client_hello).await?;
|
|
||||||
|
|
||||||
// Drain all server handshake records (ServerHello, CCS, fake encrypted records).
|
|
||||||
// The server sends exactly REALITY_SERVER_HANDSHAKE_RECORDS records before data starts.
|
|
||||||
// Reading them explicitly prevents RealityStream from seeing non-AppData bytes.
|
|
||||||
for i in 0..REALITY_SERVER_HANDSHAKE_RECORDS {
|
|
||||||
let mut head = [0u8; 5];
|
|
||||||
tcp_stream.read_exact(&mut head).await
|
|
||||||
.with_context(|| format!("reality handshake: failed reading record {} header", i))?;
|
|
||||||
if i == 0 && head[0] != 0x16 {
|
|
||||||
anyhow::bail!("expected ServerHello (0x16), got 0x{:02x}", head[0]);
|
|
||||||
}
|
|
||||||
let record_len = u16::from_be_bytes([head[3], head[4]]) as usize;
|
|
||||||
if record_len > 16384 {
|
|
||||||
anyhow::bail!("reality handshake: record {} too large: {} bytes", i, record_len);
|
|
||||||
}
|
|
||||||
let mut _payload = vec![0u8; record_len];
|
|
||||||
tcp_stream.read_exact(&mut _payload).await
|
|
||||||
.with_context(|| format!("reality handshake: failed reading record {} payload", i))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reality_stream = RealityStream::new(tcp_stream, data_key);
|
|
||||||
xhttp_handshake_and_loop(reality_stream, target_ip, sni, access_key, wss).await
|
|
||||||
} else {
|
|
||||||
xhttp_handshake_and_loop(tcp_stream, target_ip, sni, access_key, wss).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// RealityStream: Wraps a TCP stream in fake TLS Application Data Records
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
struct RealityStream {
|
|
||||||
inner: TcpStream,
|
|
||||||
data_key: ChaCha20Poly1305,
|
|
||||||
rx_nonce: u64,
|
|
||||||
tx_nonce: u64,
|
|
||||||
rx_buf: BytesMut,
|
|
||||||
plaintext_buf: BytesMut,
|
|
||||||
tx_buf: BytesMut,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RealityStream {
|
|
||||||
fn new(inner: TcpStream, data_key: ChaCha20Poly1305) -> Self {
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
data_key,
|
|
||||||
rx_nonce: 0,
|
|
||||||
tx_nonce: 0,
|
|
||||||
rx_buf: BytesMut::with_capacity(16384),
|
|
||||||
plaintext_buf: BytesMut::new(),
|
|
||||||
tx_buf: BytesMut::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_nonce(seq: u64) -> [u8; 12] {
|
|
||||||
let mut nonce = [0u8; 12];
|
|
||||||
nonce[4..12].copy_from_slice(&seq.to_le_bytes());
|
|
||||||
nonce
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl tokio::io::AsyncRead for RealityStream {
|
|
||||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
loop {
|
|
||||||
if !self.plaintext_buf.is_empty() {
|
|
||||||
let out_len = std::cmp::min(buf.remaining(), self.plaintext_buf.len());
|
|
||||||
buf.put_slice(&self.plaintext_buf[..out_len]);
|
|
||||||
self.plaintext_buf.advance(out_len);
|
|
||||||
return Poll::Ready(Ok(()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.rx_buf.len() >= 5 {
|
|
||||||
let len = u16::from_be_bytes([self.rx_buf[3], self.rx_buf[4]]) as usize;
|
|
||||||
if self.rx_buf.len() >= 5 + len {
|
|
||||||
if self.rx_buf[0] != 0x17 {
|
|
||||||
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "expected application data record")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ciphertext = &self.rx_buf[5..5+len];
|
|
||||||
let nonce_bytes = Self::make_nonce(self.rx_nonce);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
match self.data_key.decrypt(nonce, ciphertext) {
|
|
||||||
Ok(plaintext) => {
|
|
||||||
self.rx_nonce += 1;
|
|
||||||
self.plaintext_buf.put_slice(&plaintext);
|
|
||||||
self.rx_buf.advance(5 + len);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
return Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reality decrypt failed")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut read_buf = [0u8; 8192];
|
|
||||||
let mut tokio_buf = tokio::io::ReadBuf::new(&mut read_buf);
|
|
||||||
match Pin::new(&mut self.inner).poll_read(cx, &mut tokio_buf) {
|
|
||||||
Poll::Ready(Ok(())) => {
|
|
||||||
if tokio_buf.filled().is_empty() {
|
|
||||||
return Poll::Ready(Ok(()));
|
|
||||||
}
|
|
||||||
self.rx_buf.put_slice(tokio_buf.filled());
|
|
||||||
}
|
|
||||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => return Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl tokio::io::AsyncWrite for RealityStream {
|
|
||||||
fn poll_write(self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
|
|
||||||
let this = self.get_mut();
|
|
||||||
while !this.tx_buf.is_empty() {
|
|
||||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
|
||||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
|
||||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => return Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let nonce_bytes = Self::make_nonce(this.tx_nonce);
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
match this.data_key.encrypt(nonce, buf) {
|
|
||||||
Ok(ciphertext) => {
|
|
||||||
this.tx_nonce += 1;
|
|
||||||
this.tx_buf.reserve(5 + ciphertext.len());
|
|
||||||
this.tx_buf.put_u8(0x17);
|
|
||||||
this.tx_buf.put_u16(0x0303);
|
|
||||||
this.tx_buf.put_u16(ciphertext.len() as u16);
|
|
||||||
this.tx_buf.put_slice(&ciphertext);
|
|
||||||
|
|
||||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
|
||||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
|
||||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => {}
|
|
||||||
}
|
|
||||||
Poll::Ready(Ok(buf.len()))
|
|
||||||
}
|
|
||||||
Err(_) => Poll::Ready(Err(std::io::Error::new(std::io::ErrorKind::Other, "reality encrypt failed"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
let this = self.get_mut();
|
|
||||||
while !this.tx_buf.is_empty() {
|
|
||||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
|
||||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
|
||||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => return Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Pin::new(&mut this.inner).poll_flush(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
let this = self.get_mut();
|
|
||||||
while !this.tx_buf.is_empty() {
|
|
||||||
match Pin::new(&mut this.inner).poll_write(cx, &this.tx_buf) {
|
|
||||||
Poll::Ready(Ok(n)) => this.tx_buf.advance(n),
|
|
||||||
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
|
|
||||||
Poll::Pending => return Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Pin::new(&mut this.inner).poll_shutdown(cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn xhttp_handshake_and_loop<S>(
|
|
||||||
mut stream: S,
|
|
||||||
target_ip: IpAddr,
|
|
||||||
sni: &str,
|
|
||||||
access_key: &[u8],
|
|
||||||
wss: bool,
|
|
||||||
) -> Result<(mpsc::Sender<Bytes>, Arc<tokio::sync::Mutex<mpsc::Receiver<Bytes>>>)>
|
|
||||||
where
|
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
|
||||||
{
|
|
||||||
// 1. Generate auth token: [8-byte timestamp BE] ++ [HMAC-SHA256]
|
|
||||||
let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
|
|
||||||
let ts_bytes = timestamp.to_be_bytes();
|
|
||||||
use hmac::Mac;
|
|
||||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(access_key).unwrap_or_else(|_| <HmacSha256 as Mac>::new_from_slice(b"").unwrap());
|
|
||||||
mac.update(&ts_bytes);
|
|
||||||
let mac_bytes = mac.finalize().into_bytes();
|
|
||||||
|
|
||||||
let mut sig_bytes = Vec::with_capacity(8 + mac_bytes.len());
|
|
||||||
sig_bytes.extend_from_slice(&ts_bytes);
|
|
||||||
sig_bytes.extend_from_slice(&mac_bytes);
|
|
||||||
|
|
||||||
let auth_token = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&sig_bytes);
|
|
||||||
|
|
||||||
let http_host = if sni.is_empty() { target_ip.to_string() } else { sni.to_string() };
|
|
||||||
|
|
||||||
let req = if wss {
|
|
||||||
format!(
|
|
||||||
"GET /wss HTTP/1.1\r\n\
|
|
||||||
Host: {}\r\n\
|
|
||||||
Upgrade: websocket\r\n\
|
|
||||||
Connection: upgrade\r\n\
|
|
||||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
|
|
||||||
Sec-WebSocket-Version: 13\r\n\
|
|
||||||
Authorization: Bearer {}\r\n\
|
|
||||||
\r\n",
|
|
||||||
http_host, auth_token
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"GET /stream HTTP/1.1\r\n\
|
|
||||||
Host: {}\r\n\
|
|
||||||
Authorization: Bearer {}\r\n\
|
|
||||||
\r\n",
|
|
||||||
http_host, auth_token
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.write_all(req.as_bytes()).await?;
|
|
||||||
|
|
||||||
// Wait for HTTP 200 OK or 101 Switching Protocols
|
|
||||||
let mut header_buf = Vec::new();
|
|
||||||
let mut temp = [0u8; 1];
|
|
||||||
loop {
|
|
||||||
let n = stream.read(&mut temp).await?;
|
|
||||||
if n == 0 {
|
|
||||||
anyhow::bail!("connection closed by server during handshake");
|
|
||||||
}
|
|
||||||
header_buf.push(temp[0]);
|
|
||||||
if header_buf.ends_with(b"\r\n\r\n") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if header_buf.len() > 8192 {
|
|
||||||
anyhow::bail!("server response too long");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp_str = String::from_utf8_lossy(&header_buf);
|
|
||||||
if wss {
|
|
||||||
if !resp_str.starts_with("HTTP/1.1 101 ") {
|
|
||||||
anyhow::bail!("failed to switch protocols: {}", resp_str.lines().next().unwrap_or(""));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !resp_str.starts_with("HTTP/1.1 200 OK") {
|
|
||||||
anyhow::bail!("server rejected stream: {}", resp_str.lines().next().unwrap_or(""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (tx, mut rx) = mpsc::channel::<Bytes>(16384);
|
|
||||||
let (mut read_half, mut write_half) = tokio::io::split(stream);
|
|
||||||
|
|
||||||
let writer_task = tokio::spawn(async move {
|
|
||||||
while let Some(packet) = rx.recv().await {
|
|
||||||
if wss {
|
|
||||||
let header = encode_wss_frame(&packet, true);
|
|
||||||
if write_half.write_all(&header).await.is_err() { break; }
|
|
||||||
} else {
|
|
||||||
let mut out = BytesMut::with_capacity(2 + packet.len());
|
|
||||||
out.put_u16(packet.len() as u16);
|
|
||||||
out.put_slice(&packet);
|
|
||||||
if write_half.write_all(&out).await.is_err() { break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let (in_tx, in_rx) = mpsc::channel::<Bytes>(16384);
|
|
||||||
let in_rx_arc = Arc::new(tokio::sync::Mutex::new(in_rx));
|
|
||||||
|
|
||||||
let in_tx_clone = in_tx.clone();
|
|
||||||
let reader_task = tokio::spawn(async move {
|
|
||||||
if wss {
|
|
||||||
let mut read_buf = BytesMut::with_capacity(65536);
|
|
||||||
let mut tmp = [0u8; 8192];
|
|
||||||
loop {
|
|
||||||
match read_half.read(&mut tmp).await {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => {
|
|
||||||
read_buf.put_slice(&tmp[..n]);
|
|
||||||
loop {
|
|
||||||
match decode_wss_frame(&mut read_buf) {
|
|
||||||
WssFrameResult::Frame { payload, total_len } => {
|
|
||||||
if in_tx_clone.send(Bytes::from(payload)).await.is_err() { return; }
|
|
||||||
read_buf.advance(total_len);
|
|
||||||
}
|
|
||||||
WssFrameResult::Incomplete => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let mut len_buf = [0u8; 2];
|
|
||||||
loop {
|
|
||||||
if read_half.read_exact(&mut len_buf).await.is_err() { break; }
|
|
||||||
let len = u16::from_be_bytes(len_buf) as usize;
|
|
||||||
if len > 65535 { break; }
|
|
||||||
let mut data = vec![0u8; len];
|
|
||||||
if read_half.read_exact(&mut data).await.is_err() { break; }
|
|
||||||
if in_tx_clone.send(Bytes::from(data)).await.is_err() { break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = tokio::join!(writer_task, reader_task);
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok((tx, in_rx_arc))
|
|
||||||
}
|
|
||||||
|
|
@ -126,7 +126,6 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
let mut target_inode = None;
|
|
||||||
let hex_port = format!("{:04X}", port);
|
let hex_port = format!("{:04X}", port);
|
||||||
|
|
||||||
let check_net_file = |path: &str| -> Option<u64> {
|
let check_net_file = |path: &str| -> Option<u64> {
|
||||||
|
|
@ -146,7 +145,7 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
target_inode = check_net_file("/proc/net/tcp")
|
let target_inode = check_net_file("/proc/net/tcp")
|
||||||
.or_else(|| check_net_file("/proc/net/tcp6"))
|
.or_else(|| check_net_file("/proc/net/tcp6"))
|
||||||
.or_else(|| check_net_file("/proc/net/udp"))
|
.or_else(|| check_net_file("/proc/net/udp"))
|
||||||
.or_else(|| check_net_file("/proc/net/udp6"));
|
.or_else(|| check_net_file("/proc/net/udp6"));
|
||||||
|
|
|
||||||
|
|
@ -208,10 +208,7 @@ pub async fn run_local_socks5_proxy(
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
||||||
|
|
||||||
if true {
|
|
||||||
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
||||||
tracing::info!("Windows system proxy: set HTTP proxy to {}. tun2socks: SOCKS5 on same address.", cfg.bind_addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
let physical_if_index = tokio::task::spawn_blocking(get_windows_physical_if_index).await.unwrap_or(None);
|
let physical_if_index = tokio::task::spawn_blocking(get_windows_physical_if_index).await.unwrap_or(None);
|
||||||
let physical_if_name = tokio::task::spawn_blocking(get_linux_physical_if_name).await.unwrap_or(None);
|
let physical_if_name = tokio::task::spawn_blocking(get_linux_physical_if_name).await.unwrap_or(None);
|
||||||
|
|
@ -364,6 +361,10 @@ async fn handle_udp_associate(
|
||||||
|
|
||||||
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
||||||
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
||||||
|
// Held only to keep the direct-UDP readers' cancellation senders alive;
|
||||||
|
// dropping this (on every return path from this function) is what tells
|
||||||
|
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
|
||||||
|
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
|
||||||
|
|
||||||
let mut tcp_buf = [0u8; 1];
|
let mut tcp_buf = [0u8; 1];
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -435,7 +436,9 @@ async fn handle_udp_associate(
|
||||||
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
let s_arc = Arc::new(s);
|
let s_arc = Arc::new(s);
|
||||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||||
|
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||||
|
direct_udp_cancel_txs.push(cancel_tx);
|
||||||
direct_udp_v6 = Some(s_arc);
|
direct_udp_v6 = Some(s_arc);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -449,7 +452,9 @@ async fn handle_udp_associate(
|
||||||
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
let s_arc = Arc::new(s);
|
let s_arc = Arc::new(s);
|
||||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||||
|
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||||
|
direct_udp_cancel_txs.push(cancel_tx);
|
||||||
direct_udp_v4 = Some(s_arc);
|
direct_udp_v4 = Some(s_arc);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -523,11 +528,24 @@ fn spawn_direct_udp_reader(
|
||||||
sock_tx: Arc<UdpSocket>,
|
sock_tx: Arc<UdpSocket>,
|
||||||
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
||||||
_debug: bool,
|
_debug: bool,
|
||||||
|
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
match direct_socket.recv_from(&mut buf).await {
|
let recv_result = tokio::select! {
|
||||||
|
// Fires as soon as the sender half (held by handle_udp_associate
|
||||||
|
// for exactly this reason) is dropped - which happens the
|
||||||
|
// instant that function returns, on every exit path, with no
|
||||||
|
// explicit signaling needed. Without this, a UDP-associate
|
||||||
|
// session that ever bypassed traffic direct (excluded IP/
|
||||||
|
// domain) leaked this socket + task for the rest of the
|
||||||
|
// process's life once the session ended: nothing else ever
|
||||||
|
// stopped this loop.
|
||||||
|
_ = &mut cancel_rx => break,
|
||||||
|
res = direct_socket.recv_from(&mut buf) => res,
|
||||||
|
};
|
||||||
|
match recv_result {
|
||||||
Ok((len, target_addr)) => {
|
Ok((len, target_addr)) => {
|
||||||
let client_addr = {
|
let client_addr = {
|
||||||
let guard = client_udp_addr.lock().unwrap();
|
let guard = client_udp_addr.lock().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ pub async fn run_udp_nat(
|
||||||
async fn start_udp_bypass_session(
|
async fn start_udp_bypass_session(
|
||||||
client_src: SocketAddr,
|
client_src: SocketAddr,
|
||||||
phys_if_index: Option<u32>,
|
phys_if_index: Option<u32>,
|
||||||
phys_if_name: Option<String>,
|
_phys_if_name: Option<String>,
|
||||||
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
|
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
|
||||||
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
|
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
|
@ -134,31 +134,38 @@ async fn start_udp_bypass_session(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if let Some(ref name) = phys_if_name {
|
if let Some(ref name) = _phys_if_name {
|
||||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket = Arc::new(socket);
|
// A single select! loop over both directions, rather than spawning a
|
||||||
let socket_rx = socket.clone();
|
// separate task for the read side, so the whole session - physical
|
||||||
|
// socket included - is torn down the moment this function returns
|
||||||
// Spawn a task to read from physical socket and send back to smoltcp
|
// (e.g. when session_rx closes). The previous spawned-task version left
|
||||||
let tx_clone = smoltcp_tx.clone();
|
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
||||||
tokio::spawn(async move {
|
// alive) running forever after this function returned: nothing ever
|
||||||
|
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
|
||||||
|
// mode) leaked one socket + one task for the lifetime of the process.
|
||||||
use futures::SinkExt;
|
use futures::SinkExt;
|
||||||
let mut buf = [0u8; 65536];
|
let mut buf = [0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
match socket_rx.recv_from(&mut buf).await {
|
tokio::select! {
|
||||||
|
outbound = session_rx.recv() => {
|
||||||
|
match outbound {
|
||||||
|
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inbound = socket.recv_from(&mut buf) => {
|
||||||
|
match inbound {
|
||||||
Ok((n, peer)) => {
|
Ok((n, peer)) => {
|
||||||
let mut lock = tx_clone.lock().await;
|
let mut lock = smoltcp_tx.lock().await;
|
||||||
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
while let Some((payload, dst)) = session_rx.recv().await {
|
|
||||||
socket.send_to(&payload, dst).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
//! bandwidth and minimum RTT to determine the optimal sending rate.
|
//! bandwidth and minimum RTT to determine the optimal sending rate.
|
||||||
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
|
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
|
||||||
//! congestion window that responds to network conditions.
|
//! congestion window that responds to network conditions.
|
||||||
|
//!
|
||||||
|
//! RTO calculation follows RFC 6298:
|
||||||
|
//! SRTT = (1 - α) * SRTT + α * RTT (α = 1/8)
|
||||||
|
//! RTTVAR = (1 - β) * RTTVAR + β * |SRTT - RTT| (β = 1/4)
|
||||||
|
//! RTO = SRTT + 4 * RTTVAR
|
||||||
|
//! clamped to [RTO_MIN, RTO_MAX]
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|
@ -15,8 +21,14 @@ pub struct CongestionController {
|
||||||
ssthresh: u64,
|
ssthresh: u64,
|
||||||
/// Current phase
|
/// Current phase
|
||||||
phase: Phase,
|
phase: Phase,
|
||||||
/// Minimum RTT observed
|
/// Minimum RTT observed (for BBR-style bandwidth estimation)
|
||||||
min_rtt: Duration,
|
min_rtt: Duration,
|
||||||
|
/// Smoothed RTT (RFC 6298 SRTT)
|
||||||
|
srtt: Duration,
|
||||||
|
/// RTT variance (RFC 6298 RTTVAR)
|
||||||
|
rttvar: Duration,
|
||||||
|
/// Whether we have received a first RTT sample
|
||||||
|
rtt_initialized: bool,
|
||||||
/// Bytes currently in flight (unacknowledged)
|
/// Bytes currently in flight (unacknowledged)
|
||||||
bytes_in_flight: u64,
|
bytes_in_flight: u64,
|
||||||
/// Total bytes acknowledged (for bandwidth estimation)
|
/// Total bytes acknowledged (for bandwidth estimation)
|
||||||
|
|
@ -27,46 +39,141 @@ pub struct CongestionController {
|
||||||
loss_count: u32,
|
loss_count: u32,
|
||||||
/// Pacing rate: bytes per second
|
/// Pacing rate: bytes per second
|
||||||
pacing_rate: u64,
|
pacing_rate: u64,
|
||||||
|
/// Token-bucket allowance for pacing, in bytes.
|
||||||
|
pacing_tokens: f64,
|
||||||
|
pacing_last_refill: Instant,
|
||||||
/// MTU estimate (used for cwnd → packet count conversion)
|
/// MTU estimate (used for cwnd → packet count conversion)
|
||||||
mtu: u64,
|
mtu: u64,
|
||||||
/// Min RTT expiry: re-probe after 10 seconds
|
/// Min RTT expiry: re-probe after 10 seconds
|
||||||
min_rtt_stamp: Instant,
|
min_rtt_stamp: Instant,
|
||||||
|
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
|
||||||
|
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
|
||||||
|
slow_start_losses: u32,
|
||||||
|
/// Start of the current loss-tolerance window.
|
||||||
|
slow_start_loss_window_start: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Phase {
|
enum Phase {
|
||||||
/// Exponential growth until loss or ssthresh
|
/// Exponential growth until loss or ssthresh
|
||||||
SlowStart,
|
SlowStart,
|
||||||
/// Probe bandwidth: cycle through pacing gains
|
/// Probe bandwidth: additive increase
|
||||||
ProbeBandwidth,
|
ProbeBandwidth,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial congestion window: 10 packets × MTU
|
/// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
|
||||||
const INITIAL_CWND_PACKETS: u64 = 10;
|
const INITIAL_CWND_PACKETS: u64 = 32;
|
||||||
/// Minimum cwnd: 2 packets
|
/// Minimum cwnd: 2 packets
|
||||||
const MIN_CWND_PACKETS: u64 = 2;
|
const MIN_CWND_PACKETS: u64 = 2;
|
||||||
/// Min RTT expiry window (after which we re-probe)
|
/// Min RTT expiry window (after which we re-probe)
|
||||||
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||||
|
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
||||||
|
/// Absolute ceiling on the congestion window, in packets. At a ~1200-byte MTU
|
||||||
|
/// this is roughly 1.2 MB in flight — already far above the bandwidth-delay
|
||||||
|
/// product of any link this protocol realistically runs over, so anything
|
||||||
|
/// beyond it is standing queue, not throughput. The client previously allowed
|
||||||
|
/// up to 16384 packets (~20 MB), which on a mobile uplink is minutes of buffer.
|
||||||
|
const MAX_CWND_PACKETS: u64 = 1024;
|
||||||
|
/// SRTT/min_rtt ratio at which slow start stops. Doubling is what fills a deep
|
||||||
|
/// buffer fastest, so growth must end when the queue starts building rather
|
||||||
|
/// than waiting for a loss that a deep buffer may never produce.
|
||||||
|
const RTT_INFLATION_EXIT_SLOW_START: f64 = 2.0;
|
||||||
|
/// SRTT/min_rtt ratio treated as a standing queue that must be actively drained.
|
||||||
|
const RTT_INFLATION_BACKOFF: f64 = 4.0;
|
||||||
|
/// How much pacing allowance may accumulate, expressed as time-at-rate.
|
||||||
|
const PACING_BURST: Duration = Duration::from_millis(10);
|
||||||
|
const RTO_MIN: Duration = Duration::from_millis(50);
|
||||||
|
/// Maximum RTO
|
||||||
|
const RTO_MAX: Duration = Duration::from_secs(16);
|
||||||
|
/// Initial RTT estimate — 30 ms is reasonable for a well-connected VPN server.
|
||||||
|
/// Will be replaced by first real measurement within milliseconds.
|
||||||
|
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
||||||
|
|
||||||
|
/// Isolated packet loss during slow start (a single dropped frame from
|
||||||
|
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
|
||||||
|
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
|
||||||
|
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
|
||||||
|
/// on any link with a non-zero background loss rate permanently downgrades
|
||||||
|
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
|
||||||
|
/// growth within the first few RTTs - turning what should be a sub-second
|
||||||
|
/// ramp-up into tens of seconds to minutes before throughput opens up
|
||||||
|
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
|
||||||
|
/// claws back up). Only treat loss as a real congestion signal - and pay
|
||||||
|
/// the full slow-start-exit + halving cost - once this many losses land
|
||||||
|
/// within SLOW_START_LOSS_WINDOW.
|
||||||
|
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
|
||||||
|
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
|
||||||
|
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
|
||||||
|
/// well-connected link, generous on a slow one.
|
||||||
|
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
impl CongestionController {
|
impl CongestionController {
|
||||||
pub fn new(mtu: u64) -> Self {
|
pub fn new(mtu: u64) -> Self {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
|
let initial_cwnd = INITIAL_CWND_PACKETS * mtu;
|
||||||
|
// Initial pacing: deliver cwnd in ~2 RTTs to fill the pipe quickly
|
||||||
|
let initial_pacing = initial_cwnd * 1_000_000 / INITIAL_RTT.as_micros().max(1) as u64;
|
||||||
Self {
|
Self {
|
||||||
cwnd: initial_cwnd,
|
cwnd: initial_cwnd,
|
||||||
ssthresh: u64::MAX,
|
ssthresh: u64::MAX,
|
||||||
phase: Phase::SlowStart,
|
phase: Phase::SlowStart,
|
||||||
min_rtt: Duration::from_millis(100), // Conservative initial estimate
|
min_rtt: INITIAL_RTT,
|
||||||
|
srtt: INITIAL_RTT,
|
||||||
|
rttvar: INITIAL_RTT / 2,
|
||||||
|
rtt_initialized: false,
|
||||||
bytes_in_flight: 0,
|
bytes_in_flight: 0,
|
||||||
total_acked: 0,
|
total_acked: 0,
|
||||||
last_ack_time: now,
|
last_ack_time: now,
|
||||||
loss_count: 0,
|
loss_count: 0,
|
||||||
pacing_rate: initial_cwnd * 10, // initial: ~10 windows/sec
|
pacing_rate: initial_pacing,
|
||||||
mtu,
|
mtu,
|
||||||
min_rtt_stamp: now,
|
min_rtt_stamp: now,
|
||||||
|
slow_start_losses: 0,
|
||||||
|
slow_start_loss_window_start: now,
|
||||||
|
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
|
||||||
|
pacing_last_refill: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes of pacing allowance available right now, without consuming any.
|
||||||
|
///
|
||||||
|
/// Read-only so the send path can use it as an admission check before it
|
||||||
|
/// commits to building a datagram.
|
||||||
|
pub fn pacing_available(&self) -> f64 {
|
||||||
|
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
|
||||||
|
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether at least one full-size packet may be released right now.
|
||||||
|
pub fn can_pace_packet(&self) -> bool {
|
||||||
|
self.pacing_available() >= self.mtu as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ceiling on accumulated allowance.
|
||||||
|
///
|
||||||
|
/// Pacing intervals here are fractions of a millisecond, so releasing
|
||||||
|
/// strictly one packet at a time would need a sub-millisecond timer per
|
||||||
|
/// packet. Instead we allow a short burst — the same trade every real
|
||||||
|
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
|
||||||
|
/// can still saturate the configured rate, with a small floor so a
|
||||||
|
/// cold/low estimate can never wedge sending entirely.
|
||||||
|
fn pacing_burst(&self) -> f64 {
|
||||||
|
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
|
||||||
|
by_rate.max((self.mtu * 4) as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refill from elapsed time and deduct `bytes`. Called on the real send
|
||||||
|
/// path; allowance is permitted to go negative so an oversized packet still
|
||||||
|
/// pays for itself rather than being released for free.
|
||||||
|
fn consume_pacing(&mut self, bytes: u64) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
|
||||||
|
self.pacing_last_refill = now;
|
||||||
|
self.pacing_tokens =
|
||||||
|
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||||
|
- bytes as f64;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the current congestion window in bytes.
|
/// Returns the current congestion window in bytes.
|
||||||
pub fn cwnd(&self) -> u64 {
|
pub fn cwnd(&self) -> u64 {
|
||||||
self.cwnd
|
self.cwnd
|
||||||
|
|
@ -82,9 +189,20 @@ impl CongestionController {
|
||||||
self.pacing_rate
|
self.pacing_rate
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the smoothed RTT estimate.
|
/// Returns the smoothed RTT estimate (SRTT).
|
||||||
pub fn smoothed_rtt(&self) -> Duration {
|
pub fn smoothed_rtt(&self) -> Duration {
|
||||||
self.min_rtt
|
self.srtt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the adaptive RTO computed per RFC 6298:
|
||||||
|
/// RTO = SRTT + 4 * RTTVAR, clamped to [RTO_MIN, RTO_MAX].
|
||||||
|
///
|
||||||
|
/// This replaces the static `rto_ms` field in ProtocolMachine so that
|
||||||
|
/// retransmit timers automatically track changing network conditions.
|
||||||
|
pub fn rto(&self) -> Duration {
|
||||||
|
let rttvar4 = self.rttvar.saturating_mul(4);
|
||||||
|
let rto = self.srtt.saturating_add(rttvar4);
|
||||||
|
rto.clamp(RTO_MIN, RTO_MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns how many bytes can still be sent.
|
/// Returns how many bytes can still be sent.
|
||||||
|
|
@ -107,6 +225,24 @@ impl CongestionController {
|
||||||
/// Record that we sent `bytes` of data.
|
/// Record that we sent `bytes` of data.
|
||||||
pub fn on_send(&mut self, bytes: u64) {
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
||||||
|
// Charge the pacing bucket here rather than at the admission check, so
|
||||||
|
// every byte that actually reaches the wire is paid for exactly once —
|
||||||
|
// including retransmits, which are precisely what must not be allowed
|
||||||
|
// to bypass the rate limit and pile into an already-full queue.
|
||||||
|
self.consume_pacing(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
|
||||||
|
/// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids
|
||||||
|
/// measuring RTT from it). The window still advances; only the RTT estimator
|
||||||
|
/// is left untouched.
|
||||||
|
pub fn on_ack_no_rtt(&mut self, bytes: u64) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||||
|
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||||
|
self.grow_window(bytes);
|
||||||
|
self.update_pacing_rate();
|
||||||
|
self.last_ack_time = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that `bytes` were acknowledged with the given RTT sample.
|
/// Record that `bytes` were acknowledged with the given RTT sample.
|
||||||
|
|
@ -115,16 +251,57 @@ impl CongestionController {
|
||||||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||||
self.total_acked = self.total_acked.saturating_add(bytes);
|
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||||
|
|
||||||
// Update RTT
|
// Update RTT measurements
|
||||||
self.update_rtt(rtt, now);
|
self.update_rtt(rtt, now);
|
||||||
|
|
||||||
// Update bandwidth estimate
|
self.grow_window(bytes);
|
||||||
self.update_bandwidth(bytes, now);
|
self.update_pacing_rate();
|
||||||
|
self.last_ack_time = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Congestion-window growth shared by both ACK paths (slow start / probe).
|
||||||
|
fn grow_window(&mut self, bytes: u64) {
|
||||||
|
// ── Delay-based congestion signal ────────────────────────────────────
|
||||||
|
// A loss-only controller is blind on a deeply-buffered path, and mobile
|
||||||
|
// carrier buffers are very deep: they absorb a burst instead of dropping
|
||||||
|
// it, so no loss is ever signalled and cwnd keeps growing. The queue —
|
||||||
|
// not the link — is what grows, and the standing delay it adds shows up
|
||||||
|
// as RTT inflating far above the path's floor. Left unchecked this is a
|
||||||
|
// positive feedback loop: bigger queue -> larger RTT samples -> larger
|
||||||
|
// SRTT -> larger RTO -> retransmits pile on -> bigger queue, which is
|
||||||
|
// how a session ends up reporting multi-second (even multi-minute) RTT
|
||||||
|
// and stalls video until the buffer finally drains or the user
|
||||||
|
// reconnects. Treat sustained RTT inflation as congestion in its own
|
||||||
|
// right, exactly as it is.
|
||||||
|
let inflation = if self.rtt_initialized && !self.min_rtt.is_zero() {
|
||||||
|
self.srtt.as_secs_f64() / self.min_rtt.as_secs_f64()
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
if inflation >= RTT_INFLATION_BACKOFF {
|
||||||
|
// Standing queue is severe — actively drain it.
|
||||||
|
self.cwnd = (self.cwnd / 2).max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
self.ssthresh = self.cwnd;
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: draining standing queue");
|
||||||
|
self.clamp_cwnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// State machine
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
Phase::SlowStart => {
|
Phase::SlowStart => {
|
||||||
// Exponential growth: increase cwnd by acked bytes
|
// Exponential doubling is what fills a deep buffer fastest, so
|
||||||
|
// leave slow start as soon as the queue starts to build rather
|
||||||
|
// than waiting for the loss that may never come.
|
||||||
|
if inflation >= RTT_INFLATION_EXIT_SLOW_START {
|
||||||
|
self.ssthresh = self.cwnd;
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: RTT inflation ended slow start");
|
||||||
|
self.clamp_cwnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
||||||
self.cwnd = self.cwnd.saturating_add(bytes);
|
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||||
if self.cwnd >= self.ssthresh {
|
if self.cwnd >= self.ssthresh {
|
||||||
self.phase = Phase::ProbeBandwidth;
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
|
@ -137,8 +314,20 @@ impl CongestionController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.update_pacing_rate();
|
self.clamp_cwnd();
|
||||||
self.last_ack_time = now;
|
}
|
||||||
|
|
||||||
|
/// Hard ceiling on the congestion window.
|
||||||
|
///
|
||||||
|
/// Independent of any estimate: no real path this protocol runs over has a
|
||||||
|
/// bandwidth-delay product anywhere near this, so a window above it is
|
||||||
|
/// buffered queue rather than data in transit. Without it, slow start on a
|
||||||
|
/// buffer that never drops could grow the window into the tens of megabytes.
|
||||||
|
fn clamp_cwnd(&mut self) {
|
||||||
|
let ceiling = MAX_CWND_PACKETS.saturating_mul(self.mtu);
|
||||||
|
if self.cwnd > ceiling {
|
||||||
|
self.cwnd = ceiling;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a loss event.
|
/// Record a loss event.
|
||||||
|
|
@ -148,11 +337,28 @@ impl CongestionController {
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
Phase::SlowStart => {
|
Phase::SlowStart => {
|
||||||
// Exit slow start, set ssthresh to half of cwnd
|
let now = Instant::now();
|
||||||
|
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
|
||||||
|
// Previous window's losses have aged out - this loss starts a fresh count.
|
||||||
|
self.slow_start_losses = 0;
|
||||||
|
self.slow_start_loss_window_start = now;
|
||||||
|
}
|
||||||
|
self.slow_start_losses += 1;
|
||||||
|
|
||||||
|
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
|
||||||
|
// Sustained loss within the window: treat as real congestion.
|
||||||
|
// Exit slow start, set ssthresh to half of cwnd.
|
||||||
self.ssthresh = self.cwnd / 2;
|
self.ssthresh = self.cwnd / 2;
|
||||||
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
||||||
self.phase = Phase::ProbeBandwidth;
|
self.phase = Phase::ProbeBandwidth;
|
||||||
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
|
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: sustained loss during slow start, exiting");
|
||||||
|
} else {
|
||||||
|
// Isolated loss: likely non-congestive noise. Take a mild,
|
||||||
|
// temporary haircut but keep exponential growth going -
|
||||||
|
// don't throw away slow start over a single dropped frame.
|
||||||
|
self.cwnd = (self.cwnd * 8 / 10).max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
tracing::debug!(cwnd = self.cwnd, count = self.slow_start_losses, "congestion: isolated loss during slow start, staying in slow start");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Phase::ProbeBandwidth => {
|
Phase::ProbeBandwidth => {
|
||||||
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||||
|
|
@ -164,32 +370,49 @@ impl CongestionController {
|
||||||
self.update_pacing_rate();
|
self.update_pacing_rate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called periodically to update state.
|
|
||||||
pub fn on_tick(&mut self) {
|
|
||||||
// Nothing special needed per-tick -- state updates happen on ACK/loss
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private ──────────────────────────────────────────────────────────────
|
// ── Private ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
|
fn update_rtt(&mut self, rtt: Duration, now: Instant) {
|
||||||
// Track windowed minimum RTT
|
// Update windowed minimum RTT (for pacing)
|
||||||
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
|
if rtt < self.min_rtt || now.duration_since(self.min_rtt_stamp) >= MIN_RTT_EXPIRY {
|
||||||
self.min_rtt = rtt;
|
self.min_rtt = rtt;
|
||||||
self.min_rtt_stamp = now;
|
self.min_rtt_stamp = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update SRTT and RTTVAR per RFC 6298
|
||||||
|
if !self.rtt_initialized {
|
||||||
|
// First measurement: initialize directly
|
||||||
|
self.srtt = rtt;
|
||||||
|
self.rttvar = rtt / 2;
|
||||||
|
self.rtt_initialized = true;
|
||||||
|
} else {
|
||||||
|
// RTTVAR = (3/4) * RTTVAR + (1/4) * |SRTT - R|
|
||||||
|
let diff = if rtt > self.srtt {
|
||||||
|
rtt - self.srtt
|
||||||
|
} else {
|
||||||
|
self.srtt - rtt
|
||||||
|
};
|
||||||
|
// Integer-safe: RTTVAR = RTTVAR - RTTVAR/4 + diff/4
|
||||||
|
self.rttvar = self.rttvar
|
||||||
|
.saturating_sub(self.rttvar / 4)
|
||||||
|
.saturating_add(diff / 4);
|
||||||
|
|
||||||
|
// SRTT = (7/8) * SRTT + (1/8) * R
|
||||||
|
self.srtt = self.srtt
|
||||||
|
.saturating_sub(self.srtt / 8)
|
||||||
|
.saturating_add(rtt / 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_bandwidth(&mut self, _acked_bytes: u64, now: Instant) {
|
tracing::trace!(
|
||||||
let elapsed = now.duration_since(self.last_ack_time);
|
srtt_ms = self.srtt.as_millis(),
|
||||||
if elapsed.as_micros() > 0 {
|
rttvar_ms = self.rttvar.as_millis(),
|
||||||
// Removed bw_samples tracking
|
rto_ms = self.rto().as_millis(),
|
||||||
|
"congestion: RTT updated"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn update_pacing_rate(&mut self) {
|
fn update_pacing_rate(&mut self) {
|
||||||
// Pacing rate = cwnd / min_rtt (with gain)
|
// Pacing rate = cwnd / min_rtt (delivery rate target)
|
||||||
let rtt_us = self.min_rtt.as_micros().max(1) as u64;
|
let rtt_us = self.min_rtt.as_micros().max(1) as u64;
|
||||||
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
|
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
|
||||||
}
|
}
|
||||||
|
|
@ -202,19 +425,18 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_initial_state() {
|
fn test_initial_state() {
|
||||||
let cc = CongestionController::new(1200);
|
let cc = CongestionController::new(1200);
|
||||||
assert_eq!(cc.cwnd(), 12000); // 10 * 1200
|
assert_eq!(cc.cwnd(), 32 * 1200); // 32 * 1200
|
||||||
assert!(cc.can_send());
|
assert!(cc.can_send());
|
||||||
assert_eq!(cc.cwnd_packets(), 10);
|
assert_eq!(cc.cwnd_packets(), 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_slow_start_growth() {
|
fn test_slow_start_growth() {
|
||||||
let mut cc = CongestionController::new(1200);
|
let mut cc = CongestionController::new(1200);
|
||||||
// Simulate sending and ACKing
|
let initial = cc.cwnd();
|
||||||
cc.on_send(1200);
|
cc.on_send(1200);
|
||||||
cc.on_ack(1200, Duration::from_millis(50));
|
cc.on_ack(1200, Duration::from_millis(50));
|
||||||
// cwnd should grow
|
assert!(cc.cwnd() > initial);
|
||||||
assert!(cc.cwnd() > 12000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -225,11 +447,143 @@ mod tests {
|
||||||
assert!(cc.cwnd() < initial);
|
assert!(cc.cwnd() < initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
|
||||||
|
/// ever lost, but the standing queue inflates RTT. A loss-only controller
|
||||||
|
/// grows cwnd forever here — which is how a session ends up reporting
|
||||||
|
/// multi-second RTT and stalling video.
|
||||||
|
#[test]
|
||||||
|
fn test_rtt_inflation_halts_growth_without_any_loss() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
|
||||||
|
// Establish a low path floor; this becomes min_rtt.
|
||||||
|
for _ in 0..4 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let cwnd_before = cc.cwnd();
|
||||||
|
|
||||||
|
// Queue builds: RTT climbs far above the floor, still zero loss.
|
||||||
|
for _ in 0..20 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(400));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cc.cwnd() <= cwnd_before,
|
||||||
|
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
|
||||||
|
cwnd_before,
|
||||||
|
cc.cwnd()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pacing must actually bound the release rate: draining the bucket has to
|
||||||
|
/// deny the next packet. Without this the congestion window alone decides,
|
||||||
|
/// and a whole window leaves back-to-back.
|
||||||
|
#[test]
|
||||||
|
fn test_pacing_bucket_denies_once_drained() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
|
||||||
|
|
||||||
|
// Spend well beyond one burst allowance.
|
||||||
|
let burst_bytes = cc.pacing_available();
|
||||||
|
let mut spent = 0.0;
|
||||||
|
while spent <= burst_bytes + 1200.0 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
spent += 1200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!cc.can_pace_packet(),
|
||||||
|
"pacing allowed unbounded sending: {} bytes still available after spending {}",
|
||||||
|
cc.pacing_available(),
|
||||||
|
spent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The allowance must refill over time, or sending would stall permanently
|
||||||
|
/// once the first burst is spent.
|
||||||
|
#[test]
|
||||||
|
fn test_pacing_bucket_refills_over_time() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
while cc.can_pace_packet() {
|
||||||
|
cc.on_send(1200);
|
||||||
|
}
|
||||||
|
assert!(!cc.can_pace_packet());
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
assert!(
|
||||||
|
cc.can_pace_packet(),
|
||||||
|
"pacing bucket never refilled; sending would be stuck forever"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cwnd must never exceed the absolute ceiling, however long slow start
|
||||||
|
/// runs unopposed — above it the window is buffered queue, not throughput.
|
||||||
|
#[test]
|
||||||
|
fn test_cwnd_never_exceeds_absolute_ceiling() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Constant RTT: no inflation signal, so only the hard cap can stop this.
|
||||||
|
for _ in 0..5000 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(30));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
|
||||||
|
"cwnd {} exceeded the {}-packet ceiling",
|
||||||
|
cc.cwnd(),
|
||||||
|
MAX_CWND_PACKETS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
||||||
|
// A single dropped packet (wireless noise, a brief handover blip) is
|
||||||
|
// normal on real links and must not permanently downgrade the
|
||||||
|
// session from exponential to linear growth.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
|
||||||
|
|
||||||
|
// It should still shrink the window somewhat (not ignored entirely),
|
||||||
|
// just far less punishing than the sustained-congestion case.
|
||||||
|
let after_one = cc.cwnd();
|
||||||
|
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sustained_slow_start_loss_exits_slow_start() {
|
||||||
|
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
|
||||||
|
// a real congestion signal and must still trigger the harsher
|
||||||
|
// exit-slow-start + halve response.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
for _ in 0..SLOW_START_LOSS_TOLERANCE {
|
||||||
|
cc.on_loss(1200);
|
||||||
|
}
|
||||||
|
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slow_start_loss_window_resets_after_expiry() {
|
||||||
|
// Two losses far enough apart (window expired between them) must
|
||||||
|
// each be treated as isolated, not accumulated toward the sustained-
|
||||||
|
// loss threshold.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart);
|
||||||
|
|
||||||
|
// Simulate the window having expired by resetting its start
|
||||||
|
// directly (std::thread::sleep in a unit test would be flaky/slow).
|
||||||
|
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
|
||||||
|
assert_eq!(cc.slow_start_losses, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_can_send_limits() {
|
fn test_can_send_limits() {
|
||||||
let mut cc = CongestionController::new(1200);
|
let mut cc = CongestionController::new(1200);
|
||||||
// Send until cwnd is exhausted
|
// Send until cwnd is exhausted
|
||||||
for _ in 0..10 {
|
for _ in 0..32 {
|
||||||
cc.on_send(1200);
|
cc.on_send(1200);
|
||||||
}
|
}
|
||||||
assert!(!cc.can_send()); // cwnd exhausted
|
assert!(!cc.can_send()); // cwnd exhausted
|
||||||
|
|
@ -244,10 +598,63 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rtt_tracking() {
|
fn test_rtt_tracking_first_sample() {
|
||||||
let mut cc = CongestionController::new(1200);
|
let mut cc = CongestionController::new(1200);
|
||||||
cc.on_send(1200);
|
cc.on_send(1200);
|
||||||
cc.on_ack(1200, Duration::from_millis(25));
|
cc.on_ack(1200, Duration::from_millis(25));
|
||||||
|
// After first sample: SRTT = 25ms, RTTVAR = 12ms
|
||||||
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
|
assert_eq!(cc.smoothed_rtt(), Duration::from_millis(25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rto_rfc6298() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// After first sample with RTT=50ms: SRTT=50ms, RTTVAR=25ms, RTO=150ms
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(50));
|
||||||
|
let rto = cc.rto();
|
||||||
|
// RTO = 50 + 4*25 = 150ms; clamped to [50ms, 16s]
|
||||||
|
assert!(rto >= RTO_MIN);
|
||||||
|
assert!(rto <= RTO_MAX);
|
||||||
|
assert_eq!(rto, Duration::from_millis(150));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_on_ack_no_rtt_grows_window_without_touching_srtt() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Establish a known SRTT with a real sample.
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(40));
|
||||||
|
let srtt_before = cc.smoothed_rtt();
|
||||||
|
let cwnd_before = cc.cwnd();
|
||||||
|
|
||||||
|
// A Karn's-algorithm ACK (all acked frames were retransmitted): window
|
||||||
|
// must advance, RTT estimate must be untouched.
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack_no_rtt(1200);
|
||||||
|
assert!(cc.cwnd() > cwnd_before, "cwnd should still grow on a no-RTT ack");
|
||||||
|
assert_eq!(cc.smoothed_rtt(), srtt_before, "SRTT must not move on a no-RTT ack");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rto_clamp_min() {
|
||||||
|
let cc = CongestionController::new(1200);
|
||||||
|
// Even with no RTT samples, RTO should not go below RTO_MIN
|
||||||
|
assert!(cc.rto() >= RTO_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rto_adapts_after_multiple_samples() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Feed several consistent RTT samples
|
||||||
|
for _ in 0..8 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
// After convergence, RTTVAR should be small → RTO close to SRTT + small margin
|
||||||
|
let rto = cc.rto();
|
||||||
|
// Should be well below 100ms (the old hardcoded default)
|
||||||
|
assert!(rto < Duration::from_millis(200));
|
||||||
|
assert!(rto >= RTO_MIN);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ pub use noise::{NoiseRole, NoiseSession};
|
||||||
pub use obfuscation::{
|
pub use obfuscation::{
|
||||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||||
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
||||||
|
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use snow::{Builder, HandshakeState, TransportState};
|
use snow::{Builder, HandshakeState};
|
||||||
|
|
||||||
use crate::protocol::ProtocolError;
|
use crate::protocol::ProtocolError;
|
||||||
|
|
||||||
|
|
@ -10,9 +10,15 @@ pub enum NoiseRole {
|
||||||
Responder,
|
Responder,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum NoiseSession {
|
/// A Noise handshake in progress. OSTP does not use snow's transport mode: once
|
||||||
Handshake(Box<HandshakeState>),
|
/// the handshake finishes we extract the raw Split() keys (see [`raw_split`])
|
||||||
Transport(TransportState),
|
/// and drive our own out-of-order AEAD (see `crypto::aead`), because the wire
|
||||||
|
/// protocol needs explicit per-frame nonces for reordering that snow's internal
|
||||||
|
/// nonce counter can't express.
|
||||||
|
///
|
||||||
|
/// [`raw_split`]: NoiseSession::raw_split
|
||||||
|
pub struct NoiseSession {
|
||||||
|
handshake: Box<HandshakeState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NoiseSession {
|
impl NoiseSession {
|
||||||
|
|
@ -36,50 +42,92 @@ impl NoiseSession {
|
||||||
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
|
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self::Handshake(Box::new(handshake)))
|
Ok(Self { handshake: Box::new(handshake) })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||||
match self {
|
self.handshake
|
||||||
NoiseSession::Handshake(hs) => hs
|
|
||||||
.write_message(payload, out)
|
.write_message(payload, out)
|
||||||
.map_err(|_| ProtocolError::Crypto("noise-write".to_string())),
|
.map_err(|_| ProtocolError::Crypto("noise-write".to_string()))
|
||||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||||
match self {
|
self.handshake
|
||||||
NoiseSession::Handshake(hs) => hs
|
|
||||||
.read_message(input, out)
|
.read_message(input, out)
|
||||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))),
|
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e)))
|
||||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> {
|
/// Derive the two directional transport keys via Noise's Split().
|
||||||
match self {
|
///
|
||||||
NoiseSession::Handshake(hs) => {
|
/// SECURITY: keys are taken from the final chaining key `ck` (which absorbs
|
||||||
let hash = hs.get_handshake_hash();
|
/// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h`
|
||||||
if out.len() != hash.len() {
|
/// (which only absorbs public transcript data — ephemeral pubkeys and
|
||||||
return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string()));
|
/// ciphertexts — and never the DH secret). Deriving from `ck` is what gives
|
||||||
}
|
/// the session forward secrecy: an adversary who later learns the PSK still
|
||||||
out.copy_from_slice(hash);
|
/// cannot recompute these keys without the ephemeral private keys, which are
|
||||||
Ok(())
|
/// discarded after the handshake.
|
||||||
}
|
///
|
||||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
/// Must only be called once the handshake is finished (both messages of the
|
||||||
}
|
/// NNpsk0 exchange processed); at that point `ck` is final. Returns
|
||||||
}
|
/// `(send_key, recv_key)` for the given role, matching snow's TransportState
|
||||||
|
/// direction mapping: split output `.0` is initiator→responder, `.1` is
|
||||||
pub fn into_transport(self) -> Result<Self, ProtocolError> {
|
/// responder→initiator.
|
||||||
match self {
|
pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> {
|
||||||
NoiseSession::Handshake(hs) => {
|
if !self.handshake.is_handshake_finished() {
|
||||||
let transport = hs
|
return Err(ProtocolError::State("handshake not finished at key split".to_string()));
|
||||||
.into_transport_mode()
|
|
||||||
.map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?;
|
|
||||||
Ok(NoiseSession::Transport(transport))
|
|
||||||
}
|
|
||||||
NoiseSession::Transport(_) => Ok(self),
|
|
||||||
}
|
}
|
||||||
|
let (k0, k1) = self.handshake.dangerously_get_raw_split();
|
||||||
|
Ok(match role {
|
||||||
|
// Initiator sends on .0 (i→r), receives on .1 (r→i).
|
||||||
|
NoiseRole::Initiator => (k0, k1),
|
||||||
|
// Responder is the mirror image.
|
||||||
|
NoiseRole::Responder => (k1, k0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Drive a full NNpsk0 handshake and confirm both sides derive matching
|
||||||
|
/// directional keys. This guards the .0/.1 → send/recv role mapping in
|
||||||
|
/// `raw_split`: if it were wrong, the two sides' send/recv keys wouldn't
|
||||||
|
/// cross-match and the transport channel would silently fail to decrypt.
|
||||||
|
#[test]
|
||||||
|
fn raw_split_keys_agree_across_roles() {
|
||||||
|
let psk = [7u8; 32];
|
||||||
|
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
|
||||||
|
let mut responder = NoiseSession::new(NoiseRole::Responder, &psk).unwrap();
|
||||||
|
|
||||||
|
// msg1: initiator -> responder
|
||||||
|
let mut buf1 = [0u8; 1024];
|
||||||
|
let n1 = initiator.write_handshake(&[], &mut buf1).unwrap();
|
||||||
|
let mut tmp = [0u8; 1024];
|
||||||
|
responder.read_handshake(&buf1[..n1], &mut tmp).unwrap();
|
||||||
|
|
||||||
|
// msg2: responder -> initiator
|
||||||
|
let mut buf2 = [0u8; 1024];
|
||||||
|
let n2 = responder.write_handshake(&[], &mut buf2).unwrap();
|
||||||
|
initiator.read_handshake(&buf2[..n2], &mut tmp).unwrap();
|
||||||
|
|
||||||
|
let (i_send, i_recv) = initiator.raw_split(NoiseRole::Initiator).unwrap();
|
||||||
|
let (r_send, r_recv) = responder.raw_split(NoiseRole::Responder).unwrap();
|
||||||
|
|
||||||
|
// What the initiator sends with, the responder must receive with.
|
||||||
|
assert_eq!(i_send, r_recv, "initiator send key must equal responder recv key");
|
||||||
|
assert_eq!(r_send, i_recv, "responder send key must equal initiator recv key");
|
||||||
|
// The two directions use distinct keys.
|
||||||
|
assert_ne!(i_send, i_recv, "the two directions must not share a key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// raw_split must refuse to hand out keys before the handshake is complete —
|
||||||
|
/// keys taken from a half-mixed chaining key would be wrong and insecure.
|
||||||
|
#[test]
|
||||||
|
fn raw_split_rejected_before_handshake_finishes() {
|
||||||
|
let psk = [9u8; 32];
|
||||||
|
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
|
||||||
|
// No messages exchanged yet: handshake not finished.
|
||||||
|
assert!(initiator.raw_split(NoiseRole::Initiator).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,41 @@ fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec<u8> {
|
||||||
/// The derivation uses the access key as both IKM and salt material,
|
/// The derivation uses the access key as both IKM and salt material,
|
||||||
/// split into two halves. No fixed strings are used — the access key
|
/// split into two halves. No fixed strings are used — the access key
|
||||||
/// alone determines all derived values.
|
/// alone determines all derived values.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct DerivedSecrets {
|
pub struct DerivedSecrets {
|
||||||
pub obfuscation_key: [u8; 8],
|
pub obfuscation_key: [u8; 8],
|
||||||
pub psk: [u8; 32],
|
pub psk: [u8; 32],
|
||||||
pub handshake_pad_min: usize,
|
pub handshake_pad_min: usize,
|
||||||
pub handshake_pad_max: usize,
|
pub handshake_pad_max: usize,
|
||||||
}
|
}
|
||||||
|
// NOTE: the junk marker is NOT part of DerivedSecrets — it is time-rotating and
|
||||||
|
// derived separately per window via `derive_junk_marker` (see below), so it
|
||||||
|
// carries no static per-user signature.
|
||||||
|
|
||||||
|
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
||||||
|
/// wire) so peers running incompatible versions derive entirely different
|
||||||
|
/// secrets and therefore cannot deobfuscate / decrypt each other's traffic.
|
||||||
|
///
|
||||||
|
/// This is a hard, deterministic version gate that needs NO plaintext version
|
||||||
|
/// byte on the wire — a constant marker would defeat the project's stealth
|
||||||
|
/// north-star ("no recognizable header"). A pre-0.4.0 client (which derived
|
||||||
|
/// without a version) produces a different obfuscation key, so a 0.4.0 server
|
||||||
|
/// cannot recover its handshake header and rejects it as an unauthorized probe.
|
||||||
|
///
|
||||||
|
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4;
|
||||||
|
/// version 5 (0.4.x hardening) moved transport keys from the handshake hash to
|
||||||
|
/// Noise's Split() output — a wire-breaking crypto change, so old peers must not
|
||||||
|
/// interop (they would derive different session keys and fail decryption).
|
||||||
|
pub const PROTOCOL_VERSION: u8 = 5;
|
||||||
|
|
||||||
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||||
|
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version-parameterised derivation. `derive_all_secrets` always pins the
|
||||||
|
/// current `PROTOCOL_VERSION`; this form exists so tests can prove that a
|
||||||
|
/// different version yields incompatible secrets (the version gate).
|
||||||
|
pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> DerivedSecrets {
|
||||||
// Split the key hash into two halves for salt/info separation.
|
// Split the key hash into two halves for salt/info separation.
|
||||||
// This avoids using any hardcoded strings while still providing
|
// This avoids using any hardcoded strings while still providing
|
||||||
// domain separation between the derived values.
|
// domain separation between the derived values.
|
||||||
|
|
@ -70,8 +97,16 @@ pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||||
let salt = &key_hash[..16];
|
let salt = &key_hash[..16];
|
||||||
let info_base = &key_hash[16..];
|
let info_base = &key_hash[16..];
|
||||||
|
|
||||||
// Extract PRK from access key using its own hash as salt
|
// Mix the protocol version into the IKM so a different version produces a
|
||||||
let prk = hkdf_extract(salt, access_key);
|
// completely different PRK → different obf_key / psk / padding. This is the
|
||||||
|
// wire-version gate: it is invisible on the wire (only the derived output,
|
||||||
|
// which is already indistinguishable from random, ever leaves the host).
|
||||||
|
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||||
|
ikm.extend_from_slice(access_key);
|
||||||
|
ikm.push(version);
|
||||||
|
|
||||||
|
// Extract PRK from version-tagged access key using its hash as salt
|
||||||
|
let prk = hkdf_extract(salt, &ikm);
|
||||||
|
|
||||||
// Derive obfuscation key (8 bytes) — info = key_hash[16..] || 0x01
|
// Derive obfuscation key (8 bytes) — info = key_hash[16..] || 0x01
|
||||||
let mut obf_info = info_base.to_vec();
|
let mut obf_info = info_base.to_vec();
|
||||||
|
|
@ -105,6 +140,53 @@ pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window length (seconds) for the rotating junk marker. The marker changes
|
||||||
|
/// every window, so junk carries no static per-user fingerprint on the wire;
|
||||||
|
/// the server checks the current and previous window to absorb clock skew.
|
||||||
|
pub const JUNK_MARKER_WINDOW_SECS: u64 = 60;
|
||||||
|
|
||||||
|
/// The current junk-marker time window (unix seconds / window length).
|
||||||
|
pub fn current_junk_window() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() / JUNK_MARKER_WINDOW_SECS)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the 4-byte junk marker for a given time `window`.
|
||||||
|
///
|
||||||
|
/// Uses the same version-gated HKDF scheme as [`derive_all_secrets`], with the
|
||||||
|
/// window folded into the `info` (label byte `0x04`). Folding in the window
|
||||||
|
/// makes the marker rotate: to an on-path observer the junk prefix changes every
|
||||||
|
/// window (no fixed signature), and a captured marker is only valid for ~1
|
||||||
|
/// window. Only a holder of the access key can compute it, so an outsider cannot
|
||||||
|
/// forge a silently-dropped junk packet.
|
||||||
|
pub fn derive_junk_marker(access_key: &[u8], window: u64) -> [u8; 4] {
|
||||||
|
derive_junk_marker_versioned(access_key, window, PROTOCOL_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn derive_junk_marker_versioned(access_key: &[u8], window: u64, version: u8) -> [u8; 4] {
|
||||||
|
use sha2::Digest;
|
||||||
|
let key_hash = sha2::Sha256::digest(access_key);
|
||||||
|
let salt = &key_hash[..16];
|
||||||
|
let info_base = &key_hash[16..];
|
||||||
|
|
||||||
|
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||||
|
ikm.extend_from_slice(access_key);
|
||||||
|
ikm.push(version);
|
||||||
|
let prk = hkdf_extract(salt, &ikm);
|
||||||
|
|
||||||
|
// info = key_hash[16..] || 0x04 || window(LE) — same label byte as before,
|
||||||
|
// now parameterised by the time window.
|
||||||
|
let mut info = info_base.to_vec();
|
||||||
|
info.push(0x04);
|
||||||
|
info.extend_from_slice(&window.to_le_bytes());
|
||||||
|
let bytes = hkdf_expand(&prk, &info, 4);
|
||||||
|
let mut marker = [0u8; 4];
|
||||||
|
marker.copy_from_slice(&bytes);
|
||||||
|
marker
|
||||||
|
}
|
||||||
|
|
||||||
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
||||||
|
|
||||||
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,37 @@ mod tests {
|
||||||
assert_eq!(correct_sid, session_id, "correct key must recover session_id");
|
assert_eq!(correct_sid, session_id, "correct key must recover session_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §C version gate: a peer on a different PROTOCOL_VERSION derives
|
||||||
|
/// different secrets, so a handshake obfuscated with the OLD version's key
|
||||||
|
/// does NOT deobfuscate to a valid session_id under the current version.
|
||||||
|
/// This is exactly what makes an old (pre-0.4.0) client fail to connect to
|
||||||
|
/// a new server — with no plaintext version marker on the wire.
|
||||||
|
#[test]
|
||||||
|
fn test_protocol_version_gates_old_clients() {
|
||||||
|
let key = b"shared_access_key_across_versions";
|
||||||
|
let new = derive_all_secrets(key); // == derive_all_secrets_versioned(key, PROTOCOL_VERSION)
|
||||||
|
let old = derive_all_secrets_versioned(key, PROTOCOL_VERSION.wrapping_sub(1));
|
||||||
|
|
||||||
|
// Different protocol version → different derived secrets.
|
||||||
|
assert_ne!(new.obfuscation_key, old.obfuscation_key, "version must change obf_key");
|
||||||
|
assert_ne!(new.psk, old.psk, "version must change psk");
|
||||||
|
|
||||||
|
// Concretely: a handshake the old client obfuscated with its key does
|
||||||
|
// not recover a valid session_id when the new server deobfuscates it.
|
||||||
|
let session_id: u32 = 0x11223344;
|
||||||
|
let noise = [0x33u8; 48];
|
||||||
|
let mut pkt = Vec::new();
|
||||||
|
pkt.extend_from_slice(&session_id.to_be_bytes());
|
||||||
|
pkt.extend_from_slice(&(noise.len() as u16).to_be_bytes());
|
||||||
|
pkt.extend_from_slice(&noise);
|
||||||
|
pkt.extend_from_slice(&[0u8; 32]);
|
||||||
|
|
||||||
|
obfuscate_packet_inplace(&mut pkt, &old.obfuscation_key, true); // old client
|
||||||
|
deobfuscate_packet_inplace(&mut pkt, &new.obfuscation_key, true); // new server
|
||||||
|
let recovered = u32::from_be_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]);
|
||||||
|
assert_ne!(recovered, session_id, "old-version client must NOT be accepted by new server");
|
||||||
|
}
|
||||||
|
|
||||||
/// Verifies data packet obfuscation round-trip (non-handshake path).
|
/// Verifies data packet obfuscation round-trip (non-handshake path).
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_packet_obfuscation_roundtrip() {
|
fn test_data_packet_obfuscation_roundtrip() {
|
||||||
|
|
@ -160,4 +191,29 @@ mod tests {
|
||||||
assert_eq!(recovered_nonce, nonce);
|
assert_eq!(recovered_nonce, nonce);
|
||||||
assert_eq!(&packet[12..], &ciphertext);
|
assert_eq!(&packet[12..], &ciphertext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The junk marker must: be stable within a window (client and server agree),
|
||||||
|
/// rotate across windows (no static on-wire fingerprint), and differ per key
|
||||||
|
/// (one user's marker never silently-drops on another user's flow).
|
||||||
|
#[test]
|
||||||
|
fn test_junk_marker_rotation() {
|
||||||
|
let key_a = b"access-key-alpha";
|
||||||
|
let key_b = b"access-key-bravo";
|
||||||
|
|
||||||
|
// Stable within a window.
|
||||||
|
assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000));
|
||||||
|
|
||||||
|
// Rotates across adjacent windows.
|
||||||
|
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001));
|
||||||
|
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999));
|
||||||
|
|
||||||
|
// Distinct per key within the same window.
|
||||||
|
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000));
|
||||||
|
|
||||||
|
// A different protocol version yields a different marker (version gate).
|
||||||
|
assert_ne!(
|
||||||
|
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION),
|
||||||
|
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
|
||||||
use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce};
|
|
||||||
use hkdf::Hkdf;
|
|
||||||
use sha2::Sha256;
|
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
|
||||||
use rand::{rngs::OsRng, RngCore};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
const REALITY_INFO: &[u8] = b"ostp-reality-v1";
|
|
||||||
const RECORD_HEADER_LEN: usize = 5;
|
|
||||||
const HANDSHAKE_HEADER_LEN: usize = 4;
|
|
||||||
|
|
||||||
/// Number of TLS records sent by the server during the fake handshake phase.
|
|
||||||
/// Client must read and discard this many records before starting RealityStream.
|
|
||||||
/// Layout: 1× ServerHello (0x16) + 1× CCS (0x14) + 3× fake encrypted records (0x17)
|
|
||||||
pub const REALITY_SERVER_HANDSHAKE_RECORDS: usize = 5;
|
|
||||||
|
|
||||||
/// Generates an X25519 keypair
|
|
||||||
pub fn generate_x25519_keypair() -> (StaticSecret, PublicKey) {
|
|
||||||
let secret = StaticSecret::random_from_rng(OsRng);
|
|
||||||
let public = PublicKey::from(&secret);
|
|
||||||
(secret, public)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derives the Auth Key and Data Key from the X25519 shared secret
|
|
||||||
pub fn derive_keys(shared_secret: &[u8; 32]) -> (ChaCha20Poly1305, ChaCha20Poly1305) {
|
|
||||||
let hk = Hkdf::<Sha256>::new(None, shared_secret);
|
|
||||||
let mut okm = [0u8; 64];
|
|
||||||
hk.expand(REALITY_INFO, &mut okm).expect("HKDF expand failed");
|
|
||||||
|
|
||||||
let auth_key = ChaCha20Poly1305::new_from_slice(&okm[0..32]).unwrap();
|
|
||||||
let data_key = ChaCha20Poly1305::new_from_slice(&okm[32..64]).unwrap();
|
|
||||||
(auth_key, data_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an authenticated Session ID payload (32 bytes)
|
|
||||||
/// sid: 8 bytes, timestamp: 8 bytes. Encrypted with ChaCha20Poly1305 (16 byte tag). Total = 32 bytes.
|
|
||||||
pub fn generate_session_id(auth_aead: &ChaCha20Poly1305, sid: &[u8; 8]) -> [u8; 32] {
|
|
||||||
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
|
||||||
let mut plaintext = [0u8; 16];
|
|
||||||
plaintext[0..8].copy_from_slice(sid);
|
|
||||||
plaintext[8..16].copy_from_slice(&ts.to_be_bytes());
|
|
||||||
|
|
||||||
let nonce = Nonce::from_slice(&[0u8; 12]); // Fixed nonce since auth key is ephemeral per connection
|
|
||||||
let ciphertext = auth_aead.encrypt(nonce, plaintext.as_ref()).expect("encryption failed");
|
|
||||||
|
|
||||||
let mut session_id = [0u8; 32];
|
|
||||||
session_id.copy_from_slice(&ciphertext);
|
|
||||||
session_id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifies and decrypts the Session ID payload. Returns (sid, timestamp)
|
|
||||||
pub fn verify_session_id(auth_aead: &ChaCha20Poly1305, session_id: &[u8; 32]) -> Option<([u8; 8], u64)> {
|
|
||||||
let nonce = Nonce::from_slice(&[0u8; 12]);
|
|
||||||
let plaintext = auth_aead.decrypt(nonce, session_id.as_ref()).ok()?;
|
|
||||||
|
|
||||||
if plaintext.len() != 16 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut sid = [0u8; 8];
|
|
||||||
sid.copy_from_slice(&plaintext[0..8]);
|
|
||||||
let mut ts_bytes = [0u8; 8];
|
|
||||||
ts_bytes.copy_from_slice(&plaintext[8..16]);
|
|
||||||
let ts = u64::from_be_bytes(ts_bytes);
|
|
||||||
|
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
|
||||||
// Allow up to 60 seconds of clock drift
|
|
||||||
if ts > now + 60 || ts < now.saturating_sub(60) {
|
|
||||||
return None; // Replay protection / stale connection
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((sid, ts))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a fake TLS 1.3 ClientHello matching Chrome's fingerprint
|
|
||||||
pub fn build_client_hello(sni: &str, session_id: &[u8; 32], c_pub: &PublicKey) -> Bytes {
|
|
||||||
let mut ext = BytesMut::new();
|
|
||||||
|
|
||||||
// SNI Extension
|
|
||||||
let sni_bytes = sni.as_bytes();
|
|
||||||
ext.put_u16(0x0000); // Type: server_name
|
|
||||||
ext.put_u16((sni_bytes.len() + 5) as u16);
|
|
||||||
ext.put_u16((sni_bytes.len() + 3) as u16); // Server Name list length
|
|
||||||
ext.put_u8(0x00); // Name Type: host_name
|
|
||||||
ext.put_u16(sni_bytes.len() as u16);
|
|
||||||
ext.put_slice(sni_bytes);
|
|
||||||
|
|
||||||
// Supported Groups
|
|
||||||
ext.put_u16(0x000a); // Type
|
|
||||||
ext.put_u16(8); // Length
|
|
||||||
ext.put_u16(6); // List length
|
|
||||||
ext.put_u16(0x001d); // x25519
|
|
||||||
ext.put_u16(0x0017); // secp256r1
|
|
||||||
ext.put_u16(0x0018); // secp384r1
|
|
||||||
|
|
||||||
// Key Share
|
|
||||||
let pub_bytes = c_pub.as_bytes();
|
|
||||||
ext.put_u16(0x0033); // Type
|
|
||||||
ext.put_u16((pub_bytes.len() + 6) as u16); // Length
|
|
||||||
ext.put_u16((pub_bytes.len() + 4) as u16); // ClientShares length
|
|
||||||
ext.put_u16(0x001d); // Group: x25519
|
|
||||||
ext.put_u16(pub_bytes.len() as u16);
|
|
||||||
ext.put_slice(pub_bytes);
|
|
||||||
|
|
||||||
// Supported Versions
|
|
||||||
ext.put_u16(0x002b); // Type
|
|
||||||
ext.put_u16(5); // Length
|
|
||||||
ext.put_u8(4); // List length
|
|
||||||
ext.put_u16(0x0304); // TLS 1.3
|
|
||||||
ext.put_u16(0x0303); // TLS 1.2
|
|
||||||
|
|
||||||
// ALPN
|
|
||||||
let alpn = b"\x02h2\x08http/1.1";
|
|
||||||
ext.put_u16(0x0010); // Type
|
|
||||||
ext.put_u16((alpn.len() + 2) as u16);
|
|
||||||
ext.put_u16(alpn.len() as u16);
|
|
||||||
ext.put_slice(alpn);
|
|
||||||
|
|
||||||
// Signature Algorithms
|
|
||||||
ext.put_u16(0x000d); // Type
|
|
||||||
ext.put_u16(10); // Length
|
|
||||||
ext.put_u16(8); // List length
|
|
||||||
ext.put_u16(0x0403); // ecdsa_secp256r1_sha256
|
|
||||||
ext.put_u16(0x0804); // rsa_pss_rsae_sha256
|
|
||||||
ext.put_u16(0x0401); // rsa_pkcs1_sha256
|
|
||||||
ext.put_u16(0x0503); // ecdsa_secp384r1_sha384
|
|
||||||
|
|
||||||
let mut handshake = BytesMut::new();
|
|
||||||
handshake.put_u16(0x0303); // Client Version
|
|
||||||
let mut random = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut random);
|
|
||||||
handshake.put_slice(&random); // Random
|
|
||||||
|
|
||||||
handshake.put_u8(32); // Session ID length
|
|
||||||
handshake.put_slice(session_id); // Session ID
|
|
||||||
|
|
||||||
// Cipher Suites
|
|
||||||
handshake.put_u16(6); // Length
|
|
||||||
handshake.put_u16(0x1301); // TLS_AES_128_GCM_SHA256
|
|
||||||
handshake.put_u16(0x1303); // TLS_CHACHA20_POLY1305_SHA256
|
|
||||||
handshake.put_u16(0x1302); // TLS_AES_256_GCM_SHA384
|
|
||||||
|
|
||||||
// Compression
|
|
||||||
handshake.put_u8(1); // Length
|
|
||||||
handshake.put_u8(0); // null
|
|
||||||
|
|
||||||
// Extensions
|
|
||||||
handshake.put_u16(ext.len() as u16);
|
|
||||||
handshake.put_slice(&ext);
|
|
||||||
|
|
||||||
let handshake_len = handshake.len();
|
|
||||||
|
|
||||||
let mut record = BytesMut::new();
|
|
||||||
record.put_u8(0x16); // Handshake
|
|
||||||
record.put_u16(0x0301); // TLS 1.0 (Compatibility)
|
|
||||||
record.put_u16((handshake_len + HANDSHAKE_HEADER_LEN) as u16); // Length
|
|
||||||
|
|
||||||
record.put_u8(0x01); // ClientHello
|
|
||||||
record.put_u8((handshake_len >> 16) as u8);
|
|
||||||
record.put_u8((handshake_len >> 8) as u8);
|
|
||||||
record.put_u8(handshake_len as u8);
|
|
||||||
record.put_slice(&handshake);
|
|
||||||
|
|
||||||
// Append ChangeCipherSpec for TLS 1.3 middlebox compatibility (RFC 8446 §D.4)
|
|
||||||
// This makes the flow look like: ClientHello → ServerHello → CCS → AppData
|
|
||||||
// instead of the DPI-suspicious: ClientHello → AppData directly.
|
|
||||||
let mut out = BytesMut::new();
|
|
||||||
out.put_slice(&record);
|
|
||||||
out.put_slice(&[0x14, 0x03, 0x03, 0x00, 0x01, 0x01]);
|
|
||||||
out.freeze()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ParsedClientHello {
|
|
||||||
pub sni: String,
|
|
||||||
pub session_id: [u8; 32],
|
|
||||||
pub c_pub: PublicKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a TLS ClientHello. Returns None if invalid or missing required fields.
|
|
||||||
pub fn parse_client_hello(mut buf: &[u8]) -> Option<ParsedClientHello> {
|
|
||||||
if buf.len() < RECORD_HEADER_LEN + HANDSHAKE_HEADER_LEN {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record Header
|
|
||||||
let typ = buf.get_u8();
|
|
||||||
if typ != 0x16 { return None; } // Not a handshake
|
|
||||||
let _version = buf.get_u16();
|
|
||||||
let record_len = buf.get_u16() as usize;
|
|
||||||
|
|
||||||
if buf.len() < record_len {
|
|
||||||
return None; // Incomplete record
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut payload = &buf[..record_len];
|
|
||||||
|
|
||||||
// Handshake Header
|
|
||||||
let hs_type = payload.get_u8();
|
|
||||||
if hs_type != 0x01 { return None; } // Not ClientHello
|
|
||||||
let hs_len_hi = payload.get_u8() as usize;
|
|
||||||
let hs_len_mid = payload.get_u8() as usize;
|
|
||||||
let hs_len_lo = payload.get_u8() as usize;
|
|
||||||
let hs_len = (hs_len_hi << 16) | (hs_len_mid << 8) | hs_len_lo;
|
|
||||||
|
|
||||||
if payload.len() < hs_len { return None; }
|
|
||||||
|
|
||||||
let mut ch = &payload[..hs_len];
|
|
||||||
let _client_version = ch.get_u16();
|
|
||||||
if ch.len() < 32 { return None; }
|
|
||||||
ch.advance(32); // Skip Random
|
|
||||||
|
|
||||||
let sid_len = ch.get_u8() as usize;
|
|
||||||
if sid_len != 32 || ch.len() < 32 { return None; }
|
|
||||||
|
|
||||||
let mut session_id = [0u8; 32];
|
|
||||||
session_id.copy_from_slice(&ch[..32]);
|
|
||||||
ch.advance(32);
|
|
||||||
|
|
||||||
let ciphers_len = ch.get_u16() as usize;
|
|
||||||
if ch.len() < ciphers_len { return None; }
|
|
||||||
ch.advance(ciphers_len);
|
|
||||||
|
|
||||||
let comp_len = ch.get_u8() as usize;
|
|
||||||
if ch.len() < comp_len { return None; }
|
|
||||||
ch.advance(comp_len);
|
|
||||||
|
|
||||||
let ext_len = ch.get_u16() as usize;
|
|
||||||
if ch.len() < ext_len { return None; }
|
|
||||||
|
|
||||||
let mut exts = &ch[..ext_len];
|
|
||||||
|
|
||||||
let mut parsed_sni = None;
|
|
||||||
let mut parsed_c_pub = None;
|
|
||||||
|
|
||||||
while exts.len() >= 4 {
|
|
||||||
let ext_type = exts.get_u16();
|
|
||||||
let ext_len = exts.get_u16() as usize;
|
|
||||||
if exts.len() < ext_len { break; }
|
|
||||||
|
|
||||||
let mut ext_data = &exts[..ext_len];
|
|
||||||
|
|
||||||
if ext_type == 0x0000 { // SNI
|
|
||||||
let _list_len = ext_data.get_u16() as usize;
|
|
||||||
if ext_data.len() >= 3 {
|
|
||||||
let name_type = ext_data.get_u8();
|
|
||||||
if name_type == 0x00 { // Hostname
|
|
||||||
let name_len = ext_data.get_u16() as usize;
|
|
||||||
if ext_data.len() >= name_len {
|
|
||||||
if let Ok(name) = std::str::from_utf8(&ext_data[..name_len]) {
|
|
||||||
parsed_sni = Some(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ext_type == 0x0033 { // Key Share
|
|
||||||
let _client_shares_len = ext_data.get_u16() as usize;
|
|
||||||
while ext_data.len() >= 4 {
|
|
||||||
let group = ext_data.get_u16();
|
|
||||||
let key_ex_len = ext_data.get_u16() as usize;
|
|
||||||
if ext_data.len() < key_ex_len { break; }
|
|
||||||
|
|
||||||
if group == 0x001d && key_ex_len == 32 { // X25519
|
|
||||||
let mut pub_bytes = [0u8; 32];
|
|
||||||
pub_bytes.copy_from_slice(&ext_data[..32]);
|
|
||||||
parsed_c_pub = Some(PublicKey::from(pub_bytes));
|
|
||||||
}
|
|
||||||
ext_data.advance(key_ex_len);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exts.advance(ext_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
match (parsed_sni, parsed_c_pub) {
|
|
||||||
(Some(sni), Some(c_pub)) => Some(ParsedClientHello { sni, session_id, c_pub }),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,8 +13,6 @@ pub enum FrameKind {
|
||||||
KeepAlive = 4,
|
KeepAlive = 4,
|
||||||
Nack = 5,
|
Nack = 5,
|
||||||
Ack = 6,
|
Ack = 6,
|
||||||
/// 0-RTT session resumption: client sends ticket + early data
|
|
||||||
Resume = 7,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<u8> for FrameKind {
|
impl TryFrom<u8> for FrameKind {
|
||||||
|
|
@ -28,7 +26,6 @@ impl TryFrom<u8> for FrameKind {
|
||||||
4 => Ok(Self::KeepAlive),
|
4 => Ok(Self::KeepAlive),
|
||||||
5 => Ok(Self::Nack),
|
5 => Ok(Self::Nack),
|
||||||
6 => Ok(Self::Ack),
|
6 => Ok(Self::Ack),
|
||||||
7 => Ok(Self::Resume),
|
|
||||||
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
|
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +101,15 @@ impl FramedPacket {
|
||||||
let payload_len = header.payload_len as usize;
|
let payload_len = header.payload_len as usize;
|
||||||
let pad_len = header.pad_len as usize;
|
let pad_len = header.pad_len as usize;
|
||||||
|
|
||||||
let expected = FRAME_HEADER_LEN + payload_len + pad_len;
|
// Use checked arithmetic: payload_len is a u32 from the (decrypted, but
|
||||||
|
// still to-be-trusted) header, and on 32-bit targets — MIPS/ARMv7
|
||||||
|
// routers are supported build targets — header+payload+pad can overflow
|
||||||
|
// usize and wrap to a small value that spuriously passes the length
|
||||||
|
// check, causing an out-of-range slice below.
|
||||||
|
let expected = FRAME_HEADER_LEN
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.and_then(|v| v.checked_add(pad_len))
|
||||||
|
.ok_or_else(|| ProtocolError::Framing("frame length overflow".to_string()))?;
|
||||||
if buf.len() < expected {
|
if buf.len() < expected {
|
||||||
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod padding;
|
pub mod padding;
|
||||||
pub mod wss;
|
|
||||||
|
|
||||||
pub use frame::{FrameHeader, FrameKind, FramedPacket};
|
pub use frame::{FrameHeader, FrameKind, FramedPacket};
|
||||||
pub use padding::{AdaptivePadder, PaddingStrategy, TrafficProfile};
|
pub use padding::{AdaptivePadder, PaddingStrategy, TrafficProfile};
|
||||||
pub use wss::{encode_wss_frame, decode_wss_frame, WssFrameResult};
|
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
use rand::RngCore;
|
|
||||||
|
|
||||||
pub enum WssFrameResult {
|
|
||||||
Incomplete,
|
|
||||||
Frame { payload: Vec<u8>, total_len: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_wss_frame(payload: &[u8], masked: bool) -> Vec<u8> {
|
|
||||||
let len = payload.len();
|
|
||||||
let mut header = Vec::with_capacity(14 + len);
|
|
||||||
header.push(0x82); // FIN + Binary
|
|
||||||
|
|
||||||
let mask_bit = if masked { 0x80 } else { 0x00 };
|
|
||||||
|
|
||||||
if len <= 125 {
|
|
||||||
header.push(mask_bit | (len as u8));
|
|
||||||
} else if len <= 65535 {
|
|
||||||
header.push(mask_bit | 126);
|
|
||||||
header.extend_from_slice(&(len as u16).to_be_bytes());
|
|
||||||
} else {
|
|
||||||
header.push(mask_bit | 127);
|
|
||||||
header.extend_from_slice(&(len as u64).to_be_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
if masked {
|
|
||||||
let mut mask = [0u8; 4];
|
|
||||||
rand::thread_rng().fill_bytes(&mut mask);
|
|
||||||
header.extend_from_slice(&mask);
|
|
||||||
|
|
||||||
for (i, &b) in payload.iter().enumerate() {
|
|
||||||
header.push(b ^ mask[i % 4]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
header.extend_from_slice(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
header
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_wss_frame(buffer: &[u8]) -> WssFrameResult {
|
|
||||||
if buffer.len() < 2 {
|
|
||||||
return WssFrameResult::Incomplete;
|
|
||||||
}
|
|
||||||
let is_masked = (buffer[1] & 0x80) != 0;
|
|
||||||
let payload_len_7 = (buffer[1] & 0x7F) as usize;
|
|
||||||
|
|
||||||
let (header_len, payload_len) = if payload_len_7 == 126 {
|
|
||||||
if buffer.len() < 4 { return WssFrameResult::Incomplete; }
|
|
||||||
(4, u16::from_be_bytes([buffer[2], buffer[3]]) as usize)
|
|
||||||
} else if payload_len_7 == 127 {
|
|
||||||
if buffer.len() < 10 { return WssFrameResult::Incomplete; }
|
|
||||||
(10, u64::from_be_bytes([buffer[2], buffer[3], buffer[4], buffer[5], buffer[6], buffer[7], buffer[8], buffer[9]]) as usize)
|
|
||||||
} else {
|
|
||||||
(2, payload_len_7)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mask_offset = header_len;
|
|
||||||
let full_header_len = header_len + if is_masked { 4 } else { 0 };
|
|
||||||
let total_frame_len = full_header_len + payload_len;
|
|
||||||
|
|
||||||
if buffer.len() < total_frame_len {
|
|
||||||
return WssFrameResult::Incomplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut payload = buffer[full_header_len..total_frame_len].to_vec();
|
|
||||||
if is_masked {
|
|
||||||
let mask = [buffer[mask_offset], buffer[mask_offset+1], buffer[mask_offset+2], buffer[mask_offset+3]];
|
|
||||||
for (i, b) in payload.iter_mut().enumerate() {
|
|
||||||
*b ^= mask[i % 4];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WssFrameResult::Frame { payload, total_len: total_frame_len }
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ pub mod crypto;
|
||||||
pub mod framing;
|
pub mod framing;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
pub mod resumption;
|
|
||||||
|
|
||||||
pub use crypto::NoiseRole;
|
pub use crypto::NoiseRole;
|
||||||
pub use framing::{TrafficProfile, PaddingStrategy};
|
pub use framing::{TrafficProfile, PaddingStrategy};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Upper bound on a single frame's retransmit timer, after exponential backoff
|
||||||
|
/// is applied to the adaptive RTO. Past this the session is dead from the
|
||||||
|
/// user's point of view, and waiting longer only delays recovery.
|
||||||
|
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
use crate::congestion::CongestionController;
|
use crate::congestion::CongestionController;
|
||||||
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
||||||
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
||||||
|
|
@ -103,6 +107,17 @@ pub struct ProtocolMachine {
|
||||||
_mtu: usize,
|
_mtu: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gap recovery (see `ProtocolMachine::recover_stalled_gap`) ────────────────
|
||||||
|
// How long the receive sequence may sit stuck behind a missing frame, with
|
||||||
|
// later frames already buffered, before that frame is declared unrecoverable
|
||||||
|
// and skipped. Derived from the live RTO so it scales with the path instead of
|
||||||
|
// guessing, then clamped: the floor keeps a fast link from discarding a frame
|
||||||
|
// that is merely late, the ceiling bounds how long a stall can be visible to
|
||||||
|
// the user before the tunnel unblocks itself.
|
||||||
|
const GAP_RECOVERY_RTO_MULTIPLIER: u32 = 8;
|
||||||
|
const GAP_RECOVERY_MIN: Duration = Duration::from_secs(2);
|
||||||
|
const GAP_RECOVERY_MAX: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct SentFrame {
|
struct SentFrame {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
|
|
@ -156,10 +171,33 @@ impl ProtocolMachine {
|
||||||
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sum of retry counters across in-flight frames. Test-only: lets a test
|
||||||
|
/// assert the core retransmit invariant (a retry is only ever charged to a
|
||||||
|
/// frame that was actually put on the wire) without needing to advance the
|
||||||
|
/// clock through several seconds of exponential backoff.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn total_retries(&self) -> usize {
|
||||||
|
self.sent_history
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.is_retransmittable)
|
||||||
|
.map(|f| f.retries as usize)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cwnd_packets(&self) -> usize {
|
pub fn cwnd_packets(&self) -> usize {
|
||||||
self.cc.cwnd_packets() as usize
|
self.cc.cwnd_packets() as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the pacing bucket currently allows releasing another packet.
|
||||||
|
///
|
||||||
|
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
|
||||||
|
/// nothing about how fast that window is emptied onto the wire. Sending a
|
||||||
|
/// whole window back-to-back is what drives a deep buffer into standing
|
||||||
|
/// queue, so admission is gated on both.
|
||||||
|
pub fn can_pace_packet(&self) -> bool {
|
||||||
|
self.cc.can_pace_packet()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_send(&mut self, bytes: u64) {
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
self.cc.on_send(bytes);
|
self.cc.on_send(bytes);
|
||||||
}
|
}
|
||||||
|
|
@ -207,13 +245,16 @@ impl ProtocolMachine {
|
||||||
.map(ProtocolAction::SendDatagram)
|
.map(ProtocolAction::SendDatagram)
|
||||||
}
|
}
|
||||||
(OstpState::Closing, OstpEvent::Inbound(raw)) => {
|
(OstpState::Closing, OstpEvent::Inbound(raw)) => {
|
||||||
// Process final in-flight packets to prevent data loss during teardown.
|
// The remote may still have data or ACKs in transit when we initiated
|
||||||
// The remote may still have data or ACKs in transit when we initiated Close.
|
// Close. Stay in Closing and process them; handle_inbound transitions to
|
||||||
let result = self.handle_inbound(raw);
|
// Closed only when it actually receives the peer's Close frame — the old
|
||||||
self.state = OstpState::Closed;
|
// code force-closed after a single inbound packet, losing in-flight data.
|
||||||
result
|
// (Ported from 0.3.x 47d44fa.)
|
||||||
|
self.handle_inbound(raw)
|
||||||
}
|
}
|
||||||
(OstpState::Established, OstpEvent::Tick) => self.handle_tick(),
|
(OstpState::Established, OstpEvent::Tick) => self.handle_tick(),
|
||||||
|
// Retransmit our Close frame (and drain pending) while waiting for teardown.
|
||||||
|
(OstpState::Closing, OstpEvent::Tick) => self.handle_tick(),
|
||||||
(OstpState::Closed, _) => Ok(ProtocolAction::Noop),
|
(OstpState::Closed, _) => Ok(ProtocolAction::Noop),
|
||||||
(_, OstpEvent::Close) => {
|
(_, OstpEvent::Close) => {
|
||||||
self.state = OstpState::Closed;
|
self.state = OstpState::Closed;
|
||||||
|
|
@ -234,7 +275,9 @@ impl ProtocolMachine {
|
||||||
|
|
||||||
let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]);
|
let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]);
|
||||||
if session_id != self.session_id {
|
if session_id != self.session_id {
|
||||||
tracing::error!("session id mismatch! expected={:#010x}, got={:#010x}, is_handshake={}, raw_len={}", self.session_id, session_id, is_handshake, raw_vec.len());
|
// Per-packet, attacker-triggerable event: keep at debug and don't
|
||||||
|
// dump internal session ids (log-flood + info-leak surface).
|
||||||
|
tracing::debug!("session id mismatch (is_handshake={})", is_handshake);
|
||||||
return Err(ProtocolError::State("session id mismatch".to_string()));
|
return Err(ProtocolError::State("session id mismatch".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,7 +303,6 @@ impl ProtocolMachine {
|
||||||
noise_len, raw_vec.len() - 6
|
noise_len, raw_vec.len() - 6
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
tracing::info!("handle_inbound: raw_vec.len()={}, noise_len={}, raw_vec[0..6]={:?}", raw_vec.len(), noise_len, &raw_vec[0..6]);
|
|
||||||
|
|
||||||
let mut read_out = vec![0_u8; 1024];
|
let mut read_out = vec![0_u8; 1024];
|
||||||
let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
|
let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
|
||||||
|
|
@ -278,9 +320,12 @@ impl ProtocolMachine {
|
||||||
NoiseRole::Initiator => None,
|
NoiseRole::Initiator => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut key = [0_u8; 32];
|
// Transport keys come from Noise's Split() over the final chaining key,
|
||||||
self.noise.handshake_hash(&mut key)?;
|
// so they depend on the ephemeral `ee` DH secret and give the session
|
||||||
let (send_key, recv_key) = derive_split_keys(&key, self.role);
|
// forward secrecy. (Previously these were derived from the handshake
|
||||||
|
// hash, which never absorbs the DH result — see raw_split's SECURITY
|
||||||
|
// note. That is the wire-breaking change gated by PROTOCOL_VERSION.)
|
||||||
|
let (send_key, recv_key) = self.noise.raw_split(self.role)?;
|
||||||
self.send_cipher = Some(SessionCipher::new(&send_key));
|
self.send_cipher = Some(SessionCipher::new(&send_key));
|
||||||
self.recv_cipher = Some(SessionCipher::new(&recv_key));
|
self.recv_cipher = Some(SessionCipher::new(&recv_key));
|
||||||
self.state = OstpState::Established;
|
self.state = OstpState::Established;
|
||||||
|
|
@ -290,7 +335,107 @@ impl ProtocolMachine {
|
||||||
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores liveness when the receive sequence is stuck behind a frame that
|
||||||
|
/// can never arrive.
|
||||||
|
///
|
||||||
|
/// Delivery is gated on `expected_recv_nonce`, so a single missing frame
|
||||||
|
/// holds back every later frame. That is correct *while the sender can still
|
||||||
|
/// retransmit* — but the sender drops a frame from `sent_history` once it
|
||||||
|
/// exceeds `max_retries + 2` attempts (see the zombie eviction in
|
||||||
|
/// `handle_tick`). After that the frame is gone for good and the two sides
|
||||||
|
/// deadlock: the receiver buffers forever and NACKs a nonce nobody can
|
||||||
|
/// resend.
|
||||||
|
///
|
||||||
|
/// That deadlock is invisible to the keepalive watchdog, which is why it
|
||||||
|
/// presented as a hard freeze rather than a reconnect: retransmits, ACKs and
|
||||||
|
/// NACKs keep flowing, so the client's `last_valid_recv` keeps refreshing and
|
||||||
|
/// its stall detector never fires. The RTT readout freezes at its last value
|
||||||
|
/// for the same reason — Pong rides in a Data frame stuck behind the gap.
|
||||||
|
///
|
||||||
|
/// So: once we have been stuck long enough that retransmission has provably
|
||||||
|
/// given up, skip to the lowest buffered nonce and drain. This drops the
|
||||||
|
/// missing frame's payload (one RelayMessage — a chunk of one stream), which
|
||||||
|
/// is a real cost, but the alternative is a permanently dead tunnel.
|
||||||
|
fn recover_stalled_gap(&mut self) -> Vec<ProtocolAction> {
|
||||||
|
let mut recovered = Vec::new();
|
||||||
|
if self.reorder_buffer.is_empty() {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait out the sender's full retransmit budget before giving up, so a
|
||||||
|
// frame that is merely late is never discarded. The sender backs off
|
||||||
|
// exponentially, so key this off the live RTO estimate rather than a
|
||||||
|
// flat constant, with a floor that keeps low-RTT links from skipping
|
||||||
|
// too eagerly and a ceiling that bounds the visible freeze.
|
||||||
|
let timeout = self
|
||||||
|
.cc
|
||||||
|
.rto()
|
||||||
|
.saturating_mul(GAP_RECOVERY_RTO_MULTIPLIER)
|
||||||
|
.clamp(GAP_RECOVERY_MIN, GAP_RECOVERY_MAX);
|
||||||
|
if self.last_recv_advance.elapsed() < timeout {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(&resume_at) = self.reorder_buffer.keys().next() else {
|
||||||
|
return recovered;
|
||||||
|
};
|
||||||
|
let skipped = resume_at.saturating_sub(self.expected_recv_nonce);
|
||||||
|
tracing::warn!(
|
||||||
|
"Gap recovery: no progress for {:?}; skipping {} unrecoverable frame(s) \
|
||||||
|
(nonce {} -> {}) to unblock the session",
|
||||||
|
self.last_recv_advance.elapsed(),
|
||||||
|
skipped,
|
||||||
|
self.expected_recv_nonce,
|
||||||
|
resume_at
|
||||||
|
);
|
||||||
|
|
||||||
|
self.expected_recv_nonce = resume_at;
|
||||||
|
while let Some(buffered) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
||||||
|
recovered.push(buffered);
|
||||||
|
match self.expected_recv_nonce.checked_add(1) {
|
||||||
|
Some(next) => self.expected_recv_nonce = next,
|
||||||
|
// u64 nonce space exhausted: stop draining rather than wrap.
|
||||||
|
// The session is finished either way; the caller's next decrypt
|
||||||
|
// will fail and tear it down.
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_recv_advance = Instant::now();
|
||||||
|
// The peer must learn the sequence moved on, or it will keep
|
||||||
|
// retransmitting into the void.
|
||||||
|
self.ack_pending = true;
|
||||||
|
|
||||||
|
recovered
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
|
// Check for a stalled gap before classifying this frame, so the rest of
|
||||||
|
// the function sees an already-advanced `expected_recv_nonce`. Runs here
|
||||||
|
// rather than on Tick because both tick handlers discard DeliverApp
|
||||||
|
// actions, and because inbound frames keep arriving throughout the stall
|
||||||
|
// (retransmits/ACKs/NACKs/keepalives) — so this path is reliably reached.
|
||||||
|
let recovered = self.recover_stalled_gap();
|
||||||
|
let result = self.handle_data_inbound_frame(raw_vec)?;
|
||||||
|
if recovered.is_empty() {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovered payloads are older than anything this frame produces, so
|
||||||
|
// they go first to preserve delivery order.
|
||||||
|
let mut all = recovered;
|
||||||
|
match result {
|
||||||
|
ProtocolAction::Noop => {}
|
||||||
|
ProtocolAction::Multiple(list) => all.extend(list),
|
||||||
|
single => all.push(single),
|
||||||
|
}
|
||||||
|
Ok(if all.len() == 1 {
|
||||||
|
all.pop().unwrap()
|
||||||
|
} else {
|
||||||
|
ProtocolAction::Multiple(all)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_data_inbound_frame(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
if raw_vec.len() < 12 {
|
if raw_vec.len() < 12 {
|
||||||
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -355,13 +500,8 @@ impl ProtocolMachine {
|
||||||
FrameKind::Data => {
|
FrameKind::Data => {
|
||||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
||||||
}
|
}
|
||||||
FrameKind::Resume => {
|
|
||||||
// 0-RTT: treat early data as application data
|
|
||||||
tracing::info!("0-RTT Resume frame received, processing early data");
|
|
||||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
|
||||||
}
|
|
||||||
FrameKind::Close => {
|
FrameKind::Close => {
|
||||||
tracing::info!("Received Close frame, terminating session");
|
tracing::debug!("Received Close frame, terminating session");
|
||||||
self.state = OstpState::Closed;
|
self.state = OstpState::Closed;
|
||||||
ProtocolAction::Noop
|
ProtocolAction::Noop
|
||||||
}
|
}
|
||||||
|
|
@ -392,18 +532,20 @@ impl ProtocolMachine {
|
||||||
self.last_recv_advance = Instant::now();
|
self.last_recv_advance = Instant::now();
|
||||||
} else {
|
} else {
|
||||||
// Gap detected
|
// Gap detected
|
||||||
|
if nonce >= self.expected_recv_nonce {
|
||||||
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
||||||
self.reorder_buffer.insert(nonce, action);
|
self.reorder_buffer.insert(nonce, action);
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("Reorder buffer full ({}/{}), dropping frame nonce={}",
|
tracing::warn!("Reorder buffer still full after gap recovery, dropping frame nonce={}", nonce);
|
||||||
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
|
}
|
||||||
);
|
} else {
|
||||||
|
tracing::debug!("Frame nonce={} arrived too late after gap recovery, dropping", nonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate-limited NACK: send at most once per 30ms to prevent retransmit storms.
|
// Rate-limited NACK: send at most once per (rto/2) to prevent retransmit storms.
|
||||||
// Under high load with natural UDP reordering, sending a NACK per packet
|
// Using rto/2 means we send a NACK before the sender's timer fires, prompting
|
||||||
// causes exponential retransmit explosion that saturates the channel.
|
// fast retransmit without flooding. Floor at 10ms to handle very low-RTT links.
|
||||||
let nack_cooldown = Duration::from_millis(30);
|
let nack_cooldown = (self.cc.rto() / 2).max(Duration::from_millis(10));
|
||||||
if self.last_nack_sent.elapsed() >= nack_cooldown {
|
if self.last_nack_sent.elapsed() >= nack_cooldown {
|
||||||
self.last_nack_sent = Instant::now();
|
self.last_nack_sent = Instant::now();
|
||||||
let nack_payload = self.expected_recv_nonce.to_be_bytes();
|
let nack_payload = self.expected_recv_nonce.to_be_bytes();
|
||||||
|
|
@ -511,44 +653,18 @@ impl ProtocolMachine {
|
||||||
fn handle_tick(&mut self) -> Result<ProtocolAction, ProtocolError> {
|
fn handle_tick(&mut self) -> Result<ProtocolAction, ProtocolError> {
|
||||||
let mut actions = Vec::new();
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
// ── Gap Recovery ──────────────────────────────────────────────
|
|
||||||
// If expected_recv_nonce hasn't advanced for 500ms+ and there
|
|
||||||
// are buffered frames waiting, the sender likely evicted the lost
|
|
||||||
// frame from sent_history. Skip the gap to restore data flow.
|
|
||||||
// This trades a small amount of data loss for connection liveness.
|
|
||||||
if !self.reorder_buffer.is_empty()
|
|
||||||
&& self.last_recv_advance.elapsed() > Duration::from_millis(500)
|
|
||||||
{
|
|
||||||
if let Some(&first_buffered) = self.reorder_buffer.keys().next() {
|
|
||||||
let skipped = first_buffered.saturating_sub(self.expected_recv_nonce);
|
|
||||||
self.expected_recv_nonce = first_buffered;
|
|
||||||
self.last_recv_advance = Instant::now();
|
|
||||||
|
|
||||||
let mut delivered = 0u64;
|
|
||||||
while let Some(buffered_action) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
|
||||||
actions.push(buffered_action);
|
|
||||||
self.expected_recv_nonce = self.expected_recv_nonce.saturating_add(1);
|
|
||||||
delivered += 1;
|
|
||||||
}
|
|
||||||
self.ack_pending = true;
|
|
||||||
tracing::debug!("Gap recovery: skipped {} lost frames, delivered {} buffered frames (reorder_buf={})",
|
|
||||||
skipped, delivered, self.reorder_buffer.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pending ACK flush ─────────────────────────────────────────
|
// ── Pending ACK flush ─────────────────────────────────────────
|
||||||
if let Some(ack_frame) = self.build_ack_if_due()? {
|
if let Some(ack_frame) = self.build_ack_if_due()? {
|
||||||
actions.push(ProtocolAction::SendDatagram(ack_frame));
|
actions.push(ProtocolAction::SendDatagram(ack_frame));
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let base_rto_ms = self.rto.as_millis().max(1) as u64;
|
// Use the adaptive RTO from the congestion controller (RFC 6298 SRTT + 4*RTTVAR).
|
||||||
|
// Falls back to rto_initial before the first ACK is received.
|
||||||
|
let base_rto_ms = self.cc.rto().max(self.rto).as_millis().max(1) as u64;
|
||||||
|
|
||||||
// ── Zombie frame eviction ────────────────────────────────────
|
// ── Zombie frame eviction ────────────────────────────────────
|
||||||
// Evict frames that exceeded max_retries + 2 grace retries.
|
// Evict frames that exceeded max_retries + 2 grace retries.
|
||||||
// Shorter grace period than before (was +4) to free memory faster
|
|
||||||
// after high-throughput bursts.
|
|
||||||
let grace = self.max_retries.saturating_add(2);
|
let grace = self.max_retries.saturating_add(2);
|
||||||
let before = self.sent_history.len();
|
let before = self.sent_history.len();
|
||||||
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace);
|
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace);
|
||||||
|
|
@ -559,26 +675,48 @@ impl ProtocolMachine {
|
||||||
|
|
||||||
// ── Retransmit expired frames ────────────────────────────────
|
// ── Retransmit expired frames ────────────────────────────────
|
||||||
// Limit retransmits per tick to prevent bandwidth saturation
|
// Limit retransmits per tick to prevent bandwidth saturation
|
||||||
|
// Backoff starts from retry #0 (immediately effective):
|
||||||
|
// effective_rto = base_rto * 2^retries, capped at 2^6 = 64×
|
||||||
let mut retransmit_budget: usize = self.cc.retransmit_budget();
|
let mut retransmit_budget: usize = self.cc.retransmit_budget();
|
||||||
for frame in self.sent_history.iter_mut() {
|
for frame in self.sent_history.iter_mut() {
|
||||||
if !frame.is_retransmittable {
|
if !frame.is_retransmittable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Out of budget for this tick — stop scanning rather than walking the
|
||||||
|
// rest of the queue. sent_history is in send order, so everything we
|
||||||
|
// skip is strictly newer than what we already handled; deferring it to
|
||||||
|
// the next tick preserves oldest-first retransmit priority.
|
||||||
|
if retransmit_budget == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let retry_over = frame.retries.saturating_sub(self.max_retries);
|
// Exponential backoff, but bounded in absolute terms. base_rto is
|
||||||
let backoff_factor = 1u64 << retry_over.min(6);
|
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
|
||||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
// multiplying that by the 64x backoff cap yields a frame that sits
|
||||||
|
// unretransmitted for ~17 MINUTES, long past the point where the
|
||||||
|
// session is simply dead to the user. Cap the product so backoff
|
||||||
|
// stays a backoff rather than an outage.
|
||||||
|
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
||||||
|
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor))
|
||||||
|
.min(MAX_EFFECTIVE_RTO);
|
||||||
|
|
||||||
if now.duration_since(frame.last_sent) >= effective_rto {
|
if now.duration_since(frame.last_sent) >= effective_rto {
|
||||||
|
// Only burn the retry counter and reset the RTO timer when the
|
||||||
|
// frame is ACTUALLY put on the wire. Doing it unconditionally
|
||||||
|
// meant that whenever the per-tick budget ran out — which is
|
||||||
|
// exactly when loss is heavy and retransmits matter most —
|
||||||
|
// frames accumulated "phantom retries" they never actually got,
|
||||||
|
// and the zombie eviction above then silently dropped them after
|
||||||
|
// `grace` such rounds. The peer never received that data and
|
||||||
|
// never would: that stream stalls forever while the session
|
||||||
|
// itself stays healthy, which is precisely the reported "tunnel
|
||||||
|
// frozen at 0 b/s but the session still up" symptom.
|
||||||
frame.last_sent = now;
|
frame.last_sent = now;
|
||||||
frame.retries = frame.retries.saturating_add(1);
|
frame.retries = frame.retries.saturating_add(1);
|
||||||
|
|
||||||
if retransmit_budget > 0 {
|
|
||||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||||
retransmit_budget -= 1;
|
retransmit_budget -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if actions.is_empty() {
|
if actions.is_empty() {
|
||||||
Ok(ProtocolAction::Noop)
|
Ok(ProtocolAction::Noop)
|
||||||
|
|
@ -705,24 +843,34 @@ impl ProtocolMachine {
|
||||||
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut acked_bytes = 0u64;
|
let mut acked_bytes = 0u64;
|
||||||
let mut min_rtt = Duration::from_secs(60);
|
let mut min_rtt: Option<Duration> = None;
|
||||||
|
|
||||||
// Compute RTT from the oldest acked frame's send timestamp
|
|
||||||
for frame in self.sent_history.iter() {
|
for frame in self.sent_history.iter() {
|
||||||
if nonce_in_ranges(frame.nonce, ranges) {
|
if nonce_in_ranges(frame.nonce, ranges) {
|
||||||
acked_bytes += frame.bytes.len() as u64;
|
acked_bytes += frame.bytes.len() as u64;
|
||||||
|
// Karn's algorithm: never take an RTT sample from a frame that
|
||||||
|
// was retransmitted. `last_sent` is bumped on every retransmit,
|
||||||
|
// so an ACK for the ORIGINAL transmission would be measured
|
||||||
|
// against the retransmit time, yielding a spuriously small RTT
|
||||||
|
// that drags SRTT/RTO down and triggers more spurious
|
||||||
|
// retransmits. Only unambiguous (never-retried) frames qualify.
|
||||||
|
if frame.retries == 0 {
|
||||||
let rtt = now.duration_since(frame.last_sent);
|
let rtt = now.duration_since(frame.last_sent);
|
||||||
if rtt < min_rtt {
|
min_rtt = Some(min_rtt.map_or(rtt, |m| m.min(rtt)));
|
||||||
min_rtt = rtt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
|
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
|
||||||
|
|
||||||
// Notify congestion controller
|
// Notify congestion controller. Feed an RTT sample only when we had at
|
||||||
|
// least one unambiguous ACK; otherwise update the window without
|
||||||
|
// polluting the RTT estimator.
|
||||||
if acked_bytes > 0 {
|
if acked_bytes > 0 {
|
||||||
self.cc.on_ack(acked_bytes, min_rtt);
|
match min_rtt {
|
||||||
|
Some(rtt) => self.cc.on_ack(acked_bytes, rtt),
|
||||||
|
None => self.cc.on_ack_no_rtt(acked_bytes),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -752,26 +900,6 @@ fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool {
|
||||||
ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end)
|
ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_split_keys(base_key: &[u8; 32], role: NoiseRole) -> ([u8; 32], [u8; 32]) {
|
|
||||||
let mut initiator_key = [0u8; 32];
|
|
||||||
let mut responder_key = [0u8; 32];
|
|
||||||
|
|
||||||
let mut h1 = Sha256::new();
|
|
||||||
h1.update(base_key);
|
|
||||||
h1.update(b"ostp-initiator");
|
|
||||||
initiator_key.copy_from_slice(&h1.finalize());
|
|
||||||
|
|
||||||
let mut h2 = Sha256::new();
|
|
||||||
h2.update(base_key);
|
|
||||||
h2.update(b"ostp-responder");
|
|
||||||
responder_key.copy_from_slice(&h2.finalize());
|
|
||||||
|
|
||||||
match role {
|
|
||||||
NoiseRole::Initiator => (initiator_key, responder_key),
|
|
||||||
NoiseRole::Responder => (responder_key, initiator_key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -1003,4 +1131,154 @@ mod tests {
|
||||||
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
||||||
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A retry may only be charged to a frame that was actually retransmitted.
|
||||||
|
///
|
||||||
|
/// The retransmit loop is budget-limited per tick. It used to bump
|
||||||
|
/// `retries` and reset `last_sent` for every due frame regardless of
|
||||||
|
/// whether the budget allowed it to actually send — so under heavy loss
|
||||||
|
/// (exactly when the budget runs out) frames racked up retries they never
|
||||||
|
/// received, and the zombie eviction dropped them after `max_retries + 2`
|
||||||
|
/// such rounds. That data was never delivered and never would be: the
|
||||||
|
/// stream stalls permanently while the session itself stays up.
|
||||||
|
#[test]
|
||||||
|
fn test_retransmit_budget_charges_retries_only_for_frames_actually_sent() {
|
||||||
|
let (mut client, _server) = do_handshake();
|
||||||
|
|
||||||
|
// Queue far more in-flight frames than a single tick's budget allows.
|
||||||
|
const FRAMES: usize = 40;
|
||||||
|
for i in 0..FRAMES {
|
||||||
|
let payload = Bytes::from(vec![i as u8; 200]);
|
||||||
|
client.on_event(OstpEvent::Outbound(1, payload)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(client.in_flight_count(), FRAMES);
|
||||||
|
assert_eq!(client.total_retries(), 0, "nothing retransmitted yet");
|
||||||
|
|
||||||
|
// Let every frame's RTO lapse so that on the next tick all FRAMES frames
|
||||||
|
// are due at once and the per-tick budget is guaranteed to run out. The
|
||||||
|
// effective RTO here is max(cc.rto(), config rto_ms) = 100ms at retries=0.
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
|
||||||
|
let sent = count_datagrams(&client.on_event(OstpEvent::Tick).unwrap());
|
||||||
|
|
||||||
|
assert!(sent > 0, "expected some retransmits after the RTO lapsed");
|
||||||
|
assert!(
|
||||||
|
sent < FRAMES,
|
||||||
|
"budget should have capped this tick below the {FRAMES} due frames, got {sent}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.total_retries(),
|
||||||
|
sent,
|
||||||
|
"charged {} retries but only put {} frames on the wire — the \
|
||||||
|
difference is phantom retries that will silently evict live data",
|
||||||
|
client.total_retries(),
|
||||||
|
sent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.in_flight_count(),
|
||||||
|
FRAMES,
|
||||||
|
"nothing was acked, so no frame may be evicted yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many datagrams an action tree actually puts on the wire.
|
||||||
|
fn count_datagrams(action: &ProtocolAction) -> usize {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::SendDatagram(_) => 1,
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().map(count_datagrams).sum(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many application payloads an action tree actually delivers.
|
||||||
|
fn delivered_payloads(action: &ProtocolAction) -> Vec<Bytes> {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::DeliverApp(_, data) => vec![data.clone()],
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().flat_map(delivered_payloads).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `count` data frames on `client`, returning them without delivering
|
||||||
|
/// any — lets a test choose which ones to "lose" in transit.
|
||||||
|
fn make_data_frames(client: &mut ProtocolMachine, count: u8) -> Vec<Bytes> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| {
|
||||||
|
let payload = Bytes::from(vec![i; 32]);
|
||||||
|
match client.on_event(OstpEvent::Outbound(1, payload)).unwrap() {
|
||||||
|
ProtocolAction::SendDatagram(d) => d,
|
||||||
|
_ => panic!("expected SendDatagram for frame {i}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The freeze this fixes: a frame is lost, the sender eventually stops
|
||||||
|
/// retransmitting it, and the receiver — which gates delivery on
|
||||||
|
/// `expected_recv_nonce` — waits for it forever. Every later frame piles up
|
||||||
|
/// undelivered while the transport itself stays healthy, so nothing upstream
|
||||||
|
/// notices. Recovery must eventually skip the hole and release the backlog.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_releases_permanently_stalled_frames() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 4);
|
||||||
|
|
||||||
|
// Frame 0 arrives in order and is delivered straight through.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert_eq!(delivered_payloads(&action).len(), 1, "in-order frame should deliver");
|
||||||
|
|
||||||
|
// Frame 1 is lost. 2 and 3 arrive but must be held back — delivering them
|
||||||
|
// now would reorder the stream.
|
||||||
|
for idx in [2usize, 3] {
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[idx].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"frame {idx} must stay buffered behind the missing frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand in for "the sender exhausted its retries and dropped frame 1":
|
||||||
|
// the sequence has not advanced for longer than the recovery timeout.
|
||||||
|
server.last_recv_advance = Instant::now() - GAP_RECOVERY_MAX - Duration::from_secs(1);
|
||||||
|
|
||||||
|
// The next inbound frame (a retransmitted duplicate, which is exactly what
|
||||||
|
// a real stalled session keeps receiving) must unblock the backlog.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(
|
||||||
|
delivered.len(),
|
||||||
|
2,
|
||||||
|
"both buffered frames must be released once the gap is declared unrecoverable"
|
||||||
|
);
|
||||||
|
// ...and in order: frame 2 before frame 3.
|
||||||
|
assert_eq!(delivered[0][0], 2);
|
||||||
|
assert_eq!(delivered[1][0], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovery must not be trigger-happy: a frame that is merely late still has
|
||||||
|
/// to be waited for, or we would discard data the sender is about to resend.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_does_not_fire_before_timeout() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 3);
|
||||||
|
|
||||||
|
server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[2].clone())).unwrap();
|
||||||
|
assert!(delivered_payloads(&action).is_empty());
|
||||||
|
|
||||||
|
// Well inside the timeout — the gap must still be respected.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"must keep waiting while retransmission is still plausible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And once the genuinely-late frame shows up, normal in-order delivery
|
||||||
|
// resumes with nothing dropped.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[1].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(delivered.len(), 2, "late frame plus the buffered one");
|
||||||
|
assert_eq!(delivered[0][0], 1);
|
||||||
|
assert_eq!(delivered[1][0], 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
//! 0-RTT Session Resumption for OSTP.
|
|
||||||
//!
|
|
||||||
//! When a client has previously connected to a server, it can cache
|
|
||||||
//! a "session ticket" that allows it to send encrypted data in the
|
|
||||||
//! very first packet — eliminating the handshake round-trip entirely.
|
|
||||||
//!
|
|
||||||
//! How it works:
|
|
||||||
//! 1. After a successful handshake, the server issues a SessionTicket
|
|
||||||
//! containing enough state to resume the session.
|
|
||||||
//! 2. The client stores the ticket locally (encrypted with the PSK).
|
|
||||||
//! 3. On reconnection, the client sends a ResumptionRequest with the
|
|
||||||
//! ticket + early data in the first packet.
|
|
||||||
//! 4. The server validates the ticket and immediately begins processing
|
|
||||||
//! data, achieving 0-RTT.
|
|
||||||
//!
|
|
||||||
//! Security considerations:
|
|
||||||
//! - Tickets have a TTL (default 3600s) to limit replay window.
|
|
||||||
//! - The server maintains a ticket nonce set to prevent replay.
|
|
||||||
//! - Early data is idempotent by protocol design (relay CONNECT is safe
|
|
||||||
//! because duplicate CONNECTs to the same target are no-ops).
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
/// A session ticket that allows 0-RTT resumption.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SessionTicket {
|
|
||||||
/// Unique ticket identifier (prevents replay)
|
|
||||||
pub ticket_id: [u8; 16],
|
|
||||||
/// Server session ID to resume
|
|
||||||
pub session_id: u32,
|
|
||||||
/// Derived cipher key for early data
|
|
||||||
pub cipher_key: [u8; 32],
|
|
||||||
/// Timestamp of issuance (seconds since epoch)
|
|
||||||
pub issued_at: u64,
|
|
||||||
/// Time-to-live in seconds
|
|
||||||
pub ttl: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum ticket age (1 hour default)
|
|
||||||
const DEFAULT_TICKET_TTL: u64 = 3600;
|
|
||||||
/// Maximum tickets in the anti-replay set
|
|
||||||
const MAX_REPLAY_SET: usize = 10000;
|
|
||||||
|
|
||||||
impl SessionTicket {
|
|
||||||
/// Create a new session ticket from the transport key material.
|
|
||||||
pub fn new(session_id: u32, transport_key: &[u8; 32], psk: &[u8; 32]) -> Self {
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Derive ticket ID from key material + timestamp
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(transport_key);
|
|
||||||
hasher.update(now.to_be_bytes());
|
|
||||||
hasher.update(b"ostp-ticket-id");
|
|
||||||
let hash = hasher.finalize();
|
|
||||||
let mut ticket_id = [0u8; 16];
|
|
||||||
ticket_id.copy_from_slice(&hash[..16]);
|
|
||||||
|
|
||||||
// Derive cipher key for early data from PSK + ticket
|
|
||||||
let mut key_hasher = Sha256::new();
|
|
||||||
key_hasher.update(psk);
|
|
||||||
key_hasher.update(ticket_id);
|
|
||||||
key_hasher.update(b"ostp-early-data-key");
|
|
||||||
let cipher_key_hash = key_hasher.finalize();
|
|
||||||
let mut cipher_key = [0u8; 32];
|
|
||||||
cipher_key.copy_from_slice(&cipher_key_hash);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
ticket_id,
|
|
||||||
session_id,
|
|
||||||
cipher_key,
|
|
||||||
issued_at: now,
|
|
||||||
ttl: DEFAULT_TICKET_TTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the ticket has expired.
|
|
||||||
pub fn is_expired(&self) -> bool {
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
now > self.issued_at + self.ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize the ticket to bytes for storage/transmission.
|
|
||||||
/// Wire format: [ticket_id:16][session_id:4][cipher_key:32][issued_at:8][ttl:8]
|
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(68);
|
|
||||||
out.extend_from_slice(&self.ticket_id);
|
|
||||||
out.extend_from_slice(&self.session_id.to_be_bytes());
|
|
||||||
out.extend_from_slice(&self.cipher_key);
|
|
||||||
out.extend_from_slice(&self.issued_at.to_be_bytes());
|
|
||||||
out.extend_from_slice(&self.ttl.to_be_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize a ticket from bytes.
|
|
||||||
pub fn from_bytes(data: &[u8]) -> Option<Self> {
|
|
||||||
if data.len() < 68 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut ticket_id = [0u8; 16];
|
|
||||||
ticket_id.copy_from_slice(&data[0..16]);
|
|
||||||
|
|
||||||
let session_id = u32::from_be_bytes(data[16..20].try_into().ok()?);
|
|
||||||
|
|
||||||
let mut cipher_key = [0u8; 32];
|
|
||||||
cipher_key.copy_from_slice(&data[20..52]);
|
|
||||||
|
|
||||||
let issued_at = u64::from_be_bytes(data[52..60].try_into().ok()?);
|
|
||||||
let ttl = u64::from_be_bytes(data[60..68].try_into().ok()?);
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
ticket_id,
|
|
||||||
session_id,
|
|
||||||
cipher_key,
|
|
||||||
issued_at,
|
|
||||||
ttl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt the ticket with a PSK for client-side storage.
|
|
||||||
/// Uses a simple XOR cipher with HMAC-SHA256 derived key.
|
|
||||||
pub fn encrypt(&self, psk: &[u8; 32]) -> Vec<u8> {
|
|
||||||
let raw = self.to_bytes();
|
|
||||||
let mut enc_key_hasher = Sha256::new();
|
|
||||||
enc_key_hasher.update(psk);
|
|
||||||
enc_key_hasher.update(b"ostp-ticket-encryption");
|
|
||||||
let enc_key = enc_key_hasher.finalize();
|
|
||||||
|
|
||||||
let mut encrypted = raw.clone();
|
|
||||||
for (i, byte) in encrypted.iter_mut().enumerate() {
|
|
||||||
*byte ^= enc_key[i % 32];
|
|
||||||
}
|
|
||||||
encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt a ticket from encrypted bytes.
|
|
||||||
pub fn decrypt(encrypted: &[u8], psk: &[u8; 32]) -> Option<Self> {
|
|
||||||
let mut enc_key_hasher = Sha256::new();
|
|
||||||
enc_key_hasher.update(psk);
|
|
||||||
enc_key_hasher.update(b"ostp-ticket-encryption");
|
|
||||||
let enc_key = enc_key_hasher.finalize();
|
|
||||||
|
|
||||||
let mut decrypted = encrypted.to_vec();
|
|
||||||
for (i, byte) in decrypted.iter_mut().enumerate() {
|
|
||||||
*byte ^= enc_key[i % 32];
|
|
||||||
}
|
|
||||||
Self::from_bytes(&decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Server-side anti-replay guard for session tickets.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct TicketValidator {
|
|
||||||
/// Set of consumed ticket IDs (prevents replay)
|
|
||||||
consumed: HashSet<[u8; 16]>,
|
|
||||||
/// PSK for ticket validation
|
|
||||||
psk: [u8; 32],
|
|
||||||
/// Maximum age for tickets
|
|
||||||
max_age: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketValidator {
|
|
||||||
pub fn new(psk: [u8; 32]) -> Self {
|
|
||||||
Self {
|
|
||||||
consumed: HashSet::new(),
|
|
||||||
psk,
|
|
||||||
max_age: Duration::from_secs(DEFAULT_TICKET_TTL),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate a ticket from the client. Returns the ticket if valid,
|
|
||||||
/// or None if expired, replayed, or invalid.
|
|
||||||
pub fn validate(&mut self, encrypted_ticket: &[u8]) -> Option<SessionTicket> {
|
|
||||||
let ticket = SessionTicket::decrypt(encrypted_ticket, &self.psk)?;
|
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
if ticket.is_expired() {
|
|
||||||
tracing::debug!("0-RTT ticket rejected: expired");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check replay
|
|
||||||
if self.consumed.contains(&ticket.ticket_id) {
|
|
||||||
tracing::warn!("0-RTT ticket rejected: replay detected");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept and mark as consumed
|
|
||||||
self.consumed.insert(ticket.ticket_id);
|
|
||||||
|
|
||||||
// Garbage collection: remove old entries when set grows too large
|
|
||||||
if self.consumed.len() > MAX_REPLAY_SET {
|
|
||||||
// Simple strategy: clear the entire set. This is safe because
|
|
||||||
// expired tickets would fail the expiry check anyway.
|
|
||||||
self.consumed.clear();
|
|
||||||
self.consumed.insert(ticket.ticket_id);
|
|
||||||
tracing::debug!("0-RTT replay set cleared (overflow)");
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::debug!("0-RTT ticket accepted: session_id={}", ticket.session_id);
|
|
||||||
Some(ticket)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue a new ticket for a completed session.
|
|
||||||
pub fn issue_ticket(&self, session_id: u32, transport_key: &[u8; 32]) -> SessionTicket {
|
|
||||||
SessionTicket::new(session_id, transport_key, &self.psk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_serialize_roundtrip() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(12345, &key, &psk);
|
|
||||||
|
|
||||||
let bytes = ticket.to_bytes();
|
|
||||||
let restored = SessionTicket::from_bytes(&bytes).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(ticket.ticket_id, restored.ticket_id);
|
|
||||||
assert_eq!(ticket.session_id, restored.session_id);
|
|
||||||
assert_eq!(ticket.cipher_key, restored.cipher_key);
|
|
||||||
assert_eq!(ticket.issued_at, restored.issued_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_encrypt_decrypt() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(99, &key, &psk);
|
|
||||||
|
|
||||||
let encrypted = ticket.encrypt(&psk);
|
|
||||||
let decrypted = SessionTicket::decrypt(&encrypted, &psk).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(ticket.ticket_id, decrypted.ticket_id);
|
|
||||||
assert_eq!(ticket.session_id, decrypted.session_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_wrong_psk_fails() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let wrong_psk = [99u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(1, &key, &psk);
|
|
||||||
let encrypted = ticket.encrypt(&psk);
|
|
||||||
|
|
||||||
// Decrypting with wrong PSK produces garbage, from_bytes should
|
|
||||||
// still return Some but ticket_id won't match
|
|
||||||
let decrypted = SessionTicket::decrypt(&encrypted, &wrong_psk);
|
|
||||||
// It may parse but the data will be wrong
|
|
||||||
if let Some(d) = decrypted {
|
|
||||||
assert_ne!(d.ticket_id, ticket.ticket_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_not_expired() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(1, &key, &psk);
|
|
||||||
assert!(!ticket.is_expired());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validator_replay_protection() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let mut validator = TicketValidator::new(psk);
|
|
||||||
|
|
||||||
let ticket = validator.issue_ticket(1, &key);
|
|
||||||
let encrypted = ticket.encrypt(&psk);
|
|
||||||
|
|
||||||
// First use should succeed
|
|
||||||
assert!(validator.validate(&encrypted).is_some());
|
|
||||||
|
|
||||||
// Replay should fail
|
|
||||||
assert!(validator.validate(&encrypted).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validator_different_tickets() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let mut validator = TicketValidator::new(psk);
|
|
||||||
|
|
||||||
let ticket1 = validator.issue_ticket(1, &[1u8; 32]);
|
|
||||||
let ticket2 = validator.issue_ticket(2, &[2u8; 32]);
|
|
||||||
|
|
||||||
assert!(validator.validate(&ticket1.encrypt(&psk)).is_some());
|
|
||||||
assert!(validator.validate(&ticket2.encrypt(&psk)).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncated_ticket_fails() {
|
|
||||||
assert!(SessionTicket::from_bytes(&[0u8; 10]).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
|
|
@ -5,6 +8,37 @@ plugins {
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Release signing material ────────────────────────────────────────────────
|
||||||
|
// Supplied out-of-band and never committed: either an `android/key.properties`
|
||||||
|
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
|
||||||
|
//
|
||||||
|
// This exists because the release build used to be signed with the DEBUG
|
||||||
|
// keystore (the stock Flutter template TODO). Android identifies an app by
|
||||||
|
// applicationId + signing key, and refuses to update across a key change. The
|
||||||
|
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
|
||||||
|
// so every published build carried a different random key — which is why
|
||||||
|
// updating on top of a previous install failed with "App not installed" /
|
||||||
|
// "unable to parse the package" and only a full uninstall+reinstall worked.
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propsFile = rootProject.file("key.properties")
|
||||||
|
if (propsFile.exists()) {
|
||||||
|
FileInputStream(propsFile).use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank counts as absent. GitHub Actions substitutes an EMPTY STRING (not an
|
||||||
|
// unset variable) for a secret that doesn't exist, so `getenv(...) ?: fallback`
|
||||||
|
// silently kept the empty value — the elvis operator only catches null. That is
|
||||||
|
// how an unset ANDROID_KEY_PASSWORD ended up being used as the literal key
|
||||||
|
// password instead of falling back to the store password, producing Gradle's
|
||||||
|
// "Get Key failed: Given final block not properly padded".
|
||||||
|
fun signingSetting(propKey: String, envKey: String): String? =
|
||||||
|
(keystoreProperties.getProperty(propKey) ?: System.getenv(envKey))
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
|
||||||
|
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.ospab.ostp_client"
|
namespace = "com.ospab.ostp_client"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|
@ -34,11 +68,43 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
val store = signingSetting("storePassword", "OSTP_KEYSTORE_PASSWORD")
|
||||||
|
storeFile = file(releaseStorePath!!)
|
||||||
|
storePassword = store
|
||||||
|
keyAlias = signingSetting("keyAlias", "OSTP_KEY_ALIAS")
|
||||||
|
// PKCS12 (the keytool default since Java 9, and what our upload
|
||||||
|
// keystore is) cannot hold a key password that differs from the
|
||||||
|
// store password — the format simply has no place to put one. So
|
||||||
|
// treat a missing key password as "same as the store password"
|
||||||
|
// instead of demanding a secret that, for this keystore, can only
|
||||||
|
// ever be a duplicate. An explicit value still wins, for the older
|
||||||
|
// JKS format where the two genuinely can differ.
|
||||||
|
keyPassword = signingSetting("keyPassword", "OSTP_KEY_PASSWORD") ?: store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Use the real upload key when one was supplied; otherwise fall back to
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// the debug keystore so a plain local `flutter build apk --release`
|
||||||
|
// still works for development. Anything PUBLISHED must take the first
|
||||||
|
// branch — a debug-signed build cannot be updated over, and its key is
|
||||||
|
// machine-local, so it also can't be reproduced later.
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"OSTP: no release keystore configured (android/key.properties or " +
|
||||||
|
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
|
||||||
|
"is for local use only: users cannot update over it, and the key is " +
|
||||||
|
"not reproducible on another machine."
|
||||||
|
)
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,29 @@ class MainActivity : FlutterActivity() {
|
||||||
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
||||||
result.success(metrics ?: "{}")
|
result.success(metrics ?: "{}")
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
// Surfaced into the in-app log viewer (not just logcat) so a
|
||||||
|
// broken traffic counter is diagnosable from a user's bug
|
||||||
|
// report without adb access.
|
||||||
|
android.util.Log.e("MainActivity", "getMetrics failed", e)
|
||||||
|
try {
|
||||||
|
net.ostp.client.OstpClientSdk.addLog("getMetrics failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
} catch (_: Throwable) {}
|
||||||
result.error("ERROR", e.message, null)
|
result.error("ERROR", e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"getInstalledApps" -> {
|
"getInstalledApps" -> {
|
||||||
try {
|
// MethodChannel handlers run on the main/UI thread by default.
|
||||||
|
// Enumerating every installed package AND decoding+re-encoding
|
||||||
|
// each one's icon to PNG/base64 is expensive (100+ apps is
|
||||||
|
// common) — done inline here it blocked the main thread for
|
||||||
|
// 10-15s, during which Flutter couldn't render ANY frame, not
|
||||||
|
// even the "loading" spinner, so the screen just appeared to
|
||||||
|
// hang before jumping straight to the fully-loaded list.
|
||||||
|
// Do the work on a background thread; only the final
|
||||||
|
// `result.success(...)` needs to hop back onto the UI thread.
|
||||||
val pm = packageManager
|
val pm = packageManager
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||||
val list = apps.map { app ->
|
val list = apps.map { app ->
|
||||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
||||||
|
|
@ -110,10 +127,11 @@ class MainActivity : FlutterActivity() {
|
||||||
"icon" to (iconBase64 ?: "")
|
"icon" to (iconBase64 ?: "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
result.success(list)
|
runOnUiThread { result.success(list) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("ERROR", e.message, null)
|
runOnUiThread { result.error("ERROR", e.message, null) }
|
||||||
}
|
}
|
||||||
|
}.start()
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |