Compare commits
176 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 | |
|
|
31d0020483 | |
|
|
04761fb6a3 | |
|
|
feaac0c713 | |
|
|
b841053628 | |
|
|
cf92089005 | |
|
|
e0a13702ea | |
|
|
c36e7373e8 | |
|
|
3671a83971 | |
|
|
c7bca41616 | |
|
|
486d745d47 | |
|
|
74b6648db1 | |
|
|
4543fa82f8 | |
|
|
83ba39e59a | |
|
|
533466b63a | |
|
|
6dee7613a5 | |
|
|
4c0263f7f7 | |
|
|
4d228cf1e1 | |
|
|
55215567dd | |
|
|
ab8d2c2185 | |
|
|
875177f779 | |
|
|
2a24ac34d0 | |
|
|
8fc61f986f | |
|
|
ee6768dee1 | |
|
|
091bb2c707 | |
|
|
2d05fb282d | |
|
|
3c54aba63f | |
|
|
a9e4511190 | |
|
|
fbf13b86f3 | |
|
|
9f35caf4ca | |
|
|
7bb7d211fa | |
|
|
430ab8a743 | |
|
|
04c31c7f53 | |
|
|
60282d730f | |
|
|
da238fad5c | |
|
|
85f0cb19cf | |
|
|
c95720f3da | |
|
|
730eab8553 |
|
|
@ -1,29 +1,151 @@
|
|||
name: Universal CI/CD Release Matrix
|
||||
name: CI/CD
|
||||
|
||||
|
||||
# `run-name` is evaluated at workflow-start, BEFORE any job runs - it cannot
|
||||
# see resolve-channel's computed tag_name (e.g. "0.4.3-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:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: >-
|
||||
Manually build+release just this rolling channel. Stable releases
|
||||
are NEVER picked here on purpose - cut those only via a real
|
||||
"vX.Y.Z" tag push, so a manual dispatch can't accidentally publish
|
||||
a "stable" release.
|
||||
type: choice
|
||||
required: true
|
||||
default: alpha
|
||||
options:
|
||||
- alpha
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# ── Global defaults ─────────────────────────────────────────────────────────
|
||||
# -- Global defaults ---------------------------------------------------------
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
|
||||
jobs:
|
||||
# Computes ONE channel + release tag for this whole run, so every build
|
||||
# job (native matrix + all 3 GUI platforms + Android) uploads to the exact
|
||||
# same release under the exact same tag, instead of repeating this logic
|
||||
# (and risking it drifting out of sync) in five separate places.
|
||||
#
|
||||
# Tag shape:
|
||||
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
|
||||
# - push to `alpha` -> "{version}-alpha" (rolling, same tag every push)
|
||||
# - push to `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:
|
||||
name: Check & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Restore Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: cargo-check-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-check-
|
||||
|
||||
- name: Install musl-tools
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Create dummy dist for rust-embed
|
||||
run: mkdir -p ostp-control/dist && touch ostp-control/dist/index.html
|
||||
|
||||
- name: cargo check
|
||||
run: cargo check --workspace
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --workspace --lib
|
||||
|
||||
publish-release-matrix:
|
||||
name: Release for ${{ matrix.target }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# ── Windows ──────────────────────────────────────────────────────
|
||||
# -- Windows ------------------------------------------------------
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: ostp.exe
|
||||
|
|
@ -42,7 +164,7 @@ jobs:
|
|||
release_name: ostp-windows-arm64.zip
|
||||
wintun_arch: arm64
|
||||
|
||||
# ── macOS ─────────────────────────────────────────────────────────
|
||||
# -- macOS ---------------------------------------------------------
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: ostp
|
||||
|
|
@ -53,7 +175,7 @@ jobs:
|
|||
artifact_name: ostp
|
||||
release_name: ostp-darwin-arm64.tar.gz
|
||||
|
||||
# ── Linux native ──────────────────────────────────────────────────
|
||||
# -- Linux native --------------------------------------------------
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
|
|
@ -65,7 +187,7 @@ jobs:
|
|||
release_name: ostp-linux-386.tar.gz
|
||||
use_cross: true
|
||||
|
||||
# ── Linux cross ───────────────────────────────────────────────────
|
||||
# -- Linux cross ---------------------------------------------------
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact_name: ostp
|
||||
|
|
@ -103,25 +225,31 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# ── Frontend Build ─────────────────────────────────────────────────────
|
||||
# -- Frontend Build -----------------------------------------------------
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Build Web Panel
|
||||
working-directory: ostp-control
|
||||
- name: Build Web Panel (skip if no source; use committed dist/)
|
||||
shell: bash
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
mkdir -p ostp-control/dist
|
||||
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
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ matrix.toolchain || 'stable' }}
|
||||
targets: ${{ !matrix.use_cross && matrix.target || '' }}
|
||||
|
||||
# ── Cargo cache (shared per target) ───────────────────────────────────
|
||||
# -- Cargo cache (shared per target) -----------------------------------
|
||||
- name: Restore Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
|
@ -134,18 +262,18 @@ jobs:
|
|||
restore-keys: |
|
||||
cargo-${{ matrix.target }}-
|
||||
|
||||
# ── MUSL tools for native Linux musl builds ────────────────────────────
|
||||
# -- MUSL tools for native Linux musl builds ----------------------------
|
||||
- name: Install musl-tools
|
||||
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
# ── Native build ───────────────────────────────────────────────────────
|
||||
# -- Native build -------------------------------------------------------
|
||||
- name: Build (native)
|
||||
if: ${{ !matrix.use_cross }}
|
||||
shell: bash
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
# ── Cross build ────────────────────────────────────────────────────────
|
||||
# -- Cross build --------------------------------------------------------
|
||||
- name: Restore cross binary cache
|
||||
if: ${{ matrix.use_cross }}
|
||||
id: cross-cache
|
||||
|
|
@ -156,13 +284,21 @@ jobs:
|
|||
|
||||
- name: Install cross (if not cached)
|
||||
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)
|
||||
if: ${{ matrix.use_cross }}
|
||||
run: cross build --release --target ${{ matrix.target }} --bin ostp
|
||||
|
||||
# ── Driver dependencies ────────────────────────────────────────────────
|
||||
# -- Driver dependencies ------------------------------------------------
|
||||
- name: Download wintun (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
|
|
@ -174,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/"
|
||||
Remove-Item "$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
|
||||
|
||||
# ── Package ────────────────────────────────────────────────────────────
|
||||
# -- Package ------------------------------------------------------------
|
||||
- name: Package (Windows)
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
|
|
@ -193,17 +329,23 @@ jobs:
|
|||
FILES="${{ matrix.artifact_name }}"
|
||||
tar -czf "${{ matrix.release_name }}" -C "$dir" $FILES
|
||||
|
||||
# ── Upload ─────────────────────────────────────────────────────────────
|
||||
# -- Upload -------------------------------------------------------------
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
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 }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-windows-gui:
|
||||
name: Build Windows GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -236,7 +378,15 @@ jobs:
|
|||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
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
|
||||
shell: pwsh
|
||||
|
|
@ -268,15 +418,21 @@ jobs:
|
|||
Compress-Archive -Path "$dir/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch }}.zip" -Force
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-linux-gui:
|
||||
name: Build Linux GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -312,30 +468,46 @@ jobs:
|
|||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-linux-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
run: |
|
||||
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 }}
|
||||
|
||||
- name: Package Portable Tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir 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 }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-macos-gui:
|
||||
name: Build macOS GUI (Tauri) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -368,7 +540,10 @@ jobs:
|
|||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-macos-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
|
|
@ -383,15 +558,21 @@ jobs:
|
|||
tar -czf ostp-macos-gui-${{ matrix.arch }}.tar.gz ostp-macos-gui-${{ matrix.arch }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
build-android:
|
||||
name: Build Android Client (Flutter) - ${{ matrix.arch }}
|
||||
needs: [check-and-test, resolve-channel]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
|
@ -402,7 +583,6 @@ jobs:
|
|||
- arch: armeabi-v7a
|
||||
rust_target: armv7-linux-androideabi
|
||||
flutter_target: android-arm
|
||||
tun2socks_arch: linux-armv7
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -428,32 +608,117 @@ jobs:
|
|||
with:
|
||||
ndk-version: r26b
|
||||
|
||||
- name: Install cargo-ndk
|
||||
run: cargo install cargo-ndk
|
||||
# The Android jobs had no Rust caching at all, so every release recompiled
|
||||
# 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
|
||||
shell: bash
|
||||
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: |
|
||||
# 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 }}
|
||||
|
||||
|
||||
cd ../ostp-jni
|
||||
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
||||
cd ../ostp-flutter
|
||||
|
||||
|
||||
|
||||
# 3. Build Flutter APK
|
||||
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
||||
|
||||
# 4. Copy to output
|
||||
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
|
||||
|
||||
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
|
||||
# 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
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
**/*.rs.bk
|
||||
.idea/
|
||||
.vscode/
|
||||
**/node_modules/
|
||||
|
||||
# Binaries & libraries
|
||||
*.exe
|
||||
|
|
@ -25,6 +26,17 @@ test_route.ps1
|
|||
config.json
|
||||
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
|
||||
*.log
|
||||
|
||||
|
|
@ -34,5 +46,14 @@ turn-harvesting-idea.md
|
|||
|
||||
# Private tooling (closed-source)
|
||||
ostp-prober/
|
||||
ostp-lab/
|
||||
|
||||
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)
|
||||
2. [Project Structure](#project-structure)
|
||||
3. [Development Workflow](#development-workflow)
|
||||
4. [Coding Guidelines](#coding-guidelines)
|
||||
5. [Submitting Pull Requests](#submitting-pull-requests)
|
||||
6. [Security Vulnerabilities](#security-vulnerabilities)
|
||||
3. [Branch Strategy](#branch-strategy)
|
||||
4. [Development Workflow](#development-workflow)
|
||||
5. [Commit Message Conventions](#commit-message-conventions)
|
||||
6. [Coding Guidelines](#coding-guidelines)
|
||||
7. [Submitting Pull Requests](#submitting-pull-requests)
|
||||
8. [Security Vulnerabilities](#security-vulnerabilities)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,20 +35,19 @@ To build and test OSTP locally, you will need:
|
|||
cd ostp
|
||||
```
|
||||
|
||||
2. **Build the control panel frontend**:
|
||||
```bash
|
||||
cd ostp-control
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **Build the entire Cargo workspace**:
|
||||
2. **Build the entire Cargo workspace**:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
`ostp-control` (the web panel) is only needed if you're working on it
|
||||
specifically - the server build embeds a dummy `dist/` via `rust-embed`
|
||||
otherwise, so this step is not required for day-to-day core/client/server
|
||||
work. If you *are* touching the panel:
|
||||
```bash
|
||||
cd ostp-control && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
4. **Run tests**:
|
||||
3. **Run tests**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
||||
* [`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-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.
|
||||
|
|
@ -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
|
||||
|
||||
1. **Check for existing issues** or open a new one to discuss proposed changes before starting work.
|
||||
2. **Fork the repository** and create a new branch from `master`:
|
||||
2. **Fork the repository** and create a new branch from `alpha`:
|
||||
```bash
|
||||
git checkout alpha
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
3. **Implement your changes**, ensuring you write appropriate unit or integration tests.
|
||||
|
|
@ -89,6 +107,32 @@ The repository is organized as a Cargo workspace containing the following crates
|
|||
|
||||
---
|
||||
|
||||
## Commit Message Conventions
|
||||
|
||||
```
|
||||
<type>(<scope>): <short, imperative summary>
|
||||
|
||||
<optional body - explain WHY, not what; the diff already shows what changed>
|
||||
```
|
||||
|
||||
- **Type** - one of: `feat` (new capability), `fix` (bug fix), `docs`, `refactor` (no behavior change), `perf`, `test`, `chore` (deps/tooling/version bumps), `ci`, `security`.
|
||||
- **Scope** (optional) - the crate or area touched: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs`, etc. e.g. `fix(client): ...`.
|
||||
- **Summary** - imperative mood ("add", not "added"/"adds"), no trailing period, ideally under ~70 characters.
|
||||
- **Body** - only when the *why* isn't obvious from the diff: a prior bug this fixes, a constraint that shaped the approach, a tradeoff you made. Don't restate what the diff already shows. Wrap at ~72 columns.
|
||||
|
||||
```
|
||||
fix(server): drop junk frames by per-key marker instead of a global one
|
||||
|
||||
A fixed 4-byte marker on every junk packet is itself a DPI signature any
|
||||
observer can filter on across every OSTP deployment. Derive the marker
|
||||
from the access key (HKDF, same scheme as obfuscation_key/psk) so it's
|
||||
per-user and indistinguishable from the packet's own random payload.
|
||||
```
|
||||
|
||||
Multiple unrelated changes belong in separate commits, not one bundled commit - it keeps `git bisect` and review useful. Squash-merge is fine for a PR with a few "fix typo" / "address review" commits, but don't squash logically distinct changes together.
|
||||
|
||||
---
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
* **Safety**: Avoid using `unsafe` blocks unless absolutely necessary for low-level system bindings (e.g., FFI configurations like `setsockopt`). When using `unsafe`, add safety doc comments explaining why it is safe.
|
||||
|
|
@ -104,7 +148,7 @@ The repository is organized as a Cargo workspace containing the following crates
|
|||
```bash
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
2. Open a Pull Request (PR) targeting the `master` branch.
|
||||
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `beta`, never direct PRs).
|
||||
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
||||
4. Verify that GitHub Actions CI runs successfully on your PR.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
|
||||
1. [Подготовка окружения](#подготовка-окружения)
|
||||
2. [Структура проекта](#структура-проекта)
|
||||
3. [Процесс разработки](#процесс-разработки)
|
||||
4. [Правила оформления кода](#правила-оформления-кода)
|
||||
5. [Создание Pull Request](#создание-pull-request)
|
||||
6. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||
3. [Стратегия веток](#стратегия-веток)
|
||||
4. [Процесс разработки](#процесс-разработки)
|
||||
5. [Оформление коммитов](#оформление-коммитов)
|
||||
6. [Правила оформления кода](#правила-оформления-кода)
|
||||
7. [Создание Pull Request](#создание-pull-request)
|
||||
8. [Уязвимости безопасности](#уязвимости-безопасности)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,20 +35,19 @@
|
|||
cd ostp
|
||||
```
|
||||
|
||||
2. **Соберите веб-интерфейс панели управления**:
|
||||
```bash
|
||||
cd ostp-control
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **Соберите весь Cargo-workspace**:
|
||||
2. **Соберите весь Cargo-workspace**:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
`ostp-control` (веб-панель) нужна только если вы работаете конкретно над
|
||||
ней - в остальных случаях сервер собирается с пустым `dist/` через
|
||||
`rust-embed`, и этот шаг не нужен для повседневной работы над
|
||||
core/client/server. Если вы всё же трогаете панель:
|
||||
```bash
|
||||
cd ostp-control && npm install && npm run build && cd ..
|
||||
```
|
||||
|
||||
4. **Запустите тесты**:
|
||||
3. **Запустите тесты**:
|
||||
```bash
|
||||
cargo test --workspace
|
||||
```
|
||||
|
|
@ -58,7 +59,7 @@
|
|||
Репозиторий представляет собой единый Cargo-workspace со следующими компонентами:
|
||||
|
||||
* [`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-control/`](file:///d:/ospab-projects/ostp/ostp-control): Панель администратора (пользователи, статистика трафика в реальном времени, лимиты скорости и объема данных).
|
||||
* [`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) для обсуждения предлагаемых изменений.
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `master`:
|
||||
2. **Сделайте fork репозитория** и создайте новую ветку от `alpha`:
|
||||
```bash
|
||||
git checkout alpha
|
||||
git checkout -b feat/имя-вашей-фичи
|
||||
```
|
||||
3. **Внесите необходимые изменения** и добавьте соответствующие модульные или интеграционные тесты.
|
||||
|
|
@ -89,6 +107,33 @@
|
|||
|
||||
---
|
||||
|
||||
## Оформление коммитов
|
||||
|
||||
```
|
||||
<тип>(<область>): <краткое описание в повелительном наклонении>
|
||||
|
||||
<опционально: тело - объясняет ПОЧЕМУ, а не что; диф и так показывает что изменилось>
|
||||
```
|
||||
|
||||
- **Тип** - один из: `feat` (новая функциональность), `fix` (исправление бага), `docs`, `refactor` (без изменения поведения), `perf`, `test`, `chore` (зависимости/тулинг/версии), `ci`, `security`.
|
||||
- **Область** (опционально) - крейт или часть проекта: `client`, `server`, `core`, `gui`, `flutter`, `ci`, `docs` и т.д., например `fix(client): ...`.
|
||||
- **Краткое описание** - повелительное наклонение ("добавь", а не "добавил"/"добавляет"), без точки в конце, желательно до ~70 символов.
|
||||
- **Тело** - только когда причина не очевидна из дифа: какой баг это чинит, какое ограничение определило подход, на какой trade-off вы пошли. Не пересказывайте то, что и так видно в дифе. Перенос строк на ~72 символах.
|
||||
|
||||
```
|
||||
fix(server): отбрасывать junk-фреймы по маркеру для каждого ключа, а не глобальному
|
||||
|
||||
Фиксированный 4-байтовый маркер на каждом junk-пакете сам по себе - сигнатура
|
||||
DPI, по которой можно фильтровать любого наблюдателя во всех деплойментах OSTP
|
||||
сразу. Выводим маркер из access_key (HKDF, та же схема что у
|
||||
obfuscation_key/psk), чтобы он был индивидуальным для ключа и неотличимым от
|
||||
случайной полезной нагрузки пакета.
|
||||
```
|
||||
|
||||
Несколько несвязанных изменений - это несколько отдельных коммитов, а не один сборный. Это сохраняет пользу от `git bisect` и код-ревью. Squash-merge подходит для PR с парой коммитов вроде "fix typo" / "address review", но не сквошьте вместе логически разные изменения.
|
||||
|
||||
---
|
||||
|
||||
## Правила оформления кода
|
||||
|
||||
* **Безопасность (Safety)**: Избегайте использования блоков `unsafe` везде, где это возможно. Допускается их использование только для низкоуровневых системных вызовов (например, FFI-настройки сокетов `setsockopt`). Любой блок `unsafe` должен сопровождаться комментарием `// SAFETY: ...`.
|
||||
|
|
@ -104,7 +149,7 @@
|
|||
```bash
|
||||
git push origin feat/имя-вашей-фичи
|
||||
```
|
||||
2. Создайте Pull Request (PR) в ветку `master` основного репозитория.
|
||||
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `beta`, PR туда не принимаются напрямую).
|
||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||
|
||||
|
|
|
|||
|
|
@ -432,6 +432,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
|
|
@ -525,6 +534,15 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -1298,7 +1316,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "netstack-smoltcp"
|
||||
version = "0.2.2"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c38f66cdd673ff0e760752f27c6d34a7e3a140f0b1eea9efae3c46d8867c83d"
|
||||
dependencies = [
|
||||
"etherparse",
|
||||
"futures",
|
||||
|
|
@ -1331,6 +1351,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -1360,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||
|
||||
[[package]]
|
||||
name = "ostp"
|
||||
version = "0.2.83"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1371,8 +1397,10 @@ dependencies = [
|
|||
"ostp-core",
|
||||
"ostp-server",
|
||||
"rand 0.8.5",
|
||||
"rlimit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
@ -1381,21 +1409,20 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.2.83"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"json_comments",
|
||||
"libc",
|
||||
"netstack-smoltcp",
|
||||
"ostp-core",
|
||||
"ostp-tun",
|
||||
"portable-atomic",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
|
|
@ -1404,15 +1431,16 @@ dependencies = [
|
|||
"socket2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tun",
|
||||
"webpki-roots 0.26.11",
|
||||
"winapi",
|
||||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.2.83"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1446,7 +1474,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-server"
|
||||
version = "0.2.83"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1469,6 +1497,7 @@ dependencies = [
|
|||
"sha2",
|
||||
"simple-dns",
|
||||
"socket2",
|
||||
"subtle",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
|
|
@ -1476,9 +1505,21 @@ dependencies = [
|
|||
"x25519-dalek",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tun",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ostp-tun-helper"
|
||||
version = "0.2.83"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -1557,6 +1598,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
|
|
@ -1811,6 +1858,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
|
|
@ -2119,6 +2175,12 @@ version = "2.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
|
@ -2199,6 +2261,37 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
|
|
@ -2341,6 +2434,19 @@ dependencies = [
|
|||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-appender"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"symlink",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
|
|
|
|||
11
Cargo.toml
|
|
@ -5,27 +5,24 @@ members = [
|
|||
"ostp-server",
|
||||
"ostp-jni", "ostp",
|
||||
"ostp-tun-helper"
|
||||
]
|
||||
, "ostp-tun"]
|
||||
exclude = ["ostp-gui/src-tauri", "ostp-brain", "ostp-prober"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "BSL 1.1"
|
||||
version = "0.2.83"
|
||||
license = "AGPL-3.0"
|
||||
version = "0.4.4"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0"
|
||||
bytes = "1.6"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
snow = "0.9"
|
||||
snow = { version = "0.9", features = ["risky-raw-split"] }
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
||||
tracing = "0.1"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
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)
|
||||
Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all
|
||||
associated workspace crates, utilities, and documents.
|
||||
Additional Use Grant: The Licensor hereby grants you the right to copy,
|
||||
modify, create derivative works, redistribute, and
|
||||
make non-production and non-commercial use of the
|
||||
Licensed Work. You are also permitted to use the
|
||||
Licensed Work in production for personal, private
|
||||
utility and non-profit organizations.
|
||||
Change Date: May 14, 2030
|
||||
Change License: MIT License (as defined below)
|
||||
Preamble
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
Terms
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
1. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
||||
redistribute, and make use of the Licensed Work only as permitted by the
|
||||
Additional Use Grant.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
2. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
||||
redistribute, and make use of the Licensed Work under the terms of the Change
|
||||
License on and after the Change Date.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
3. To the extent that any term of this License (including the Additional Use Grant
|
||||
and the Change License) is in conflict with the Terms of this License, these
|
||||
Terms shall take precedence.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
4. Every copy of the Licensed Work and any derivative work must include this
|
||||
License and all other copyright, trademark, and proprietary notices included
|
||||
with the Licensed Work.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
5. Any use of the Licensed Work that is not permitted by this License is a breach
|
||||
of this License and may terminate your rights under this License.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED
|
||||
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR
|
||||
WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY
|
||||
OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE,
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
|
||||
WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL,
|
||||
CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE
|
||||
USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
-----------------------------------------------------------------------------------
|
||||
0. Definitions.
|
||||
|
||||
Change License Text (MIT License)
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
Copyright (c) 2026 Syralev Georgiy (Ospab Foundation)
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
169
README.md
|
|
@ -1,14 +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)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**OSTP** is a high-performance, censorship-resistant transport protocol designed to tunnel TCP traffic over UDP with full traffic obfuscation. Every byte on the wire — including packet headers — is cryptographically indistinguishable from random noise. Resistant to Deep Packet Inspection (DPI), active probing, and statistical traffic analysis.
|
||||
> 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).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -33,17 +35,17 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Full Traffic Obfuscation** | Every packet — including headers — is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||
| **Full Traffic Obfuscation** | Every packet - including headers - is indistinguishable from random noise. Session IDs and nonces are masked with per-packet HMAC-derived keys. |
|
||||
| **Noise Protocol Handshake** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - PSK-authenticated, forward-secret key exchange with no static identity exposure. |
|
||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK with rate-limited retransmission, configurable reorder buffer, and exponential backoff. |
|
||||
| **Multiplexed Streams** | Multiple logical TCP streams over a single encrypted UDP session with per-stream flow control. |
|
||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption — tracked by session-ID, not IP. |
|
||||
| **Seamless Roaming** | Clients can switch networks (WiFi ↔ LTE) without session interruption - tracked by session-ID, not IP. |
|
||||
| **Management API** | Built-in REST API for third-party panels (3x-ui, custom dashboards). Per-user stats, traffic limits, key CRUD. |
|
||||
| **Fallback Server** | TCP fallback proxy to a web server — makes OSTP indistinguishable from nginx during active probing. |
|
||||
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
|
||||
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
||||
| **TUN Mode** | Full-system VPN via `tun2socks` integration. All traffic transparently routed through the tunnel. |
|
||||
| **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel disguised as standard HTTP/1.1 or TLS traffic to bypass Level 1 Deep Packet Inspection (DPI) whitelists. |
|
||||
| **XTLS-Reality** | Custom, dependency-free implementation of the Reality protocol using ChaCha20Poly1305 and X25519 for perfect TLS 1.3 impersonation. |
|
||||
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
||||
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
||||
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
||||
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
||||
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
||||
| **Structured Logging** | `tracing`-based logging with `RUST_LOG` filtering. JSON/file/syslog output support. |
|
||||
|
|
@ -53,40 +55,43 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Client │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
|
||||
│ │ Browser │──▸│ SOCKS5/ │──▸│ Bridge (Mux) │ │
|
||||
│ │ / Apps │ │ HTTP │ │ ┌─────────────────┐ │ │
|
||||
│ │ │ │ Proxy │ │ │ ProtocolMachine │ │ │
|
||||
│ └──────────┘ └──────────┘ │ │ (Noise + AEAD) │ │ │
|
||||
│ │ └────────┬────────┘ │ │
|
||||
│ ┌──────────┐ │ │ │ │
|
||||
│ │ TUN Mode │──────────────────┤ UDP Socket │ │
|
||||
│ │tun2socks │ │ (32MB buffers, │ │
|
||||
│ └──────────┘ │ obfuscated wire) │ │
|
||||
│ └───────────┬────────────┘ │
|
||||
└────────────────────────────────────────────┼────────────────┘
|
||||
│ UDP
|
||||
┌────────────────────────────────────────────┼────────────────┐
|
||||
│ Server │ │
|
||||
│ ┌─────────────────────────────────────────┴───────────┐ │
|
||||
│ │ Dispatcher │ │
|
||||
│ │ (Session lookup, roaming, replay guard, per-user │ │
|
||||
│ │ traffic accounting, limit enforcement) │ │
|
||||
│ └──┬──────────────────────┬───────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──▾──────────────────┐ ┌─▾──────────────────────────┐ │
|
||||
│ │ Relay Loop │ │ Management API (REST) │ │
|
||||
│ │ (per-stream TCP) │ │ /api/users, /api/stats │ │
|
||||
│ │ ──▸ Internet │ │ Bearer token auth │ │
|
||||
│ └─────────────────────┘ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Fallback TCP Proxy ──▸ nginx/caddy (anti-DPI) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Styles
|
||||
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||
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 Local["💻 Client Device"]
|
||||
Apps["Web Browser / Apps"]:::userApp
|
||||
Socks["SOCKS5 / HTTP Proxy"]:::ostpCore
|
||||
Tun["Global TUN (VPN)"]:::ostpCore
|
||||
Client["OSTP Client Protocol Engine\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||
|
||||
Apps -->|TCP/UDP| Socks
|
||||
Apps -->|IP Packets| Tun
|
||||
Socks --> Client
|
||||
Tun --> Client
|
||||
end
|
||||
|
||||
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
|
||||
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ Remote VPS (Server)"]
|
||||
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
|
||||
|
||||
Server -->|Decrypted Traffic| Relay
|
||||
Server -->|Active Probe / Scanner| Fallback
|
||||
Relay -->|Clear Traffic| Target
|
||||
end
|
||||
|
||||
Client <==> Tunnel <==> Server
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -97,15 +102,15 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
|
||||
```bash
|
||||
# On your VPS (server):
|
||||
./ostp --init server
|
||||
./ostp init server
|
||||
|
||||
# On your machine (client):
|
||||
./ostp --init client
|
||||
./ostp init client
|
||||
```
|
||||
|
||||
### 2. Edit config
|
||||
|
||||
**Server** — set your access keys:
|
||||
**Server** - set your access keys:
|
||||
```jsonc
|
||||
{
|
||||
"mode": "server",
|
||||
|
|
@ -116,14 +121,14 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
}
|
||||
```
|
||||
|
||||
**Client** — point to your server:
|
||||
**Client** - point to your server:
|
||||
```jsonc
|
||||
{
|
||||
"mode": "client",
|
||||
"server": "YOUR_SERVER_IP:50000",
|
||||
"access_key": "YOUR_SECRET_KEY",
|
||||
"socks5_bind": "127.0.0.1:1088",
|
||||
"transport": { "mode": "udp", "stealth_sni": "vk.com", "stealth_port": 443 },
|
||||
"transport": { "mode": "udp" },
|
||||
"tun": { "enable": false, "dns": "1.1.1.1" }
|
||||
}
|
||||
```
|
||||
|
|
@ -131,18 +136,20 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
### 3. Run
|
||||
|
||||
```bash
|
||||
./ostp # Uses config.json in current directory
|
||||
./ostp --config /path/to.json # Custom config path
|
||||
./ostp --check # Validate config without running
|
||||
./ostp --generate-key # Generate a new access key
|
||||
./ostp --links # Print client share links
|
||||
./ostp # Uses config.json in current directory
|
||||
./ostp --config /path/to.json # Custom config path
|
||||
./ostp check # Validate config without running
|
||||
./ostp gk # Generate a new access key
|
||||
./ostp links # Print client share links
|
||||
```
|
||||
|
||||
### 4. Connect via share link (one-liner)
|
||||
```bash
|
||||
./ostp "ostp://ACCESS_KEY@server.com:50000?..."
|
||||
./ostp connect "ostp://ACCESS_KEY@server.com:50000?..."
|
||||
```
|
||||
> **Note**: Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`.
|
||||
|
||||
> [!WARNING]
|
||||
> Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -171,29 +178,40 @@ Full API reference: [Management API](https://github.com/ospab/ostp/wiki/Manageme
|
|||
## CLI Reference
|
||||
|
||||
```
|
||||
ostp [OPTIONS] [URL]
|
||||
ostp [--config <PATH>] [COMMAND]
|
||||
|
||||
Options:
|
||||
Commands:
|
||||
run Run the daemon using the config file (default when no command is given)
|
||||
connect <URL> Connect once using a share link: ostp://KEY@HOST:PORT
|
||||
setup Interactive setup wizard
|
||||
init <MODE> Generate a template config (server/client/relay)
|
||||
check Validate the configuration file and exit
|
||||
gk Generate a secure access key (alias: generate-key)
|
||||
--format <FMT> Key format: hex, base64 (default: hex)
|
||||
-n, --count <N> Number of keys to generate (default: 1)
|
||||
links Print client share links from the server config
|
||||
import <URL> Import a share link into the config file
|
||||
update Update OSTP to the latest release
|
||||
-b, --branch <NAME> Release channel: stable, 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
|
||||
|
||||
Global options:
|
||||
--config <PATH> Config file path (default: config.json)
|
||||
--init <MODE> Generate template config (server/client)
|
||||
--check Validate configuration and exit
|
||||
-g, --generate-key Generate a secure access key
|
||||
-c, --count <N> Number of keys to generate (default: 1)
|
||||
--format <FMT> Key format: hex, base64 (default: hex)
|
||||
--links Print client share links from server config
|
||||
|
||||
Arguments:
|
||||
[URL] Connect via share link: ostp://KEY@HOST:PORT
|
||||
```
|
||||
|
||||
Every subcommand also accepts `-h`/`--help` for its own option list.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Summary
|
||||
|
||||
| Layer | Mechanism |
|
||||
|-------|-----------|
|
||||
| XTLS-Reality | Spoofed TLS 1.3 ClientHello, X25519 Key Exchange, ChaCha20-Poly1305 AEAD |
|
||||
| Key Exchange | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) |
|
||||
| Key Exchange | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) zero-RTT |
|
||||
| Encryption | ChaCha20-Poly1305 AEAD per-packet |
|
||||
| Header Obfuscation | HMAC-SHA256 derived per-packet mask |
|
||||
| Reliability | Selective ACK with cumulative + SACK ranges |
|
||||
|
|
@ -219,7 +237,7 @@ cargo test -p ostp-core -p ostp-server
|
|||
|
||||
## Documentation
|
||||
|
||||
- **[Wiki](https://github.com/ospab/ostp/wiki)** — Full documentation
|
||||
- **[Wiki](https://github.com/ospab/ostp/wiki)** - Full documentation
|
||||
- [Installation](https://github.com/ospab/ostp/wiki/Installation)
|
||||
- [Configuration Reference](https://github.com/ospab/ostp/wiki/Configuration)
|
||||
- [Management API](https://github.com/ospab/ostp/wiki/Management-API)
|
||||
|
|
@ -231,8 +249,7 @@ cargo test -p ostp-core -p ostp-server
|
|||
|
||||
## License
|
||||
|
||||
Business Source License 1.1. Free for personal and non-commercial use.
|
||||
Converts to MIT License on May 14, 2030.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
136
README.ru.md
|
|
@ -1,12 +1,16 @@
|
|||
# OSTP — Ospab Stealth Transport Protocol
|
||||
# OSTP - Ospab Stealth Transport Protocol
|
||||
|
||||
[English](README.md) · [Contributing](CONTRIBUTING.ru.md)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
OSTP — высокопроизводительный транспортный протокол, устойчивый к цензуре. Туннелирует TCP-трафик поверх UDP с полной обфускацией. Устойчив к Deep Packet Inspection (DPI), активному зондированию и статистическому анализу трафика.
|
||||
> Быстрый кастомный зашифрованный транспортный протокол на Rust.
|
||||
|
||||
**OSTP** (Ospab Stealth Transport Protocol) - кастомный транспортный протокол. Реализует собственный ARQ-транспорт поверх UDP, а также режим UoT (UDP-over-TCP). Каждый байт, включая заголовки пакетов, криптографически неотличим от случайного шума, что делает его устойчивым к системам глубокого анализа трафика (DPI).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -15,13 +19,13 @@ OSTP — высокопроизводительный транспортный
|
|||
| Возможность | Описание |
|
||||
|-------------|----------|
|
||||
| **Обфускация трафика** | Каждый пакет, включая заголовки, неотличим от случайного шума. Session ID и nonce маскируются HMAC-ключами, уникальными для каждого пакета. |
|
||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` — аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
||||
| **Noise Protocol** | `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` - аутентификация через PSK, forward secrecy, без раскрытия идентичности. |
|
||||
| **Reliable UDP (ARQ)** | Selective ACK/NACK с rate-limited ретрансмиссией, настраиваемым reorder-буфером и exponential backoff. Разработан для 10 Гбит/с. |
|
||||
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии — сервер отслеживает session-ID, а не IP-адрес. |
|
||||
| **TUN-режим** | Полносистемный VPN через интеграцию с `tun2socks` на Windows и Linux. |
|
||||
| **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, замаскированный под обычный HTTP/1.1 или TLS трафик для обхода белых списков ТСПУ (DPI). |
|
||||
| **XTLS-Reality** | Собственная реализация протокола Reality (без зависимостей) с использованием ChaCha20Poly1305 и X25519 для идеальной маскировки под TLS 1.3. |
|
||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
|
||||
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
||||
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
||||
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
||||
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
||||
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
||||
| **Кросс-платформа** | Windows, Linux, macOS, Android. Один бинарник, без зависимостей. |
|
||||
|
|
@ -30,33 +34,43 @@ OSTP — высокопроизводительный транспортный
|
|||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Клиент │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
|
||||
│ │ Браузер │──▸│ SOCKS5/ │──▸│ Bridge (Mux) │ │
|
||||
│ │ / Прил. │ │ HTTP │ │ ┌─────────────────┐ │ │
|
||||
│ │ │ │ Прокси │ │ │ ProtocolMachine │ │ │
|
||||
│ └──────────┘ └──────────┘ │ │ (Noise + AEAD) │ │ │
|
||||
│ │ └────────┬────────┘ │ │
|
||||
│ ┌──────────┐ │ │ │ │
|
||||
│ │ TUN Mode │──────────────────┤ UDP-сокет │ │
|
||||
│ │tun2socks │ │ (32МБ буферы, │ │
|
||||
│ └──────────┘ │ обфускация) │ │
|
||||
│ └───────────┬────────────┘ │
|
||||
└────────────────────────────────────────────┼────────────────┘
|
||||
│ UDP
|
||||
┌────────────────────────────────────────────┼────────────────┐
|
||||
│ Сервер │ │
|
||||
│ ┌─────────────────────────────────────────┴──────────┐ │
|
||||
│ │ Dispatcher │ │
|
||||
│ │ (Поиск сессий, роуминг, защита от replay) │ │
|
||||
│ └──────────────┬──────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────▾──────────────────┐ │
|
||||
│ │ Relay Loop (TCP per-stream) │──▸ Интернет / Backend │
|
||||
│ └─────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Styles
|
||||
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||
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 Local["💻 Устройство клиента"]
|
||||
Apps["Браузер / Приложения"]:::userApp
|
||||
Socks["SOCKS5 / HTTP Прокси"]:::ostpCore
|
||||
Tun["Global TUN (VPN)"]:::ostpCore
|
||||
Client["OSTP Клиент\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||
|
||||
Apps -->|TCP/UDP| Socks
|
||||
Apps -->|IP Пакеты| Tun
|
||||
Socks --> Client
|
||||
Tun --> Client
|
||||
end
|
||||
|
||||
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
|
||||
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ Удаленный сервер (VPS)"]
|
||||
Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore
|
||||
Relay["Мультиплексор соединений"]:::ostpCore
|
||||
Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback
|
||||
Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external
|
||||
|
||||
Server -->|Расшифрованный трафик| Relay
|
||||
Server -->|Сканеры цензоров| Fallback
|
||||
Relay -->|Чистый трафик| Target
|
||||
end
|
||||
|
||||
Client <==> Tunnel <==> Server
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -79,8 +93,8 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
|
||||
Создать конфиг по умолчанию:
|
||||
```bash
|
||||
./ostp --init server # VPS
|
||||
./ostp --init client # Локальная машина
|
||||
./ostp init server # VPS
|
||||
./ostp init client # Локальная машина
|
||||
```
|
||||
|
||||
### Сервер (`config.json`)
|
||||
|
|
@ -111,9 +125,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
"debug": false,
|
||||
// Настройки транспорта (udp или uot)
|
||||
"transport": {
|
||||
"mode": "udp",
|
||||
"stealth_sni": "vk.com",
|
||||
"stealth_port": 443
|
||||
"mode": "udp"
|
||||
},
|
||||
// TUN-режим (полносистемный VPN)
|
||||
"tun": {
|
||||
|
|
@ -152,11 +164,41 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
./ostp
|
||||
```
|
||||
|
||||
### Справка по командам
|
||||
|
||||
```
|
||||
ostp [--config <PATH>] [КОМАНДА]
|
||||
|
||||
Команды:
|
||||
run Запустить демон по конфигу (по умолчанию, если команда не указана)
|
||||
connect <URL> Подключиться по share-ссылке: ostp://KEY@HOST:PORT
|
||||
setup Интерактивный мастер настройки
|
||||
init <MODE> Сгенерировать шаблон конфига (server/client/relay)
|
||||
check Проверить конфиг и выйти
|
||||
gk Сгенерировать access-key (алиас: generate-key)
|
||||
--format <FMT> Формат ключа: hex, base64 (по умолчанию hex)
|
||||
-n, --count <N> Количество ключей (по умолчанию 1)
|
||||
links Вывести client-share-ссылки из серверного конфига
|
||||
import <URL> Импортировать share-ссылку в конфиг
|
||||
update Обновить OSTP до актуального релиза
|
||||
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
|
||||
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||
migrate Принудительно мигрировать конфиг к текущему формату
|
||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||
proxy-env-clear Вывести shell-команды для их отмены
|
||||
uninstall Остановить сервис и удалить бинарник с конфигом
|
||||
|
||||
Глобальные опции:
|
||||
--config <PATH> Путь к конфигу (по умолчанию config.json)
|
||||
```
|
||||
|
||||
У каждой подкоманды есть своя справка через `-h`/`--help`.
|
||||
|
||||
### TUN-режим (Windows)
|
||||
Требуется `tun2socks.exe` в той же директории. Автоматически запрашивает права Администратора.
|
||||
Использует встроенный сетевой стек `smoltcp` и виртуальный адаптер `wintun` (необходима `wintun.dll`). Требует запуска с правами Администратора.
|
||||
|
||||
### TUN-режим (Linux)
|
||||
Требуется root. Нужен бинарник `tun2socks` (рядом или в `$PATH`).
|
||||
Использует встроенный сетевой стек `smoltcp` и `/dev/net/tun`. Требует запуска от имени `root` (или наличия `CAP_NET_ADMIN`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -164,8 +206,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
|||
|
||||
| Уровень | Механизм |
|
||||
|---------|----------|
|
||||
| XTLS-Reality | Поддельный TLS 1.3 ClientHello, X25519 обмен ключами, ChaCha20-Poly1305 AEAD |
|
||||
| Обмен ключами | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) |
|
||||
| Обмен ключами | Noise NNpsk0 (X25519 + ChaChaPoly + BLAKE2s) zero-RTT |
|
||||
| Шифрование | ChaCha20-Poly1305 AEAD на каждый пакет |
|
||||
| Обфускация заголовков | HMAC-SHA256 маска session_id + nonce, уникальная для каждого пакета |
|
||||
| Надёжность | Selective ACK с cumulative + SACK диапазонами |
|
||||
|
|
@ -201,5 +242,4 @@ cross build --release --target x86_64-unknown-linux-gnu
|
|||
|
||||
## Лицензия
|
||||
|
||||
Business Source License 1.1. Бесплатно для личного и некоммерческого использования.
|
||||
Переходит в MIT License 14 мая 2030 года.
|
||||
GNU Affero General Public License v3.0 (AGPL-3.0). Полный текст - в файле [LICENSE](LICENSE).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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-движка).
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
____ _____ _______ _____
|
||||
/ __ \ / ____|__ __| __ \
|
||||
| | | | (___ | | | |__) |
|
||||
| | | |\___ \ | | | ___/
|
||||
| |__| |____) | | | | |
|
||||
\____/|_____/ |_| |_|
|
||||
|
|
@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
* **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).
|
||||
|
|
|
|||
|
|
@ -20,9 +20,15 @@
|
|||
// Адрес следующего узла в цепочке — UDP
|
||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||
|
||||
// URL API конечного (целевого) сервера для синхронизации access_keys
|
||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
|
||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
|
||||
// URL API конечного (целевого) сервера для синхронизации access_keys.
|
||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
|
||||
//
|
||||
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
|
||||
// сервера). Management API смонтирован ВНУТРИ этого пути — именно он скрывает
|
||||
// панель от сканеров, — поэтому голый host:port попадает в несуществующий
|
||||
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
|
||||
// Это тот же адрес, по которому вы открываете веб-панель.
|
||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
|
||||
|
||||
// Bearer-токен для доступа к API целевого сервера
|
||||
// Должен совпадать с api.token в конфиге target-сервера
|
||||
|
|
|
|||
|
|
@ -90,11 +90,23 @@ OSTP поддерживает **внутреннее криптографиче
|
|||
|
||||
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
||||
|
||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256.
|
||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
|
||||
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
|
||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256.
|
||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия.
|
||||
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-шифра.
|
||||
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
||||
* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают.
|
||||
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#111827" />
|
||||
<stop offset="100%" stop-color="#374151" />
|
||||
</linearGradient>
|
||||
<linearGradient id="g2_path" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3B82F6" />
|
||||
<stop offset="100%" stop-color="#14B8A6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="120" fill="url(#g2)" />
|
||||
<path d="M144 256c0-61.9 50.1-112 112-112s112 50.1 112 112-50.1 112-112 112S144 317.9 144 256zm-48 0c0 88.4 71.6 160 160 160s160-71.6 160-160S344.4 96 256 96 96 167.6 96 256z" fill="url(#g2_path)"/>
|
||||
<circle cx="256" cy="256" r="40" fill="#F59E0B" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
|
|
@ -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,561 +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::{unbounded_channel, Receiver, Sender, UnboundedReceiver, UnboundedSender},
|
||||
Notify,
|
||||
},
|
||||
};
|
||||
use tracing::{error, trace};
|
||||
|
||||
use crate::{
|
||||
device::VirtualDevice,
|
||||
packet::{AnyIpPktFrame, IpPacket},
|
||||
Runner,
|
||||
};
|
||||
|
||||
// NOTE: Default buffer could contain 20 AEAD packets
|
||||
const DEFAULT_TCP_SEND_BUFFER_SIZE: u32 = 0x3FFF * 20;
|
||||
const DEFAULT_TCP_RECV_BUFFER_SIZE: u32 = 0x3FFF * 20;
|
||||
|
||||
#[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: UnboundedSender<TcpStream>,
|
||||
sockets: HashMap<SocketHandle, SharedControl>,
|
||||
) -> Runner {
|
||||
Runner::new(async move {
|
||||
let notify = Arc::new(Notify::new());
|
||||
let (socket_tx, socket_rx) = unbounded_channel::<TcpSocketCreation>();
|
||||
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: UnboundedSender<TcpStream>,
|
||||
socket_tx: UnboundedSender<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,
|
||||
}));
|
||||
|
||||
stream_tx
|
||||
.send(TcpStream {
|
||||
src_addr,
|
||||
dst_addr,
|
||||
notify: notify.clone(),
|
||||
control: control.clone(),
|
||||
})
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
||||
socket_tx
|
||||
.send(TcpSocketCreation { control, socket })
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
||||
}
|
||||
|
||||
// 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: UnboundedReceiver<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: UnboundedReceiver<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) = unbounded_channel();
|
||||
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
|
|
@ -9,7 +9,10 @@ anyhow.workspace = true
|
|||
bytes.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
ostp-core = { path = "../ostp-core" }
|
||||
ostp-tun = { path = "../ostp-tun" }
|
||||
rand.workspace = true
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
|
@ -26,7 +29,4 @@ tun = { version = "0.8.9", features = ["async"] }
|
|||
netstack-smoltcp = "0.2.2"
|
||||
futures = "0.3.32"
|
||||
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"] }
|
||||
winapi = { version = "0.3.9", features = ["iphlpapi", "tcpmib", "processthreadsapi", "psapi", "handleapi", "winerror", "minwindef", "winnt", "iptypes", "ws2def"] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
fn main() {
|
||||
let socket = std::net::UdpSocket::bind("0.0.0.0:0").unwrap();
|
||||
let port = socket.local_addr().unwrap().port();
|
||||
println!("Bound UDP to port {}", port);
|
||||
|
||||
if let Some(name) = ostp_client::tunnel::process_lookup::get_process_name_from_port_udp(port) {
|
||||
println!("Found process for UDP port {}: {}", port, name);
|
||||
} else {
|
||||
println!("Process not found for UDP port {}", port);
|
||||
}
|
||||
|
||||
let tcp_socket = std::net::TcpListener::bind("0.0.0.0:0").unwrap();
|
||||
let tcp_port = tcp_socket.local_addr().unwrap().port();
|
||||
println!("Bound TCP to port {}", tcp_port);
|
||||
|
||||
if let Some(name) = ostp_client::tunnel::process_lookup::get_process_name_from_port(tcp_port) {
|
||||
println!("Found process for TCP port {}: {}", tcp_port, name);
|
||||
} else {
|
||||
println!("Process not found for TCP port {}", tcp_port);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ pub struct ClientConfig {
|
|||
pub debug: bool,
|
||||
pub ostp: OstpConfig,
|
||||
pub local_proxy: LocalProxyConfig,
|
||||
pub reality: RealityConfig,
|
||||
#[serde(default)]
|
||||
pub transport: TransportConfig,
|
||||
#[serde(default)]
|
||||
|
|
@ -22,6 +21,10 @@ pub struct ClientConfig {
|
|||
pub dns_server: Option<String>,
|
||||
#[serde(default = "default_tun_stack")]
|
||||
pub tun_stack: String,
|
||||
#[serde(default)]
|
||||
pub kill_switch: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_tun_stack() -> String { "system".to_string() }
|
||||
|
|
@ -67,53 +70,51 @@ pub struct LocalProxyConfig {
|
|||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct TransportConfig {
|
||||
/// "udp" or "uot"
|
||||
#[serde(default = "default_transport_mode")]
|
||||
pub mode: String,
|
||||
/// TLS SNI and HTTP Host for stealth routing
|
||||
#[serde(default)]
|
||||
pub stealth_sni: String,
|
||||
/// TCP Port for the stealth connection
|
||||
#[serde(default = "default_stealth_port")]
|
||||
pub stealth_port: u16,
|
||||
/// Enable strict RFC 6455 WebSocket framing
|
||||
#[serde(default)]
|
||||
pub wss: bool,
|
||||
/// Split the first UoT/TCP packet (handshake) into tiny TCP segments to
|
||||
/// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
|
||||
pub tcp_fragmentation: bool,
|
||||
/// TCP chunk size (bytes)
|
||||
#[serde(default = "default_frag_chunk")]
|
||||
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_stealth_port() -> u16 { 443 }
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: default_transport_mode(),
|
||||
stealth_sni: String::new(),
|
||||
stealth_port: default_stealth_port(),
|
||||
wss: false,
|
||||
tcp_fragmentation: false,
|
||||
frag_chunk: default_frag_chunk(),
|
||||
frag_sleep: default_frag_sleep(),
|
||||
junk_pc: default_junk_count(),
|
||||
junk_ps: default_junk_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RealityConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub sni: String,
|
||||
#[serde(default)]
|
||||
pub fp: String,
|
||||
#[serde(default)]
|
||||
pub pbk: String,
|
||||
#[serde(default)]
|
||||
pub sid: String,
|
||||
#[serde(default)]
|
||||
pub spx: String,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Default for OstpConfig {
|
||||
|
|
@ -147,12 +148,13 @@ impl Default for ClientConfig {
|
|||
debug: false,
|
||||
ostp: OstpConfig::default(),
|
||||
local_proxy: LocalProxyConfig::default(),
|
||||
reality: RealityConfig::default(),
|
||||
transport: TransportConfig::default(),
|
||||
exclusions: ExclusionConfig::default(),
|
||||
multiplex: MultiplexConfig::default(),
|
||||
dns_server: None,
|
||||
tun_stack: "system".to_string(),
|
||||
kill_switch: false,
|
||||
gui: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,16 +182,18 @@ struct RawUnifiedConfig {
|
|||
tun: Option<RawTunSection>,
|
||||
exclude: Option<RawExcludeSection>,
|
||||
mux: Option<RawMuxSection>,
|
||||
reality: Option<RawRealitySection>,
|
||||
transport: Option<RawTransportSection>,
|
||||
gui: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawTransportSection {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
stealth_port: Option<u16>,
|
||||
wss: Option<bool>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
frag_chunk: Option<usize>,
|
||||
frag_sleep: Option<u64>,
|
||||
junk_pc: Option<[usize; 2]>,
|
||||
junk_ps: Option<[usize; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -197,6 +201,7 @@ struct RawTunSection {
|
|||
enable: Option<bool>,
|
||||
dns: Option<String>,
|
||||
stack: Option<String>,
|
||||
kill_switch: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -212,15 +217,7 @@ struct RawMuxSection {
|
|||
sessions: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawRealitySection {
|
||||
enabled: Option<bool>,
|
||||
sni: Option<String>,
|
||||
fp: Option<String>,
|
||||
pbk: Option<String>,
|
||||
sid: Option<String>,
|
||||
spx: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
impl ClientConfig {
|
||||
/// Hot-reload from `config.json` placed next to the running binary.
|
||||
|
|
@ -267,19 +264,13 @@ impl ClientConfig {
|
|||
bind_addr: socks5,
|
||||
connect_timeout_ms: 15000,
|
||||
},
|
||||
reality: RealityConfig {
|
||||
enabled: raw.reality.as_ref().and_then(|t| t.enabled).unwrap_or(false),
|
||||
sni: raw.reality.as_ref().and_then(|t| t.sni.clone()).unwrap_or_default(),
|
||||
fp: raw.reality.as_ref().and_then(|t| t.fp.clone()).unwrap_or_default(),
|
||||
pbk: raw.reality.as_ref().and_then(|t| t.pbk.clone()).unwrap_or_default(),
|
||||
sid: raw.reality.as_ref().and_then(|t| t.sid.clone()).unwrap_or_default(),
|
||||
spx: raw.reality.as_ref().and_then(|t| t.spx.clone()).unwrap_or_default(),
|
||||
},
|
||||
transport: TransportConfig {
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
|
||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()),
|
||||
stealth_port: raw.transport.as_ref().and_then(|t| t.stealth_port).unwrap_or(443),
|
||||
wss: raw.transport.as_ref().and_then(|t| t.wss).unwrap_or(false),
|
||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
||||
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
||||
frag_chunk: raw.transport.as_ref().and_then(|t| t.frag_chunk).unwrap_or_else(default_frag_chunk),
|
||||
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
|
||||
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 {
|
||||
domains: exclusions.domains.unwrap_or_default(),
|
||||
|
|
@ -292,7 +283,256 @@ impl ClientConfig {
|
|||
},
|
||||
dns_server: raw.tun.as_ref().and_then(|t| t.dns.clone()),
|
||||
tun_stack: raw.tun.as_ref().and_then(|t| t.stack.clone()).unwrap_or_else(|| "system".to_string()),
|
||||
kill_switch: raw.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||
gui: raw.gui,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 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 bridge;
|
||||
pub mod config;
|
||||
pub mod migrate;
|
||||
pub mod signal;
|
||||
pub mod sysproxy;
|
||||
pub mod transport;
|
||||
|
|
@ -8,3 +9,4 @@ pub mod tunnel;
|
|||
|
||||
|
||||
pub mod runner;
|
||||
pub mod logging;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
/// The single canonical log file for the whole core. Every process (CLI daemon,
|
||||
/// GUI, TUN helper) and every subsystem (tracing, the core event logger, the
|
||||
/// helper IPC, panics) writes here — no more per-binary / per-subsystem sprawl
|
||||
/// (`ostp-cli.log` + `ostp-core.log` + `ostp-helper.log` + `ostp-crash.log`).
|
||||
pub const LOG_FILE_NAME: &str = "ostp.log";
|
||||
|
||||
/// Absolute path to the shared log file, next to the running executable.
|
||||
pub fn log_file_path() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join(LOG_FILE_NAME)))
|
||||
.unwrap_or_else(|| PathBuf::from(LOG_FILE_NAME))
|
||||
}
|
||||
|
||||
/// True if this invocation is the long-running daemon (a client/server run),
|
||||
/// as opposed to a one-shot subcommand (`gk`, `check`, `init`, `-V`, ...).
|
||||
///
|
||||
/// Used to gate log truncation: only the daemon clears the log at startup, so a
|
||||
/// one-shot command run while a daemon is live can never wipe the daemon's log.
|
||||
/// A daemon invocation is simply one that carries none of the one-shot tokens
|
||||
/// (`ostp`, `ostp run`, `ostp connect <url>` → daemon; everything else → one-shot).
|
||||
pub fn invocation_is_daemon<I: IntoIterator<Item = String>>(args: I) -> bool {
|
||||
const ONE_SHOT: &[&str] = &[
|
||||
"gk", "generate-key", "check", "init", "setup", "links", "import",
|
||||
"update", "migrate", "prober", "proxy-env", "proxy-env-clear",
|
||||
"uninstall", "-V", "--version", "-h", "--help", "help",
|
||||
];
|
||||
!args
|
||||
.into_iter()
|
||||
.skip(1) // program name
|
||||
.any(|a| ONE_SHOT.contains(&a.as_str()))
|
||||
}
|
||||
|
||||
/// Append a single timestamped line to the shared log file. Used by the manual
|
||||
/// writers (core event logger, TUN helper IPC) so their output lands in the same
|
||||
/// `ostp.log` as the tracing subscriber instead of a separate file.
|
||||
pub fn append_line(msg: &str) {
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}] {}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_panic_hook() {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let payload = info.payload();
|
||||
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
*s
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.as_str()
|
||||
} else {
|
||||
"Box<dyn Any>"
|
||||
};
|
||||
|
||||
let location = info.location().unwrap_or_else(|| std::panic::Location::caller());
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
|
||||
let crash_msg = format!(
|
||||
"[{}] PANIC at {}:{}\nMessage: {}\nBacktrace:\n{:?}",
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
location.file(),
|
||||
location.line(),
|
||||
msg,
|
||||
backtrace
|
||||
);
|
||||
|
||||
eprintln!("{}", crash_msg);
|
||||
tracing::error!("{}", crash_msg);
|
||||
|
||||
// Crashes land in the same shared log file (append — a crash must never
|
||||
// truncate, and the tracing worker may already be dead so we write direct).
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_file_path()) {
|
||||
let _ = file.write_all(crash_msg.as_bytes());
|
||||
let _ = file.write_all(b"\n===================================================\n");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Initialises tracing and writes to the shared `ostp.log` next to the executable.
|
||||
///
|
||||
/// The `level` parameter controls the minimum log level:
|
||||
/// - `"error"` — only errors
|
||||
/// - `"warn"` — warnings and errors
|
||||
/// - `"info"` — informational messages (default)
|
||||
/// - `"debug"` — detailed debug messages (use when `debug: true` in config)
|
||||
/// - `"trace"` — all messages including very verbose internal state
|
||||
///
|
||||
/// The environment variable `RUST_LOG` overrides this value if set.
|
||||
///
|
||||
/// `truncate`: clear the log at startup. Honoured **only on Windows** — Linux
|
||||
/// servers keep their history (OS-rotated). Pass `true` only from the daemon's
|
||||
/// own entrypoint; one-shot commands and child processes (the TUN helper) pass
|
||||
/// `false` so they append instead of wiping a running daemon's log.
|
||||
pub fn init_tracing(
|
||||
level: &str,
|
||||
app_name: &str,
|
||||
version: &str,
|
||||
truncate: bool,
|
||||
) -> Option<tracing_appender::non_blocking::WorkerGuard> {
|
||||
// RUST_LOG overrides the config-derived level
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| {
|
||||
// When debug or trace is requested, enable for all ostp crates
|
||||
if level == "debug" || level == "trace" {
|
||||
// Enable the requested level for ostp crates, but keep noisy deps at warn
|
||||
EnvFilter::new(format!(
|
||||
"warn,ostp_client={level},ostp_core={level},ostp_jni={level},ostp_gui_lib={level}"
|
||||
))
|
||||
} else {
|
||||
EnvFilter::new(level)
|
||||
}
|
||||
});
|
||||
|
||||
let path = log_file_path();
|
||||
|
||||
let mut open_opts = OpenOptions::new();
|
||||
open_opts.create(true);
|
||||
// Truncate-on-startup is Windows-only and daemon-only. Everywhere else append:
|
||||
// Linux keeps server history, and one-shot commands / the TUN helper must not
|
||||
// wipe a running daemon's log.
|
||||
if truncate && cfg!(windows) {
|
||||
open_opts.write(true).truncate(true);
|
||||
} else {
|
||||
open_opts.append(true);
|
||||
}
|
||||
|
||||
if let Ok(mut file) = open_opts.open(&path) {
|
||||
// Write the startup banner directly to the log file, bypassing the
|
||||
// tracing subscriber entirely. Emitting it via tracing::info!() hits
|
||||
// BOTH layers below (file AND stderr), so every one-shot CLI command
|
||||
// (`ostp -V`, `ostp gk`, `ostp check`, ...) printed this banner to the
|
||||
// terminal on every single invocation — pure noise for anything that
|
||||
// isn't the long-running daemon. It's still genuinely useful for
|
||||
// whoever's reading the log file later, so keep it there, just not on
|
||||
// screen for commands that aren't the daemon.
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"{} v{} | OS: {} | Arch: {} | log_level: {} | log_file: {}",
|
||||
app_name,
|
||||
version,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
level,
|
||||
path.display(),
|
||||
);
|
||||
|
||||
let (file_writer, guard) = tracing_appender::non_blocking(file);
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_line_number(true)
|
||||
.with_thread_ids(false)
|
||||
.with_thread_names(false)
|
||||
.with_ansi(false)
|
||||
.with_writer(file_writer);
|
||||
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt_layer)
|
||||
.with(stderr_layer)
|
||||
.try_init();
|
||||
|
||||
Some(guard)
|
||||
} else {
|
||||
// Fallback: stderr only
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_writer(std::io::stderr);
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(EnvFilter::new(level))
|
||||
.with(stderr_layer)
|
||||
.try_init();
|
||||
eprintln!("[WARN] Could not open log file at {}. Logging to stderr only.", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
//! The ONE authoritative place that upgrades an old `config.json` to the
|
||||
//! current schema. Reachable only via the explicit `ostp migrate` command —
|
||||
//! nothing else in this codebase silently rewrites a user's config on their
|
||||
//! behalf (the old 0.3.x line used to auto-migrate on every load with just a
|
||||
//! log warning; that's exactly the kind of "invisible until something looks
|
||||
//! wrong" behavior this module replaces).
|
||||
//!
|
||||
//! Every field this module cannot map forward is reported explicitly in
|
||||
//! `MigrationReport.notes`, never silently dropped without a trace.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MigrationReport {
|
||||
/// Whether anything was actually different from the current schema.
|
||||
pub changed: bool,
|
||||
/// Human-readable line per field added, converted, or dropped.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl MigrationReport {
|
||||
fn note(&mut self, msg: impl Into<String>) {
|
||||
self.changed = true;
|
||||
self.notes.push(msg.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Which config this file is (mirrors `AppMode`'s `"mode"` tag). Old configs
|
||||
/// from before that tag existed are sniffed structurally as a fallback.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigKind {
|
||||
Client,
|
||||
Server,
|
||||
Relay,
|
||||
}
|
||||
|
||||
pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
|
||||
match json.get("mode").and_then(|v| v.as_str()) {
|
||||
Some("client") => return Some(ConfigKind::Client),
|
||||
Some("server") => return Some(ConfigKind::Server),
|
||||
Some("relay") => return Some(ConfigKind::Relay),
|
||||
_ => {}
|
||||
}
|
||||
// No (or unrecognized) "mode" tag — this is an older config from before
|
||||
// it was mandatory. Sniff by the fields that have been present on each
|
||||
// shape since the earliest surviving config format.
|
||||
if json.get("upstream_tcp").is_some() || json.get("upstream_api_url").is_some() {
|
||||
Some(ConfigKind::Relay)
|
||||
} else if json.get("access_keys").is_some() || json.get("listen").is_some() {
|
||||
Some(ConfigKind::Server)
|
||||
} else if json.get("access_key").is_some() || json.get("server").is_some() {
|
||||
Some(ConfigKind::Client)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrates a client config of any known past shape to the current flat
|
||||
/// schema. Returns the migrated JSON and a report of every change made.
|
||||
///
|
||||
/// Known input shapes, oldest first:
|
||||
/// - **v0.3.1–v0.3.21 "modular multi-server"**: `inbounds`/`outbounds` arrays
|
||||
/// + `routing.rules`. Only the first `ostp`-type outbound is kept (this
|
||||
/// line no longer supports multiple simultaneous servers); every other
|
||||
/// `ostp` outbound is reported by tag+address so nothing vanishes
|
||||
/// invisibly. `urltest`/`selector`/`direct`/`block` outbounds have no
|
||||
/// equivalent and are dropped (reported).
|
||||
/// - **pre-0.3.1 flat (up to v0.2.98)**: same field names as today
|
||||
/// (`server`, `access_key`, `tun`, `exclude`, `mux`, `transport`, ...)
|
||||
/// except `tun.wintun_path`/`tun.ipv4_address` (internal driver detail,
|
||||
/// never user-meaningful data) and `transport.wss` (the WSS framing
|
||||
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
|
||||
/// explicit note; everything else maps 1:1, nothing to convert.
|
||||
/// - **configs carrying a leftover `transport.stealth_sni`**: dropped with a
|
||||
/// note, same reasoning as `wss` — it never fed into anything on the wire
|
||||
/// (no TLS/HTTP mimicry exists in this project), so there is no successor
|
||||
/// field. Not tied to a specific version: it lingered in the schema well
|
||||
/// past when the mimicry work it was meant for got removed.
|
||||
/// - **current flat schema**: no-op, `changed = false`.
|
||||
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
||||
let mut report = MigrationReport::default();
|
||||
|
||||
let has_inbounds = json.get("inbounds").and_then(|v| v.as_array()).is_some();
|
||||
let has_outbounds = json.get("outbounds").and_then(|v| v.as_array()).is_some();
|
||||
|
||||
if has_inbounds && has_outbounds {
|
||||
return migrate_client_from_modular(json, report);
|
||||
}
|
||||
|
||||
// Flat shape already (current or pre-0.3.1) — normalize obsolete fields
|
||||
// in place rather than rebuilding the whole document from scratch, so
|
||||
// any field this module doesn't know about yet still survives untouched.
|
||||
let mut out = json;
|
||||
|
||||
if let Some(tun) = out.get_mut("tun").and_then(|t| t.as_object_mut()) {
|
||||
for dead_field in ["wintun_path", "ipv4_address"] {
|
||||
if tun.remove(dead_field).is_some() {
|
||||
report.note(format!(
|
||||
"Dropped tun.{dead_field} — internal driver detail from an older WinTun \
|
||||
integration, not applicable to the current TUN implementation."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(transport) = out.get_mut("transport").and_then(|t| t.as_object_mut()) {
|
||||
if transport.remove("wss").is_some() {
|
||||
report.note(
|
||||
"Dropped transport.wss — WSS framing was removed in the 0.4.0 rebuild \
|
||||
(the project follows a zapret-like approach: no protocol mimicry, \
|
||||
just packet-level obfuscation/manipulation, so there is no successor field)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if transport.remove("stealth_sni").is_some() {
|
||||
report.note(
|
||||
"Dropped transport.stealth_sni — never actually used to construct any wire \
|
||||
bytes (no TLS/HTTP mimicry exists in this project — same zapret-like \
|
||||
reasoning as transport.wss), so it was unused config plumbing with no effect."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Value, MigrationReport) {
|
||||
report.changed = true; // the shape itself is being replaced regardless of field-level detail
|
||||
|
||||
let inbounds = json.get("inbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let outbounds = json.get("outbounds").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let routing = json.get("routing").cloned().unwrap_or(json!({}));
|
||||
let default_outbound = routing.get("default_outbound").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
// ── Pick the primary "ostp" outbound ────────────────────────────────
|
||||
// Prefer the one routing.default_outbound points at (directly, or via a
|
||||
// urltest/selector group that references it); otherwise take the first
|
||||
// ostp outbound in file order. Every other ostp outbound is reported by
|
||||
// tag+address, not silently discarded.
|
||||
let ostp_outbounds: Vec<&Value> = outbounds
|
||||
.iter()
|
||||
.filter(|o| o.get("type").and_then(|t| t.as_str()) == Some("ostp"))
|
||||
.collect();
|
||||
|
||||
// default_outbound might name an ostp outbound directly, OR name a
|
||||
// urltest/selector GROUP whose first member is the one to actually use —
|
||||
// check both, since a plain `.or_else` here would never even attempt the
|
||||
// group lookup while default_outbound is Some(_) (which it almost always
|
||||
// is), silently falling through to "just take the first ostp outbound in
|
||||
// file order" instead — exactly the kind of silent wrong answer this
|
||||
// migrator exists to avoid.
|
||||
let primary_tag: Option<String> = default_outbound.as_deref().and_then(|def_tag| {
|
||||
if ostp_outbounds.iter().any(|o| o.get("tag").and_then(|t| t.as_str()) == Some(def_tag)) {
|
||||
return Some(def_tag.to_string());
|
||||
}
|
||||
outbounds.iter().find_map(|o| {
|
||||
let is_group = matches!(o.get("type").and_then(|t| t.as_str()), Some("urltest") | Some("selector"));
|
||||
let tag_matches = o.get("tag").and_then(|t| t.as_str()) == Some(def_tag);
|
||||
if is_group && tag_matches {
|
||||
o.get("outbounds")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let primary = primary_tag
|
||||
.as_deref()
|
||||
.and_then(|tag| ostp_outbounds.iter().find(|o| o.get("tag").and_then(|t| t.as_str()) == Some(tag)))
|
||||
.copied()
|
||||
.or_else(|| ostp_outbounds.first().copied());
|
||||
|
||||
let Some(primary) = primary else {
|
||||
report.note(
|
||||
"No 'ostp'-type outbound found in the old modular config — nothing to migrate \
|
||||
the server connection from. Wrote a placeholder; you MUST fill in server/access_key \
|
||||
by hand or re-import a share link."
|
||||
.to_string(),
|
||||
);
|
||||
return (
|
||||
json!({
|
||||
"server": "127.0.0.1:50000",
|
||||
"access_key": "",
|
||||
}),
|
||||
report,
|
||||
);
|
||||
};
|
||||
|
||||
for other in &ostp_outbounds {
|
||||
if !std::ptr::eq(*other, primary) {
|
||||
let tag = other.get("tag").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
let addr = other.get("server").and_then(|t| t.as_str()).unwrap_or("?");
|
||||
let port = other.get("port").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
report.note(format!(
|
||||
"Dropped additional server '{tag}' ({addr}:{port}) — multi-server / urltest \
|
||||
failover is no longer supported; only one server per config now. Kept the \
|
||||
one from routing.default_outbound (or the first one if that wasn't set)."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let server = primary.get("server").and_then(|v| v.as_str()).unwrap_or("127.0.0.1").to_string();
|
||||
let port = primary.get("port").and_then(|v| v.as_u64()).unwrap_or(50000);
|
||||
let access_key = primary.get("access_key").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let transport_type = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("type").or_else(|| t.get("mode")))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("udp")
|
||||
.to_string();
|
||||
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
|
||||
if !sni.is_empty() {
|
||||
report.note(format!(
|
||||
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
|
||||
any wire bytes; unused config plumbing with no successor field."
|
||||
));
|
||||
}
|
||||
}
|
||||
let tcp_fragmentation = primary
|
||||
.get("transport")
|
||||
.and_then(|t| t.get("tcp_fragmentation"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mux_enabled = primary.get("multiplex").and_then(|m| m.get("enabled")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let mux_sessions = primary.get("multiplex").and_then(|m| m.get("sessions")).and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
|
||||
// ── TUN + local proxy inbounds ───────────────────────────────────────
|
||||
let tun_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("tun"));
|
||||
let proxy_inbound = inbounds.iter().find(|i| i.get("type").and_then(|t| t.as_str()) == Some("local_proxy"));
|
||||
|
||||
let tun_enable = tun_inbound.is_some();
|
||||
let mtu = tun_inbound.and_then(|t| t.get("mtu")).and_then(|v| v.as_u64());
|
||||
|
||||
let socks5_bind = proxy_inbound
|
||||
.map(|p| {
|
||||
let listen = p.get("listen").and_then(|v| v.as_str()).unwrap_or("127.0.0.1");
|
||||
let port = p.get("port").and_then(|v| v.as_u64()).unwrap_or(1088);
|
||||
format!("{listen}:{port}")
|
||||
})
|
||||
.unwrap_or_else(|| "127.0.0.1:1088".to_string());
|
||||
|
||||
// ── Exclusions from routing.rules → direct ──────────────────────────
|
||||
let mut ex_domains: Vec<String> = Vec::new();
|
||||
let mut ex_ips: Vec<String> = Vec::new();
|
||||
let mut ex_processes: Vec<String> = Vec::new();
|
||||
if let Some(rules) = routing.get("rules").and_then(|v| v.as_array()) {
|
||||
for rule in rules {
|
||||
if rule.get("outbound").and_then(|v| v.as_str()) != Some("direct") {
|
||||
continue; // only "route to direct" rules were ever exclusions in the old format
|
||||
}
|
||||
if let Some(v) = rule.get("domain_suffix").and_then(|v| v.as_array()) {
|
||||
ex_domains.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
if let Some(v) = rule.get("ip_cidr").and_then(|v| v.as_array()) {
|
||||
ex_ips.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
if let Some(v) = rule.get("process_name").and_then(|v| v.as_array()) {
|
||||
ex_processes.extend(v.iter().filter_map(|s| s.as_str().map(String::from)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for other_rule_outbound in routing
|
||||
.get("rules")
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| r.get("outbound").and_then(|v| v.as_str()))
|
||||
.filter(|o| *o != "direct")
|
||||
{
|
||||
report.note(format!(
|
||||
"Dropped a routing rule targeting outbound '{other_rule_outbound}' — only \
|
||||
\"route to direct\" rules map to today's exclusions; anything else \
|
||||
(custom per-domain outbound selection) has no equivalent anymore."
|
||||
));
|
||||
}
|
||||
|
||||
let debug = json.get("log").and_then(|l| l.get("level")).and_then(|v| v.as_str()) == Some("debug");
|
||||
|
||||
let mut client = json!({
|
||||
"server": server,
|
||||
"port": port,
|
||||
"access_key": access_key,
|
||||
"socks5_bind": socks5_bind,
|
||||
"debug": debug,
|
||||
"tun": {
|
||||
"enable": tun_enable,
|
||||
"dns": null,
|
||||
"kill_switch": false,
|
||||
},
|
||||
"exclude": {
|
||||
"domains": ex_domains,
|
||||
"ips": ex_ips,
|
||||
"processes": ex_processes,
|
||||
},
|
||||
"mux": {
|
||||
"enabled": mux_enabled,
|
||||
"sessions": mux_sessions,
|
||||
},
|
||||
"transport": {
|
||||
"mode": transport_type,
|
||||
"tcp_fragmentation": tcp_fragmentation,
|
||||
},
|
||||
});
|
||||
if let Some(mtu) = mtu {
|
||||
client["mtu"] = json!(mtu);
|
||||
}
|
||||
if let Some(gui) = json.get("gui") {
|
||||
client["gui"] = gui.clone();
|
||||
}
|
||||
|
||||
(client, report)
|
||||
}
|
||||
|
||||
/// Migrates a server config. The server shape has stayed structurally
|
||||
/// identical since the earliest surviving version — this only backfills the
|
||||
/// `api` section (added after some configs already existed) and drops the
|
||||
/// legacy `api.token` field. Ported from the ad-hoc Python snippet that used
|
||||
/// to live in `scripts/install.sh` and only ran at install/update time.
|
||||
pub fn migrate_server_json(json: Value) -> (Value, MigrationReport) {
|
||||
let mut report = MigrationReport::default();
|
||||
let mut out = json;
|
||||
|
||||
let obj = match out.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return (out, report),
|
||||
};
|
||||
|
||||
let api = obj.entry("api").or_insert_with(|| json!({}));
|
||||
if let Some(api_obj) = api.as_object_mut() {
|
||||
let defaults: [(&str, Value); 5] = [
|
||||
("enabled", json!(false)),
|
||||
("bind", json!("0.0.0.0:9090")),
|
||||
("webpath", json!("")),
|
||||
("username", json!("")),
|
||||
("password_hash", json!("")),
|
||||
];
|
||||
for (key, default) in defaults {
|
||||
if !api_obj.contains_key(key) {
|
||||
report.note(format!("Added api.{key} = {default} (missing default)"));
|
||||
api_obj.insert(key.to_string(), default);
|
||||
}
|
||||
}
|
||||
if api_obj.remove("token").is_some() {
|
||||
report.note(
|
||||
"Dropped legacy api.token — superseded by api.password_hash; \
|
||||
set a new admin password with the management API or panel."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A realistic v0.3.21-shaped modular config (TUN + local_proxy inbounds,
|
||||
/// a single ostp outbound, exclusion rules, mux) — mirrors the actual
|
||||
/// shape from that tag, field for field.
|
||||
#[test]
|
||||
fn modular_single_server_preserves_every_field() {
|
||||
let old = json!({
|
||||
"version": "0.3.21",
|
||||
"log": { "level": "debug" },
|
||||
"inbounds": [
|
||||
{ "type": "tun", "tag": "tun-in", "auto_route": true, "mtu": 1350 },
|
||||
{ "type": "local_proxy", "tag": "socks-in", "protocol": "socks", "listen": "127.0.0.1", "port": 1088 }
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "ostp", "tag": "proxy",
|
||||
"server": "203.0.113.5", "port": 50000, "access_key": "sekrit123",
|
||||
"transport": { "type": "uot", "stealth_sni": "vk.com", "tcp_fragmentation": true },
|
||||
"multiplex": { "enabled": true, "sessions": 4 }
|
||||
},
|
||||
{ "type": "direct", "tag": "direct" },
|
||||
{ "type": "block", "tag": "block" }
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "domain_suffix": ["local.lan", "internal.corp"], "outbound": "direct" },
|
||||
{ "ip_cidr": ["192.168.0.0/16"], "outbound": "direct" },
|
||||
{ "process_name": ["steam.exe"], "outbound": "direct" }
|
||||
],
|
||||
"default_outbound": "proxy"
|
||||
}
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
assert!(report.changed);
|
||||
assert_eq!(new["server"], "203.0.113.5");
|
||||
assert_eq!(new["port"], 50000);
|
||||
assert_eq!(new["access_key"], "sekrit123");
|
||||
assert_eq!(new["socks5_bind"], "127.0.0.1:1088");
|
||||
assert_eq!(new["mtu"], 1350);
|
||||
assert_eq!(new["debug"], true);
|
||||
assert_eq!(new["tun"]["enable"], true);
|
||||
assert_eq!(new["transport"]["mode"], "uot");
|
||||
assert_eq!(new["transport"]["tcp_fragmentation"], true);
|
||||
assert_eq!(new["mux"]["enabled"], true);
|
||||
assert_eq!(new["mux"]["sessions"], 4);
|
||||
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
|
||||
assert_eq!(new["exclude"]["ips"], json!(["192.168.0.0/16"]));
|
||||
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
|
||||
// stealth_sni never fed into any wire bytes — dropped, not carried forward.
|
||||
assert!(new["transport"].get("stealth_sni").is_none());
|
||||
assert!(report.notes.iter().any(|n| n.contains("stealth_sni") && n.contains("vk.com")));
|
||||
}
|
||||
|
||||
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
|
||||
/// must keep the one routing.default_outbound points at and report every
|
||||
/// other one by name/address rather than picking silently.
|
||||
#[test]
|
||||
fn modular_multi_server_keeps_default_and_reports_the_rest() {
|
||||
let old = json!({
|
||||
"inbounds": [],
|
||||
"outbounds": [
|
||||
{ "type": "ostp", "tag": "proxy-0", "server": "1.1.1.1", "port": 50000, "access_key": "k1" },
|
||||
{ "type": "ostp", "tag": "proxy-1", "server": "2.2.2.2", "port": 50000, "access_key": "k2" },
|
||||
{
|
||||
"type": "urltest", "tag": "proxy",
|
||||
"outbounds": ["proxy-1", "proxy-0"], "url": "http://cp.cloudflare.com"
|
||||
}
|
||||
],
|
||||
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
// urltest's first member (proxy-1 / 2.2.2.2) is the one actually picked.
|
||||
assert_eq!(new["server"], "2.2.2.2");
|
||||
assert_eq!(new["access_key"], "k2");
|
||||
assert!(report.notes.iter().any(|n| n.contains("proxy-0") && n.contains("1.1.1.1")));
|
||||
}
|
||||
|
||||
/// Pre-0.3.1 flat config carrying fields that no longer exist
|
||||
/// (tun.wintun_path, tun.ipv4_address, transport.wss, transport.stealth_sni)
|
||||
/// — those get dropped with a note; every field that's still meaningful
|
||||
/// passes through untouched, byte for byte.
|
||||
#[test]
|
||||
fn flat_legacy_drops_only_dead_fields() {
|
||||
let old = json!({
|
||||
"server": "198.51.100.9:50000",
|
||||
"access_key": "oldkey",
|
||||
"mtu": 1200,
|
||||
"socks5_bind": "127.0.0.1:1090",
|
||||
"tun": {
|
||||
"enable": true,
|
||||
"wintun_path": "C:\\Program Files\\wintun\\wintun.dll",
|
||||
"ipv4_address": "10.0.0.2",
|
||||
"dns": "1.1.1.1",
|
||||
"kill_switch": true
|
||||
},
|
||||
"exclude": { "domains": ["a.com"], "ips": null, "processes": null },
|
||||
"mux": { "enabled": false, "sessions": 1 },
|
||||
"transport": { "mode": "udp", "stealth_sni": "bing.com", "wss": true }
|
||||
});
|
||||
|
||||
let (new, report) = migrate_client_json(old);
|
||||
assert!(report.changed);
|
||||
// Untouched fields survive exactly as they were.
|
||||
assert_eq!(new["server"], "198.51.100.9:50000");
|
||||
assert_eq!(new["access_key"], "oldkey");
|
||||
assert_eq!(new["mtu"], 1200);
|
||||
assert_eq!(new["tun"]["enable"], true);
|
||||
assert_eq!(new["tun"]["dns"], "1.1.1.1");
|
||||
assert_eq!(new["tun"]["kill_switch"], true);
|
||||
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
|
||||
// Dead fields are gone...
|
||||
assert!(new["tun"].get("wintun_path").is_none());
|
||||
assert!(new["tun"].get("ipv4_address").is_none());
|
||||
assert!(new["transport"].get("wss").is_none());
|
||||
assert!(new["transport"].get("stealth_sni").is_none());
|
||||
// ...and their removal was reported, not silent.
|
||||
assert!(report.notes.iter().any(|n| n.contains("wintun_path")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("wss")));
|
||||
assert!(report.notes.iter().any(|n| n.contains("stealth_sni")));
|
||||
}
|
||||
|
||||
/// A config already in the current shape must be a true no-op: report
|
||||
/// says nothing changed, and every field is untouched.
|
||||
#[test]
|
||||
fn current_flat_config_is_a_no_op() {
|
||||
let current = json!({
|
||||
"server": "example.com:50000",
|
||||
"access_key": "k",
|
||||
"tun": { "enable": false, "dns": null, "kill_switch": false },
|
||||
"exclude": { "domains": [], "ips": [], "processes": [] },
|
||||
"mux": { "enabled": false, "sessions": 1 },
|
||||
"transport": { "mode": "udp", "tcp_fragmentation": false }
|
||||
});
|
||||
let (new, report) = migrate_client_json(current.clone());
|
||||
assert!(!report.changed);
|
||||
assert_eq!(new, current);
|
||||
}
|
||||
|
||||
/// Every migrated output must actually deserialize into the ONE
|
||||
/// canonical schema (`crate::config`) — this is the same check
|
||||
/// `cmd_migrate` runs at runtime before ever touching a user's file,
|
||||
/// exercised here directly so a schema/migrator drift fails a fast unit
|
||||
/// test instead of surfacing as "your migrated config won't load".
|
||||
#[test]
|
||||
fn every_migrated_output_matches_the_canonical_schema() {
|
||||
let modular = json!({
|
||||
"inbounds": [{ "type": "tun", "tag": "tun-in", "mtu": 1350 }],
|
||||
"outbounds": [
|
||||
{ "type": "ostp", "tag": "proxy", "server": "1.2.3.4", "port": 50000, "access_key": "k" },
|
||||
{ "type": "direct", "tag": "direct" }
|
||||
],
|
||||
"routing": { "rules": [], "default_outbound": "proxy" }
|
||||
});
|
||||
let (new, _) = migrate_client_json(modular);
|
||||
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||
.expect("modular->flat migration output must match ClientFileConfig");
|
||||
|
||||
let legacy_flat = json!({
|
||||
"server": "1.2.3.4:50000",
|
||||
"access_key": "k",
|
||||
"tun": { "enable": true, "wintun_path": "x", "ipv4_address": "y" }
|
||||
});
|
||||
let (new, _) = migrate_client_json(legacy_flat);
|
||||
serde_json::from_value::<crate::config::ClientFileConfig>(new)
|
||||
.expect("legacy-flat migration output must match ClientFileConfig");
|
||||
|
||||
let server = json!({ "listen": "0.0.0.0:50000", "access_keys": ["k"] });
|
||||
let (new, _) = migrate_server_json(server);
|
||||
serde_json::from_value::<crate::config::ServerConfig>(new)
|
||||
.expect("server migration output must match ServerConfig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_config_backfills_api_defaults_and_drops_legacy_token() {
|
||||
let old = json!({
|
||||
"listen": "0.0.0.0:50000",
|
||||
"access_keys": ["k1"],
|
||||
"api": { "token": "old-plain-token" }
|
||||
});
|
||||
let (new, report) = migrate_server_json(old);
|
||||
assert!(report.changed);
|
||||
assert_eq!(new["api"]["enabled"], false);
|
||||
assert_eq!(new["api"]["bind"], "0.0.0.0:9090");
|
||||
assert!(new["api"].get("token").is_none());
|
||||
assert!(report.notes.iter().any(|n| n.contains("api.token")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_kind_falls_back_to_structural_sniffing_without_mode_tag() {
|
||||
assert_eq!(detect_kind(&json!({"access_key": "x", "server": "y"})), Some(ConfigKind::Client));
|
||||
assert_eq!(detect_kind(&json!({"access_keys": ["x"], "listen": "y"})), Some(ConfigKind::Server));
|
||||
assert_eq!(detect_kind(&json!({"upstream_tcp": "x", "upstream_api_url": "y"})), Some(ConfigKind::Relay));
|
||||
assert_eq!(detect_kind(&json!({"mode": "client", "server": "x"})), Some(ConfigKind::Client));
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,9 @@ use std::fs::OpenOptions;
|
|||
use std::io::Write as _;
|
||||
|
||||
fn log_to_core_file(msg: &str) {
|
||||
let path = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("ostp-core.log")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("ostp-core.log"));
|
||||
// Writes into the single shared ostp.log (same file as the tracing appender),
|
||||
// not a separate ostp-core.log — see logging::LOG_FILE_NAME.
|
||||
let path = crate::logging::log_file_path();
|
||||
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(file, "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), msg);
|
||||
}
|
||||
|
|
@ -180,13 +179,71 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
|
|||
}
|
||||
});
|
||||
|
||||
run_client_core(config, metrics, shutdown_rx).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(
|
||||
config: crate::config::ClientConfig,
|
||||
metrics: Arc<BridgeMetrics>,
|
||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||
config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||
) -> Result<()> {
|
||||
use portable_atomic::Ordering;
|
||||
|
||||
const BACKOFF_SCHEDULE_SECS: [u64; 6] = [1, 2, 5, 10, 20, 30];
|
||||
// A run that stayed up at least this long counts as "was actually
|
||||
// connected", so a later drop restarts the backoff from the top instead
|
||||
// of inheriting a long delay from a previous flaky stretch.
|
||||
const STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut backoff_idx = 0usize;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let attempt_start = std::time::Instant::now();
|
||||
let result = run_client_once(config.clone(), metrics.clone(), shutdown_rx_ext.clone(), config_rx.clone()).await;
|
||||
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
// Shutdown was requested during (or right after) this attempt — honor it, don't retry.
|
||||
return result;
|
||||
}
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!("client run ended unexpectedly, will auto-reconnect: {e}");
|
||||
}
|
||||
|
||||
if attempt_start.elapsed() >= STABLE_UPTIME {
|
||||
backoff_idx = 0;
|
||||
}
|
||||
let delay = BACKOFF_SCHEDULE_SECS[backoff_idx.min(BACKOFF_SCHEDULE_SECS.len() - 1)];
|
||||
backoff_idx += 1;
|
||||
|
||||
// Reflect the retry wait as "connecting" rather than "disconnected".
|
||||
metrics.connection_state.store(1, Ordering::Relaxed);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(delay)) => {}
|
||||
_ = shutdown_rx_ext.changed() => {
|
||||
if *shutdown_rx_ext.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_client_once(
|
||||
mut config: crate::config::ClientConfig,
|
||||
metrics: Arc<BridgeMetrics>,
|
||||
mut shutdown_rx_ext: watch::Receiver<bool>,
|
||||
mut config_rx: Option<watch::Receiver<crate::config::ClientConfig>>,
|
||||
) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if config.mode == "tun" && !is_admin() {
|
||||
|
|
@ -238,19 +295,25 @@ pub async fn run_client_core(
|
|||
}
|
||||
|
||||
let _sysproxy_guard = if config.mode == "proxy" {
|
||||
Some(crate::sysproxy::SystemProxyGuard::enable(&config.local_proxy.bind_addr))
|
||||
// Enable system proxy and set initial ProxyOverride with user exclusions
|
||||
let guard = Some(crate::sysproxy::SystemProxyGuard::enable(&config.local_proxy.bind_addr));
|
||||
crate::sysproxy::update_proxy_bypass_list(
|
||||
&config.exclusions.domains,
|
||||
&config.exclusions.ips,
|
||||
);
|
||||
guard
|
||||
} else {
|
||||
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 (client_msgs_tx, client_msgs_rx) = mpsc::unbounded_channel();
|
||||
|
||||
// Setup exclusions hot-reload channel
|
||||
let (reload_tx, reload_rx) = watch::channel(config.exclusions.clone());
|
||||
|
||||
let bridge = Bridge::new(&config, metrics)?;
|
||||
let mut bridge = Bridge::new(&config, metrics)?;
|
||||
bridge.reload_tx = Some(reload_tx.clone());
|
||||
|
||||
let (ui_tx, mut ui_rx) = mpsc::channel(512);
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(128);
|
||||
|
|
@ -305,11 +368,12 @@ pub async fn run_client_core(
|
|||
});
|
||||
|
||||
let config_clone = config.clone();
|
||||
let proxy_exclusions_rx = reload_rx.clone();
|
||||
let mut proxy_task = tokio::spawn(async move {
|
||||
tunnel::run_local_proxy(
|
||||
config.local_proxy,
|
||||
config.ostp,
|
||||
config.exclusions,
|
||||
proxy_exclusions_rx,
|
||||
config.debug,
|
||||
proxy_shutdown_rx,
|
||||
proxy_events_tx,
|
||||
|
|
@ -319,14 +383,49 @@ pub async fn run_client_core(
|
|||
});
|
||||
|
||||
let wintun_shutdown_rx = shutdown_tx.subscribe();
|
||||
let wintun_exclusions_rx = reload_rx.clone();
|
||||
let mut wintun_task = if config_clone.mode == "tun" {
|
||||
Some(tokio::spawn(async move {
|
||||
tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx).await
|
||||
tunnel::run_tun_tunnel(config_clone, wintun_shutdown_rx, wintun_exclusions_rx).await
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Wait for local_shutdown
|
||||
let mut local_shutdown = shutdown_rx_ext.clone();
|
||||
let cmd_tx_loop = cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = local_shutdown.changed() => {
|
||||
if *local_shutdown.borrow() {
|
||||
let _ = cmd_tx_loop.send(BridgeCommand::Shutdown).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) = async {
|
||||
if let Some(ref mut rx) = config_rx {
|
||||
Some(rx.changed().await)
|
||||
} else {
|
||||
std::future::pending().await
|
||||
}
|
||||
} => {
|
||||
if let Some(ref rx) = config_rx {
|
||||
let new_cfg = rx.borrow().clone();
|
||||
// Update Windows ProxyOverride so excluded domains/IPs
|
||||
// bypass the system proxy immediately (proxy mode only).
|
||||
crate::sysproxy::update_proxy_bypass_list(
|
||||
&new_cfg.exclusions.domains,
|
||||
&new_cfg.exclusions.ips,
|
||||
);
|
||||
let _ = reload_tx.send(new_cfg.exclusions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either external shutdown OR any task to fail
|
||||
tokio::select! {
|
||||
_ = shutdown_rx_ext.changed() => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,79 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
// Set bypass list to prevent proxy loop for localhost traffic
|
||||
// Set initial bypass list (will be expanded by update_proxy_bypass_list)
|
||||
update_proxy_bypass_list_windows(&[], &[]);
|
||||
|
||||
refresh_wininet();
|
||||
tracing::info!("System proxy enabled successfully");
|
||||
}
|
||||
|
||||
/// Update the Windows ProxyOverride registry value to include user-configured
|
||||
/// excluded domains and IPs. This makes excluded hosts bypass the OSTP proxy
|
||||
/// entirely at the OS level — the most reliable split-tunneling mechanism.
|
||||
///
|
||||
/// For each domain `d`, adds both `d` and `*.d` so both the root and all
|
||||
/// subdomains bypass the proxy.
|
||||
/// For IPs, adds them verbatim (Windows supports exact IPs and wildcards like
|
||||
/// `192.168.*`).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn update_proxy_bypass_list(domains: &[String], ips: &[String]) {
|
||||
update_proxy_bypass_list_windows(domains, ips);
|
||||
refresh_wininet();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn update_proxy_bypass_list(_domains: &[String], _ips: &[String]) {
|
||||
// Linux/macOS: no-op (gnome/kde proxy bypass list update not implemented)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn update_proxy_bypass_list_windows(domains: &[String], ips: &[String]) {
|
||||
// Base list: always bypass local addresses
|
||||
let mut parts: Vec<String> = vec![
|
||||
"localhost".into(),
|
||||
"127.*".into(),
|
||||
"10.*".into(),
|
||||
"172.16.*".into(),
|
||||
"172.17.*".into(),
|
||||
"172.18.*".into(),
|
||||
"172.19.*".into(),
|
||||
"172.20.*".into(),
|
||||
"172.21.*".into(),
|
||||
"172.22.*".into(),
|
||||
"172.23.*".into(),
|
||||
"172.24.*".into(),
|
||||
"172.25.*".into(),
|
||||
"172.26.*".into(),
|
||||
"172.27.*".into(),
|
||||
"172.28.*".into(),
|
||||
"172.29.*".into(),
|
||||
"172.30.*".into(),
|
||||
"172.31.*".into(),
|
||||
"192.168.*".into(),
|
||||
"<local>".into(),
|
||||
];
|
||||
|
||||
// Add excluded domains: both exact and wildcard subdomain form
|
||||
for d in domains {
|
||||
let d = d.trim().trim_start_matches('.').to_lowercase();
|
||||
if d.is_empty() { continue; }
|
||||
parts.push(d.clone());
|
||||
parts.push(format!("*.{}", d));
|
||||
}
|
||||
|
||||
// Add excluded IPs verbatim
|
||||
for ip in ips {
|
||||
let ip = ip.trim();
|
||||
if ip.is_empty() { continue; }
|
||||
// Strip CIDR suffix if present — Windows ProxyOverride doesn't support CIDR
|
||||
let host = ip.split('/').next().unwrap_or(ip);
|
||||
parts.push(host.to_string());
|
||||
}
|
||||
|
||||
let override_value = parts.join(";");
|
||||
tracing::info!("Updating ProxyOverride: {}", override_value);
|
||||
|
||||
let _ = Command::new("reg")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
|
|
@ -72,13 +144,10 @@ pub fn enable_windows_proxy(proxy_addr: &str) {
|
|||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
|
||||
"/v", "ProxyOverride",
|
||||
"/t", "REG_SZ",
|
||||
"/d", "localhost;127.*;10.*;192.168.*;<local>",
|
||||
"/d", &override_value,
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
|
||||
refresh_wininet();
|
||||
tracing::info!("System proxy enabled successfully");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -120,7 +189,7 @@ fn refresh_wininet() {
|
|||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn enable_system_proxy(proxy_addr: &str) {
|
||||
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 is_gui = std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
|
||||
|
|
@ -166,7 +235,7 @@ pub fn enable_system_proxy(proxy_addr: &str) {
|
|||
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
||||
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
||||
println!("configure your system proxy. To route traffic from this terminal, run:");
|
||||
println!("\n eval $(ostp --proxy-env)\n");
|
||||
println!("\n eval $(ostp proxy-env)\n");
|
||||
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
||||
println!("===================================================================\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
pub mod xhttp;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
|
|
|||
|
|
@ -1,384 +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::net::TcpStream::connect(addr).await
|
||||
.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());
|
||||
tcp_socket.connect(addr).await
|
||||
.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))
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
mod proxy;
|
||||
pub mod native_handler;
|
||||
|
||||
mod udp_nat;
|
||||
|
||||
pub async fn run_tun_tunnel(
|
||||
config: crate::config::ClientConfig,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
exclusions_rx: tokio::sync::watch::Receiver<crate::config::ExclusionConfig>,
|
||||
) -> anyhow::Result<()> {
|
||||
native_handler::run_native_tunnel(config, shutdown).await
|
||||
native_handler::run_native_tunnel(config, shutdown, exclusions_rx).await
|
||||
}
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
|
@ -51,17 +53,15 @@ pub enum ProxyToClientMsg {
|
|||
pub async fn run_local_proxy(
|
||||
cfg: LocalProxyConfig,
|
||||
ostp: OstpConfig,
|
||||
exclusions: ExclusionConfig,
|
||||
exclusions_rx: watch::Receiver<ExclusionConfig>,
|
||||
debug: bool,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
||||
client_msgs_rx: mpsc::UnboundedReceiver<(u16, ProxyToClientMsg)>,
|
||||
) -> anyhow::Result<()> {
|
||||
run_local_socks5_proxy(cfg, ostp, exclusions, debug, shutdown, proxy_events_tx, client_msgs_rx).await
|
||||
run_local_socks5_proxy(cfg, ostp, exclusions_rx, debug, shutdown, proxy_events_tx, client_msgs_rx).await
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub mod exclusion;
|
||||
pub mod process_lookup;
|
||||
pub mod sni_sniff;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use tokio::sync::watch;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Windows / Linux desktop TUN
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
pub async fn run_native_tunnel(
|
||||
config: crate::config::ClientConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
) -> Result<()> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::process::Command;
|
||||
use netstack_smoltcp::StackBuilder;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use futures::{StreamExt, SinkExt};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
|
@ -26,12 +27,12 @@ pub async fn run_native_tunnel(
|
|||
println!("===================================================================\n");
|
||||
print!("Are you sure you want to initialize the TUN interface? [yes/no]: ");
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).unwrap();
|
||||
let ans = input.trim().to_lowercase();
|
||||
if ans != "y" && ans != "yes" {
|
||||
return Err(anyhow!("TUN initialization aborted by user. Run without TUN to use as a local proxy."));
|
||||
return Err(anyhow!("TUN initialization aborted by user."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,111 +40,73 @@ pub async fn run_native_tunnel(
|
|||
let debug = config.debug;
|
||||
tracing::info!("Initializing NATIVE TUN tunnel (smoltcp)...");
|
||||
|
||||
let server_ip = config.ostp.server_addr.to_socket_addrs()
|
||||
.map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))?
|
||||
// Capture physical interface index for bypass BEFORE we create the TUN device and alter routes.
|
||||
#[cfg(target_os = "windows")]
|
||||
let phys_if_for_bypass: Option<u32> = ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let phys_if_for_bypass: Option<u32> = None;
|
||||
|
||||
// ── 1. Resolve server IP ──────────────────────────────────────────────────
|
||||
let server_ip = config
|
||||
.ostp
|
||||
.server_addr
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| anyhow!("Failed to resolve server IP: {}", e))?
|
||||
.next()
|
||||
.map(|addr| addr.ip())
|
||||
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
|
||||
|
||||
.map(|a| a.ip())
|
||||
.ok_or_else(|| anyhow!("Could not resolve server host"))?;
|
||||
#[allow(unused_variables)]
|
||||
let server_ip_str = server_ip.to_string();
|
||||
|
||||
let mut tun_cfg = tun::Configuration::default();
|
||||
tun_cfg.tun_name("ostp_tun")
|
||||
.address((10, 1, 0, 2))
|
||||
.netmask((255, 255, 255, 0))
|
||||
.destination((10, 1, 0, 1))
|
||||
.mtu(config.ostp.mtu as u16)
|
||||
.up();
|
||||
// ── 2. Resolve excluded domains → IP addresses for bypass routing ─────────
|
||||
let mut bypass_ips: Vec<std::net::IpAddr> = Vec::new();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
tun_cfg.platform_config(|config| {
|
||||
config.packet_information(false);
|
||||
});
|
||||
// Server IP always bypasses TUN
|
||||
bypass_ips.push(server_ip);
|
||||
|
||||
let dev = tun::create(&tun_cfg)
|
||||
.map_err(|e| anyhow!("Failed to create TUN device: {}", e))?;
|
||||
let dev = tun::AsyncDevice::new(dev)
|
||||
.map_err(|e| anyhow!("Failed to make TUN device async: {}", e))?;
|
||||
for ip_str in &config.exclusions.ips {
|
||||
let host = ip_str.split('/').next().unwrap_or(ip_str);
|
||||
if let Ok(ip) = host.parse() {
|
||||
bypass_ips.push(ip);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("TUN device created natively.");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
|
||||
|
||||
let setup_script = format!(
|
||||
"$remote_ip = '{}'\n\
|
||||
$exe_path = '{}'\n\
|
||||
$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object {{ $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' }} | Sort-Object RouteMetric | Select-Object -First 1\n\
|
||||
if ($route) {{\n\
|
||||
$gw = $route.NextHop\n\
|
||||
$ifIndex = $route.InterfaceIndex\n\
|
||||
if ($gw -eq '0.0.0.0' -or $gw -eq '::') {{\n\
|
||||
New-NetRoute -DestinationPrefix \"$remote_ip/32\" -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
||||
}} else {{\n\
|
||||
New-NetRoute -DestinationPrefix \"$remote_ip/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
||||
}}\n\
|
||||
if ($gw -ne '0.0.0.0') {{\n\
|
||||
New-NetRoute -DestinationPrefix \"$gw/32\" -NextHop '0.0.0.0' -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
|
||||
}}\n\
|
||||
}}\n\
|
||||
New-NetFirewallRule -DisplayName 'OSTP Tunnel In' -Direction Inbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\
|
||||
New-NetFirewallRule -DisplayName 'OSTP Tunnel Out' -Direction Outbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\
|
||||
netsh interface ipv4 set interface name=\"ostp_tun\" metric=1\n\
|
||||
New-NetRoute -DestinationPrefix '0.0.0.0/0' -InterfaceAlias 'ostp_tun' -NextHop '10.1.0.1' -RouteMetric 1 -ErrorAction SilentlyContinue\n",
|
||||
server_ip_str, current_exe
|
||||
);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
Command::new("powershell")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-Command", &setup_script])
|
||||
.output()
|
||||
}).await.unwrap()?;
|
||||
|
||||
if let Some(ref dns) = config.dns_server {
|
||||
if !dns.is_empty() {
|
||||
let net_setup = format!("netsh interface ipv4 set dnsservers name=\"ostp_tun\" static {} primary\n", dns);
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
Command::new("powershell")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-Command", &net_setup])
|
||||
.output()
|
||||
}).await.unwrap()?;
|
||||
for domain in &config.exclusions.domains {
|
||||
match tokio::net::lookup_host((domain.as_str(), 443u16)).await {
|
||||
Ok(addrs) => {
|
||||
for addr in addrs {
|
||||
bypass_ips.push(addr.ip());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to pre-resolve excluded domain {domain}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Get real gateway before routing through TUN
|
||||
let gw_out = Command::new("ip")
|
||||
.args(["route", "show", "default"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok());
|
||||
|
||||
// ── 3. Create TUN device via ostp-tun crate ───────────────────────────────
|
||||
let opts = ostp_tun::OstpTunOptions {
|
||||
server_ip,
|
||||
bypass_ips,
|
||||
dns_server: config.dns_server.clone(),
|
||||
kill_switch: config.kill_switch,
|
||||
mtu: config.ostp.mtu as u16,
|
||||
wintun_path: None,
|
||||
};
|
||||
|
||||
let tun_interface = ostp_tun::OstpTunInterface::create(opts)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to create OstpTunInterface: {}", e))?;
|
||||
|
||||
let real_gw = gw_out.as_deref().and_then(|s| {
|
||||
// "default via 192.168.1.1 dev eth0" -> "192.168.1.1"
|
||||
s.split_whitespace().skip_while(|w| *w != "via").nth(1).map(|s| s.to_string())
|
||||
});
|
||||
let real_dev = gw_out.as_deref().and_then(|s| {
|
||||
s.split_whitespace().skip_while(|w| *w != "dev").nth(1).map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Add exclusion route for server IP via real gateway (bypass TUN)
|
||||
if let (Some(ref gw), Some(ref dev)) = (&real_gw, &real_dev) {
|
||||
let _ = Command::new("ip").args(["route", "add", &format!("{}/32", server_ip_str), "via", gw, "dev", dev]).output();
|
||||
}
|
||||
|
||||
// Add default route through TUN (lower metric to take priority)
|
||||
let _ = Command::new("ip").args(["route", "add", "default", "via", "10.1.0.1", "dev", "ostp_tun", "metric", "10"]).output();
|
||||
}
|
||||
let dev = tun_interface.device;
|
||||
let _route_guard = tun_interface.guard;
|
||||
|
||||
// ── 7. Build smoltcp network stack ────────────────────────────────────────
|
||||
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
||||
.stack_buffer_size(100000)
|
||||
.tcp_buffer_size(100000)
|
||||
.udp_buffer_size(100000)
|
||||
.stack_buffer_size(1024)
|
||||
.tcp_buffer_size(1024)
|
||||
.udp_buffer_size(1024)
|
||||
.enable_tcp(true)
|
||||
.enable_udp(true)
|
||||
.mtu(config.ostp.mtu)
|
||||
|
|
@ -155,6 +118,7 @@ pub async fn run_native_tunnel(
|
|||
}
|
||||
});
|
||||
|
||||
// ── 8. Wire TUN ↔ smoltcp stack ───────────────────────────────────────────
|
||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
||||
let (mut tun_read, mut tun_write) = tokio::io::split(dev);
|
||||
|
||||
|
|
@ -172,8 +136,7 @@ pub async fn run_native_tunnel(
|
|||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("tun_read error: {}", e);
|
||||
// continue reading
|
||||
tracing::debug!("tun_read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,55 +145,245 @@ pub async fn run_native_tunnel(
|
|||
let mut stack_to_tun = tokio::spawn(async move {
|
||||
while let Some(Ok(frame)) = stack_stream.next().await {
|
||||
if let Err(e) = tun_write.write(&frame).await {
|
||||
tracing::debug!("tun_write error: {}", e);
|
||||
tracing::debug!("tun_write error: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let udp_proxy_addr = config.local_proxy.bind_addr.clone();
|
||||
let debug_udp = config.debug;
|
||||
// ── 9. UDP: forward everything through OSTP proxy ─────────────────────────
|
||||
// UDP exclusions are handled at the routing table level (step 5), so
|
||||
// UDP packets for excluded IPs never reach smoltcp at all.
|
||||
let udp_proxy_addr = {
|
||||
let mut a = config.local_proxy.bind_addr.clone();
|
||||
if a.starts_with("0.0.0.0:") {
|
||||
a = a.replace("0.0.0.0:", "127.0.0.1:");
|
||||
}
|
||||
a
|
||||
};
|
||||
// Build exclusion matcher for dynamic bypass
|
||||
let current_exclusions = exclusions_rx.borrow().clone();
|
||||
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None);
|
||||
let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher));
|
||||
|
||||
let matcher_clone = matcher_arc.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(_) = exclusions_rx.changed().await {
|
||||
let current = exclusions_rx.borrow().clone();
|
||||
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||
*matcher_clone.write().await = new_matcher;
|
||||
if true {
|
||||
tracing::debug!("Desktop TUN exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Linux: physical interface name for SO_BINDTODEVICE
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_phys_name = crate::tunnel::proxy::get_linux_physical_if_name();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let linux_phys_name: Option<String> = None;
|
||||
let _ = &linux_phys_name; // suppress unused warning on Windows
|
||||
|
||||
let debug_udp = debug;
|
||||
let udp_matcher = matcher_arc.clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
let udp_lin_name = linux_phys_name.clone();
|
||||
|
||||
let mut udp_proxy_task = tokio::spawn(async move {
|
||||
if let Some(udp_sock) = udp_socket {
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
|
||||
#[cfg(target_os = "linux")]
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp, udp_matcher, phys_if_for_bypass, udp_lin_name).await;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp, udp_matcher, phys_if_for_bypass, None).await;
|
||||
}
|
||||
});
|
||||
|
||||
let proxy_addr = config.local_proxy.bind_addr.clone();
|
||||
// ── 10. TCP: forward to OSTP proxy (with domain-level bypass via SNI) ─────
|
||||
//
|
||||
// For IP-based exclusions: handled by routing table → packets never arrive here.
|
||||
// For domain-based exclusions: The IP is already in routing table (pre-resolved in
|
||||
// step 3), so most traffic won't arrive. As a belt-and-suspenders fallback,
|
||||
// we also sniff TLS SNI and bypass if it matches — this covers CDN cases where
|
||||
// the IP wasn't known at startup.
|
||||
//
|
||||
// For bypassed connections we bind the outgoing socket to the physical interface
|
||||
// (IP_UNICAST_IF) so it goes out via the real NIC, not TUN.
|
||||
|
||||
let proxy_addr_tcp = {
|
||||
let mut a = config.local_proxy.bind_addr.clone();
|
||||
if a.starts_with("0.0.0.0:") {
|
||||
a = a.replace("0.0.0.0:", "127.0.0.1:");
|
||||
}
|
||||
a
|
||||
};
|
||||
|
||||
// Physical interface index was captured at the start of the function.
|
||||
|
||||
let mut tcp_accept_task = tokio::spawn(async move {
|
||||
if let Some(mut listener) = tcp_listener {
|
||||
while let Some((mut stream, _local, remote)) = listener.next().await {
|
||||
let proxy_addr = proxy_addr.clone();
|
||||
tokio::spawn(async move {
|
||||
if debug { tracing::info!("Native TUN intercepted TCP to {}", remote); }
|
||||
if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await {
|
||||
// SOCKS5 bypass handshake locally (loopback)
|
||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||
let mut buf = [0u8; 2];
|
||||
if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; }
|
||||
|
||||
let ip = remote.ip();
|
||||
let port = remote.port();
|
||||
let mut req = vec![5, 1, 0];
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
req.push(1);
|
||||
req.extend_from_slice(&v4.octets());
|
||||
let Some(mut listener) = tcp_listener else { return; };
|
||||
|
||||
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||
let proxy_addr = proxy_addr_tcp.clone();
|
||||
let matcher_arc = matcher_arc.clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
let lin_name = linux_phys_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let matcher = matcher_arc.read().await.clone();
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP {local} → {remote}");
|
||||
}
|
||||
|
||||
// ── Sniff TLS ClientHello for SNI ─────────────────────────────
|
||||
let mut sniff_buf = [0u8; 2048];
|
||||
let sniff_len =
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
stream.read(&mut sniff_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// ── Decide: bypass or tunnel? ─────────────────────────────────
|
||||
let mut should_bypass = false;
|
||||
|
||||
// 1. Process match via OS Extended TCP Table (Windows)
|
||||
#[cfg(target_os = "windows")]
|
||||
if !should_bypass {
|
||||
if let Some(proc_name) = crate::tunnel::process_lookup::get_process_name_from_port(local.port()) {
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP lookup: port {} -> process {}", local.port(), proc_name);
|
||||
}
|
||||
if matcher.match_process(&proc_name) {
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP BYPASS (Process match): {} → {remote}", proc_name);
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
req.push(4);
|
||||
req.extend_from_slice(&v6.octets());
|
||||
should_bypass = true;
|
||||
}
|
||||
} else {
|
||||
if debug {
|
||||
tracing::debug!("TUN TCP lookup: port {} -> no process found", local.port());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. SNI domain check (belt-and-suspenders for CDNs / late-resolved IPs)
|
||||
if !should_bypass && sniff_len > 0 {
|
||||
if let Some(sni) =
|
||||
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||
{
|
||||
if debug {
|
||||
tracing::debug!("TUN SNI: {sni}");
|
||||
}
|
||||
if matcher.match_domain(&sni) {
|
||||
if debug {
|
||||
tracing::info!("TUN TCP BYPASS (SNI domain): {sni} → {remote}");
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Destination IP CIDR check (for IPs not in routing table / IPv6)
|
||||
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||
if debug {
|
||||
tracing::info!("TUN TCP BYPASS (IP match): {remote}");
|
||||
}
|
||||
should_bypass = true;
|
||||
}
|
||||
|
||||
// ── Bypass path: direct TCP bypassing TUN ─────────────────────
|
||||
if should_bypass {
|
||||
let socket = match remote {
|
||||
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
|
||||
};
|
||||
let Ok(socket) = socket else { return; };
|
||||
|
||||
// Bind to physical interface so packets don't loop back into TUN
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(idx) = phys_if_for_bypass {
|
||||
if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface(
|
||||
&socket,
|
||||
remote.is_ipv6(),
|
||||
idx,
|
||||
) {
|
||||
tracing::error!("TUN TCP BYPASS failed to bind to physical interface {}: {}", idx, e);
|
||||
} else {
|
||||
if debug {
|
||||
tracing::info!("TUN TCP BYPASS bound to physical interface {}", idx);
|
||||
}
|
||||
}
|
||||
req.extend_from_slice(&port.to_be_bytes());
|
||||
if socks.write_all(&req).await.is_err() { return; }
|
||||
|
||||
let mut rep = [0u8; 10];
|
||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||
} else {
|
||||
tracing::warn!("TUN TCP BYPASS has no physical interface index!");
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref name) = lin_name {
|
||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||
}
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
socket.connect(remote),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut direct)) => {
|
||||
if sniff_len > 0 {
|
||||
if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Direct bypass connect to {remote} failed");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Tunnel path: forward via local OSTP SOCKS5 proxy ──────────
|
||||
let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// SOCKS5 handshake (no auth)
|
||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||
let mut buf2 = [0u8; 2];
|
||||
if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// CONNECT request
|
||||
let mut req = vec![5u8, 1, 0];
|
||||
match remote.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
req.push(1);
|
||||
req.extend_from_slice(&v4.octets());
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
req.push(4);
|
||||
req.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
req.extend_from_slice(&remote.port().to_be_bytes());
|
||||
if socks.write_all(&req).await.is_err() { return; }
|
||||
|
||||
let mut rep = [0u8; 10];
|
||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||
|
||||
// Replay sniffed bytes
|
||||
if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -246,45 +399,35 @@ pub async fn run_native_tunnel(
|
|||
}
|
||||
|
||||
tracing::info!("Deactivating NATIVE TUN tunnel...");
|
||||
// Cleanup routes
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let cleanup_script = format!(
|
||||
"$remote_ip = '{}'\n\
|
||||
Remove-NetRoute -DestinationPrefix \"$remote_ip/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\
|
||||
Remove-NetFirewallRule -DisplayName 'OSTP Tunnel*' -ErrorAction SilentlyContinue\n\
|
||||
netsh interface ipv4 set dnsservers name=\"ostp_tun\" source=dhcp 2>$null\n",
|
||||
server_ip_str
|
||||
);
|
||||
let _ = Command::new("powershell")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args(["-NoProfile", "-Command", &cleanup_script])
|
||||
.output();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Remove default route via TUN and server exclusion route
|
||||
let _ = Command::new("ip").args(["route", "del", "default", "dev", "ostp_tun"]).output();
|
||||
let _ = Command::new("ip").args(["route", "del", &format!("{}/32", server_ip_str)]).output();
|
||||
}
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
// Cleanup is handled automatically by the _route_guard Drop trait in ostp-tun
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Stub for unsupported platforms
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
pub async fn run_native_tunnel(
|
||||
_config: crate::config::ClientConfig,
|
||||
_shutdown: watch::Receiver<bool>,
|
||||
_exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux currently"))
|
||||
Err(anyhow!("Native TUN tunnel is only supported on Windows/Linux"))
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Android: TUN from file-descriptor (opened by VpnService)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub async fn run_native_tunnel_from_fd(
|
||||
config: crate::config::ClientConfig,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
mut exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
fd: i32,
|
||||
) -> Result<()> {
|
||||
use netstack_smoltcp::StackBuilder;
|
||||
|
|
@ -304,16 +447,16 @@ pub async fn run_native_tunnel_from_fd(
|
|||
|
||||
let read_fd = unsafe { libc::dup(fd) };
|
||||
if read_fd < 0 {
|
||||
return Err(anyhow::anyhow!("Failed to dup tun fd for reading"));
|
||||
return Err(anyhow!("Failed to dup tun fd for reading"));
|
||||
}
|
||||
|
||||
|
||||
let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
|
||||
let tun_stream = tokio::io::unix::AsyncFd::new(file)?;
|
||||
|
||||
let (stack, tcp_runner, udp_socket, tcp_listener) = StackBuilder::default()
|
||||
.stack_buffer_size(100000)
|
||||
.tcp_buffer_size(100000)
|
||||
.udp_buffer_size(100000)
|
||||
.stack_buffer_size(1024)
|
||||
.tcp_buffer_size(1024)
|
||||
.udp_buffer_size(1024)
|
||||
.enable_tcp(true)
|
||||
.enable_udp(true)
|
||||
.mtu(config.ostp.mtu)
|
||||
|
|
@ -334,25 +477,29 @@ pub async fn run_native_tunnel_from_fd(
|
|||
Ok(g) => g,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let n = match guard.try_io(|inner| {
|
||||
let res = unsafe { libc::read(inner.as_raw_fd(), buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
||||
let res = unsafe {
|
||||
libc::read(
|
||||
inner.as_raw_fd(),
|
||||
buf.as_mut_ptr() as *mut libc::c_void,
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
Err(err)
|
||||
} else {
|
||||
// EINTR or other transient error — treat as zero (will continue)
|
||||
Ok(0_isize)
|
||||
}
|
||||
} else {
|
||||
Ok(res as isize)
|
||||
Ok(res)
|
||||
}
|
||||
}) {
|
||||
Ok(Ok(n)) if n > 0 => n as usize,
|
||||
Ok(Ok(_)) => continue, // 0 = EINTR or transient error, try again
|
||||
Ok(Err(_)) => continue, // WouldBlock retry
|
||||
Err(_would_block) => continue,
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => continue,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let frame = buf[..n].to_vec();
|
||||
|
|
@ -366,7 +513,7 @@ pub async fn run_native_tunnel_from_fd(
|
|||
|
||||
let write_fd = unsafe { libc::dup(fd) };
|
||||
if write_fd < 0 {
|
||||
return Err(anyhow!("Failed to dup tun fd"));
|
||||
return Err(anyhow!("Failed to dup tun fd for writing"));
|
||||
}
|
||||
unsafe {
|
||||
let flags = libc::fcntl(write_fd, libc::F_GETFL);
|
||||
|
|
@ -385,9 +532,14 @@ pub async fn run_native_tunnel_from_fd(
|
|||
Ok(g) => g,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let res = guard.try_io(|inner| {
|
||||
let res = unsafe { libc::write(inner.as_raw_fd(), frame[written..].as_ptr() as *const libc::c_void, frame.len() - written) };
|
||||
let res = unsafe {
|
||||
libc::write(
|
||||
inner.as_raw_fd(),
|
||||
frame[written..].as_ptr() as *const libc::c_void,
|
||||
frame.len() - written,
|
||||
)
|
||||
};
|
||||
if res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
|
|
@ -399,11 +551,9 @@ pub async fn run_native_tunnel_from_fd(
|
|||
Ok(res)
|
||||
}
|
||||
});
|
||||
|
||||
match res {
|
||||
Ok(Ok(n)) if n > 0 => written += n as usize,
|
||||
Ok(Ok(n)) if n == 0 => break,
|
||||
Ok(Ok(_)) => break, // n < 0, error writing, drop this frame
|
||||
Ok(Ok(_)) => break,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
|
|
@ -411,111 +561,160 @@ pub async fn run_native_tunnel_from_fd(
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
let mut proxy_addr = config.local_proxy.bind_addr.clone();
|
||||
if proxy_addr.starts_with("0.0.0.0:") {
|
||||
proxy_addr = proxy_addr.replace("0.0.0.0:", "127.0.0.1:");
|
||||
}
|
||||
|
||||
let udp_proxy_addr = proxy_addr.clone();
|
||||
let debug_udp = config.debug;
|
||||
let mut udp_proxy_task = tokio::spawn(async move {
|
||||
if let Some(udp_sock) = udp_socket {
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp).await;
|
||||
let current_exclusions = exclusions_rx.borrow().clone();
|
||||
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t_exclusions, None, None);
|
||||
let matcher_arc = std::sync::Arc::new(tokio::sync::RwLock::new(matcher));
|
||||
|
||||
let matcher_clone = matcher_arc.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(_) = exclusions_rx.changed().await {
|
||||
let current = exclusions_rx.borrow().clone();
|
||||
let new_matcher = crate::tunnel::exclusion::ExclusionMatcher::new(¤t, None, None);
|
||||
*matcher_clone.write().await = new_matcher;
|
||||
if true {
|
||||
tracing::debug!("Android TUN exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let matcher = crate::tunnel::exclusion::ExclusionMatcher::new(&config.exclusions, None, None);
|
||||
let udp_proxy_addr = proxy_addr.clone();
|
||||
let debug_udp = debug;
|
||||
let udp_matcher = matcher_arc.clone();
|
||||
let mut udp_proxy_task = tokio::spawn(async move {
|
||||
if let Some(udp_sock) = udp_socket {
|
||||
super::udp_nat::run_udp_nat(udp_sock, udp_proxy_addr, debug_udp, udp_matcher, None, None).await;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
let mut tcp_accept_task = tokio::spawn(async move {
|
||||
if let Some(mut listener) = tcp_listener {
|
||||
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||
let proxy_addr = proxy_addr.clone();
|
||||
let matcher = matcher.clone();
|
||||
tokio::spawn(async move {
|
||||
if debug { tracing::info!("Native TUN intercepted TCP {local} -> {remote}"); }
|
||||
let Some(mut listener) = tcp_listener else { return; };
|
||||
|
||||
// Peak first chunk to see SNI
|
||||
let mut sniff_buf = [0u8; 1500];
|
||||
let sniff_len = match tokio::time::timeout(std::time::Duration::from_millis(50), stream.read(&mut sniff_buf)).await {
|
||||
while let Some((mut stream, local, remote)) = listener.next().await {
|
||||
let proxy_addr = proxy_addr.clone();
|
||||
let matcher_arc = matcher_arc.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let matcher = matcher_arc.read().await.clone();
|
||||
|
||||
if true {
|
||||
tracing::debug!("Android TUN TCP {local} → {remote}");
|
||||
}
|
||||
|
||||
// Sniff SNI
|
||||
let mut sniff_buf = [0u8; 2048];
|
||||
let sniff_len =
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
stream.read(&mut sniff_buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
_ => 0, // Timeout or error
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let mut should_bypass = false;
|
||||
let mut should_bypass = false;
|
||||
|
||||
// 1. Check SNI
|
||||
if sniff_len > 0 {
|
||||
if let Some(sni) = crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len]) {
|
||||
if debug { tracing::info!("Native TUN sniffed SNI: {}", sni); }
|
||||
if matcher.match_domain(&sni) {
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Process
|
||||
if !should_bypass {
|
||||
if let Some(exe) = crate::tunnel::process_lookup::get_process_name_from_port(local.port()) {
|
||||
if debug { tracing::info!("Native TUN source port {} maps to EXE: {}", local.port(), exe); }
|
||||
if matcher.match_process(&exe) {
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check Target IP
|
||||
if !should_bypass {
|
||||
if matcher.match_ip(&remote.ip()) {
|
||||
// 1. SNI domain
|
||||
if sniff_len > 0 {
|
||||
if let Some(sni) =
|
||||
crate::tunnel::sni_sniff::extract_sni(&sniff_buf[..sniff_len])
|
||||
{
|
||||
if true { tracing::debug!("Android TUN SNI: {sni}"); }
|
||||
if matcher.match_domain(&sni) {
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_bypass {
|
||||
if debug { tracing::info!("Native TUN BYPASS matched for {}", remote); }
|
||||
if let Ok(mut direct) = tokio::time::timeout(std::time::Duration::from_secs(5), tokio::net::TcpStream::connect(remote)).await.unwrap_or(Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "Direct connect timeout"))) {
|
||||
// 2. Process (Android: /proc/net lookup)
|
||||
if !should_bypass {
|
||||
if let Some(exe) =
|
||||
crate::tunnel::process_lookup::get_process_name_from_port(local.port())
|
||||
{
|
||||
if true {
|
||||
tracing::debug!("Android TUN port {} → EXE: {}", local.port(), exe);
|
||||
}
|
||||
if matcher.match_process(&exe) {
|
||||
should_bypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. IP CIDR
|
||||
if !should_bypass && matcher.match_ip(&remote.ip()) {
|
||||
should_bypass = true;
|
||||
}
|
||||
|
||||
// Bypass: connect directly (Android VPN service already protects the socket
|
||||
// from re-entering the TUN through VpnService.protect())
|
||||
if should_bypass {
|
||||
if true {
|
||||
tracing::debug!("Android TUN BYPASS: {remote}");
|
||||
}
|
||||
let socket = match remote {
|
||||
std::net::SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4(),
|
||||
std::net::SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6(),
|
||||
};
|
||||
let Ok(socket) = socket else { return; };
|
||||
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
socket.connect(remote),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(mut direct)) => {
|
||||
if sniff_len > 0 {
|
||||
let _ = direct.write_all(&sniff_buf[..sniff_len]).await;
|
||||
if direct.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut direct).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await {
|
||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||
let mut buf = [0u8; 2];
|
||||
if socks.read_exact(&mut buf).await.is_err() || buf[0] != 5 || buf[1] != 0 { return; }
|
||||
|
||||
let ip = remote.ip();
|
||||
let port = remote.port();
|
||||
let mut req = vec![5, 1, 0];
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
req.push(1);
|
||||
req.extend_from_slice(&v4.octets());
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
req.push(4);
|
||||
req.extend_from_slice(&v6.octets());
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Android bypass connect to {remote} failed");
|
||||
}
|
||||
req.extend_from_slice(&port.to_be_bytes());
|
||||
if socks.write_all(&req).await.is_err() { return; }
|
||||
|
||||
let mut rep = [0u8; 10];
|
||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||
|
||||
// Write sniffed buffer to socks
|
||||
if sniff_len > 0 {
|
||||
if socks.write_all(&sniff_buf[..sniff_len]).await.is_err() { return; }
|
||||
}
|
||||
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tunnel via SOCKS5 proxy
|
||||
let Ok(mut socks) = tokio::net::TcpStream::connect(&proxy_addr).await else {
|
||||
return;
|
||||
};
|
||||
if socks.write_all(&[5, 1, 0]).await.is_err() { return; }
|
||||
let mut buf2 = [0u8; 2];
|
||||
if socks.read_exact(&mut buf2).await.is_err() || buf2[0] != 5 || buf2[1] != 0 {
|
||||
return;
|
||||
}
|
||||
let mut req = vec![5u8, 1, 0];
|
||||
match remote.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
req.push(1);
|
||||
req.extend_from_slice(&v4.octets());
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
req.push(4);
|
||||
req.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
req.extend_from_slice(&remote.port().to_be_bytes());
|
||||
if socks.write_all(&req).await.is_err() { return; }
|
||||
let mut rep = [0u8; 10];
|
||||
if socks.read_exact(&mut rep).await.is_err() || rep[1] != 0 { return; }
|
||||
if sniff_len > 0 && socks.write_all(&sniff_buf[..sniff_len]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut socks).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -530,7 +729,7 @@ pub async fn run_native_tunnel_from_fd(
|
|||
_ = &mut tcp_accept_task => {}
|
||||
}
|
||||
|
||||
tracing::info!("Deactivating NATIVE TUN tunnel...");
|
||||
tracing::info!("NATIVE TUN (Android) deactivated.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -538,8 +737,8 @@ pub async fn run_native_tunnel_from_fd(
|
|||
pub async fn run_native_tunnel_from_fd(
|
||||
_config: crate::config::ClientConfig,
|
||||
_shutdown: watch::Receiver<bool>,
|
||||
_exclusions_rx: watch::Receiver<crate::config::ExclusionConfig>,
|
||||
_fd: i32,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("Native TUN from FD is only supported on Android"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#[cfg(target_os = "windows")]
|
||||
pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
||||
use winapi::shared::minwindef::{DWORD, ULONG};
|
||||
use winapi::shared::minwindef::ULONG;
|
||||
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
||||
use winapi::um::iphlpapi::GetExtendedTcpTable;
|
||||
use winapi::shared::tcpmib::{MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID};
|
||||
|
|
@ -47,6 +47,54 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
|
||||
use winapi::shared::minwindef::ULONG;
|
||||
use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
|
||||
use winapi::um::iphlpapi::GetExtendedUdpTable;
|
||||
use winapi::shared::udpmib::{MIB_UDPTABLE_OWNER_PID, MIB_UDPROW_OWNER_PID};
|
||||
|
||||
let mut size: ULONG = 0;
|
||||
let table_class = 1; // UDP_TABLE_OWNER_PID
|
||||
let mut table = vec![0u8; 1024];
|
||||
|
||||
unsafe {
|
||||
let mut ret = GetExtendedUdpTable(
|
||||
table.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
0,
|
||||
2, // AF_INET
|
||||
table_class,
|
||||
0,
|
||||
);
|
||||
|
||||
if ret == ERROR_INSUFFICIENT_BUFFER {
|
||||
table.resize(size as usize, 0);
|
||||
ret = GetExtendedUdpTable(
|
||||
table.as_mut_ptr() as *mut _,
|
||||
&mut size,
|
||||
0,
|
||||
2, // AF_INET
|
||||
table_class,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if ret == 0 {
|
||||
let udp_table = &*(table.as_ptr() as *const MIB_UDPTABLE_OWNER_PID);
|
||||
let row_ptr = &udp_table.table[0] as *const MIB_UDPROW_OWNER_PID;
|
||||
for i in 0..udp_table.dwNumEntries {
|
||||
let row = &*row_ptr.add(i as usize);
|
||||
let local_port = u16::from_be(row.dwLocalPort as u16);
|
||||
if local_port == port {
|
||||
return get_process_name_from_pid(row.dwOwningPid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_process_name_from_pid(pid: u32) -> Option<String> {
|
||||
use winapi::um::processthreadsapi::OpenProcess;
|
||||
|
|
@ -78,7 +126,6 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
|||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
let mut target_inode = None;
|
||||
let hex_port = format!("{:04X}", port);
|
||||
|
||||
let check_net_file = |path: &str| -> Option<u64> {
|
||||
|
|
@ -98,7 +145,7 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
|||
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/udp"))
|
||||
.or_else(|| check_net_file("/proc/net/udp6"));
|
||||
|
|
@ -140,3 +187,8 @@ pub fn get_process_name_from_port(port: u16) -> Option<String> {
|
|||
pub fn get_process_name_from_port(_port: u16) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn get_process_name_from_port_udp(port: u16) -> Option<String> {
|
||||
get_process_name_from_port(port)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
use crate::tunnel::exclusion::{ExclusionMatcher, Cidr};
|
||||
use crate::tunnel::exclusion::ExclusionMatcher;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
||||
|
|
@ -29,9 +29,10 @@ extern "system" {
|
|||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
|
||||
pub fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index: u32) -> std::io::Result<()> {
|
||||
let s = socket.as_raw_socket() as usize;
|
||||
if is_ipv6 {
|
||||
// IPV6_UNICAST_IF expects interface index in host byte order
|
||||
let optval = if_index;
|
||||
let ret = unsafe {
|
||||
setsockopt(
|
||||
|
|
@ -46,6 +47,7 @@ fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index:
|
|||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
} else {
|
||||
// IP_UNICAST_IF expects interface index in NETWORK byte order (big-endian)
|
||||
let optval = if_index.to_be();
|
||||
let ret = unsafe {
|
||||
setsockopt(
|
||||
|
|
@ -64,7 +66,7 @@ fn bind_socket_to_interface(socket: &impl AsRawSocket, is_ipv6: bool, if_index:
|
|||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> {
|
||||
pub fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Result<()> {
|
||||
let fd = socket.as_raw_fd();
|
||||
let mut if_name_bytes = if_name.as_bytes().to_vec();
|
||||
if_name_bytes.push(0);
|
||||
|
|
@ -83,31 +85,18 @@ fn bind_socket_to_interface(socket: &impl AsRawFd, if_name: &str) -> std::io::Re
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn get_windows_physical_if_index() -> Option<u32> {
|
||||
pub fn get_windows_physical_if_index() -> Option<u32> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let output = std::process::Command::new("powershell")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object { $_.InterfaceAlias -notmatch 'ostp' -and $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' } | Sort-Object RouteMetric | Select-Object -ExpandProperty InterfaceIndex -First 1"
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(index) = s.trim().parse::<u32>() {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
return ostp_tun::windows::windows_route::sys::get_default_ipv4_route().map(|(_, idx)| idx);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_linux_physical_if_name() -> Option<String> {
|
||||
pub fn get_linux_physical_if_name() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let output = std::process::Command::new("ip")
|
||||
|
|
@ -208,7 +197,7 @@ async fn create_udp_socket_bypassing_tun(
|
|||
pub async fn run_local_socks5_proxy(
|
||||
cfg: LocalProxyConfig,
|
||||
ostp: OstpConfig,
|
||||
exclusions: ExclusionConfig,
|
||||
mut exclusions_rx: watch::Receiver<ExclusionConfig>,
|
||||
debug: bool,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
proxy_events_tx: mpsc::Sender<ProxyEvent>,
|
||||
|
|
@ -219,10 +208,7 @@ pub async fn run_local_socks5_proxy(
|
|||
.await
|
||||
.with_context(|| format!("failed to bind local HTTP/SOCKS5 proxy at {}", cfg.bind_addr))?;
|
||||
|
||||
if debug {
|
||||
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);
|
||||
}
|
||||
tracing::info!("local HTTP/SOCKS5 proxy listening at {}", cfg.bind_addr);
|
||||
|
||||
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);
|
||||
|
|
@ -234,7 +220,8 @@ pub async fn run_local_socks5_proxy(
|
|||
tracing::info!("Local proxy physical interface name: {:?}", physical_if_name);
|
||||
}
|
||||
|
||||
let matcher = ExclusionMatcher::new(&exclusions, physical_if_index, physical_if_name.clone());
|
||||
let mut current_exclusions = exclusions_rx.borrow().clone();
|
||||
let mut matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone());
|
||||
let (connect_tx, mut connect_rx) = mpsc::channel(128);
|
||||
let max_chunk = ostp.mtu.saturating_sub(150).max(512);
|
||||
|
||||
|
|
@ -248,6 +235,13 @@ pub async fn run_local_socks5_proxy(
|
|||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) = exclusions_rx.changed() => {
|
||||
current_exclusions = exclusions_rx.borrow().clone();
|
||||
matcher = ExclusionMatcher::new(¤t_exclusions, physical_if_index, physical_if_name.clone());
|
||||
if true {
|
||||
tracing::info!("Local proxy exclusions hot-reloaded");
|
||||
}
|
||||
}
|
||||
accepted = listener.accept() => {
|
||||
let (socket, _) = accepted?;
|
||||
let stream_id = next_stream_id;
|
||||
|
|
@ -291,7 +285,7 @@ pub async fn run_local_socks5_proxy(
|
|||
Some((stream_id, msg)) = client_msgs_rx.recv() => {
|
||||
if stream_id == 0 {
|
||||
if let ProxyToClientMsg::Close = msg {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("Resetting all active proxy streams on reconnect");
|
||||
}
|
||||
for (_, tx) in active_streams.drain() {
|
||||
|
|
@ -367,6 +361,10 @@ async fn handle_udp_associate(
|
|||
|
||||
let mut direct_udp_v4: 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];
|
||||
loop {
|
||||
|
|
@ -426,8 +424,8 @@ async fn handle_udp_associate(
|
|||
let target_port = match split_host_port(&target) { Some((_, p)) => p, None => 0 };
|
||||
// Check if target should bypass the tunnel
|
||||
if matcher.should_bypass_target(&target_host, target_port, connect_timeout).await {
|
||||
if debug {
|
||||
tracing::info!("proxy UDP BYPASS target={}", target);
|
||||
if true {
|
||||
tracing::debug!("proxy UDP BYPASS target={}", target);
|
||||
}
|
||||
// Resolve target to find if it is IPv4 or IPv6
|
||||
if let Ok(resolved_addrs) = tokio::net::lookup_host(&target).await {
|
||||
|
|
@ -438,7 +436,9 @@ async fn handle_udp_associate(
|
|||
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||
Ok(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);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -452,7 +452,9 @@ async fn handle_udp_associate(
|
|||
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||
Ok(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);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -465,7 +467,7 @@ async fn handle_udp_associate(
|
|||
|
||||
if let Some(s) = direct_socket {
|
||||
if let Err(e) = s.send_to(&payload, target_addr).await {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::warn!("failed to send bypass UDP packet to {}: {}", target_addr, e);
|
||||
}
|
||||
}
|
||||
|
|
@ -525,12 +527,25 @@ fn spawn_direct_udp_reader(
|
|||
direct_socket: Arc<UdpSocket>,
|
||||
sock_tx: Arc<UdpSocket>,
|
||||
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 {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
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)) => {
|
||||
let client_addr = {
|
||||
let guard = client_udp_addr.lock().unwrap();
|
||||
|
|
@ -550,14 +565,14 @@ fn spawn_direct_udp_reader(
|
|||
packet.extend_from_slice(&target_addr.port().to_be_bytes());
|
||||
packet.extend_from_slice(&buf[..len]);
|
||||
if let Err(e) = sock_tx.send_to(&packet, client_addr).await {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::warn!("failed to send direct UDP response to client: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::debug!("direct UDP socket read loop exiting: {e}");
|
||||
}
|
||||
break;
|
||||
|
|
@ -647,7 +662,7 @@ async fn handle_proxy_client(
|
|||
};
|
||||
|
||||
if is_udp {
|
||||
if debug { tracing::info!("proxy UDP ASSOCIATE stream_id={stream_id}"); }
|
||||
if true { tracing::debug!("proxy UDP ASSOCIATE stream_id={stream_id}"); }
|
||||
let udp_socket = UdpSocket::bind("127.0.0.1:0").await?;
|
||||
let port = udp_socket.local_addr()?.port();
|
||||
let mut reply = vec![0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1];
|
||||
|
|
@ -668,9 +683,7 @@ async fn handle_proxy_client(
|
|||
).await;
|
||||
}
|
||||
|
||||
if debug {
|
||||
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||
}
|
||||
tracing::debug!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
|
||||
let target_port = match split_host_port(&target) { Some((_, p)) => p, None => 0 };
|
||||
if matcher.should_bypass_target(&target_host, target_port, connect_timeout).await {
|
||||
|
|
@ -755,7 +768,7 @@ async fn handle_proxy_client(
|
|||
extract_host_port(raw_uri, default_port)
|
||||
};
|
||||
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy CONNECT stream_id={stream_id} target={target}");
|
||||
}
|
||||
let target_host = if let Some((host, _)) = split_host_port(&target) { host } else { target.clone() };
|
||||
|
|
@ -815,7 +828,7 @@ async fn handle_proxy_client(
|
|||
match read_res {
|
||||
Ok(0) => {
|
||||
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy CLOSE stream_id={stream_id}");
|
||||
}
|
||||
break;
|
||||
|
|
@ -833,7 +846,7 @@ async fn handle_proxy_client(
|
|||
}
|
||||
Err(_) => {
|
||||
let _ = event_tx.send(ProxyEvent::Close { stream_id }).await;
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy CLOSE stream_id={stream_id}");
|
||||
}
|
||||
break;
|
||||
|
|
@ -885,9 +898,9 @@ async fn direct_connect_socks5(
|
|||
physical_if_index: Option<u32>,
|
||||
physical_if_name: &Option<String>,
|
||||
close_tx: mpsc::Sender<u16>,
|
||||
debug: bool,
|
||||
_debug: bool,
|
||||
) -> Result<()> {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||
}
|
||||
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
|
||||
|
|
@ -907,9 +920,9 @@ async fn direct_connect_http(
|
|||
physical_if_index: Option<u32>,
|
||||
physical_if_name: &Option<String>,
|
||||
close_tx: mpsc::Sender<u16>,
|
||||
debug: bool,
|
||||
_debug: bool,
|
||||
) -> Result<()> {
|
||||
if debug {
|
||||
if true {
|
||||
tracing::info!("proxy BYPASS stream_id={stream_id} target={target}");
|
||||
}
|
||||
let mut remote = connect_bypassing_tun(target, physical_if_index, physical_if_name).await?;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ pub fn extract_sni(data: &[u8]) -> Option<String> {
|
|||
|
||||
if ext_type == 0x0000 { // Server Name Indication (SNI)
|
||||
if pos + 5 <= extensions_end {
|
||||
let list_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
|
||||
let _list_len = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
|
||||
let name_type = data[pos + 2];
|
||||
if name_type == 0 { // Hostname
|
||||
let name_len = ((data[pos + 3] as usize) << 8) | (data[pos + 4] as usize);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ pub async fn run_udp_nat(
|
|||
udp_socket: netstack_smoltcp::UdpSocket,
|
||||
proxy_addr: String,
|
||||
debug: bool,
|
||||
matcher: std::sync::Arc<tokio::sync::RwLock<crate::tunnel::exclusion::ExclusionMatcher>>,
|
||||
phys_if_index: Option<u32>,
|
||||
phys_if_name: Option<String>,
|
||||
) {
|
||||
let (mut rx, tx) = udp_socket.split();
|
||||
let tx = Arc::new(Mutex::new(tx));
|
||||
|
|
@ -17,33 +20,158 @@ pub async fn run_udp_nat(
|
|||
// map from internal client src to a channel that sends (payload, external_dst)
|
||||
let mut sessions: HashMap<SocketAddr, mpsc::Sender<(Vec<u8>, SocketAddr)>> = HashMap::new();
|
||||
|
||||
while let Some((payload, src, dst)) = rx.next().await {
|
||||
if payload.is_empty() { continue; }
|
||||
let mut cleanup_tick = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
|
||||
if !sessions.contains_key(&src) {
|
||||
let (session_tx, mut session_rx) = mpsc::channel::<(Vec<u8>, SocketAddr)>(100000);
|
||||
sessions.insert(src, session_tx);
|
||||
loop {
|
||||
tokio::select! {
|
||||
packet = rx.next() => {
|
||||
match packet {
|
||||
Some((payload, src, dst)) => {
|
||||
if payload.is_empty() { continue; }
|
||||
|
||||
let proxy_addr_clone = proxy_addr.clone();
|
||||
let tx_clone = tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if debug { tracing::info!("Starting UDP NAT session for {}", src); }
|
||||
let res = start_udp_session(src, proxy_addr_clone, &mut session_rx, tx_clone).await;
|
||||
if debug && res.is_err() {
|
||||
tracing::info!("UDP NAT session for {} ended: {:?}", src, res.err());
|
||||
if !sessions.contains_key(&src) {
|
||||
let (session_tx, mut session_rx) = mpsc::channel::<(Vec<u8>, SocketAddr)>(1024);
|
||||
sessions.insert(src, session_tx);
|
||||
|
||||
let proxy_addr_clone = proxy_addr.clone();
|
||||
let tx_clone = tx.clone();
|
||||
|
||||
let mut should_bypass = false;
|
||||
{
|
||||
let matcher_guard = matcher.read().await;
|
||||
if matcher_guard.match_ip(&dst.ip()) {
|
||||
should_bypass = true;
|
||||
if debug {
|
||||
tracing::info!("TUN UDP BYPASS (IP match): {} → {}", src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if !should_bypass {
|
||||
if let Some(proc_name) = crate::tunnel::process_lookup::get_process_name_from_port_udp(src.port()) {
|
||||
if debug {
|
||||
tracing::debug!("TUN UDP lookup: port {} -> process {}", src.port(), proc_name);
|
||||
}
|
||||
if matcher_guard.match_process(&proc_name) {
|
||||
should_bypass = true;
|
||||
if debug {
|
||||
tracing::debug!("TUN UDP BYPASS (Process match): {} ({} → {})", proc_name, src, dst);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if debug {
|
||||
tracing::debug!("TUN UDP lookup: port {} -> no process found", src.port());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let p_if_idx = phys_if_index;
|
||||
let p_if_name = phys_if_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if should_bypass {
|
||||
if debug {
|
||||
tracing::info!("Starting UDP BYPASS session for {}", src);
|
||||
}
|
||||
let res = start_udp_bypass_session(src, p_if_idx, p_if_name, &mut session_rx, tx_clone).await;
|
||||
if res.is_err() {
|
||||
tracing::debug!("UDP BYPASS session for {} ended: {:?}", src, res.err());
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Starting UDP NAT session for {}", src);
|
||||
let res = start_udp_session(src, proxy_addr_clone, &mut session_rx, tx_clone).await;
|
||||
if res.is_err() {
|
||||
tracing::debug!("UDP NAT session for {} ended: {:?}", src, res.err());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(sender) = sessions.get(&src) {
|
||||
match sender.try_send((payload, dst)) {
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
sessions.remove(&src);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
// Drop packet to avoid blocking the TUN interface loop
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(sender) = sessions.get(&src) {
|
||||
if sender.send((payload, dst)).await.is_err() {
|
||||
sessions.remove(&src);
|
||||
}
|
||||
_ = cleanup_tick.tick() => {
|
||||
sessions.retain(|_, sender| !sender.is_closed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_udp_bypass_session(
|
||||
client_src: SocketAddr,
|
||||
phys_if_index: Option<u32>,
|
||||
_phys_if_name: Option<String>,
|
||||
session_rx: &mut mpsc::Receiver<(Vec<u8>, SocketAddr)>,
|
||||
smoltcp_tx: Arc<Mutex<netstack_smoltcp::udp::WriteHalf>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let socket = match client_src {
|
||||
SocketAddr::V4(_) => UdpSocket::bind("0.0.0.0:0").await?,
|
||||
SocketAddr::V6(_) => UdpSocket::bind("[::]:0").await?,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(idx) = phys_if_index {
|
||||
if let Err(e) = crate::tunnel::proxy::bind_socket_to_interface(&socket, client_src.is_ipv6(), idx) {
|
||||
tracing::error!("TUN UDP BYPASS failed to bind to physical interface {}: {}", idx, e);
|
||||
} else {
|
||||
// Keep debug log
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("TUN UDP BYPASS has no physical interface index!");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(ref name) = _phys_if_name {
|
||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||
}
|
||||
|
||||
// A single select! loop over both directions, rather than spawning a
|
||||
// separate task for the read side, so the whole session - physical
|
||||
// socket included - is torn down the moment this function returns
|
||||
// (e.g. when session_rx closes). The previous spawned-task version left
|
||||
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
||||
// 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;
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
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)) => {
|
||||
let mut lock = smoltcp_tx.lock().await;
|
||||
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
async fn start_udp_session(
|
||||
client_src: SocketAddr,
|
||||
proxy_addr: String,
|
||||
|
|
@ -98,6 +226,15 @@ async fn start_udp_session(
|
|||
|
||||
// Local SOCKS5 proxy always returns 127.0.0.1 (IPv4), so always bind IPv4
|
||||
let udp = UdpSocket::bind("127.0.0.1:0").await?;
|
||||
|
||||
// CRITICAL for Android: protect this UDP socket so it goes out via the
|
||||
// real physical interface, not back into the TUN (which would cause an
|
||||
// infinite routing loop for DNS and all other UDP traffic).
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
crate::bridge::protect_socket(udp.as_raw_fd());
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; 65536];
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
//! bandwidth and minimum RTT to determine the optimal sending rate.
|
||||
//! This replaces the fixed `retransmit_budget = 8` with an adaptive
|
||||
//! 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};
|
||||
|
||||
|
|
@ -15,8 +21,14 @@ pub struct CongestionController {
|
|||
ssthresh: u64,
|
||||
/// Current phase
|
||||
phase: Phase,
|
||||
/// Minimum RTT observed
|
||||
/// Minimum RTT observed (for BBR-style bandwidth estimation)
|
||||
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_in_flight: u64,
|
||||
/// Total bytes acknowledged (for bandwidth estimation)
|
||||
|
|
@ -27,46 +39,141 @@ pub struct CongestionController {
|
|||
loss_count: u32,
|
||||
/// Pacing rate: bytes per second
|
||||
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: u64,
|
||||
/// Min RTT expiry: re-probe after 10 seconds
|
||||
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)]
|
||||
enum Phase {
|
||||
/// Exponential growth until loss or ssthresh
|
||||
SlowStart,
|
||||
/// Probe bandwidth: cycle through pacing gains
|
||||
/// Probe bandwidth: additive increase
|
||||
ProbeBandwidth,
|
||||
}
|
||||
|
||||
/// Initial congestion window: 10 packets × MTU
|
||||
const INITIAL_CWND_PACKETS: u64 = 10;
|
||||
/// Initial congestion window: 32 packets × MTU (IW10 is too conservative for modern links)
|
||||
const INITIAL_CWND_PACKETS: u64 = 32;
|
||||
/// Minimum cwnd: 2 packets
|
||||
const MIN_CWND_PACKETS: u64 = 2;
|
||||
/// Min RTT expiry window (after which we re-probe)
|
||||
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 {
|
||||
pub fn new(mtu: u64) -> Self {
|
||||
let now = Instant::now();
|
||||
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 {
|
||||
cwnd: initial_cwnd,
|
||||
ssthresh: u64::MAX,
|
||||
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,
|
||||
total_acked: 0,
|
||||
last_ack_time: now,
|
||||
loss_count: 0,
|
||||
pacing_rate: initial_cwnd * 10, // initial: ~10 windows/sec
|
||||
pacing_rate: initial_pacing,
|
||||
mtu,
|
||||
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.
|
||||
pub fn cwnd(&self) -> u64 {
|
||||
self.cwnd
|
||||
|
|
@ -82,9 +189,20 @@ impl CongestionController {
|
|||
self.pacing_rate
|
||||
}
|
||||
|
||||
/// Returns the smoothed RTT estimate.
|
||||
/// Returns the smoothed RTT estimate (SRTT).
|
||||
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.
|
||||
|
|
@ -107,6 +225,24 @@ impl CongestionController {
|
|||
/// Record that we sent `bytes` of data.
|
||||
pub fn on_send(&mut self, bytes: u64) {
|
||||
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.
|
||||
|
|
@ -115,16 +251,57 @@ impl CongestionController {
|
|||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||
|
||||
// Update RTT
|
||||
// Update RTT measurements
|
||||
self.update_rtt(rtt, now);
|
||||
|
||||
// Update bandwidth estimate
|
||||
self.update_bandwidth(bytes, now);
|
||||
self.grow_window(bytes);
|
||||
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 {
|
||||
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);
|
||||
if self.cwnd >= self.ssthresh {
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
|
|
@ -137,8 +314,20 @@ impl CongestionController {
|
|||
}
|
||||
}
|
||||
|
||||
self.update_pacing_rate();
|
||||
self.last_ack_time = now;
|
||||
self.clamp_cwnd();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -148,11 +337,28 @@ impl CongestionController {
|
|||
|
||||
match self.phase {
|
||||
Phase::SlowStart => {
|
||||
// Exit slow start, set ssthresh to half of cwnd
|
||||
self.ssthresh = self.cwnd / 2;
|
||||
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
|
||||
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.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
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 => {
|
||||
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||
|
|
@ -164,32 +370,49 @@ impl CongestionController {
|
|||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
self.min_rtt = rtt;
|
||||
self.min_rtt_stamp = now;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_bandwidth(&mut self, _acked_bytes: u64, now: Instant) {
|
||||
let elapsed = now.duration_since(self.last_ack_time);
|
||||
if elapsed.as_micros() > 0 {
|
||||
// Removed bw_samples tracking
|
||||
// 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);
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
srtt_ms = self.srtt.as_millis(),
|
||||
rttvar_ms = self.rttvar.as_millis(),
|
||||
rto_ms = self.rto().as_millis(),
|
||||
"congestion: RTT updated"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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;
|
||||
self.pacing_rate = self.cwnd * 1_000_000 / rtt_us;
|
||||
}
|
||||
|
|
@ -202,19 +425,18 @@ mod tests {
|
|||
#[test]
|
||||
fn test_initial_state() {
|
||||
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_eq!(cc.cwnd_packets(), 10);
|
||||
assert_eq!(cc.cwnd_packets(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slow_start_growth() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Simulate sending and ACKing
|
||||
let initial = cc.cwnd();
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(50));
|
||||
// cwnd should grow
|
||||
assert!(cc.cwnd() > 12000);
|
||||
assert!(cc.cwnd() > initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -225,11 +447,143 @@ mod tests {
|
|||
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]
|
||||
fn test_can_send_limits() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Send until cwnd is exhausted
|
||||
for _ in 0..10 {
|
||||
for _ in 0..32 {
|
||||
cc.on_send(1200);
|
||||
}
|
||||
assert!(!cc.can_send()); // cwnd exhausted
|
||||
|
|
@ -244,10 +598,63 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_rtt_tracking() {
|
||||
fn test_rtt_tracking_first_sample() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(25));
|
||||
// After first sample: SRTT = 25ms, RTTVAR = 12ms
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
pub mod aead;
|
||||
pub mod noise;
|
||||
pub mod obfuscation;
|
||||
pub mod reality;
|
||||
|
||||
|
||||
pub use aead::SessionCipher;
|
||||
pub use noise::{NoiseRole, NoiseSession};
|
||||
pub use obfuscation::{
|
||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
||||
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use snow::{Builder, HandshakeState, TransportState};
|
||||
use snow::{Builder, HandshakeState};
|
||||
|
||||
use crate::protocol::ProtocolError;
|
||||
|
||||
|
|
@ -10,9 +10,15 @@ pub enum NoiseRole {
|
|||
Responder,
|
||||
}
|
||||
|
||||
pub enum NoiseSession {
|
||||
Handshake(Box<HandshakeState>),
|
||||
Transport(TransportState),
|
||||
/// A Noise handshake in progress. OSTP does not use snow's transport mode: once
|
||||
/// the handshake finishes we extract the raw Split() keys (see [`raw_split`])
|
||||
/// 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 {
|
||||
|
|
@ -36,50 +42,92 @@ impl NoiseSession {
|
|||
.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> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => hs
|
||||
.write_message(payload, out)
|
||||
.map_err(|_| ProtocolError::Crypto("noise-write".to_string())),
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
self.handshake
|
||||
.write_message(payload, out)
|
||||
.map_err(|_| ProtocolError::Crypto("noise-write".to_string()))
|
||||
}
|
||||
|
||||
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => hs
|
||||
.read_message(input, out)
|
||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))),
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
self.handshake
|
||||
.read_message(input, out)
|
||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e)))
|
||||
}
|
||||
|
||||
pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => {
|
||||
let hash = hs.get_handshake_hash();
|
||||
if out.len() != hash.len() {
|
||||
return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string()));
|
||||
}
|
||||
out.copy_from_slice(hash);
|
||||
Ok(())
|
||||
}
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_transport(self) -> Result<Self, ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => {
|
||||
let transport = hs
|
||||
.into_transport_mode()
|
||||
.map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?;
|
||||
Ok(NoiseSession::Transport(transport))
|
||||
}
|
||||
NoiseSession::Transport(_) => Ok(self),
|
||||
/// Derive the two directional transport keys via Noise's Split().
|
||||
///
|
||||
/// SECURITY: keys are taken from the final chaining key `ck` (which absorbs
|
||||
/// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h`
|
||||
/// (which only absorbs public transcript data — ephemeral pubkeys and
|
||||
/// ciphertexts — and never the DH secret). Deriving from `ck` is what gives
|
||||
/// the session forward secrecy: an adversary who later learns the PSK still
|
||||
/// cannot recompute these keys without the ephemeral private keys, which are
|
||||
/// discarded after the handshake.
|
||||
///
|
||||
/// 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
|
||||
/// responder→initiator.
|
||||
pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> {
|
||||
if !self.handshake.is_handshake_finished() {
|
||||
return Err(ProtocolError::State("handshake not finished at key split".to_string()));
|
||||
}
|
||||
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,
|
||||
/// split into two halves. No fixed strings are used — the access key
|
||||
/// alone determines all derived values.
|
||||
#[derive(Clone)]
|
||||
pub struct DerivedSecrets {
|
||||
pub obfuscation_key: [u8; 8],
|
||||
pub psk: [u8; 32],
|
||||
pub handshake_pad_min: 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 {
|
||||
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.
|
||||
// This avoids using any hardcoded strings while still providing
|
||||
// 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 info_base = &key_hash[16..];
|
||||
|
||||
// Extract PRK from access key using its own hash as salt
|
||||
let prk = hkdf_extract(salt, access_key);
|
||||
// Mix the protocol version into the IKM so a different version produces a
|
||||
// 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
|
||||
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) ─────────────────────────────
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// §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).
|
||||
#[test]
|
||||
fn test_data_packet_obfuscation_roundtrip() {
|
||||
|
|
@ -160,4 +191,29 @@ mod tests {
|
|||
assert_eq!(recovered_nonce, nonce);
|
||||
assert_eq!(&packet[12..], &ciphertext);
|
||||
}
|
||||
|
||||
/// The junk marker must: be stable within a window (client and server agree),
|
||||
/// rotate across windows (no static on-wire fingerprint), and differ per key
|
||||
/// (one user's marker never silently-drops on another user's flow).
|
||||
#[test]
|
||||
fn test_junk_marker_rotation() {
|
||||
let key_a = b"access-key-alpha";
|
||||
let key_b = b"access-key-bravo";
|
||||
|
||||
// Stable within a window.
|
||||
assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000));
|
||||
|
||||
// Rotates across adjacent windows.
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001));
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999));
|
||||
|
||||
// Distinct per key within the same window.
|
||||
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000));
|
||||
|
||||
// A different protocol version yields a different marker (version gate).
|
||||
assert_ne!(
|
||||
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION),
|
||||
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
Nack = 5,
|
||||
Ack = 6,
|
||||
/// 0-RTT session resumption: client sends ticket + early data
|
||||
Resume = 7,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for FrameKind {
|
||||
|
|
@ -28,7 +26,6 @@ impl TryFrom<u8> for FrameKind {
|
|||
4 => Ok(Self::KeepAlive),
|
||||
5 => Ok(Self::Nack),
|
||||
6 => Ok(Self::Ack),
|
||||
7 => Ok(Self::Resume),
|
||||
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
|
||||
}
|
||||
}
|
||||
|
|
@ -104,7 +101,15 @@ impl FramedPacket {
|
|||
let payload_len = header.payload_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 {
|
||||
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
pub mod frame;
|
||||
pub mod padding;
|
||||
pub mod wss;
|
||||
|
||||
pub use frame::{FrameHeader, FrameKind, FramedPacket};
|
||||
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 protocol;
|
||||
pub mod relay;
|
||||
pub mod resumption;
|
||||
|
||||
pub use crypto::NoiseRole;
|
||||
pub use framing::{TrafficProfile, PaddingStrategy};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
use bytes::Bytes;
|
||||
use rand::Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
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::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
||||
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
||||
|
|
@ -103,6 +107,17 @@ pub struct ProtocolMachine {
|
|||
_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)]
|
||||
struct SentFrame {
|
||||
nonce: u64,
|
||||
|
|
@ -156,10 +171,37 @@ impl ProtocolMachine {
|
|||
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 {
|
||||
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) {
|
||||
self.cc.on_send(bytes);
|
||||
}
|
||||
|
||||
pub fn state(&self) -> OstpState {
|
||||
self.state
|
||||
}
|
||||
|
|
@ -203,13 +245,16 @@ impl ProtocolMachine {
|
|||
.map(ProtocolAction::SendDatagram)
|
||||
}
|
||||
(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 Close.
|
||||
let result = self.handle_inbound(raw);
|
||||
self.state = OstpState::Closed;
|
||||
result
|
||||
// The remote may still have data or ACKs in transit when we initiated
|
||||
// Close. Stay in Closing and process them; handle_inbound transitions to
|
||||
// Closed only when it actually receives the peer's Close frame — the old
|
||||
// code force-closed after a single inbound packet, losing in-flight data.
|
||||
// (Ported from 0.3.x 47d44fa.)
|
||||
self.handle_inbound(raw)
|
||||
}
|
||||
(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),
|
||||
(_, OstpEvent::Close) => {
|
||||
self.state = OstpState::Closed;
|
||||
|
|
@ -230,7 +275,9 @@ impl ProtocolMachine {
|
|||
|
||||
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 {
|
||||
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()));
|
||||
}
|
||||
|
||||
|
|
@ -256,8 +303,7 @@ impl ProtocolMachine {
|
|||
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 n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
|
||||
ProtocolError::Crypto(format!("noise-read: {:?} (raw_len={}, noise_len={})", e, raw_vec.len(), noise_len))
|
||||
|
|
@ -274,9 +320,12 @@ impl ProtocolMachine {
|
|||
NoiseRole::Initiator => None,
|
||||
};
|
||||
|
||||
let mut key = [0_u8; 32];
|
||||
self.noise.handshake_hash(&mut key)?;
|
||||
let (send_key, recv_key) = derive_split_keys(&key, self.role);
|
||||
// Transport keys come from Noise's Split() over the final chaining key,
|
||||
// so they depend on the ephemeral `ee` DH secret and give the session
|
||||
// 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.recv_cipher = Some(SessionCipher::new(&recv_key));
|
||||
self.state = OstpState::Established;
|
||||
|
|
@ -286,7 +335,107 @@ impl ProtocolMachine {
|
|||
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> {
|
||||
// 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 {
|
||||
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
||||
}
|
||||
|
|
@ -351,13 +500,8 @@ impl ProtocolMachine {
|
|||
FrameKind::Data => {
|
||||
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 => {
|
||||
tracing::info!("Received Close frame, terminating session");
|
||||
tracing::debug!("Received Close frame, terminating session");
|
||||
self.state = OstpState::Closed;
|
||||
ProtocolAction::Noop
|
||||
}
|
||||
|
|
@ -388,18 +532,20 @@ impl ProtocolMachine {
|
|||
self.last_recv_advance = Instant::now();
|
||||
} else {
|
||||
// Gap detected
|
||||
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
||||
self.reorder_buffer.insert(nonce, action);
|
||||
if nonce >= self.expected_recv_nonce {
|
||||
if self.reorder_buffer.len() < self.max_reorder_buffer {
|
||||
self.reorder_buffer.insert(nonce, action);
|
||||
} else {
|
||||
tracing::warn!("Reorder buffer still full after gap recovery, dropping frame nonce={}", nonce);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Reorder buffer full ({}/{}), dropping frame nonce={}",
|
||||
self.reorder_buffer.len(), self.max_reorder_buffer, nonce
|
||||
);
|
||||
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.
|
||||
// Under high load with natural UDP reordering, sending a NACK per packet
|
||||
// causes exponential retransmit explosion that saturates the channel.
|
||||
let nack_cooldown = Duration::from_millis(30);
|
||||
// Rate-limited NACK: send at most once per (rto/2) to prevent retransmit storms.
|
||||
// Using rto/2 means we send a NACK before the sender's timer fires, prompting
|
||||
// fast retransmit without flooding. Floor at 10ms to handle very low-RTT links.
|
||||
let nack_cooldown = (self.cc.rto() / 2).max(Duration::from_millis(10));
|
||||
if self.last_nack_sent.elapsed() >= nack_cooldown {
|
||||
self.last_nack_sent = Instant::now();
|
||||
let nack_payload = self.expected_recv_nonce.to_be_bytes();
|
||||
|
|
@ -507,44 +653,18 @@ impl ProtocolMachine {
|
|||
fn handle_tick(&mut self) -> Result<ProtocolAction, ProtocolError> {
|
||||
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 ─────────────────────────────────────────
|
||||
if let Some(ack_frame) = self.build_ack_if_due()? {
|
||||
actions.push(ProtocolAction::SendDatagram(ack_frame));
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────
|
||||
// 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 before = self.sent_history.len();
|
||||
self.sent_history.retain(|f| !f.is_retransmittable || f.retries <= grace);
|
||||
|
|
@ -555,24 +675,46 @@ impl ProtocolMachine {
|
|||
|
||||
// ── Retransmit expired frames ────────────────────────────────
|
||||
// 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();
|
||||
for frame in self.sent_history.iter_mut() {
|
||||
if !frame.is_retransmittable {
|
||||
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);
|
||||
let backoff_factor = 1u64 << retry_over.min(6);
|
||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
||||
// Exponential backoff, but bounded in absolute terms. base_rto is
|
||||
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
|
||||
// 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 {
|
||||
// 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.retries = frame.retries.saturating_add(1);
|
||||
|
||||
if retransmit_budget > 0 {
|
||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||
retransmit_budget -= 1;
|
||||
}
|
||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||
retransmit_budget -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -677,6 +819,9 @@ impl ProtocolMachine {
|
|||
}
|
||||
|
||||
fn push_sent_frame(&mut self, nonce: u64, bytes: Bytes, is_retransmittable: bool) {
|
||||
if is_retransmittable {
|
||||
self.cc.on_send(bytes.len() as u64);
|
||||
}
|
||||
self.sent_history.push_back(SentFrame {
|
||||
nonce,
|
||||
bytes,
|
||||
|
|
@ -698,24 +843,34 @@ impl ProtocolMachine {
|
|||
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
||||
let now = Instant::now();
|
||||
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() {
|
||||
if nonce_in_ranges(frame.nonce, ranges) {
|
||||
acked_bytes += frame.bytes.len() as u64;
|
||||
let rtt = now.duration_since(frame.last_sent);
|
||||
if rtt < min_rtt {
|
||||
min_rtt = rtt;
|
||||
// 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);
|
||||
min_rtt = Some(min_rtt.map_or(rtt, |m| m.min(rtt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -745,26 +900,6 @@ fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool {
|
|||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -996,4 +1131,154 @@ mod tests {
|
|||
let _ = client.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 {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
|
|
@ -5,6 +8,37 @@ plugins {
|
|||
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 {
|
||||
namespace = "com.ospab.ostp_client"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
|
@ -28,13 +62,50 @@ android {
|
|||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Use the real upload key when one was supplied; otherwise fall back to
|
||||
// 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")
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-keep class net.ostp.client.OstpClientSdk { *; }
|
||||
-keep class com.ospab.ostp_client.OstpVpnService { *; }
|
||||
-keep class com.ospab.ostp_client.MainActivity { *; }
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
|
|
@ -9,7 +10,8 @@
|
|||
<application
|
||||
android:label="ostp_client"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/launcher_icon_round"
|
||||
android:extractNativeLibs="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
|
@ -32,6 +34,9 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
|
@ -42,6 +47,7 @@
|
|||
<service
|
||||
android:name=".OstpVpnService"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService"/>
|
||||
|
|
@ -51,7 +57,7 @@
|
|||
<!-- Quick Settings Tile -->
|
||||
<service
|
||||
android:name=".OstpTileService"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:label="OSTP VPN"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true">
|
||||
|
|
|
|||
|
|
@ -92,28 +92,46 @@ class MainActivity : FlutterActivity() {
|
|||
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
||||
result.success(metrics ?: "{}")
|
||||
} catch (e: Throwable) {
|
||||
// Surfaced into the in-app log viewer (not just logcat) so a
|
||||
// broken traffic counter is diagnosable from a user's bug
|
||||
// report without adb access.
|
||||
android.util.Log.e("MainActivity", "getMetrics failed", e)
|
||||
try {
|
||||
net.ostp.client.OstpClientSdk.addLog("getMetrics failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} catch (_: Throwable) {}
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
"getInstalledApps" -> {
|
||||
try {
|
||||
val pm = packageManager
|
||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
val list = apps.map { app ->
|
||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
||||
(pm.getLaunchIntentForPackage(app.packageName) == null)
|
||||
val iconBase64 = getAppIconBase64(pm, app)
|
||||
mapOf(
|
||||
"name" to pm.getApplicationLabel(app).toString(),
|
||||
"package" to app.packageName,
|
||||
"isSystem" to isSystem,
|
||||
"icon" to (iconBase64 ?: "")
|
||||
)
|
||||
// MethodChannel handlers run on the main/UI thread by default.
|
||||
// Enumerating every installed package AND decoding+re-encoding
|
||||
// each one's icon to PNG/base64 is expensive (100+ apps is
|
||||
// common) — done inline here it blocked the main thread for
|
||||
// 10-15s, during which Flutter couldn't render ANY frame, not
|
||||
// even the "loading" spinner, so the screen just appeared to
|
||||
// hang before jumping straight to the fully-loaded list.
|
||||
// Do the work on a background thread; only the final
|
||||
// `result.success(...)` needs to hop back onto the UI thread.
|
||||
val pm = packageManager
|
||||
Thread {
|
||||
try {
|
||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
val list = apps.map { app ->
|
||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
||||
(pm.getLaunchIntentForPackage(app.packageName) == null)
|
||||
val iconBase64 = getAppIconBase64(pm, app)
|
||||
mapOf(
|
||||
"name" to pm.getApplicationLabel(app).toString(),
|
||||
"package" to app.packageName,
|
||||
"isSystem" to isSystem,
|
||||
"icon" to (iconBase64 ?: "")
|
||||
)
|
||||
}
|
||||
runOnUiThread { result.success(list) }
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread { result.error("ERROR", e.message, null) }
|
||||
}
|
||||
result.success(list)
|
||||
} catch (e: Exception) {
|
||||
result.error("ERROR", e.message, null)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
|
@ -133,6 +151,6 @@ class MainActivity : FlutterActivity() {
|
|||
if (pendingConfigJson != null) {
|
||||
intent.putExtra("configJson", pendingConfigJson)
|
||||
}
|
||||
startService(intent)
|
||||
androidx.core.content.ContextCompat.startForegroundService(this, intent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,19 @@ class OstpTileService : TileService() {
|
|||
val configJson = prefs.getString("latest_config_json", null)
|
||||
|
||||
if (configJson != null) {
|
||||
// Check if VPN consent is needed
|
||||
val vpnIntent = android.net.VpnService.prepare(this)
|
||||
if (vpnIntent != null) {
|
||||
// Consent needed, launch app
|
||||
val appIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
if (appIntent != null) {
|
||||
startActivityAndCollapse(appIntent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val startIntent = Intent(this, OstpVpnService::class.java).apply {
|
||||
action = "START"
|
||||
putExtra("configJson", configJson)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import java.io.IOException
|
|||
import androidx.annotation.Keep
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
|
||||
@Keep
|
||||
class OstpVpnService : VpnService() {
|
||||
|
|
@ -43,6 +44,7 @@ class OstpVpnService : VpnService() {
|
|||
|
||||
private var vpnInterface: ParcelFileDescriptor? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
|
@ -55,7 +57,7 @@ class OstpVpnService : VpnService() {
|
|||
if (action == "START") {
|
||||
val configJson = intent.getStringExtra("configJson") ?: return START_NOT_STICKY
|
||||
// Launch foreground immediately so Android doesn't kill us
|
||||
startForeground(NOTIF_ID, buildNotification(connecting = true))
|
||||
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(connecting = true), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||
startVpn(configJson)
|
||||
} else if (action == "STOP") {
|
||||
stopVpn()
|
||||
|
|
@ -144,6 +146,41 @@ class OstpVpnService : VpnService() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun registerNetworkCallback() {
|
||||
if (networkCallback != null) return
|
||||
try {
|
||||
val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
networkCallback = object : android.net.ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: android.net.Network) {
|
||||
super.onAvailable(network)
|
||||
OstpClientSdk.notifyNetworkChanged()
|
||||
}
|
||||
override fun onLost(network: android.net.Network) {
|
||||
super.onLost(network)
|
||||
OstpClientSdk.notifyNetworkChanged()
|
||||
}
|
||||
}
|
||||
val request = android.net.NetworkRequest.Builder()
|
||||
.addCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, networkCallback!!)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to register NetworkCallback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
try {
|
||||
if (networkCallback != null) {
|
||||
val cm = getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
cm.unregisterNetworkCallback(networkCallback!!)
|
||||
networkCallback = null
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Failed to unregister NetworkCallback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startVpn(configJson: String) {
|
||||
if (vpnInterface != null) return
|
||||
|
||||
|
|
@ -162,8 +199,13 @@ class OstpVpnService : VpnService() {
|
|||
.addRoute("::", 0)
|
||||
.addDnsServer(dnsServer)
|
||||
.setMtu(Math.max(1280, json.optJSONObject("ostp")?.optInt("mtu", 1140) ?: 1140))
|
||||
|
||||
|
||||
// Always add fallback IPv4 DNS servers
|
||||
try { builder.addDnsServer("1.1.1.1") } catch (e: Throwable) {}
|
||||
try { builder.addDnsServer("8.8.8.8") } catch (e: Throwable) {}
|
||||
// NOTE: Do NOT add IPv6 DNS servers here — Android would send DNS
|
||||
// queries over IPv6, but our smoltcp TUN stack processes them as
|
||||
// IPv4 only, causing all DNS to silently fail on LTE (IPv6-only networks).
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
builder.allowBypass()
|
||||
|
|
@ -230,8 +272,13 @@ class OstpVpnService : VpnService() {
|
|||
|
||||
} catch (e: Throwable) {
|
||||
Log.e("OstpVpnService", "Error starting VPN", e)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
android.widget.Toast.makeText(applicationContext, "VPN Error: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
stopVpn()
|
||||
}
|
||||
|
||||
registerNetworkCallback()
|
||||
}
|
||||
|
||||
private fun stopVpn() {
|
||||
|
|
@ -248,6 +295,7 @@ class OstpVpnService : VpnService() {
|
|||
|
||||
stopForeground(true)
|
||||
OstpTileService.requestListeningState(applicationContext)
|
||||
unregisterNetworkCallback()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,8 @@ object OstpClientSdk {
|
|||
@Keep
|
||||
@JvmStatic
|
||||
external fun addLog(logMsg: String)
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
external fun notifyNetworkChanged()
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
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 |
|
After Width: | Height: | Size: 3.2 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 |
|
After Width: | Height: | Size: 1.9 KiB |