Compare commits
98 Commits
v0.4.6-bet
...
master
| 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 | |
|
|
1b536e9cf3 | |
|
|
5883f5105b | |
|
|
244d3ad374 | |
|
|
6361a87072 | |
|
|
fbc37e9d39 |
|
|
@ -4,7 +4,7 @@ name: CI/CD
|
||||||
# `run-name` is evaluated at workflow-start, BEFORE any job runs - it cannot
|
# `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
|
# 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
|
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
|
||||||
# the bare branch name ("alpha"/"pre-release") for every run, which reads
|
# the bare branch name ("alpha"/"beta") for every run, which reads
|
||||||
# exactly like a literal release tag and caused real confusion - the actual
|
# exactly like a literal release tag and caused real confusion - the actual
|
||||||
# release tag has been correct (versioned) all along; only this label lied
|
# 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.
|
# about it. Spell out "channel" so nobody mistakes one for the other again.
|
||||||
|
|
@ -53,7 +53,7 @@ jobs:
|
||||||
# Tag shape:
|
# Tag shape:
|
||||||
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
|
# - 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 `alpha` -> "{version}-alpha" (rolling, same tag every push)
|
||||||
# - push to `pre-release` -> "{version}-beta" (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)
|
# - workflow_dispatch -> forced by the `channel` input (alpha|beta only)
|
||||||
resolve-channel:
|
resolve-channel:
|
||||||
name: Resolve release channel
|
name: Resolve release channel
|
||||||
|
|
@ -73,19 +73,27 @@ jobs:
|
||||||
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
BASE_VERSION=$(grep -m1 '^version' Cargo.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||||
|
|
||||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||||
CHANNEL="stable"
|
# 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 }}"
|
TAG="${{ github.ref_name }}"
|
||||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
case "$TAG" in
|
||||||
CHANNEL="${{ github.event.inputs.channel }}"
|
*-alpha*) CHANNEL="alpha" ;;
|
||||||
elif [ "${{ github.ref_name }}" = "alpha" ]; then
|
*-beta*) CHANNEL="beta" ;;
|
||||||
CHANNEL="alpha"
|
*) CHANNEL="stable" ;;
|
||||||
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
|
esac
|
||||||
CHANNEL="beta"
|
|
||||||
else
|
else
|
||||||
CHANNEL="alpha"
|
# No tag (workflow_dispatch, or a legacy branch push): pick the
|
||||||
fi
|
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
if [ "$CHANNEL" != "stable" ]; then
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
|
elif [ "${{ github.ref_name }}" = "beta" ]; then
|
||||||
|
CHANNEL="beta"
|
||||||
|
else
|
||||||
|
CHANNEL="alpha"
|
||||||
|
fi
|
||||||
TAG="v${BASE_VERSION}-${CHANNEL}"
|
TAG="v${BASE_VERSION}-${CHANNEL}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -276,7 +284,15 @@ jobs:
|
||||||
|
|
||||||
- name: Install cross (if not cached)
|
- name: Install cross (if not cached)
|
||||||
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
||||||
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
# cross-rs's own source (not ours, not a dependency of ours) uses a
|
||||||
|
# macro-at-end-of-block pattern that trips rustc's
|
||||||
|
# semicolon_in_expressions_from_macros lint on current toolchains -
|
||||||
|
# harmless in cross's actual behavior, but `cargo install` compiles
|
||||||
|
# the installed package as the "local" crate, so dependency lint
|
||||||
|
# capping doesn't shield it. --cap-lints=warn is the standard escape
|
||||||
|
# hatch for building a third-party tool against a newer compiler than
|
||||||
|
# its own lint config assumed; it doesn't touch our own build.
|
||||||
|
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
||||||
|
|
||||||
- name: Build (cross)
|
- name: Build (cross)
|
||||||
if: ${{ matrix.use_cross }}
|
if: ${{ matrix.use_cross }}
|
||||||
|
|
@ -362,7 +378,15 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
# Without a prefix fallback this cache NEVER restored on a release:
|
||||||
|
# cutting a release rewrites every Cargo.lock (version bump), which
|
||||||
|
# changes hashFiles(), which misses the exact key — so each release
|
||||||
|
# rebuilt every dependency from scratch. That is why the GUI jobs ran
|
||||||
|
# 2-4x longer than the plain release targets, which had this all along.
|
||||||
|
restore-keys: |
|
||||||
|
cargo-windows-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -444,18 +468,28 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-linux-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
|
# TUN mode shells out to this helper, elevated via pkexec. Only the
|
||||||
|
# Windows job used to build it, so the Linux package shipped without
|
||||||
|
# it and TUN could never start.
|
||||||
|
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
|
||||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Package Portable Tarball
|
- name: Package Portable Tarball
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir ostp-linux-gui-${{ matrix.arch }}
|
mkdir ostp-linux-gui-${{ matrix.arch }}
|
||||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
||||||
|
# The GUI looks for the helper next to its own executable first.
|
||||||
|
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
|
||||||
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
|
|
@ -506,7 +540,10 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-macos-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
|
|
@ -571,27 +608,107 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ndk-version: r26b
|
ndk-version: r26b
|
||||||
|
|
||||||
- name: Install cargo-ndk
|
# The Android jobs had no Rust caching at all, so every release recompiled
|
||||||
run: cargo install cargo-ndk
|
# the whole ostp-jni dependency graph from scratch — the main reason these
|
||||||
|
# were among the slowest jobs in the matrix.
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
target/
|
||||||
|
key: cargo-android-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-android-${{ matrix.arch }}-
|
||||||
|
|
||||||
|
# cargo-ndk was built from source on every run. Cache the binary the same
|
||||||
|
# way the cross-compilation jobs already cache `cross`.
|
||||||
|
- name: Restore cargo-ndk binary cache
|
||||||
|
id: cargo-ndk-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-ndk
|
||||||
|
key: cargo-ndk-bin-${{ runner.os }}-v1
|
||||||
|
|
||||||
|
- name: Install cargo-ndk (if not cached)
|
||||||
|
if: steps.cargo-ndk-cache.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install cargo-ndk --locked
|
||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ostp-flutter
|
working-directory: ostp-flutter
|
||||||
|
env:
|
||||||
|
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
# 1. Compile JNI
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. Materialise the upload keystore from secrets. Android keys an app
|
||||||
|
# by applicationId + signing key and refuses to update across a key
|
||||||
|
# change, so every published build MUST use this one key. Releases
|
||||||
|
# used to fall through to the per-machine debug keystore, which on
|
||||||
|
# ephemeral CI runners meant a different random key every build -
|
||||||
|
# hence "App not installed" on upgrade.
|
||||||
|
if [ -z "${OSTP_KEYSTORE_B64:-}" ]; then
|
||||||
|
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set. Refusing to publish a"
|
||||||
|
echo "::error::debug-signed APK: users could not update over it and the key is"
|
||||||
|
echo "::error::not reproducible. See docs for the one-time keystore setup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export OSTP_KEYSTORE_PATH="$RUNNER_TEMP/ostp-upload.jks"
|
||||||
|
# Strip any stray CR/LF before decoding: the secret is pasted from a
|
||||||
|
# shell whose line endings we don't control, and a single trailing \r
|
||||||
|
# is enough to corrupt the decode.
|
||||||
|
printf '%s' "$OSTP_KEYSTORE_B64" | tr -d '\r\n' | base64 -d > "$OSTP_KEYSTORE_PATH"
|
||||||
|
|
||||||
|
# Verify the keystore opens BEFORE spending four minutes on Gradle only
|
||||||
|
# to fail at the packaging step. The size/SHA-256 are safe to print (a
|
||||||
|
# hash reveals nothing) and let the operator compare against the local
|
||||||
|
# file to tell a transport problem apart from a wrong password.
|
||||||
|
echo "keystore: $(stat -c%s "$OSTP_KEYSTORE_PATH") bytes, sha256 $(sha256sum "$OSTP_KEYSTORE_PATH" | cut -d' ' -f1)"
|
||||||
|
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||||
|
-storepass "$OSTP_KEYSTORE_PASSWORD" >/dev/null 2>&1; then
|
||||||
|
echo "::error::The keystore did not open with ANDROID_KEYSTORE_PASSWORD."
|
||||||
|
echo "::error::If the SHA-256 above matches your local ostp-upload.jks, the file"
|
||||||
|
echo "::error::arrived intact and the password secret itself is wrong - note that"
|
||||||
|
echo "::error::PowerShell expands \$ inside double quotes, so a password containing"
|
||||||
|
echo "::error::one gets mangled unless it was set with single quotes."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||||
|
-storepass "$OSTP_KEYSTORE_PASSWORD" -alias "$OSTP_KEY_ALIAS" >/dev/null 2>&1; then
|
||||||
|
echo "::error::Keystore opened, but it has no key under ANDROID_KEY_ALIAS."
|
||||||
|
echo "::error::Aliases present in the keystore:"
|
||||||
|
keytool -list -keystore "$OSTP_KEYSTORE_PATH" -storepass "$OSTP_KEYSTORE_PASSWORD" \
|
||||||
|
| grep -i "PrivateKeyEntry" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Compile JNI
|
||||||
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
||||||
|
|
||||||
cd ../ostp-jni
|
cd ../ostp-jni
|
||||||
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
||||||
cd ../ostp-flutter
|
cd ../ostp-flutter
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 3. Build Flutter APK
|
# 3. Build Flutter APK
|
||||||
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
||||||
|
|
||||||
# 4. Copy to output
|
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
|
# than shipping another un-updatable build.
|
||||||
|
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
|
||||||
|
| grep -qi "CN=Android Debug"; then
|
||||||
|
echo "::error::APK is signed with the Android debug certificate - aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Copy to output
|
||||||
|
cp "$APK" ostp-android-${{ matrix.arch }}.apk
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
# Binaries & libraries
|
# Binaries & libraries
|
||||||
*.exe
|
*.exe
|
||||||
|
|
@ -25,6 +26,13 @@ test_route.ps1
|
||||||
config.json
|
config.json
|
||||||
wintun.dll
|
wintun.dll
|
||||||
|
|
||||||
|
# Android signing keys. The upload keystore is the ONE key every published APK
|
||||||
|
# must be signed with (Android refuses to update an app across a key change),
|
||||||
|
# so losing or leaking it is unrecoverable — it can never be committed.
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
key.properties
|
||||||
|
|
||||||
# Server runtime cache (public IP autodetect) — must never be committed,
|
# Server runtime cache (public IP autodetect) — must never be committed,
|
||||||
# it's regenerated locally and leaks whatever host it ran on last.
|
# it's regenerated locally and leaks whatever host it ran on last.
|
||||||
.ostp_public_ip
|
.ostp_public_ip
|
||||||
|
|
@ -38,8 +46,14 @@ turn-harvesting-idea.md
|
||||||
|
|
||||||
# Private tooling (closed-source)
|
# Private tooling (closed-source)
|
||||||
ostp-prober/
|
ostp-prober/
|
||||||
|
ostp-lab/
|
||||||
|
|
||||||
ostp-brain/
|
ostp-brain/
|
||||||
|
|
||||||
# Management panel built assets (built separately; dummy dist created for rust-embed build)
|
# Management panel built assets (built separately; dummy dist created for rust-embed build)
|
||||||
ostp-control/
|
ostp-control/
|
||||||
|
|
||||||
|
.agents/
|
||||||
|
netstack-smoltcp/
|
||||||
|
dnstt/
|
||||||
|
ostp-web/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "0.4.4",
|
"target_version": "0.4.4",
|
||||||
"branch": "alpha",
|
"branch": "master",
|
||||||
"prefix": "alpha"
|
"alpha_iteration": 0,
|
||||||
|
"beta_iteration": 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,10 @@ The repository runs three long-lived branches, in increasing order of stability:
|
||||||
| Branch | Role |
|
| Branch | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `alpha` | Active development. All feature work and fixes land here first. |
|
| `alpha` | Active development. All feature work and fixes land here first. |
|
||||||
| `pre-release` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
|
| `beta` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
|
||||||
| `master` | Fast-forwarded from `pre-release` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
|
| `master` | Fast-forwarded from `beta` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
|
||||||
|
|
||||||
`pre-release` and `master` are **never** committed to directly - they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `pre-release` or `master`.
|
`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`.
|
**Contributor PRs target `alpha`**, not `master`.
|
||||||
|
|
||||||
|
|
@ -148,7 +148,7 @@ Multiple unrelated changes belong in separate commits, not one bundled commit -
|
||||||
```bash
|
```bash
|
||||||
git push origin feat/your-feature-name
|
git push origin feat/your-feature-name
|
||||||
```
|
```
|
||||||
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `pre-release`, never direct PRs).
|
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `beta`, never direct PRs).
|
||||||
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
|
||||||
4. Verify that GitHub Actions CI runs successfully on your PR.
|
4. Verify that GitHub Actions CI runs successfully on your PR.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,10 @@
|
||||||
| Ветка | Роль |
|
| Ветка | Роль |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
||||||
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
| `beta` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||||
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
| `master` | Перематывается вперёд от `beta`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||||
|
|
||||||
В `pre-release` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
|
В `beta` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `beta` или `master`.
|
||||||
|
|
||||||
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
|
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
|
||||||
|
|
||||||
|
|
@ -149,7 +149,7 @@ obfuscation_key/psk), чтобы он был индивидуальным для
|
||||||
```bash
|
```bash
|
||||||
git push origin feat/имя-вашей-фичи
|
git push origin feat/имя-вашей-фичи
|
||||||
```
|
```
|
||||||
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
|
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `beta`, PR туда не принимаются напрямую).
|
||||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1316,7 +1316,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "netstack-smoltcp"
|
name = "netstack-smoltcp"
|
||||||
version = "0.2.2"
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c38f66cdd673ff0e760752f27c6d34a7e3a140f0b1eea9efae3c46d8867c83d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"etherparse",
|
"etherparse",
|
||||||
"futures",
|
"futures",
|
||||||
|
|
@ -1398,6 +1400,7 @@ dependencies = [
|
||||||
"rlimit",
|
"rlimit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -1494,6 +1497,7 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
"simple-dns",
|
"simple-dns",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,17 @@ resolver = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
bytes = "1.6"
|
bytes = "1.6"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
snow = "0.9"
|
snow = { version = "0.9", features = ["risky-raw-split"] }
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
portable-atomic = "1.10"
|
portable-atomic = "1.10"
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
netstack-smoltcp = { path = "netstack-smoltcp" }
|
|
||||||
|
|
|
||||||
701
LICENSE
|
|
@ -1,74 +1,661 @@
|
||||||
Business Source License 1.1
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Parameters
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Licensor: Ospab Foundation (represented by Syralev Georgiy)
|
Preamble
|
||||||
Licensed Work: The Ospab Stealth Transport Protocol (OSTP) and all
|
|
||||||
associated workspace crates, utilities, and documents.
|
|
||||||
Additional Use Grant: The Licensor hereby grants you the right to copy,
|
|
||||||
modify, create derivative works, redistribute, and
|
|
||||||
make non-production and non-commercial use of the
|
|
||||||
Licensed Work. You are also permitted to use the
|
|
||||||
Licensed Work in production for personal, private
|
|
||||||
utility and non-profit organizations.
|
|
||||||
Change Date: May 14, 2030
|
|
||||||
Change License: MIT License (as defined below)
|
|
||||||
|
|
||||||
-----------------------------------------------------------------------------------
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
Terms
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
1. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
When we speak of free software, we are referring to freedom, not
|
||||||
redistribute, and make use of the Licensed Work only as permitted by the
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
Additional Use Grant.
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
2. The Licensor hereby grants you the right to copy, modify, create derivative works,
|
Developers that use our General Public Licenses protect your rights
|
||||||
redistribute, and make use of the Licensed Work under the terms of the Change
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
License on and after the Change Date.
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
3. To the extent that any term of this License (including the Additional Use Grant
|
A secondary benefit of defending all users' freedom is that
|
||||||
and the Change License) is in conflict with the Terms of this License, these
|
improvements made in alternate versions of the program, if they
|
||||||
Terms shall take precedence.
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
4. Every copy of the Licensed Work and any derivative work must include this
|
The GNU Affero General Public License is designed specifically to
|
||||||
License and all other copyright, trademark, and proprietary notices included
|
ensure that, in such cases, the modified source code becomes available
|
||||||
with the Licensed Work.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
5. Any use of the Licensed Work that is not permitted by this License is a breach
|
An older license, called the Affero General Public License and
|
||||||
of this License and may terminate your rights under this License.
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
6. DISCLAIMER OF WARRANTY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED
|
The precise terms and conditions for copying, distribution and
|
||||||
WORK IS PROVIDED ON AN "AS IS" BASIS. THE LICENSOR MAKES NO REPRESENTATIONS OR
|
modification follow.
|
||||||
WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK, EXPRESS OR IMPLIED, STATUTORY
|
|
||||||
OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE,
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
|
|
||||||
|
|
||||||
7. LIMITATION OF LIABILITY. TO THE EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT
|
TERMS AND CONDITIONS
|
||||||
WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL,
|
|
||||||
CONSEQUENTIAL, PUNITIVE, OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE
|
|
||||||
USE OF THE LICENSED WORK, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE
|
|
||||||
POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
-----------------------------------------------------------------------------------
|
0. Definitions.
|
||||||
|
|
||||||
Change License Text (MIT License)
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
Copyright (c) 2026 Syralev Georgiy (Ospab Foundation)
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
in the Software without restriction, including without limitation the rights
|
"recipients" may be individuals or organizations.
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
copies or substantial portions of the Software.
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
A "covered work" means either the unmodified Program or a work based
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
on the Program.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
To "propagate" a work means to do anything with it that, without
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
permission, would make you directly or secondarily liable for
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
infringement under applicable copyright law, except executing it on a
|
||||||
SOFTWARE.
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
|
||||||
70
README.md
|
|
@ -44,7 +44,7 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
||||||
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
|
| **Fallback Server** | TCP fallback proxy to a web server - makes OSTP indistinguishable from nginx during active probing. |
|
||||||
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
| **Multi-Listener** | Bind to multiple addresses simultaneously (dual-stack IPv4/IPv6, multi-port). |
|
||||||
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
| **TUN Mode** | Full-system VPN via native `smoltcp` network stack without external dependencies. All traffic transparently routed through the tunnel. |
|
||||||
| **xHTTP Stealth (UoT)** | UDP-over-TCP tunnel that completely hides traffic. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
| **UoT (UDP-over-TCP)** | Bare UDP-over-TCP tunnel, no protocol mimicry. Since all data is fully encrypted and length-prefixed, it bypasses DPI filters that block unknown UDP traffic by riding over a plain TCP connection. |
|
||||||
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
| **Mobile & Web Apps** | Beautiful cross-platform mobile client (Flutter) and a modern Web Control Panel (React/Vite) for effortless server and client management. |
|
||||||
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
| **TURN Relay** | RFC 5766 TURN support for environments where direct UDP is blocked. |
|
||||||
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
| **Hot-Reload** | Runtime config reload without restart (access keys, exclusions, mux settings). |
|
||||||
|
|
@ -56,35 +56,42 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
flowchart LR
|
||||||
subgraph Client ["Client"]
|
%% Styles
|
||||||
A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||||
TUN[TUN Interface] -->|IP Packets| B
|
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||||
|
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||||
subgraph OSTPCoreClient ["OSTP Core Protocol"]
|
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||||
B --> C{Protocol Machine}
|
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||||
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
|
||||||
D -->|Obfuscated UDP Payload| E((UDP Socket))
|
subgraph Local["💻 Client Device"]
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
E <==>|Encrypted & Obfuscated UDP Tunnel| F
|
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
|
||||||
|
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
|
||||||
subgraph Server ["Server"]
|
|
||||||
F((UDP Socket)) --> G{Dispatcher}
|
|
||||||
|
|
||||||
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
|
||||||
G -->|Auth & Decrypt| H[Session & State Guard]
|
|
||||||
H -->|TCP Stream| I[Relay Loop]
|
|
||||||
end
|
|
||||||
|
|
||||||
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
|
||||||
FB -->|Forward| NGINX[nginx / Caddy]
|
|
||||||
|
|
||||||
H -->|Stats & Traffic| API[Management API]
|
|
||||||
|
|
||||||
I -->|Outbound| WWW((Internet))
|
|
||||||
end
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -121,7 +128,7 @@ graph TD
|
||||||
"server": "YOUR_SERVER_IP:50000",
|
"server": "YOUR_SERVER_IP:50000",
|
||||||
"access_key": "YOUR_SECRET_KEY",
|
"access_key": "YOUR_SECRET_KEY",
|
||||||
"socks5_bind": "127.0.0.1:1088",
|
"socks5_bind": "127.0.0.1:1088",
|
||||||
"transport": { "mode": "udp", "stealth_sni": "vk.com" },
|
"transport": { "mode": "udp" },
|
||||||
"tun": { "enable": false, "dns": "1.1.1.1" }
|
"tun": { "enable": false, "dns": "1.1.1.1" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -185,12 +192,11 @@ Commands:
|
||||||
links Print client share links from the server config
|
links Print client share links from the server config
|
||||||
import <URL> Import a share link into the config file
|
import <URL> Import a share link into the config file
|
||||||
update Update OSTP to the latest release
|
update Update OSTP to the latest release
|
||||||
-b, --branch <NAME> Release channel: stable, pre-release, alpha (default: stable)
|
-b, --branch <NAME> Release channel: stable, beta, alpha (default: stable)
|
||||||
-v, --version <VER> Update to an exact version instead of the channel's latest
|
-v, --version <VER> Update to an exact version instead of the channel's latest
|
||||||
migrate Force-migrate the configuration file to the current format
|
migrate Force-migrate the configuration file to the current format
|
||||||
prober Run the DNS-transport resolver prober
|
proxy-env Print shell export commands for the local SOCKS proxy
|
||||||
proxy-env Print shell export commands for the local SOCKS proxy
|
proxy-env-clear Print shell export commands to unset it
|
||||||
proxy-env-clear Print shell export commands to unset it
|
|
||||||
uninstall Stop the service and remove the binary and config
|
uninstall Stop the service and remove the binary and config
|
||||||
|
|
||||||
Global options:
|
Global options:
|
||||||
|
|
|
||||||
69
README.ru.md
|
|
@ -24,7 +24,7 @@
|
||||||
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
| **Мультиплексирование** | Несколько логических TCP-потоков поверх одной зашифрованной UDP-сессии с per-stream flow control. |
|
||||||
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
|
| **Бесшовный роуминг** | Клиент может менять сети (WiFi ↔ 4G) без разрыва сессии - сервер отслеживает session-ID, а не IP-адрес. |
|
||||||
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
| **TUN-режим** | Полносистемный VPN без внешних зависимостей (встроенный network stack на базе `smoltcp`). |
|
||||||
| **xHTTP Стелс (UoT)** | Туннель UDP-over-TCP, который полностью скрывает трафик. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
| **UoT (UDP-over-TCP)** | Голый туннель UDP-over-TCP, без имитации протоколов. Поскольку все данные полностью зашифрованы и имеют префикс длины, он обходит DPI фильтры, блокирующие неизвестный UDP трафик, передавая всё по обычному TCP соединению. |
|
||||||
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
| **Мобильные и Web приложения** | Красивый кроссплатформенный мобильный клиент (Flutter) и современная Web панель управления (React/Vite) для удобного администрирования. |
|
||||||
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
| **TURN Relay** | RFC 5766 TURN для окружений, где прямой UDP заблокирован. |
|
||||||
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
| **Hot-Reload** | Перезагрузка конфига в рантайме без перезапуска (ключи, исключения, mux, TURN). |
|
||||||
|
|
@ -35,33 +35,42 @@
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
flowchart LR
|
||||||
subgraph Client ["Клиент"]
|
%% Styles
|
||||||
A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||||
TUN[TUN Интерфейс] -->|IP Пакеты| B
|
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||||
|
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||||
subgraph OSTPCoreClient ["OSTP Core Протокол"]
|
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||||
B --> C{Protocol Machine}
|
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||||
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
|
||||||
D -->|Обфусцированный UDP| E((UDP Сокет))
|
subgraph Local["💻 Устройство клиента"]
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
E <==>|Зашифрованный UDP Туннель| F
|
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
|
||||||
|
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
|
||||||
subgraph Server ["Сервер"]
|
|
||||||
F((UDP Сокет)) --> G{Dispatcher}
|
|
||||||
|
|
||||||
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
|
||||||
G -->|Auth & Decrypt| H[Session & State Guard]
|
|
||||||
H -->|TCP Поток| I[Relay Loop]
|
|
||||||
end
|
|
||||||
|
|
||||||
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
|
||||||
FB -->|Перенаправление| NGINX[nginx / Caddy]
|
|
||||||
|
|
||||||
I -->|Outbound| WWW((Интернет))
|
|
||||||
end
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -116,8 +125,7 @@ irm https://raw.githubusercontent.com/ospab/ostp/master/scripts/install.ps1 | ie
|
||||||
"debug": false,
|
"debug": false,
|
||||||
// Настройки транспорта (udp или uot)
|
// Настройки транспорта (udp или uot)
|
||||||
"transport": {
|
"transport": {
|
||||||
"mode": "udp",
|
"mode": "udp"
|
||||||
"stealth_sni": "vk.com"
|
|
||||||
},
|
},
|
||||||
// TUN-режим (полносистемный VPN)
|
// TUN-режим (полносистемный VPN)
|
||||||
"tun": {
|
"tun": {
|
||||||
|
|
@ -173,12 +181,11 @@ ostp [--config <PATH>] [КОМАНДА]
|
||||||
links Вывести client-share-ссылки из серверного конфига
|
links Вывести client-share-ссылки из серверного конфига
|
||||||
import <URL> Импортировать share-ссылку в конфиг
|
import <URL> Импортировать share-ссылку в конфиг
|
||||||
update Обновить OSTP до актуального релиза
|
update Обновить OSTP до актуального релиза
|
||||||
-b, --branch <NAME> Канал релиза: stable, pre-release, alpha (по умолчанию stable)
|
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
|
||||||
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||||
migrate Принудительно мигрировать конфиг к текущему формату
|
migrate Принудительно мигрировать конфиг к текущему формату
|
||||||
prober Запустить DNS-transport prober
|
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
proxy-env-clear Вывести shell-команды для их отмены
|
||||||
proxy-env-clear Вывести shell-команды для их отмены
|
|
||||||
uninstall Остановить сервис и удалить бинарник с конфигом
|
uninstall Остановить сервис и удалить бинарник с конфигом
|
||||||
|
|
||||||
Глобальные опции:
|
Глобальные опции:
|
||||||
|
|
|
||||||
1
dnstt
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 0c5c52a57d899c05428c116898941761a2ed83c2
|
|
||||||
|
|
@ -90,11 +90,22 @@ Because the `Nonce` is unique per packet, the mask is cryptographically independ
|
||||||
|
|
||||||
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
|
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
|
||||||
|
|
||||||
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via SHA-256.
|
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via HKDF-SHA-256.
|
||||||
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
|
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
|
||||||
3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels.
|
3. Ephemeral Curve25519 key exchange (`ee`) is evaluated, and the two directional transport keys are taken from Noise's `Split()` over the final chaining key `ck`.
|
||||||
|
|
||||||
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window.
|
> **Forward secrecy.** The transport keys are derived from the chaining key
|
||||||
|
> `ck`, which absorbs the ephemeral `ee` Diffie-Hellman result. They are **not**
|
||||||
|
> derived from the Noise handshake hash `h` — `h` only ever absorbs public
|
||||||
|
> transcript data (ephemeral public keys and on-wire ciphertexts) and never the
|
||||||
|
> DH secret, so keys derived from it would give an access-key holder the ability
|
||||||
|
> to decrypt any recorded session. Deriving from `ck` binds each session to its
|
||||||
|
> ephemeral private keys, which are discarded after the handshake: an adversary
|
||||||
|
> who later compromises the PSK still cannot decrypt past traffic. This is a
|
||||||
|
> wire-breaking property gated by the internal protocol version (currently 5);
|
||||||
|
> peers on an older version derive different keys and cannot interoperate.
|
||||||
|
|
||||||
|
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a ±300-second (5-minute) synchronization window and additionally records accepted handshakes in an anti-replay set for that window.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -126,4 +137,5 @@ The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to
|
||||||
|
|
||||||
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
|
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
|
||||||
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
|
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
|
||||||
|
* **Handshake-trial CPU DoS:** Because there is no cleartext key identifier on the wire (a deliberate stealth property), a datagram from an unknown source must be trial-decrypted against every registered key. Servers MUST bound this work: OSTP caches each key's derived secrets and time-windowed junk markers (so a trial is a cheap comparison plus one AEAD attempt per key, not a fresh HKDF/HMAC), and gates the trial path behind a global token bucket (default 100/s) so a spoofed-source flood cannot force unbounded per-packet crypto. The established-session fast path and IP-roaming path are not subject to this bucket.
|
||||||
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).
|
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@
|
||||||
// Адрес следующего узла в цепочке — UDP
|
// Адрес следующего узла в цепочке — UDP
|
||||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||||
|
|
||||||
// URL API конечного (целевого) сервера для синхронизации access_keys
|
// URL API конечного (целевого) сервера для синхронизации access_keys.
|
||||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
|
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
|
||||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
|
//
|
||||||
|
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
|
||||||
|
// сервера). Management API смонтирован ВНУТРИ этого пути — именно он скрывает
|
||||||
|
// панель от сканеров, — поэтому голый host:port попадает в несуществующий
|
||||||
|
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
|
||||||
|
// Это тот же адрес, по которому вы открываете веб-панель.
|
||||||
|
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
|
||||||
|
|
||||||
// Bearer-токен для доступа к API целевого сервера
|
// Bearer-токен для доступа к API целевого сервера
|
||||||
// Должен совпадать с api.token в конфиге target-сервера
|
// Должен совпадать с api.token в конфиге target-сервера
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,23 @@ OSTP поддерживает **внутреннее криптографиче
|
||||||
|
|
||||||
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
||||||
|
|
||||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256.
|
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256.
|
||||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
|
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия.
|
||||||
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
|
3. Выполняется эфемерный обмен ключами Curve25519 (`ee`), и два однонаправленных транспортных ключа берутся из `Split()` протокола Noise над финальным chaining key `ck`.
|
||||||
|
|
||||||
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд).
|
> **Прямая секретность (Forward Secrecy).** Транспортные ключи выводятся из
|
||||||
|
> chaining key `ck`, который вбирает результат эфемерного обмена Диффи-Хеллмана
|
||||||
|
> `ee`. Они **не** выводятся из handshake hash `h` протокола Noise: `h` вбирает
|
||||||
|
> только публичные данные транскрипта (эфемерные публичные ключи и шифртексты с
|
||||||
|
> провода) и никогда — сам DH-секрет, поэтому ключи, выведенные из `h`, дали бы
|
||||||
|
> держателю PSK возможность расшифровать любую записанную сессию. Вывод из `ck`
|
||||||
|
> привязывает каждую сессию к её эфемерным приватным ключам, которые
|
||||||
|
> уничтожаются после рукопожатия: злоумышленник, скомпрометировавший PSK позже,
|
||||||
|
> всё равно не сможет расшифровать прошлый трафик. Это свойство ломает
|
||||||
|
> совместимость и защищено внутренней версией протокола (сейчас 5): узлы более
|
||||||
|
> старой версии выводят другие ключи и не могут взаимодействовать.
|
||||||
|
|
||||||
|
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд, 5 минут) и дополнительно фиксирует принятые рукопожатия в множестве защиты от повтора на время этого окна.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -119,4 +131,5 @@ OSTP обеспечивает надежную доставку поверх UDP
|
||||||
|
|
||||||
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
|
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
|
||||||
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
||||||
|
* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают.
|
||||||
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 769 KiB |
|
|
@ -1 +0,0 @@
|
||||||
{"v":1}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"git": {
|
|
||||||
"sha1": "702f6dfe124c5e4d343cfd3ca5a3efe0446cf6f0"
|
|
||||||
},
|
|
||||||
"path_in_vcs": ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
name: Setup Android NDK and Rust compiler ENV
|
|
||||||
description: Setup an Android_NDK_HOME environment by downloading and Rust compiler environment.
|
|
||||||
inputs:
|
|
||||||
rust-target:
|
|
||||||
description: Rust target to build
|
|
||||||
required: true
|
|
||||||
sdk-version:
|
|
||||||
description: Exact SDK version to use
|
|
||||||
default: "33"
|
|
||||||
ndk-version:
|
|
||||||
description: Exact NDK version to use
|
|
||||||
default: "25"
|
|
||||||
ndk-platform:
|
|
||||||
description: Which host platform to use
|
|
||||||
default: "linux"
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Download Android NDK
|
|
||||||
run: curl --http1.1 -O https://dl.google.com/android/repository/android-ndk-r${{ inputs.ndk-version }}-${{ inputs.ndk-platform }}.zip
|
|
||||||
shell: bash
|
|
||||||
- name: Extract Android NDK
|
|
||||||
run: unzip -q android-ndk-r${{ inputs.ndk-version }}-${{ inputs.ndk-platform }}.zip
|
|
||||||
shell: bash
|
|
||||||
- name: Set Rust compiler ENV
|
|
||||||
run: |
|
|
||||||
ndk_home=${{ github.workspace }}/android-ndk-r${{ inputs.ndk-version }}
|
|
||||||
platform=$(ls ${ndk_home}/toolchains/llvm/prebuilt/ | head -1)
|
|
||||||
ndk_tool=${ndk_home}/toolchains/llvm/prebuilt/${platform}/bin
|
|
||||||
envvar_suffix=$(echo ${{ inputs.rust-target }} | sed "s/-/_/g")
|
|
||||||
upper_suffix=$(echo ${envvar_suffix} | tr '[:lower:]' '[:upper:]')
|
|
||||||
tool_prefix=${{ inputs.rust-target }}${{ inputs.sdk-version }}
|
|
||||||
echo "ANDROID_NDK_HOME=${ndk_home}" >> $GITHUB_ENV
|
|
||||||
echo "CC_${envvar_suffix}=${ndk_tool}/${tool_prefix}-clang" >> $GITHUB_ENV
|
|
||||||
echo "AR_${envvar_suffix}=${ndk_tool}/llvm-ar" >> $GITHUB_ENV
|
|
||||||
echo "CARGO_TARGET_${upper_suffix}_LINKER=${ndk_tool}/${tool_prefix}-clang" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- build: linux-amd64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
- build: android-arm64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: aarch64-linux-android
|
|
||||||
no_run: --no-run
|
|
||||||
- build: android-amd64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-linux-android
|
|
||||||
no_run: --no-run
|
|
||||||
- build: macos-amd64
|
|
||||||
os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- build: macos-arm64
|
|
||||||
os: macos-14
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
- build: ios-arm64
|
|
||||||
os: macos-latest
|
|
||||||
target: aarch64-apple-ios
|
|
||||||
no_run: --no-run
|
|
||||||
- build: windows-amd64
|
|
||||||
os: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
- build: windows-arm64
|
|
||||||
os: windows-latest
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
no_run: --no-run
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Rust (rustup)
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
rustup toolchain install stable --no-self-update --profile minimal --target ${{ matrix.target }}
|
|
||||||
rustup default stable
|
|
||||||
shell: bash
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Setup android environment
|
|
||||||
if: contains(matrix.build, 'android')
|
|
||||||
uses: ./.github/actions/ndk-dev-rs
|
|
||||||
with:
|
|
||||||
rust-target: ${{ matrix.target }}
|
|
||||||
- run: cargo test ${{ matrix.no_run }} --workspace --target ${{ matrix.target }}
|
|
||||||
- run: cargo test ${{ matrix.no_run }} --workspace --target ${{ matrix.target }} --release
|
|
||||||
|
|
||||||
msrv_n_clippy:
|
|
||||||
name: MSRV & Clippy & Rustfmt
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- run: cargo fmt -- --check
|
|
||||||
- run: cargo clippy --all-features -- -D warnings
|
|
||||||
- run: cargo check --lib -p netstack-smoltcp
|
|
||||||
- run: cargo check --lib -p netstack-smoltcp --all-features
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Publish to crates.io
|
|
||||||
run: |
|
|
||||||
cargo publish
|
|
||||||
env:
|
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/target
|
|
||||||
/Cargo.lock
|
|
||||||
|
|
||||||
.idea
|
|
||||||
.VSCodeCounter/
|
|
||||||
.vscode
|
|
||||||
.DS_Store
|
|
||||||
*.iml
|
|
||||||
**/*.log
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
|
||||||
#
|
|
||||||
# When uploading crates to the registry Cargo will automatically
|
|
||||||
# "normalize" Cargo.toml files for maximal compatibility
|
|
||||||
# with all versions of Cargo and also rewrite `path` dependencies
|
|
||||||
# to registry (e.g., crates.io) dependencies.
|
|
||||||
#
|
|
||||||
# If you are reading this file be aware that the original Cargo.toml
|
|
||||||
# will likely look very different (and much more reasonable).
|
|
||||||
# See Cargo.toml.orig for the original contents.
|
|
||||||
|
|
||||||
[package]
|
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.75.0"
|
|
||||||
name = "netstack-smoltcp"
|
|
||||||
version = "0.2.2"
|
|
||||||
authors = ["cavivie <cavivie@gmail.com>"]
|
|
||||||
build = false
|
|
||||||
autolib = false
|
|
||||||
autobins = false
|
|
||||||
autoexamples = false
|
|
||||||
autotests = false
|
|
||||||
autobenches = false
|
|
||||||
description = """
|
|
||||||
A netstack for the special purpose of turning packets from/to a TUN interface
|
|
||||||
into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
|
|
||||||
"""
|
|
||||||
homepage = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
documentation = "https://docs.rs/netstack-smoltcp"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = [
|
|
||||||
"netstack",
|
|
||||||
"smoltcp",
|
|
||||||
"network",
|
|
||||||
"ip",
|
|
||||||
"tun",
|
|
||||||
]
|
|
||||||
categories = ["network-programming"]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
repository = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "netstack_smoltcp"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "forward"
|
|
||||||
path = "examples/forward.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "forward-offload-linux"
|
|
||||||
path = "examples/forward-offload-linux.rs"
|
|
||||||
|
|
||||||
[[test]]
|
|
||||||
name = "regression"
|
|
||||||
path = "tests/regression.rs"
|
|
||||||
|
|
||||||
[dependencies.etherparse]
|
|
||||||
version = "0.16"
|
|
||||||
|
|
||||||
[dependencies.futures]
|
|
||||||
version = "0.3"
|
|
||||||
|
|
||||||
[dependencies.rand]
|
|
||||||
version = "0.8"
|
|
||||||
|
|
||||||
[dependencies.smoltcp]
|
|
||||||
version = "0.12"
|
|
||||||
features = [
|
|
||||||
"std",
|
|
||||||
"log",
|
|
||||||
"medium-ip",
|
|
||||||
"proto-ipv4",
|
|
||||||
"proto-ipv6",
|
|
||||||
"socket-icmp",
|
|
||||||
"socket-udp",
|
|
||||||
"socket-tcp",
|
|
||||||
]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dependencies.spin]
|
|
||||||
version = "0.9"
|
|
||||||
|
|
||||||
[dependencies.tokio]
|
|
||||||
version = "1"
|
|
||||||
features = [
|
|
||||||
"sync",
|
|
||||||
"time",
|
|
||||||
"rt",
|
|
||||||
"macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies.tokio-util]
|
|
||||||
version = "0.7.10"
|
|
||||||
|
|
||||||
[dependencies.tracing]
|
|
||||||
version = "0.1"
|
|
||||||
features = ["std"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dev-dependencies.socket2]
|
|
||||||
version = "0.5.6"
|
|
||||||
|
|
||||||
[dev-dependencies.socket2-ext]
|
|
||||||
version = "0.1"
|
|
||||||
|
|
||||||
[dev-dependencies.structopt]
|
|
||||||
version = "0.3"
|
|
||||||
|
|
||||||
[dev-dependencies.tokio]
|
|
||||||
version = "1"
|
|
||||||
features = [
|
|
||||||
"rt",
|
|
||||||
"macros",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"io-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dev-dependencies.tracing]
|
|
||||||
version = "0.1"
|
|
||||||
features = ["std"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[dev-dependencies.tracing-subscriber]
|
|
||||||
version = "0.3.18"
|
|
||||||
|
|
||||||
[dev-dependencies.tun-rs]
|
|
||||||
version = "2"
|
|
||||||
features = [
|
|
||||||
"async",
|
|
||||||
"async_framed",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dev-dependencies.tun2]
|
|
||||||
version = "3"
|
|
||||||
features = ["async"]
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "netstack-smoltcp"
|
|
||||||
version = "0.2.2"
|
|
||||||
edition = "2021"
|
|
||||||
authors = ["cavivie <cavivie@gmail.com>"]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
repository = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
homepage = "https://github.com/cavivie/netstack-smoltcp"
|
|
||||||
documentation = "https://docs.rs/netstack-smoltcp"
|
|
||||||
keywords = ["netstack", "smoltcp", "network", "ip", "tun"]
|
|
||||||
categories = ["network-programming"]
|
|
||||||
description = """
|
|
||||||
A netstack for the special purpose of turning packets from/to a TUN interface
|
|
||||||
into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
|
|
||||||
"""
|
|
||||||
rust-version = "1.75.0"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
|
||||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"] }
|
|
||||||
tokio-util = "0.7.10"
|
|
||||||
etherparse = "0.16"
|
|
||||||
futures = "0.3"
|
|
||||||
rand = "0.8"
|
|
||||||
spin = "0.9"
|
|
||||||
smoltcp = { version = "0.12", default-features = false, features = [
|
|
||||||
"std",
|
|
||||||
"log",
|
|
||||||
"medium-ip",
|
|
||||||
"proto-ipv4",
|
|
||||||
"proto-ipv6",
|
|
||||||
"socket-icmp",
|
|
||||||
"socket-udp",
|
|
||||||
"socket-tcp",
|
|
||||||
] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tun2 = { version = "3", features = ["async"] }
|
|
||||||
# has better performance on linux than tun2
|
|
||||||
tun-rs = { version = "2", features = ["async", "async_framed"] }
|
|
||||||
tokio = { version = "1", features = [
|
|
||||||
"rt",
|
|
||||||
"macros",
|
|
||||||
"rt-multi-thread",
|
|
||||||
"io-util",
|
|
||||||
] }
|
|
||||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
|
||||||
tracing-subscriber = "0.3.18"
|
|
||||||
structopt = "0.3"
|
|
||||||
socket2 = "0.5.6"
|
|
||||||
socket2-ext = { version = "0.1" }
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
Copyright (c) 2024 cavivie and netstack-smoltcp Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any
|
|
||||||
person obtaining a copy of this software and associated
|
|
||||||
documentation files (the "Software"), to deal in the
|
|
||||||
Software without restriction, including without
|
|
||||||
limitation the rights to use, copy, modify, merge,
|
|
||||||
publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software
|
|
||||||
is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice
|
|
||||||
shall be included in all copies or substantial portions
|
|
||||||
of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
||||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
|
||||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
|
||||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
|
||||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
||||||
DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# Netstack Smoltcp
|
|
||||||
|
|
||||||
A netstack for the special purpose of turning packets from/to a TUN interface into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
|
|
||||||
|
|
||||||
[![Crates.io][crates-badge]][crates-url]
|
|
||||||
[![MIT licensed][mit-badge]][mit-url]
|
|
||||||
[![Apache licensed, Version 2.0][apache-badge]][apache-url]
|
|
||||||
[![Build Status][actions-badge]][actions-url]
|
|
||||||
|
|
||||||
[crates-badge]: https://img.shields.io/crates/v/netstack-smoltcp.svg
|
|
||||||
[crates-url]: https://crates.io/crates/netstack-smoltcp
|
|
||||||
[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
|
|
||||||
[mit-url]: https://github.com/automesh-network/netstack-smoltcp/blob/master/LICENSE-MIT
|
|
||||||
[apache-badge]: https://img.shields.io/badge/license-APACHE2.0-blue.svg
|
|
||||||
[apache-url]: https://github.com/automesh-network/netstack-smoltcp/blob/master/LICENSE-APACHE
|
|
||||||
[actions-badge]: https://github.com/automesh-network/netstack-smoltcp/workflows/CI/badge.svg
|
|
||||||
[actions-url]: https://github.com/automesh-network/netstack-smoltcp/actions?query=workflow%3ACI+branch%3Amain
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Supports Future Send and non-Send, mostly pepole use Send.
|
|
||||||
- Supports ICMP protocol drive by TCP runner to use ICMP ping.
|
|
||||||
- Supports filtering packets by source and destination IP addresses.
|
|
||||||
- Can read IP packets from netstack, write IP packets to netstack.
|
|
||||||
- Can receive TcpStream from TcpListener exposed from netstack.
|
|
||||||
- Can receive UDP datagram from UdpSocket exposed from netstack.
|
|
||||||
- Implements popular future streaming traits and asynchronous IO traits:
|
|
||||||
* TcpListener implements futures Stream/Sink trait
|
|
||||||
* TcpStream implements tokio AsyncRead/AsyncWrite trait
|
|
||||||
* UdpSocket(ReadHalf/WriteHalf) implements futures Stream/Sink trait.
|
|
||||||
|
|
||||||
## Platforms
|
|
||||||
|
|
||||||
This crate provides lightweight netstack support for Linux, iOS, macOS, Android and Windows.
|
|
||||||
Currently, it works on most targets, but mainly tested the popular platforms which includes:
|
|
||||||
- linux-amd64: x86_64-unknown-linux-gnu
|
|
||||||
- android-arm64: aarch64-linux-android
|
|
||||||
- android-amd64: x86_64-linux-android
|
|
||||||
- macos-amd64: x86_64-apple-darwin
|
|
||||||
- macos-arm64: aarch64-apple-darwin
|
|
||||||
- ios-arm64: aarch64-apple-ios
|
|
||||||
- windows-amd64: x86_64-pc-windows-msvc
|
|
||||||
- windows-arm64: aarch64-pc-windows-msvc
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// let device = tun2::create_as_async(&cfg)?;
|
|
||||||
// let framed = device.into_framed();
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, tcp_listener) = netstack_smoltcp::StackBuilder::default()
|
|
||||||
.stack_buffer_size(512)
|
|
||||||
.tcp_buffer_size(4096)
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_tcp(true)
|
|
||||||
.enable_icmp(true)
|
|
||||||
.mtu(9000) // virtual device usually benefits from larger MTU
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let mut udp_socket = udp_socket.unwrap(); // udp enabled
|
|
||||||
let mut tcp_listener = tcp_listener.unwrap(); // tcp/icmp enabled
|
|
||||||
if let Some(runner) = runner {
|
|
||||||
tokio::spawn(runner);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
let (mut tun_sink, mut tun_stream) = framed.split();
|
|
||||||
|
|
||||||
// Reads packet from stack and sends to TUN.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(pkt) = stack_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
tun_sink.send(pkt).await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reads packet from TUN and sends to stack.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(pkt) = tun_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
stack_sink.send(pkt).await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extracts TCP connections from stack and sends them to the dispatcher.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
handle_inbound_stream(tcp_listener).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Receive and send UDP packets between netstack and NAT manager. The NAT
|
|
||||||
// manager would maintain UDP sessions and send them to the dispatcher.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
handle_inbound_datagram(udp_socket).await;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
Typically, `netstack-smoltcp` will be used with an tun device, so a careful choice of TUN crate matters.
|
|
||||||
|
|
||||||
[tun-rs](https://github.com/tun-rs/tun-rs) have better performance on **Linux** than [rust-tun](https://github.com/meh/rust-tun/) due to GSO/GRO which allow you to process the packets in batches.
|
|
||||||
|
|
||||||
`bash scripts/bench-offload.sh` could tell that `tun-rs` boosts the performance by 4x. Try it out on your Linux machine!
|
|
||||||
|
|
||||||
The example for using `tun-rs` with `netstack-smoltcp` could be found at [forward-offload-linux.rs](examples/forward-offload-linux.rs)
|
|
||||||
|
|
||||||
For further tuning, refer to `tun-rs`'s detailed [README](https://github.com/tun-rs/tun-rs/blob/main/README.md)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under either of
|
|
||||||
|
|
||||||
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
|
|
||||||
https://www.apache.org/licenses/LICENSE-2.0)
|
|
||||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
|
||||||
https://opensource.org/licenses/MIT)
|
|
||||||
|
|
||||||
at your option.
|
|
||||||
|
|
||||||
### Contribution
|
|
||||||
|
|
||||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
|
||||||
for inclusion in netstack-smoltcp by you, as defined in the Apache-2.0 license,
|
|
||||||
shall be dual licensed as above, without any additional terms or conditions.
|
|
||||||
|
|
||||||
## Inspired By
|
|
||||||
|
|
||||||
Special thanks to these amazing projects that inspired netstack-smoltcp (in no particular order):
|
|
||||||
- [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust/)
|
|
||||||
- [netstack-lwip](https://github.com/eycorsican/netstack-lwip/)
|
|
||||||
- [rust-tun-active](https://github.com/tun2proxy/rust-tun)
|
|
||||||
- [rust-tun](https://github.com/meh/rust-tun/)
|
|
||||||
- [tun-rs](https://github.com/tun-rs/tun-rs)
|
|
||||||
- [smoltcp](https://github.com/smoltcp-rs/smoltcp)
|
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod inner {
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use netstack_smoltcp::{StackBuilder, TcpListener, UdpSocket};
|
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
|
||||||
use structopt::StructOpt;
|
|
||||||
use tokio::net::{TcpSocket, TcpStream};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
use tun_rs::{DeviceBuilder, IDEAL_BATCH_SIZE, VIRTIO_NET_HDR_LEN};
|
|
||||||
|
|
||||||
// Patched forward example: tun2 → tun-rs with Linux GRO/GSO offload.
|
|
||||||
// For further reading, check out https://blog.cloudflare.com/virtual-networking-101-understanding-tap
|
|
||||||
//
|
|
||||||
// Key changes vs forward.rs:
|
|
||||||
// 1. Use tun-rs DeviceBuilder with .offload(true) on Linux (enables
|
|
||||||
// IFF_VNET_HDR + TUN_F_CSUM/TSO4/TSO6/USO4/USO6).
|
|
||||||
// 2. TX (stack → TUN): prepend 10-byte zero virtio_net_hdr (GSO_NONE)
|
|
||||||
// so the kernel accepts the write when IFF_VNET_HDR is set.
|
|
||||||
// 3. RX (TUN → stack): use recv_multiple() for batch GSO splitting;
|
|
||||||
// buffers sized to 1600 to fit smoltcp's 1504-byte MTU segments.
|
|
||||||
#[derive(Debug, StructOpt)]
|
|
||||||
#[structopt(name = "forward", about = "Simply forward tun tcp/udp traffic.")]
|
|
||||||
struct Opt {
|
|
||||||
/// Outbound interface to bind forwarded connections to.
|
|
||||||
#[structopt(short = "i", long = "interface")]
|
|
||||||
interface: String,
|
|
||||||
/// Name of the TUN device.
|
|
||||||
#[structopt(short = "n", long = "name", default_value = "utun8")]
|
|
||||||
name: String,
|
|
||||||
/// Tracing log level.
|
|
||||||
#[structopt(long = "log-level", default_value = "debug")]
|
|
||||||
log_level: tracing::Level,
|
|
||||||
/// Use current-thread Tokio runtime (default: multi-thread).
|
|
||||||
#[structopt(long = "current-thread")]
|
|
||||||
current_thread: bool,
|
|
||||||
/// Use spawn_local instead of spawn.
|
|
||||||
#[structopt(long = "local-task")]
|
|
||||||
local_task: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn main() {
|
|
||||||
let opt = Opt::from_args();
|
|
||||||
let rt = if opt.current_thread {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
} else {
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
|
||||||
}
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
rt.block_on(main_exec(opt));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main_exec(opt: Opt) {
|
|
||||||
macro_rules! tokio_spawn {
|
|
||||||
($fut:expr) => {
|
|
||||||
if opt.local_task {
|
|
||||||
tokio::task::spawn_local($fut)
|
|
||||||
} else {
|
|
||||||
tokio::task::spawn($fut)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
tracing::subscriber::set_global_default(
|
|
||||||
tracing_subscriber::FmtSubscriber::builder()
|
|
||||||
.with_max_level(opt.log_level)
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Build TUN device with GRO/GSO offload on Linux.
|
|
||||||
let builder = DeviceBuilder::new()
|
|
||||||
.name(opt.name)
|
|
||||||
.ipv4("10.10.10.2", 24, Some("10.10.10.1"))
|
|
||||||
.mtu(9000);
|
|
||||||
let builder = builder.offload(true);
|
|
||||||
let dev = Arc::new(builder.build_async().unwrap());
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, tcp_listener) = StackBuilder::default()
|
|
||||||
.enable_tcp(true)
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_icmp(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let udp_socket = udp_socket.unwrap();
|
|
||||||
let tcp_listener = tcp_listener.unwrap();
|
|
||||||
if let Some(runner) = runner {
|
|
||||||
tokio_spawn!(runner);
|
|
||||||
}
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
|
|
||||||
let mut futs = vec![];
|
|
||||||
|
|
||||||
// stack → TUN
|
|
||||||
// With IFF_VNET_HDR every write must start with a virtio_net_hdr.
|
|
||||||
// We use all-zero (gso_type = GSO_NONE, flags = 0): plain packet,
|
|
||||||
// checksum already valid (smoltcp always computes checksums itself).
|
|
||||||
let dev1 = dev.clone();
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
while let Some(pkt) = stack_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
let result = {
|
|
||||||
let mut buf = vec![0u8; VIRTIO_NET_HDR_LEN + pkt.len()];
|
|
||||||
buf[VIRTIO_NET_HDR_LEN..].copy_from_slice(&pkt);
|
|
||||||
dev1.send(&buf).await
|
|
||||||
};
|
|
||||||
if let Err(e) = result {
|
|
||||||
warn!("failed to send packet to TUN: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// TUN → stack
|
|
||||||
// recv_multiple() does one read() syscall and returns N individual IP
|
|
||||||
// packets after splitting any incoming GRO super-packet.
|
|
||||||
// Buffer size 1600 > smoltcp MTU (1504) to avoid an out-of-bounds panic
|
|
||||||
// when the kernel segments at MSS=1464 with 40-byte IP+TCP headers.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
let mut orig = vec![0u8; VIRTIO_NET_HDR_LEN + 65535];
|
|
||||||
let mut bufs = vec![vec![0u8; 1600]; IDEAL_BATCH_SIZE];
|
|
||||||
let mut sizes = vec![0usize; IDEAL_BATCH_SIZE];
|
|
||||||
while let Ok(n) = dev.recv_multiple(&mut orig, &mut bufs, &mut sizes, 0).await {
|
|
||||||
for i in 0..n {
|
|
||||||
let pkt = &bufs[i][..sizes[i]];
|
|
||||||
if let Err(e) = stack_sink.send(pkt.to_vec()).await {
|
|
||||||
warn!("failed to send packet to stack: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
futs.push(tokio_spawn!({
|
|
||||||
let iface = opt.interface.clone();
|
|
||||||
async move {
|
|
||||||
handle_inbound_stream(tcp_listener, iface).await;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
handle_inbound_datagram(udp_socket, opt.interface).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
futures::future::join_all(futs).await.iter().for_each(|r| {
|
|
||||||
if let Err(e) = r {
|
|
||||||
error!("{:?}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_inbound_stream(mut tcp_listener: TcpListener, interface: String) {
|
|
||||||
while let Some((mut stream, local, remote)) = tcp_listener.next().await {
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
info!("tcp: {:?} => {:?}", local, remote);
|
|
||||||
match new_tcp_stream(remote, &interface).await {
|
|
||||||
Ok(mut r) => {
|
|
||||||
if let Err(e) = tokio::io::copy_bidirectional(&mut stream, &mut r).await {
|
|
||||||
warn!(
|
|
||||||
"failed to copy tcp stream {:?}=>{:?}: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to open tcp stream {:?}=>{:?}: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_inbound_datagram(udp_socket: UdpSocket, interface: String) {
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let (mut read_half, mut write_half) = udp_socket.split();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some((data, local, remote)) = rx.recv().await {
|
|
||||||
let _ = write_half.send((data, remote, local)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
while let Some((data, local, remote)) = read_half.next().await {
|
|
||||||
let tx = tx.clone();
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match new_udp_packet(remote, &interface).await {
|
|
||||||
Ok(sock) => {
|
|
||||||
let _ = sock.send(&data).await;
|
|
||||||
loop {
|
|
||||||
let mut buf = vec![0; 1024];
|
|
||||||
match sock.recv_from(&mut buf).await {
|
|
||||||
Ok((n, _)) => {
|
|
||||||
let _ = tx.send((buf[..n].to_vec(), local, remote));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("udp recv {:?}: {:?}", remote, e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("failed to open udp socket {:?}: {:?}", remote, e),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_tcp_stream(addr: SocketAddr, iface: &str) -> std::io::Result<TcpStream> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let s = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
|
|
||||||
s.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
s.set_keepalive(true)?;
|
|
||||||
s.set_nodelay(true)?;
|
|
||||||
s.set_nonblocking(true)?;
|
|
||||||
Ok(TcpSocket::from_std_stream(s.into()).connect(addr).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_udp_packet(
|
|
||||||
addr: SocketAddr,
|
|
||||||
iface: &str,
|
|
||||||
) -> std::io::Result<tokio::net::UdpSocket> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let s = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?;
|
|
||||||
s.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
s.set_nonblocking(true)?;
|
|
||||||
let sock = tokio::net::UdpSocket::from_std(s.into())?;
|
|
||||||
sock.connect(addr).await?;
|
|
||||||
Ok(sock)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
mod inner {
|
|
||||||
pub(super) fn main() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
inner::main();
|
|
||||||
}
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
use std::net::{IpAddr, SocketAddr};
|
|
||||||
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use netstack_smoltcp::{StackBuilder, TcpListener, UdpSocket};
|
|
||||||
use structopt::StructOpt;
|
|
||||||
use tokio::net::{TcpSocket, TcpStream};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
// to run this example, you should set the policy routing **after the start of the main program**
|
|
||||||
//
|
|
||||||
// linux:
|
|
||||||
// with bind device:
|
|
||||||
// `curl 1.1.1.1 --interface utun8`
|
|
||||||
// with default route:
|
|
||||||
// `bash scripts/route-linux.sh add`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
// with single route:
|
|
||||||
// `ip rule add to 1.1.1.1 table 200`
|
|
||||||
// `ip route add default dev utun8 table 200`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
//
|
|
||||||
// macos:
|
|
||||||
// with default route:
|
|
||||||
// `bash scripts/route-macos.sh add`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
//
|
|
||||||
// windows:
|
|
||||||
// with default route:
|
|
||||||
// tun2 set default route automatically, won't set agian
|
|
||||||
// # `powershell.exe scripts/route-windows.ps1 add`
|
|
||||||
// `curl 1.1.1.1`
|
|
||||||
//
|
|
||||||
// currently, the example only supports the TCP stream, and the UDP packet will be dropped.
|
|
||||||
|
|
||||||
#[derive(Debug, StructOpt)]
|
|
||||||
#[structopt(name = "forward", about = "Simply forward tun tcp/udp traffic.")]
|
|
||||||
struct Opt {
|
|
||||||
/// Default binding interface, default by guessed.
|
|
||||||
/// Specify but doesn't exist, no device is bound.
|
|
||||||
#[structopt(short = "i", long = "interface")]
|
|
||||||
interface: String,
|
|
||||||
|
|
||||||
/// name of the tun device, default to rtun8.
|
|
||||||
#[structopt(short = "n", long = "name", default_value = "utun8")]
|
|
||||||
name: String,
|
|
||||||
|
|
||||||
/// Tracing subscriber log level.
|
|
||||||
#[structopt(long = "log-level", default_value = "debug")]
|
|
||||||
log_level: tracing::Level,
|
|
||||||
|
|
||||||
/// Tokio current-thread runtime, default to multi-thread.
|
|
||||||
#[structopt(long = "current-thread")]
|
|
||||||
current_thread: bool,
|
|
||||||
|
|
||||||
/// Tokio task spawn_local, default to spwan.
|
|
||||||
#[structopt(long = "local-task")]
|
|
||||||
local_task: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let opt = Opt::from_args();
|
|
||||||
|
|
||||||
let rt = if opt.current_thread {
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
} else {
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
|
||||||
}
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rt.block_on(main_exec(opt));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn main_exec(opt: Opt) {
|
|
||||||
macro_rules! tokio_spawn {
|
|
||||||
($fut: expr) => {
|
|
||||||
if opt.local_task {
|
|
||||||
tokio::task::spawn_local($fut)
|
|
||||||
} else {
|
|
||||||
tokio::task::spawn($fut)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::subscriber::set_global_default(
|
|
||||||
tracing_subscriber::FmtSubscriber::builder()
|
|
||||||
.with_max_level(opt.log_level)
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cfg = tun2::Configuration::default();
|
|
||||||
cfg.layer(tun2::Layer::L3);
|
|
||||||
let fd = -1;
|
|
||||||
if fd >= 0 {
|
|
||||||
cfg.raw_fd(fd);
|
|
||||||
} else {
|
|
||||||
cfg.tun_name(&opt.name)
|
|
||||||
.address("10.10.10.2")
|
|
||||||
.destination("10.10.10.1")
|
|
||||||
.mtu(tun2::DEFAULT_MTU);
|
|
||||||
#[cfg(not(any(target_arch = "mips", target_arch = "mips64",)))]
|
|
||||||
{
|
|
||||||
cfg.netmask("255.255.255.0");
|
|
||||||
}
|
|
||||||
cfg.up();
|
|
||||||
}
|
|
||||||
|
|
||||||
let device = tun2::create_as_async(&cfg).unwrap();
|
|
||||||
let mut builder = StackBuilder::default()
|
|
||||||
.enable_tcp(true)
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_icmp(true)
|
|
||||||
.mtu(9000);
|
|
||||||
if let Some(device_broadcast) = get_device_broadcast(&device) {
|
|
||||||
builder = builder
|
|
||||||
// .add_ip_filter(Box::new(move |src, dst| *src != device_broadcast && *dst != device_broadcast));
|
|
||||||
.add_ip_filter_fn(move |src, dst| *src != device_broadcast && *dst != device_broadcast);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, tcp_listener) = builder.build().unwrap();
|
|
||||||
let udp_socket = udp_socket.unwrap(); // udp enabled
|
|
||||||
let tcp_listener = tcp_listener.unwrap(); // tcp enabled or icmp enabled
|
|
||||||
|
|
||||||
if let Some(runner) = runner {
|
|
||||||
tokio_spawn!(runner);
|
|
||||||
}
|
|
||||||
|
|
||||||
let framed = device.into_framed();
|
|
||||||
let (mut tun_sink, mut tun_stream) = framed.split();
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
|
|
||||||
let mut futs = vec![];
|
|
||||||
|
|
||||||
// Reads packet from stack and sends to TUN.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
while let Some(pkt) = stack_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
match tun_sink.send(pkt).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => warn!("failed to send packet to TUN, err: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Reads packet from TUN and sends to stack.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
while let Some(pkt) = tun_stream.next().await {
|
|
||||||
if let Ok(pkt) = pkt {
|
|
||||||
match stack_sink.send(pkt).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => warn!("failed to send packet to stack, err: {:?}", e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Extracts TCP connections from stack and sends them to the dispatcher.
|
|
||||||
futs.push(tokio_spawn!({
|
|
||||||
let interface = opt.interface.clone();
|
|
||||||
async move {
|
|
||||||
handle_inbound_stream(tcp_listener, interface).await;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Receive and send UDP packets between netstack and NAT manager. The NAT
|
|
||||||
// manager would maintain UDP sessions and send them to the dispatcher.
|
|
||||||
futs.push(tokio_spawn!(async move {
|
|
||||||
handle_inbound_datagram(udp_socket, opt.interface).await;
|
|
||||||
}));
|
|
||||||
|
|
||||||
futures::future::join_all(futs)
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.for_each(|res| {
|
|
||||||
if let Err(e) = res {
|
|
||||||
error!("error: {:?}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// simply forward tcp stream
|
|
||||||
async fn handle_inbound_stream(mut tcp_listener: TcpListener, interface: String) {
|
|
||||||
while let Some((mut stream, local, remote)) = tcp_listener.next().await {
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
info!("new tcp connection: {:?} => {:?}", local, remote);
|
|
||||||
match new_tcp_stream(remote, &interface).await {
|
|
||||||
Ok(mut remote_stream) => {
|
|
||||||
// pipe between two tcp stream
|
|
||||||
match tokio::io::copy_bidirectional(&mut stream, &mut remote_stream).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to copy tcp stream {:?}=>{:?}, err: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to new tcp stream {:?}=>{:?}, err: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// simply forward udp datagram
|
|
||||||
async fn handle_inbound_datagram(udp_socket: UdpSocket, interface: String) {
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
let (mut read_half, mut write_half) = udp_socket.split();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some((data, local, remote)) = rx.recv().await {
|
|
||||||
let _ = write_half.send((data, remote, local)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
while let Some((data, local, remote)) = read_half.next().await {
|
|
||||||
let tx = tx.clone();
|
|
||||||
let interface = interface.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
info!("new udp datagram: {:?} => {:?}", local, remote);
|
|
||||||
match new_udp_packet(remote, &interface).await {
|
|
||||||
Ok(remote_socket) => {
|
|
||||||
// pipe between two udp sockets
|
|
||||||
let _ = remote_socket.send(&data).await;
|
|
||||||
loop {
|
|
||||||
let mut buf = vec![0; 1024];
|
|
||||||
match remote_socket.recv_from(&mut buf).await {
|
|
||||||
Ok((len, _)) => {
|
|
||||||
let _ = tx.send((buf[..len].to_vec(), local, remote));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"failed to recv udp datagram {:?}<->{:?}: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(
|
|
||||||
"failed to new udp socket {:?}=>{:?}, err: {:?}",
|
|
||||||
local, remote, e
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_tcp_stream<'a>(addr: SocketAddr, iface: &str) -> std::io::Result<TcpStream> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
|
|
||||||
socket.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
socket.set_keepalive(true)?;
|
|
||||||
socket.set_nodelay(true)?;
|
|
||||||
socket.set_nonblocking(true)?;
|
|
||||||
|
|
||||||
let stream = TcpSocket::from_std_stream(socket.into())
|
|
||||||
.connect(addr)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_udp_packet(addr: SocketAddr, iface: &str) -> std::io::Result<tokio::net::UdpSocket> {
|
|
||||||
use socket2_ext::{AddressBinding, BindDeviceOption};
|
|
||||||
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?;
|
|
||||||
socket.bind_to_device(BindDeviceOption::v4(iface))?;
|
|
||||||
socket.set_nonblocking(true)?;
|
|
||||||
|
|
||||||
let socket = tokio::net::UdpSocket::from_std(socket.into());
|
|
||||||
if let Ok(ref socket) = socket {
|
|
||||||
socket.connect(addr).await?;
|
|
||||||
}
|
|
||||||
socket
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_device_broadcast(device: &tun2::AsyncDevice) -> Option<std::net::Ipv4Addr> {
|
|
||||||
use tun2::AbstractDevice;
|
|
||||||
|
|
||||||
let mtu = device.mtu().unwrap_or(tun2::DEFAULT_MTU);
|
|
||||||
|
|
||||||
let address = match device.address() {
|
|
||||||
Ok(a) => match a {
|
|
||||||
IpAddr::V4(v4) => v4,
|
|
||||||
IpAddr::V6(_) => return None,
|
|
||||||
},
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let netmask = match device.netmask() {
|
|
||||||
Ok(n) => match n {
|
|
||||||
IpAddr::V4(v4) => v4,
|
|
||||||
IpAddr::V6(_) => return None,
|
|
||||||
},
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
match smoltcp::wire::Ipv4Cidr::from_netmask(address, netmask) {
|
|
||||||
Ok(address_net) => match address_net.broadcast() {
|
|
||||||
Some(broadcast) => {
|
|
||||||
info!(
|
|
||||||
"tun device network: {} (address: {}, netmask: {}, broadcast: {}, mtu: {})",
|
|
||||||
address_net, address, netmask, broadcast, mtu,
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(broadcast)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
error!("invalid tun address {}, netmask {}", address, netmask);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
"invalid tun address {}, netmask {}, error: {}",
|
|
||||||
address, netmask, err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# bench-offload.sh
|
|
||||||
#
|
|
||||||
# Benchmarks netstack-smoltcp's forward examples with 2-stream iperf3.
|
|
||||||
# Compares:
|
|
||||||
# - examples/forward (tun2, no GRO/GSO offload)
|
|
||||||
# - examples/forward-offload-linux (tun-rs, Linux GRO/GSO offload via IFF_VNET_HDR)
|
|
||||||
#
|
|
||||||
# Setup: creates a veth pair + network namespace; iperf3 server runs inside
|
|
||||||
# the namespace, the forward proxy bridges traffic through a TUN device.
|
|
||||||
#
|
|
||||||
# Requirements: cargo, iperf3, ip (iproute2), root/CAP_NET_ADMIN
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# sudo bash scripts/bench-offload.sh
|
|
||||||
#
|
|
||||||
# Run from the root of the netstack-smoltcp repository.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── config ────────────────────────────────────────────────────────────────────
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
NS=bench
|
|
||||||
VETH_HOST=veth-host
|
|
||||||
VETH_NS=veth-bench
|
|
||||||
HOST_IP=172.19.0.1
|
|
||||||
NS_IP=172.19.0.2
|
|
||||||
PREFIX=24
|
|
||||||
TUN_NAME=utun8
|
|
||||||
TUN_IP=10.10.10.2
|
|
||||||
IPERF_PORT=5201
|
|
||||||
DURATION=15
|
|
||||||
STREAMS=2
|
|
||||||
|
|
||||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
|
||||||
require() { command -v "$1" &>/dev/null || die "'$1' not found"; }
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
pkill -f "forward-" 2>/dev/null || true
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
|
|
||||||
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
|
|
||||||
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
|
|
||||||
ip link del "$VETH_HOST" 2>/dev/null || true
|
|
||||||
ip netns del "$NS" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# ── preflight ─────────────────────────────────────────────────────────────────
|
|
||||||
require cargo
|
|
||||||
require iperf3
|
|
||||||
require ip
|
|
||||||
[[ $EUID -eq 0 ]] || die "run as root (needs CAP_NET_ADMIN for TUN + netns)"
|
|
||||||
[[ -f "$REPO_DIR/Cargo.toml" ]] || die "run from the netstack-smoltcp repo root"
|
|
||||||
grep -q 'name = "netstack-smoltcp"' "$REPO_DIR/Cargo.toml" \
|
|
||||||
|| die "Cargo.toml does not look like netstack-smoltcp"
|
|
||||||
|
|
||||||
# ── network setup ─────────────────────────────────────────────────────────────
|
|
||||||
echo "[net] setting up namespace '$NS' and veth pair..."
|
|
||||||
cleanup 2>/dev/null || true
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
ip netns add "$NS"
|
|
||||||
ip link add "$VETH_HOST" type veth peer name "$VETH_NS"
|
|
||||||
ip link set "$VETH_NS" netns "$NS"
|
|
||||||
ip addr add "${HOST_IP}/${PREFIX}" dev "$VETH_HOST"
|
|
||||||
ip link set "$VETH_HOST" up
|
|
||||||
ip netns exec "$NS" ip addr add "${NS_IP}/${PREFIX}" dev "$VETH_NS"
|
|
||||||
ip netns exec "$NS" ip link set "$VETH_NS" up
|
|
||||||
ip netns exec "$NS" ip link set lo up
|
|
||||||
echo "[net] ${HOST_IP} <──veth──> ${NS_IP} (ns:${NS})"
|
|
||||||
|
|
||||||
# ── build: forward (tun2, no offload) ────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "[build] examples/forward (tun2, no GRO/GSO offload)..."
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
cargo build --example forward --release --quiet
|
|
||||||
cp target/release/examples/forward /tmp/forward-tun2
|
|
||||||
)
|
|
||||||
echo "[build] done → /tmp/forward-tun2"
|
|
||||||
|
|
||||||
# ── build: forward-offload-linux (tun-rs, GRO/GSO offload) ───────────────────
|
|
||||||
echo ""
|
|
||||||
echo "[build] examples/forward-offload-linux (tun-rs, GRO/GSO offload)..."
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
cargo build --example forward-offload-linux --release --quiet
|
|
||||||
cp target/release/examples/forward-offload-linux /tmp/forward-tun-rs
|
|
||||||
)
|
|
||||||
echo "[build] done → /tmp/forward-tun-rs"
|
|
||||||
|
|
||||||
# ── benchmark runner ──────────────────────────────────────────────────────────
|
|
||||||
run_bench() {
|
|
||||||
local label="$1" binary="$2"
|
|
||||||
|
|
||||||
# clean any leftover state
|
|
||||||
pkill -f "forward-" 2>/dev/null || true
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
|
|
||||||
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
|
|
||||||
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
|
|
||||||
sleep 0.8
|
|
||||||
|
|
||||||
# start iperf3 server inside namespace
|
|
||||||
ip netns exec "$NS" iperf3 -s -p "$IPERF_PORT" -D \
|
|
||||||
--logfile /tmp/iperf3-bench-server.log
|
|
||||||
|
|
||||||
# start proxy
|
|
||||||
"$binary" -i "$VETH_HOST" -n "$TUN_NAME" --log-level warn &
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
ip link show "$TUN_NAME" &>/dev/null \
|
|
||||||
|| { echo " [!] TUN not up, skipping"; return 1; }
|
|
||||||
|
|
||||||
# route iperf3 traffic through TUN (more-specific /32 overrides /24 via veth)
|
|
||||||
ip route add "${NS_IP}/32" dev "$TUN_NAME"
|
|
||||||
|
|
||||||
echo " running iperf3: ${STREAMS} streams × ${DURATION}s …"
|
|
||||||
local out
|
|
||||||
out=$(iperf3 -c "$NS_IP" -p "$IPERF_PORT" \
|
|
||||||
-t "$DURATION" -P "$STREAMS" 2>&1)
|
|
||||||
|
|
||||||
local sender receiver
|
|
||||||
sender=$(echo "$out" | grep "SUM.*sender" | awk '{print $6, $7}')
|
|
||||||
receiver=$(echo "$out" | grep "SUM.*receiver" | awk '{print $6, $7}')
|
|
||||||
|
|
||||||
if [[ -z "$sender" ]]; then
|
|
||||||
echo " result: FAILED"
|
|
||||||
echo "$out" | tail -5 | sed 's/^/ /'
|
|
||||||
else
|
|
||||||
printf " sender: %s\n" "$sender"
|
|
||||||
printf " receiver: %s\n" "$receiver"
|
|
||||||
fi
|
|
||||||
|
|
||||||
pkill -f "forward-" 2>/dev/null || true
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
|
|
||||||
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
|
|
||||||
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
|
|
||||||
sleep 0.8
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── direct baseline ───────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " BASELINE: direct veth (no TUN, no proxy)"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true; sleep 0.3
|
|
||||||
ip netns exec "$NS" iperf3 -s -p "$IPERF_PORT" -D \
|
|
||||||
--logfile /tmp/iperf3-bench-server.log; sleep 0.3
|
|
||||||
echo " running iperf3: ${STREAMS} streams × ${DURATION}s …"
|
|
||||||
baseline_out=$(iperf3 -c "$NS_IP" -p "$IPERF_PORT" \
|
|
||||||
-t "$DURATION" -P "$STREAMS" 2>&1)
|
|
||||||
echo "$baseline_out" | grep "SUM.*sender" | awk '{printf " sender: %s %s\n", $6, $7}'
|
|
||||||
echo "$baseline_out" | grep "SUM.*receiver" | awk '{printf " receiver: %s %s\n", $6, $7}'
|
|
||||||
ip netns exec "$NS" pkill iperf3 2>/dev/null || true; sleep 0.5
|
|
||||||
|
|
||||||
# ── tun2 ─────────────────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " tun2 (main branch — no GRO/GSO offload)"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
run_bench "tun2" /tmp/forward-tun2
|
|
||||||
|
|
||||||
# ── tun-rs + offload ──────────────────────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " tun-rs (patched — GRO/GSO offload via IFF_VNET_HDR)"
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
run_bench "tun-rs+offload" /tmp/forward-tun-rs
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " done."
|
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#__author__: cavivie
|
|
||||||
|
|
||||||
DEFAULT_TUN_NAME="utun8"
|
|
||||||
|
|
||||||
function do_route() {
|
|
||||||
local route_op="${1}"
|
|
||||||
local tun_name="${2:-$DEFAULT_TUN_NAME}"
|
|
||||||
ip route ${route_op} 0.0.0.0/1 dev ${tun_name}
|
|
||||||
ip route ${route_op} 128.0.0.0/1 dev ${tun_name}
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(){
|
|
||||||
echo "Usage:
|
|
||||||
route add add tun routes to system route table
|
|
||||||
route del delete routes from system route table
|
|
||||||
route help display all usages of the shell script"
|
|
||||||
}
|
|
||||||
|
|
||||||
# START MAIN-OPTIONS
|
|
||||||
case $1 in
|
|
||||||
add) do_route add $2;;
|
|
||||||
del) do_route delete $2;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
# END MAIN-OPTIONS
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#__author__: cavivie
|
|
||||||
|
|
||||||
DEFAULT_TUN_ADDR="10.10.10.2/24"
|
|
||||||
DEFAULT_TUN_DEST="10.10.10.1"
|
|
||||||
|
|
||||||
function do_route() {
|
|
||||||
local route_op="${1}"
|
|
||||||
local tun_addr="${2:-$DEFAULT_TUN_ADDR}"
|
|
||||||
local tun_dest="${3:-$DEFAULT_TUN_DEST}"
|
|
||||||
sudo route ${route_op} -net 1.0.0.0/8 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 2.0.0.0/7 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 4.0.0.0/6 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 8.0.0.0/5 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 16.0.0.0/4 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 32.0.0.0/3 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 64.0.0.0/2 ${tun_dest}
|
|
||||||
sudo route ${route_op} -net 128.0.0.0/1 ${tun_dest}
|
|
||||||
# tun2 do like this automatically
|
|
||||||
sudo route ${route_op} -net ${tun_addr} ${tun_dest}
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(){
|
|
||||||
echo "Usage:
|
|
||||||
route add add tun routes to system route table
|
|
||||||
route del delete routes from system route table
|
|
||||||
route help display all usages of the shell script"
|
|
||||||
}
|
|
||||||
|
|
||||||
# START MAIN-OPTIONS
|
|
||||||
case $1 in
|
|
||||||
add) do_route add $2 $3;;
|
|
||||||
del) do_route delete $2 $3;;
|
|
||||||
*) usage ;;
|
|
||||||
esac
|
|
||||||
# END MAIN-OPTIONS
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
#__author__: cavivie
|
|
||||||
|
|
||||||
param(
|
|
||||||
[string]$Cmd = "help",
|
|
||||||
[string]$TunName = "utun8",
|
|
||||||
[string]$TunGateway = "10.10.10.1"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# START MAIN-OPTIONS
|
|
||||||
switch ($Cmd) {
|
|
||||||
"add" {
|
|
||||||
# tun2 do like this automatically
|
|
||||||
New-NetRoute -DestinationPrefix "0.0.0.0/1" -InterfaceAlias $TunName -NextHop "$TunGateway"
|
|
||||||
New-NetRoute -DestinationPrefix "128.0.0.0/1" -InterfaceAlias $TunName -NextHop "$TunGateway"
|
|
||||||
}
|
|
||||||
"del" {
|
|
||||||
# tun2 do like this automatically
|
|
||||||
Get-NetRoute -DestinationPrefix "0.0.0.0/1" -InterfaceAlias $TunName | Remove-NetRoute
|
|
||||||
Get-NetRoute -DestinationPrefix "128.0.0.0/1" -InterfaceAlias $TunName | Remove-NetRoute
|
|
||||||
}
|
|
||||||
default {
|
|
||||||
Write-Host "Usage:
|
|
||||||
route add add tun routes to system route table
|
|
||||||
route del delete routes from system route table
|
|
||||||
route help display all usages of the shell script"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# END MAIN-OPTIONS
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use smoltcp::{
|
|
||||||
phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc::{unbounded_channel, Permit, Sender, UnboundedReceiver, UnboundedSender};
|
|
||||||
|
|
||||||
use crate::packet::AnyIpPktFrame;
|
|
||||||
|
|
||||||
pub(super) struct VirtualDevice {
|
|
||||||
in_buf_avail: Arc<AtomicBool>,
|
|
||||||
in_buf: UnboundedReceiver<Vec<u8>>,
|
|
||||||
out_buf: Sender<AnyIpPktFrame>,
|
|
||||||
mtu: usize,
|
|
||||||
cached_packet: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VirtualDevice {
|
|
||||||
pub(super) fn new(
|
|
||||||
iface_egress_tx: Sender<AnyIpPktFrame>,
|
|
||||||
mtu: usize,
|
|
||||||
) -> (Self, UnboundedSender<Vec<u8>>, Arc<AtomicBool>) {
|
|
||||||
let iface_ingress_tx_avail = Arc::new(AtomicBool::new(false));
|
|
||||||
let (iface_ingress_tx, iface_ingress_rx) = unbounded_channel();
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
in_buf_avail: iface_ingress_tx_avail.clone(),
|
|
||||||
in_buf: iface_ingress_rx,
|
|
||||||
out_buf: iface_egress_tx,
|
|
||||||
mtu,
|
|
||||||
cached_packet: None,
|
|
||||||
},
|
|
||||||
iface_ingress_tx,
|
|
||||||
iface_ingress_tx_avail,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Device for VirtualDevice {
|
|
||||||
type RxToken<'a> = VirtualRxToken;
|
|
||||||
type TxToken<'a> = VirtualTxToken<'a>;
|
|
||||||
|
|
||||||
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
|
||||||
let buffer = if let Some(buf) = self.cached_packet.take() {
|
|
||||||
buf
|
|
||||||
} else {
|
|
||||||
let Ok(buf) = self.in_buf.try_recv() else {
|
|
||||||
self.in_buf_avail.store(false, Ordering::Release);
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
buf
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(permit) = self.out_buf.try_reserve() else {
|
|
||||||
self.cached_packet = Some(buffer);
|
|
||||||
self.in_buf_avail.store(false, Ordering::Release);
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
Some((Self::RxToken { buffer }, Self::TxToken { permit }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
|
|
||||||
match self.out_buf.try_reserve() {
|
|
||||||
Ok(permit) => Some(Self::TxToken { permit }),
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capabilities(&self) -> DeviceCapabilities {
|
|
||||||
let mut capabilities = DeviceCapabilities::default();
|
|
||||||
capabilities.medium = Medium::Ip;
|
|
||||||
capabilities.max_transmission_unit = self.mtu;
|
|
||||||
capabilities
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct VirtualRxToken {
|
|
||||||
buffer: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RxToken for VirtualRxToken {
|
|
||||||
fn consume<R, F>(self, f: F) -> R
|
|
||||||
where
|
|
||||||
F: FnOnce(&[u8]) -> R,
|
|
||||||
{
|
|
||||||
f(&self.buffer[..])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct VirtualTxToken<'a> {
|
|
||||||
permit: Permit<'a, Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> TxToken for VirtualTxToken<'a> {
|
|
||||||
fn consume<R, F>(self, len: usize, f: F) -> R
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut [u8]) -> R,
|
|
||||||
{
|
|
||||||
let mut buffer = vec![0u8; len];
|
|
||||||
let result = f(&mut buffer);
|
|
||||||
self.permit.send(buffer);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
pub type IpFilter<'a> = Box<dyn Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'a>;
|
|
||||||
|
|
||||||
pub struct IpFilters<'a> {
|
|
||||||
filters: Vec<IpFilter<'a>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Default for IpFilters<'a> {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> IpFilters<'a> {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
filters: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_non_broadcast() -> Self {
|
|
||||||
macro_rules! non_broadcast {
|
|
||||||
($addr:ident) => {
|
|
||||||
match $addr {
|
|
||||||
IpAddr::V4(a) => !(a.is_broadcast() || a.is_multicast() || a.is_unspecified()),
|
|
||||||
IpAddr::V6(a) => !(a.is_multicast() || a.is_unspecified()),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
filters: vec![Box::new(|src, dst| {
|
|
||||||
non_broadcast!(src) && non_broadcast!(dst)
|
|
||||||
})],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(&mut self, filter: IpFilter<'a>) {
|
|
||||||
self.filters.push(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_fn<F>(&mut self, filter: F)
|
|
||||||
where
|
|
||||||
F: Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'a,
|
|
||||||
{
|
|
||||||
self.filters.push(Box::new(filter));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_all<I: IntoIterator<Item = IpFilter<'a>>>(&mut self, filters: I) {
|
|
||||||
self.filters.extend(filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_allowed(&self, src: &IpAddr, dst: &IpAddr) -> bool {
|
|
||||||
self.filters.iter().all(|filter| filter(src, dst))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
mod device;
|
|
||||||
|
|
||||||
mod runner;
|
|
||||||
pub use runner::Runner;
|
|
||||||
|
|
||||||
mod packet;
|
|
||||||
pub use packet::AnyIpPktFrame;
|
|
||||||
|
|
||||||
mod filter;
|
|
||||||
pub use filter::{IpFilter, IpFilters};
|
|
||||||
|
|
||||||
pub mod udp;
|
|
||||||
pub use udp::UdpSocket;
|
|
||||||
|
|
||||||
pub mod tcp;
|
|
||||||
pub use tcp::{TcpListener, TcpStream};
|
|
||||||
|
|
||||||
pub mod stack;
|
|
||||||
pub use stack::{Stack, StackBuilder};
|
|
||||||
|
|
||||||
/// Re-export
|
|
||||||
pub use smoltcp;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
use smoltcp::wire::{IpProtocol, IpVersion, Ipv4Packet, Ipv6Packet};
|
|
||||||
|
|
||||||
pub type AnyIpPktFrame = Vec<u8>;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(super) enum IpPacket<T: AsRef<[u8]>> {
|
|
||||||
Ipv4(Ipv4Packet<T>),
|
|
||||||
Ipv6(Ipv6Packet<T>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: AsRef<[u8]> + Copy> IpPacket<T> {
|
|
||||||
pub fn new_checked(packet: T) -> smoltcp::wire::Result<IpPacket<T>> {
|
|
||||||
let buffer = packet.as_ref();
|
|
||||||
match IpVersion::of_packet(buffer)? {
|
|
||||||
IpVersion::Ipv4 => Ok(IpPacket::Ipv4(Ipv4Packet::new_checked(packet)?)),
|
|
||||||
IpVersion::Ipv6 => Ok(IpPacket::Ipv6(Ipv6Packet::new_checked(packet)?)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn src_addr(&self) -> IpAddr {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => IpAddr::from(packet.src_addr()),
|
|
||||||
IpPacket::Ipv6(ref packet) => IpAddr::from(packet.src_addr()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dst_addr(&self) -> IpAddr {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => IpAddr::from(packet.dst_addr()),
|
|
||||||
IpPacket::Ipv6(ref packet) => IpAddr::from(packet.dst_addr()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn protocol(&self) -> IpProtocol {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => packet.next_header(),
|
|
||||||
IpPacket::Ipv6(ref packet) => packet.next_header(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, T: AsRef<[u8]> + ?Sized> IpPacket<&'a T> {
|
|
||||||
/// Return a pointer to the payload.
|
|
||||||
#[inline]
|
|
||||||
pub fn payload(&self) -> &'a [u8] {
|
|
||||||
match *self {
|
|
||||||
IpPacket::Ipv4(ref packet) => packet.payload(),
|
|
||||||
IpPacket::Ipv6(ref packet) => packet.payload(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
use std::{
|
|
||||||
future::{Future, IntoFuture},
|
|
||||||
pin::Pin,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// BoxFuture acts the same as the [BoxFuture in crate futures utils],
|
|
||||||
/// which is an owned dynamically typed Future for use in cases where you
|
|
||||||
/// can’t statically type your result or need to add some indirection.
|
|
||||||
/// But the difference of this structure is that it will conditionally
|
|
||||||
/// implement Send according to the properties of type T, which does not
|
|
||||||
/// require two sets of API interfaces in single-threaded and multi-threaded.
|
|
||||||
///
|
|
||||||
/// [BoxFuture in crate futures utils]: https://docs.rs/futures-util/latest/futures_util/future/type.BoxFuture.html
|
|
||||||
pub struct BoxFuture<'a, T>(Pin<Box<dyn Future<Output = T> + Send + 'a>>);
|
|
||||||
|
|
||||||
impl<'a, T> BoxFuture<'a, T> {
|
|
||||||
pub fn new<F>(f: F) -> BoxFuture<'a, T>
|
|
||||||
where
|
|
||||||
F: IntoFuture<Output = T> + Send + 'a,
|
|
||||||
F::IntoFuture: Send + 'a,
|
|
||||||
{
|
|
||||||
BoxFuture(Box::pin(f.into_future()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
pub fn wrap(f: Pin<Box<dyn Future<Output = T> + Send + 'a>>) -> BoxFuture<'a, T> {
|
|
||||||
BoxFuture(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
impl<T> Future for BoxFuture<'_, T> {
|
|
||||||
type Output = T;
|
|
||||||
|
|
||||||
fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
|
|
||||||
self.0.as_mut().poll(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Runner = BoxFuture<'static, std::io::Result<()>>;
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
use std::{
|
|
||||||
net::IpAddr,
|
|
||||||
pin::Pin,
|
|
||||||
task::{ready, Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
use futures::{Sink, Stream};
|
|
||||||
use smoltcp::wire::IpProtocol;
|
|
||||||
use tokio::sync::mpsc::{channel, Receiver};
|
|
||||||
use tokio_util::sync::PollSender;
|
|
||||||
use tracing::{debug, trace};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
filter::{IpFilter, IpFilters},
|
|
||||||
packet::{AnyIpPktFrame, IpPacket},
|
|
||||||
runner::Runner,
|
|
||||||
tcp::TcpListener,
|
|
||||||
udp::UdpSocket,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct StackBuilder {
|
|
||||||
enable_udp: bool,
|
|
||||||
enable_tcp: bool,
|
|
||||||
enable_icmp: bool,
|
|
||||||
stack_buffer_size: usize,
|
|
||||||
udp_buffer_size: usize,
|
|
||||||
tcp_buffer_size: usize,
|
|
||||||
mtu: usize,
|
|
||||||
ip_filters: IpFilters<'static>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StackBuilder {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enable_udp: false,
|
|
||||||
enable_tcp: false,
|
|
||||||
enable_icmp: false,
|
|
||||||
stack_buffer_size: 1024,
|
|
||||||
udp_buffer_size: 512,
|
|
||||||
tcp_buffer_size: 512,
|
|
||||||
mtu: 1504, // 1500 for Ethernet + 4 for VLAN
|
|
||||||
ip_filters: IpFilters::with_non_broadcast(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
impl StackBuilder {
|
|
||||||
pub fn enable_udp(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_udp = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enable_tcp(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_tcp = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enable_icmp(mut self, enable: bool) -> Self {
|
|
||||||
self.enable_icmp = enable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stack_buffer_size(mut self, size: usize) -> Self {
|
|
||||||
self.stack_buffer_size = size;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn udp_buffer_size(mut self, size: usize) -> Self {
|
|
||||||
self.udp_buffer_size = size;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn tcp_buffer_size(mut self, size: usize) -> Self {
|
|
||||||
self.tcp_buffer_size = size;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_ip_filters(mut self, filters: IpFilters<'static>) -> Self {
|
|
||||||
self.ip_filters = filters;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ip_filter(mut self, filter: IpFilter<'static>) -> Self {
|
|
||||||
self.ip_filters.add(filter);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ip_filter_fn<F>(mut self, filter: F) -> Self
|
|
||||||
where
|
|
||||||
F: Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
self.ip_filters.add_fn(filter);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mtu(mut self, mtu: usize) -> Self {
|
|
||||||
self.mtu = mtu;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub fn build(
|
|
||||||
self,
|
|
||||||
) -> std::io::Result<(
|
|
||||||
Stack,
|
|
||||||
Option<Runner>,
|
|
||||||
Option<UdpSocket>,
|
|
||||||
Option<TcpListener>,
|
|
||||||
)> {
|
|
||||||
let (stack_tx, stack_rx) = channel(self.stack_buffer_size);
|
|
||||||
|
|
||||||
let (udp_tx, udp_rx) = if self.enable_udp {
|
|
||||||
let (udp_tx, udp_rx) = channel(self.udp_buffer_size);
|
|
||||||
(Some(PollSender::new(udp_tx)), Some(udp_rx))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let (tcp_tx, tcp_rx) = if self.enable_tcp {
|
|
||||||
let (tcp_tx, tcp_rx) = channel(self.tcp_buffer_size);
|
|
||||||
(Some(PollSender::new(tcp_tx)), Some(tcp_rx))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ICMP is handled by TCP's Interface.
|
|
||||||
// smoltcp's interface will always send replies to EchoRequest
|
|
||||||
if self.enable_icmp && !self.enable_tcp {
|
|
||||||
use std::io::{Error, ErrorKind::InvalidInput};
|
|
||||||
return Err(Error::new(InvalidInput, "ICMP requires TCP"));
|
|
||||||
}
|
|
||||||
let icmp_tx = if self.enable_icmp {
|
|
||||||
tcp_tx.clone()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let udp_socket = udp_rx.map(|udp_rx| UdpSocket::new(udp_rx, stack_tx.clone()));
|
|
||||||
|
|
||||||
let (tcp_runner, tcp_listener) = if let Some(tcp_rx) = tcp_rx {
|
|
||||||
let (tcp_runner, tcp_listener) = TcpListener::new(tcp_rx, stack_tx, self.mtu)?;
|
|
||||||
(Some(tcp_runner), Some(tcp_listener))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let stack = Stack {
|
|
||||||
ip_filters: self.ip_filters,
|
|
||||||
stack_rx,
|
|
||||||
sink_buf: None,
|
|
||||||
udp_tx,
|
|
||||||
tcp_tx,
|
|
||||||
icmp_tx,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((stack, tcp_runner, udp_socket, tcp_listener))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Stack {
|
|
||||||
ip_filters: IpFilters<'static>,
|
|
||||||
sink_buf: Option<(AnyIpPktFrame, IpProtocol)>,
|
|
||||||
udp_tx: Option<PollSender<AnyIpPktFrame>>,
|
|
||||||
tcp_tx: Option<PollSender<AnyIpPktFrame>>,
|
|
||||||
icmp_tx: Option<PollSender<AnyIpPktFrame>>,
|
|
||||||
stack_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stack {
|
|
||||||
fn poll_send(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
|
|
||||||
let (item, proto) = match self.sink_buf.take() {
|
|
||||||
Some(val) => val,
|
|
||||||
None => return Poll::Ready(Ok(())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tx = match proto {
|
|
||||||
IpProtocol::Tcp => self.tcp_tx.as_mut(),
|
|
||||||
IpProtocol::Udp => self.udp_tx.as_mut(),
|
|
||||||
IpProtocol::Icmp | IpProtocol::Icmpv6 => self.icmp_tx.as_mut(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(tx) = tx else {
|
|
||||||
return Poll::Ready(Ok(()));
|
|
||||||
};
|
|
||||||
|
|
||||||
match tx.poll_reserve(cx) {
|
|
||||||
Poll::Pending => {
|
|
||||||
self.sink_buf = Some((item, proto));
|
|
||||||
Poll::Pending
|
|
||||||
}
|
|
||||||
Poll::Ready(Err(_)) => Poll::Ready(Err(channel_closed_err("channel is closed"))),
|
|
||||||
Poll::Ready(Ok(_)) => match tx.send_item(item) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(_) => Poll::Ready(Err(channel_closed_err("channel is closed"))),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recv from stack.
|
|
||||||
impl Stream for Stack {
|
|
||||||
type Item = std::io::Result<AnyIpPktFrame>;
|
|
||||||
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
||||||
match self.stack_rx.poll_recv(cx) {
|
|
||||||
Poll::Ready(Some(pkt)) => Poll::Ready(Some(Ok(pkt))),
|
|
||||||
Poll::Ready(None) => Poll::Ready(None),
|
|
||||||
Poll::Pending => Poll::Pending,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to stack.
|
|
||||||
impl Sink<AnyIpPktFrame> for Stack {
|
|
||||||
type Error = std::io::Error;
|
|
||||||
|
|
||||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
// If a buffered item exists, try to flush it first. This also properly
|
|
||||||
// registers the waker via poll_reserve so we get woken when the channel
|
|
||||||
// has capacity. Without this, returning Pending here with _cx unused
|
|
||||||
// means the task never gets rescheduled.
|
|
||||||
if self.sink_buf.is_some() {
|
|
||||||
ready!(self.poll_send(cx))?;
|
|
||||||
}
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_send(mut self: Pin<&mut Self>, item: AnyIpPktFrame) -> Result<(), Self::Error> {
|
|
||||||
if item.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
use std::io::{Error, ErrorKind::InvalidInput};
|
|
||||||
let packet = IpPacket::new_checked(item.as_slice())
|
|
||||||
.map_err(|err| Error::new(InvalidInput, format!("invalid IP packet: {err}")))?;
|
|
||||||
|
|
||||||
let src_ip = packet.src_addr();
|
|
||||||
let dst_ip = packet.dst_addr();
|
|
||||||
|
|
||||||
let addr_allowed = self.ip_filters.is_allowed(&src_ip, &dst_ip);
|
|
||||||
if !addr_allowed {
|
|
||||||
trace!("IP packet {src_ip} -> {dst_ip} (allowed? {addr_allowed}) throwing away",);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let protocol = packet.protocol();
|
|
||||||
if matches!(
|
|
||||||
protocol,
|
|
||||||
IpProtocol::Tcp | IpProtocol::Udp | IpProtocol::Icmp | IpProtocol::Icmpv6
|
|
||||||
) {
|
|
||||||
self.sink_buf.replace((item, protocol));
|
|
||||||
} else {
|
|
||||||
debug!("tun IP packet ignored (protocol: {:?})", protocol);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
self.poll_send(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_close(
|
|
||||||
mut self: Pin<&mut Self>,
|
|
||||||
_cx: &mut Context<'_>,
|
|
||||||
) -> Poll<Result<(), Self::Error>> {
|
|
||||||
self.stack_rx.close();
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channel_closed_err<E>(err: E) -> std::io::Error
|
|
||||||
where
|
|
||||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
|
||||||
{
|
|
||||||
std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)
|
|
||||||
}
|
|
||||||
|
|
@ -1,564 +0,0 @@
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
net::SocketAddr,
|
|
||||||
pin::Pin,
|
|
||||||
sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
},
|
|
||||||
task::{Context, Poll, Waker},
|
|
||||||
};
|
|
||||||
|
|
||||||
use futures::Stream;
|
|
||||||
use smoltcp::{
|
|
||||||
iface::{Config as InterfaceConfig, Interface, SocketHandle, SocketSet},
|
|
||||||
phy::Device,
|
|
||||||
socket::tcp::{Socket as TcpSocket, SocketBuffer as TcpSocketBuffer, State as TcpState},
|
|
||||||
storage::RingBuffer,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
wire::{HardwareAddress, IpAddress, IpCidr, IpProtocol, Ipv4Address, Ipv6Address, TcpPacket},
|
|
||||||
};
|
|
||||||
use spin::Mutex as SpinMutex;
|
|
||||||
use tokio::{
|
|
||||||
io::{AsyncRead, AsyncWrite, ReadBuf},
|
|
||||||
sync::{
|
|
||||||
mpsc::{channel, Receiver, Sender, UnboundedSender},
|
|
||||||
Notify,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tracing::{error, trace};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
device::VirtualDevice,
|
|
||||||
packet::{AnyIpPktFrame, IpPacket},
|
|
||||||
Runner,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reduced buffer sizes to 16KB to prevent excessive memory overhead (was 0x3FFF * 20 = 327KB per buffer)
|
|
||||||
const DEFAULT_TCP_SEND_BUFFER_SIZE: u32 = 16384;
|
|
||||||
const DEFAULT_TCP_RECV_BUFFER_SIZE: u32 = 16384;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
|
||||||
enum TcpSocketState {
|
|
||||||
Normal,
|
|
||||||
Close,
|
|
||||||
Closing,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TcpSocketControl {
|
|
||||||
send_buffer: RingBuffer<'static, u8>,
|
|
||||||
send_waker: Option<Waker>,
|
|
||||||
recv_buffer: RingBuffer<'static, u8>,
|
|
||||||
recv_waker: Option<Waker>,
|
|
||||||
recv_state: TcpSocketState,
|
|
||||||
send_state: TcpSocketState,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TcpSocketCreation {
|
|
||||||
control: SharedControl,
|
|
||||||
socket: TcpSocket<'static>,
|
|
||||||
}
|
|
||||||
|
|
||||||
type SharedNotify = Arc<Notify>;
|
|
||||||
type SharedControl = Arc<SpinMutex<TcpSocketControl>>;
|
|
||||||
|
|
||||||
struct TcpListenerRunner;
|
|
||||||
|
|
||||||
impl TcpListenerRunner {
|
|
||||||
fn create(
|
|
||||||
device: VirtualDevice,
|
|
||||||
iface: Interface,
|
|
||||||
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
|
||||||
tcp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stream_tx: Sender<TcpStream>,
|
|
||||||
sockets: HashMap<SocketHandle, SharedControl>,
|
|
||||||
) -> Runner {
|
|
||||||
Runner::new(async move {
|
|
||||||
let notify = Arc::new(Notify::new());
|
|
||||||
let (socket_tx, socket_rx) = channel::<TcpSocketCreation>(1024);
|
|
||||||
let res = tokio::select! {
|
|
||||||
v = Self::handle_packet(notify.clone(), iface_ingress_tx, iface_ingress_tx_avail.clone(), tcp_rx, stream_tx, socket_tx) => v,
|
|
||||||
v = Self::handle_socket(notify, device, iface, iface_ingress_tx_avail, sockets, socket_rx) => v,
|
|
||||||
};
|
|
||||||
res?;
|
|
||||||
trace!("VirtDevice::poll thread exited");
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_packet(
|
|
||||||
notify: SharedNotify,
|
|
||||||
iface_ingress_tx: UnboundedSender<Vec<u8>>,
|
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
|
||||||
mut tcp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stream_tx: Sender<TcpStream>,
|
|
||||||
socket_tx: Sender<TcpSocketCreation>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
while let Some(frame) = tcp_rx.recv().await {
|
|
||||||
let packet = match IpPacket::new_checked(frame.as_slice()) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid TCP IP packet: {:?}", err,);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Specially handle icmp packet by TCP interface.
|
|
||||||
if matches!(packet.protocol(), IpProtocol::Icmp | IpProtocol::Icmpv6) {
|
|
||||||
iface_ingress_tx
|
|
||||||
.send(frame)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
|
||||||
iface_ingress_tx_avail.store(true, Ordering::Release);
|
|
||||||
notify.notify_one();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_ip = packet.src_addr();
|
|
||||||
let dst_ip = packet.dst_addr();
|
|
||||||
let payload = packet.payload();
|
|
||||||
|
|
||||||
let packet = match TcpPacket::new_checked(payload) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid TCP err: {err}, src_ip: {src_ip}, dst_ip: {dst_ip}, payload: {payload:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let src_port = packet.src_port();
|
|
||||||
let dst_port = packet.dst_port();
|
|
||||||
|
|
||||||
let src_addr = SocketAddr::new(src_ip, src_port);
|
|
||||||
let dst_addr = SocketAddr::new(dst_ip, dst_port);
|
|
||||||
|
|
||||||
// TCP first handshake packet, create a new Connection
|
|
||||||
if packet.syn() && !packet.ack() {
|
|
||||||
let mut socket = TcpSocket::new(
|
|
||||||
TcpSocketBuffer::new(vec![0u8; DEFAULT_TCP_RECV_BUFFER_SIZE as usize]),
|
|
||||||
TcpSocketBuffer::new(vec![0u8; DEFAULT_TCP_SEND_BUFFER_SIZE as usize]),
|
|
||||||
);
|
|
||||||
socket.set_keep_alive(Some(Duration::from_secs(28)));
|
|
||||||
// FIXME: It should follow system's setting. 7200 is Linux's default.
|
|
||||||
socket.set_timeout(Some(Duration::from_secs(7200)));
|
|
||||||
// NO ACK delay
|
|
||||||
// socket.set_ack_delay(None);
|
|
||||||
|
|
||||||
if let Err(err) = socket.listen(dst_addr) {
|
|
||||||
error!("listen error: {:?}", err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
trace!("created TCP connection for {} <-> {}", src_addr, dst_addr);
|
|
||||||
|
|
||||||
let control = Arc::new(SpinMutex::new(TcpSocketControl {
|
|
||||||
send_buffer: RingBuffer::new(vec![0u8; DEFAULT_TCP_SEND_BUFFER_SIZE as usize]),
|
|
||||||
send_waker: None,
|
|
||||||
recv_buffer: RingBuffer::new(vec![0u8; DEFAULT_TCP_RECV_BUFFER_SIZE as usize]),
|
|
||||||
recv_waker: None,
|
|
||||||
recv_state: TcpSocketState::Normal,
|
|
||||||
send_state: TcpSocketState::Normal,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if let Err(_) = stream_tx.try_send(TcpStream {
|
|
||||||
src_addr,
|
|
||||||
dst_addr,
|
|
||||||
notify: notify.clone(),
|
|
||||||
control: control.clone(),
|
|
||||||
}) {
|
|
||||||
error!("stream_tx full or dropped, dropping SYN from {}", src_addr);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(_) = socket_tx.try_send(TcpSocketCreation { control, socket }) {
|
|
||||||
error!("socket_tx full or dropped, dropping SYN from {}", src_addr);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pipeline tcp stream packet
|
|
||||||
iface_ingress_tx
|
|
||||||
.send(frame)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
|
|
||||||
iface_ingress_tx_avail.store(true, Ordering::Release);
|
|
||||||
notify.notify_one();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(
|
|
||||||
notify: SharedNotify,
|
|
||||||
mut device: VirtualDevice,
|
|
||||||
mut iface: Interface,
|
|
||||||
iface_ingress_tx_avail: Arc<AtomicBool>,
|
|
||||||
mut sockets: HashMap<SocketHandle, SharedControl>,
|
|
||||||
mut socket_rx: Receiver<TcpSocketCreation>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
let mut socket_set = SocketSet::new(vec![]);
|
|
||||||
loop {
|
|
||||||
while let Ok(TcpSocketCreation { control, socket }) = socket_rx.try_recv() {
|
|
||||||
let handle = socket_set.add(socket);
|
|
||||||
sockets.insert(handle, control);
|
|
||||||
}
|
|
||||||
|
|
||||||
let before_poll = Instant::now();
|
|
||||||
let updated_sockets = iface.poll(before_poll, &mut device, &mut socket_set);
|
|
||||||
if matches!(
|
|
||||||
updated_sockets,
|
|
||||||
smoltcp::iface::PollResult::SocketStateChanged
|
|
||||||
) {
|
|
||||||
trace!("VirtDevice::poll costed {}", Instant::now() - before_poll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all the sockets' status
|
|
||||||
let mut sockets_to_remove = Vec::new();
|
|
||||||
|
|
||||||
for (socket_handle, control) in sockets.iter() {
|
|
||||||
let socket_handle = *socket_handle;
|
|
||||||
let socket = socket_set.get_mut::<TcpSocket>(socket_handle);
|
|
||||||
let mut control = control.lock();
|
|
||||||
|
|
||||||
// Remove the socket only when it is in the closed state.
|
|
||||||
if socket.state() == TcpState::Closed {
|
|
||||||
sockets_to_remove.push(socket_handle);
|
|
||||||
|
|
||||||
control.send_state = TcpSocketState::Closed;
|
|
||||||
control.recv_state = TcpSocketState::Closed;
|
|
||||||
|
|
||||||
if let Some(waker) = control.send_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
if let Some(waker) = control.recv_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
|
|
||||||
trace!("closed TCP connection");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHUT_WR — only close once the send_buffer has been fully
|
|
||||||
// drained into the smoltcp socket. Closing earlier transitions
|
|
||||||
// the socket to FIN_WAIT_1, making can_send() return false, so
|
|
||||||
// the send loop below never runs and the remaining data is lost.
|
|
||||||
if matches!(control.send_state, TcpSocketState::Close)
|
|
||||||
&& control.send_buffer.is_empty()
|
|
||||||
{
|
|
||||||
trace!("closing TCP Write Half, {:?}", socket.state());
|
|
||||||
|
|
||||||
socket.close();
|
|
||||||
control.send_state = TcpSocketState::Closing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if readable
|
|
||||||
let mut wake_receiver = false;
|
|
||||||
while socket.can_recv() && !control.recv_buffer.is_full() {
|
|
||||||
let result = socket.recv(|buffer| {
|
|
||||||
let n = control.recv_buffer.enqueue_slice(buffer);
|
|
||||||
(n, ())
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(..) => wake_receiver = true,
|
|
||||||
Err(err) => {
|
|
||||||
error!("socket recv error: {:?}, {:?}", err, socket.state());
|
|
||||||
|
|
||||||
// Don't know why. Abort the connection.
|
|
||||||
socket.abort();
|
|
||||||
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Normal) {
|
|
||||||
control.recv_state = TcpSocketState::Closed;
|
|
||||||
}
|
|
||||||
wake_receiver = true;
|
|
||||||
|
|
||||||
// The socket will be recycled in the next poll.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If socket is not in ESTABLISH, FIN-WAIT-1, FIN-WAIT-2,
|
|
||||||
// the local client have closed our receiver.
|
|
||||||
let states = [
|
|
||||||
TcpState::Listen,
|
|
||||||
TcpState::SynReceived,
|
|
||||||
TcpState::Established,
|
|
||||||
TcpState::FinWait1,
|
|
||||||
TcpState::FinWait2,
|
|
||||||
];
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Normal)
|
|
||||||
&& !socket.may_recv()
|
|
||||||
&& !states.contains(&socket.state())
|
|
||||||
{
|
|
||||||
trace!("closed TCP Read Half, {:?}", socket.state());
|
|
||||||
|
|
||||||
// Let TcpStream::poll_read returns EOF.
|
|
||||||
control.recv_state = TcpSocketState::Closed;
|
|
||||||
wake_receiver = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if wake_receiver && control.recv_waker.is_some() {
|
|
||||||
if let Some(waker) = control.recv_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if writable
|
|
||||||
let mut wake_sender = false;
|
|
||||||
while socket.can_send() && !control.send_buffer.is_empty() {
|
|
||||||
let result = socket.send(|buffer| {
|
|
||||||
let n = control.send_buffer.dequeue_slice(buffer);
|
|
||||||
(n, ())
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(..) => wake_sender = true,
|
|
||||||
Err(err) => {
|
|
||||||
error!("socket send error: {:?}, {:?}", err, socket.state());
|
|
||||||
|
|
||||||
// Don't know why. Abort the connection.
|
|
||||||
socket.abort();
|
|
||||||
|
|
||||||
if matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
control.send_state = TcpSocketState::Closed;
|
|
||||||
}
|
|
||||||
wake_sender = true;
|
|
||||||
|
|
||||||
// The socket will be recycled in the next poll.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if wake_sender && control.send_waker.is_some() {
|
|
||||||
if let Some(waker) = control.send_waker.take() {
|
|
||||||
waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for socket_handle in sockets_to_remove {
|
|
||||||
sockets.remove(&socket_handle);
|
|
||||||
socket_set.remove(socket_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !iface_ingress_tx_avail.load(Ordering::Acquire) {
|
|
||||||
let next_duration = iface
|
|
||||||
.poll_delay(before_poll, &socket_set)
|
|
||||||
.unwrap_or(Duration::from_millis(5));
|
|
||||||
if next_duration != Duration::ZERO {
|
|
||||||
let _ = tokio::time::timeout(
|
|
||||||
tokio::time::Duration::from(next_duration),
|
|
||||||
notify.notified(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TcpListener {
|
|
||||||
stream_rx: Receiver<TcpStream>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TcpListener {
|
|
||||||
pub(super) fn new(
|
|
||||||
tcp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stack_tx: Sender<AnyIpPktFrame>,
|
|
||||||
mtu: usize,
|
|
||||||
) -> std::io::Result<(Runner, Self)> {
|
|
||||||
let (mut device, iface_ingress_tx, iface_ingress_tx_avail) =
|
|
||||||
VirtualDevice::new(stack_tx, mtu);
|
|
||||||
let iface = Self::create_interface(&mut device)?;
|
|
||||||
|
|
||||||
let (stream_tx, stream_rx) = channel(1024);
|
|
||||||
|
|
||||||
let runner = TcpListenerRunner::create(
|
|
||||||
device,
|
|
||||||
iface,
|
|
||||||
iface_ingress_tx,
|
|
||||||
iface_ingress_tx_avail,
|
|
||||||
tcp_rx,
|
|
||||||
stream_tx,
|
|
||||||
HashMap::new(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((runner, Self { stream_rx }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_interface<D>(device: &mut D) -> std::io::Result<Interface>
|
|
||||||
where
|
|
||||||
D: Device + ?Sized,
|
|
||||||
{
|
|
||||||
let mut iface_config = InterfaceConfig::new(HardwareAddress::Ip);
|
|
||||||
iface_config.random_seed = rand::random();
|
|
||||||
let mut iface = Interface::new(iface_config, device, Instant::now());
|
|
||||||
iface.update_ip_addrs(|ip_addrs| {
|
|
||||||
ip_addrs
|
|
||||||
.push(IpCidr::new(IpAddress::v4(0, 0, 0, 1), 0))
|
|
||||||
.expect("iface IPv4");
|
|
||||||
ip_addrs
|
|
||||||
.push(IpCidr::new(IpAddress::v6(0, 0, 0, 0, 0, 0, 0, 1), 0))
|
|
||||||
.expect("iface IPv6");
|
|
||||||
});
|
|
||||||
iface
|
|
||||||
.routes_mut()
|
|
||||||
.add_default_ipv4_route(Ipv4Address::new(0, 0, 0, 1))
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, e))?;
|
|
||||||
iface
|
|
||||||
.routes_mut()
|
|
||||||
.add_default_ipv6_route(Ipv6Address::new(0, 0, 0, 0, 0, 0, 0, 1))
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, e))?;
|
|
||||||
iface.set_any_ip(true);
|
|
||||||
Ok(iface)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream for TcpListener {
|
|
||||||
type Item = (TcpStream, SocketAddr, SocketAddr);
|
|
||||||
|
|
||||||
fn poll_next(
|
|
||||||
mut self: std::pin::Pin<&mut Self>,
|
|
||||||
cx: &mut std::task::Context<'_>,
|
|
||||||
) -> std::task::Poll<Option<Self::Item>> {
|
|
||||||
self.stream_rx.poll_recv(cx).map(|stream| {
|
|
||||||
stream.map(|stream| {
|
|
||||||
let local_addr = *stream.local_addr();
|
|
||||||
let remote_addr: SocketAddr = *stream.remote_addr();
|
|
||||||
(stream, local_addr, remote_addr)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TcpStream {
|
|
||||||
src_addr: SocketAddr,
|
|
||||||
dst_addr: SocketAddr,
|
|
||||||
notify: SharedNotify,
|
|
||||||
control: SharedControl,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TcpStream {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Normal) {
|
|
||||||
control.recv_state = TcpSocketState::Close;
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
control.send_state = TcpSocketState::Close;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.notify.notify_one();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TcpStream {
|
|
||||||
pub fn local_addr(&self) -> &SocketAddr {
|
|
||||||
&self.src_addr
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remote_addr(&self) -> &SocketAddr {
|
|
||||||
&self.dst_addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncRead for TcpStream {
|
|
||||||
fn poll_read(
|
|
||||||
self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
buf: &mut ReadBuf<'_>,
|
|
||||||
) -> Poll<std::io::Result<()>> {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
// Read from buffer
|
|
||||||
if control.recv_buffer.is_empty() {
|
|
||||||
// If socket is already closed / half closed, just return EOF directly.
|
|
||||||
if matches!(control.recv_state, TcpSocketState::Closed) {
|
|
||||||
return Ok(()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing could be read. Wait for notify.
|
|
||||||
if let Some(old_waker) = control.recv_waker.replace(cx.waker().clone()) {
|
|
||||||
if !old_waker.will_wake(cx.waker()) {
|
|
||||||
old_waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
let recv_buf = buf.initialize_unfilled();
|
|
||||||
let n = control.recv_buffer.dequeue_slice(recv_buf);
|
|
||||||
buf.advance(n);
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
self.notify.notify_one();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(()).into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncWrite for TcpStream {
|
|
||||||
fn poll_write(
|
|
||||||
self: Pin<&mut Self>,
|
|
||||||
cx: &mut Context<'_>,
|
|
||||||
buf: &[u8],
|
|
||||||
) -> Poll<std::io::Result<usize>> {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
// If state == Close | Closing | Closed, the TCP stream WR half is closed.
|
|
||||||
if !matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
return Err(std::io::ErrorKind::BrokenPipe.into()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to buffer
|
|
||||||
|
|
||||||
if control.send_buffer.is_full() {
|
|
||||||
if let Some(old_waker) = control.send_waker.replace(cx.waker().clone()) {
|
|
||||||
if !old_waker.will_wake(cx.waker()) {
|
|
||||||
old_waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Poll::Pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = control.send_buffer.enqueue_slice(buf);
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
self.notify.notify_one();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(n).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
Ok(()).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
let mut control = self.control.lock();
|
|
||||||
|
|
||||||
if matches!(control.send_state, TcpSocketState::Closed | TcpSocketState::Closing) {
|
|
||||||
return Ok(()).into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHUT_WR
|
|
||||||
if matches!(control.send_state, TcpSocketState::Normal) {
|
|
||||||
control.send_state = TcpSocketState::Close;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(old_waker) = control.send_waker.replace(cx.waker().clone()) {
|
|
||||||
if !old_waker.will_wake(cx.waker()) {
|
|
||||||
old_waker.wake();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.notify.notify_one();
|
|
||||||
|
|
||||||
Poll::Pending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
use std::{
|
|
||||||
net::SocketAddr,
|
|
||||||
pin::Pin,
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
|
|
||||||
use etherparse::PacketBuilder;
|
|
||||||
use futures::{ready, Sink, SinkExt, Stream};
|
|
||||||
use smoltcp::wire::UdpPacket;
|
|
||||||
use tokio::sync::mpsc::{Receiver, Sender};
|
|
||||||
use tokio_util::sync::PollSender;
|
|
||||||
use tracing::{error, trace};
|
|
||||||
|
|
||||||
use crate::packet::{AnyIpPktFrame, IpPacket};
|
|
||||||
|
|
||||||
pub type UdpMsg = (
|
|
||||||
Vec<u8>, /* payload */
|
|
||||||
SocketAddr, /* local */
|
|
||||||
SocketAddr, /* remote */
|
|
||||||
);
|
|
||||||
|
|
||||||
pub struct UdpSocket {
|
|
||||||
udp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
stack_tx: PollSender<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UdpSocket {
|
|
||||||
pub(super) fn new(udp_rx: Receiver<AnyIpPktFrame>, stack_tx: Sender<AnyIpPktFrame>) -> Self {
|
|
||||||
Self {
|
|
||||||
udp_rx,
|
|
||||||
stack_tx: PollSender::new(stack_tx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn split(self) -> (ReadHalf, WriteHalf) {
|
|
||||||
(
|
|
||||||
ReadHalf {
|
|
||||||
udp_rx: self.udp_rx,
|
|
||||||
},
|
|
||||||
WriteHalf {
|
|
||||||
stack_tx: self.stack_tx,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReadHalf {
|
|
||||||
udp_rx: Receiver<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct WriteHalf {
|
|
||||||
stack_tx: PollSender<AnyIpPktFrame>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream for ReadHalf {
|
|
||||||
type Item = UdpMsg;
|
|
||||||
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
|
|
||||||
loop {
|
|
||||||
match ready!(self.udp_rx.poll_recv(cx)) {
|
|
||||||
Some(frame) => {
|
|
||||||
let packet = match IpPacket::new_checked(frame.as_slice()) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid IP packet: {}", err);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let src_ip = packet.src_addr();
|
|
||||||
let dst_ip = packet.dst_addr();
|
|
||||||
let payload = packet.payload();
|
|
||||||
|
|
||||||
let packet = match UdpPacket::new_checked(payload) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(err) => {
|
|
||||||
error!("invalid err: {err}, src_ip: {src_ip}, dst_ip: {dst_ip}, payload: {payload:?}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let src_port = packet.src_port();
|
|
||||||
let dst_port = packet.dst_port();
|
|
||||||
|
|
||||||
let src_addr = SocketAddr::new(src_ip, src_port);
|
|
||||||
let dst_addr = SocketAddr::new(dst_ip, dst_port);
|
|
||||||
|
|
||||||
trace!("created UDP socket for {} <-> {}", src_addr, dst_addr);
|
|
||||||
|
|
||||||
return Poll::Ready(Some((packet.payload().to_vec(), src_addr, dst_addr)));
|
|
||||||
}
|
|
||||||
None => return Poll::Ready(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sink<UdpMsg> for WriteHalf {
|
|
||||||
type Error = std::io::Error;
|
|
||||||
|
|
||||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
match ready!(self.stack_tx.poll_ready_unpin(cx)) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(err) => Poll::Ready(Err(std::io::Error::other(err))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_send(mut self: Pin<&mut Self>, item: UdpMsg) -> Result<(), Self::Error> {
|
|
||||||
use std::io::{Error, ErrorKind::InvalidData};
|
|
||||||
let (data, src_addr, dst_addr) = item;
|
|
||||||
|
|
||||||
if data.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let builder = match (src_addr, dst_addr) {
|
|
||||||
(SocketAddr::V4(src), SocketAddr::V4(dst)) => {
|
|
||||||
PacketBuilder::ipv4(src.ip().octets(), dst.ip().octets(), 20)
|
|
||||||
.udp(src_addr.port(), dst_addr.port())
|
|
||||||
}
|
|
||||||
(SocketAddr::V6(src), SocketAddr::V6(dst)) => {
|
|
||||||
PacketBuilder::ipv6(src.ip().octets(), dst.ip().octets(), 20)
|
|
||||||
.udp(src_addr.port(), dst_addr.port())
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Error::new(InvalidData, "src or destination type unmatch"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut ip_packet_writer = Vec::with_capacity(builder.size(data.len()));
|
|
||||||
builder
|
|
||||||
.write(&mut ip_packet_writer, &data)
|
|
||||||
.map_err(|err| Error::other(format!("PacketBuilder::write: {err}")))?;
|
|
||||||
|
|
||||||
match self.stack_tx.start_send_unpin(ip_packet_writer) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(err) => Err(Error::other(format!("send error: {err}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
use std::io::Error;
|
|
||||||
match ready!(self.stack_tx.poll_flush_unpin(cx)) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(err) => Poll::Ready(Err(Error::other(format!("flush error: {err}")))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
use std::io::Error;
|
|
||||||
match ready!(self.stack_tx.poll_close_unpin(cx)) {
|
|
||||||
Ok(()) => Poll::Ready(Ok(())),
|
|
||||||
Err(err) => Poll::Ready(Err(Error::other(format!("close error: {err}")))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
//! Regression tests that reproduce the bugs found in the static analysis.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use etherparse::{IpNumber, Ipv4Header, UdpHeader};
|
|
||||||
use futures::SinkExt;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
use netstack_smoltcp::StackBuilder;
|
|
||||||
|
|
||||||
fn make_udp_ipv4(
|
|
||||||
src_ip: [u8; 4],
|
|
||||||
src_port: u16,
|
|
||||||
dst_ip: [u8; 4],
|
|
||||||
dst_port: u16,
|
|
||||||
payload: &[u8],
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let udp_hdr = UdpHeader::with_ipv4_checksum(
|
|
||||||
src_port,
|
|
||||||
dst_port,
|
|
||||||
&Ipv4Header::new(
|
|
||||||
(UdpHeader::LEN + payload.len()) as u16,
|
|
||||||
64,
|
|
||||||
IpNumber::UDP,
|
|
||||||
src_ip,
|
|
||||||
dst_ip,
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ip_hdr = Ipv4Header::new(
|
|
||||||
(UdpHeader::LEN + payload.len()) as u16,
|
|
||||||
64,
|
|
||||||
IpNumber::UDP,
|
|
||||||
src_ip,
|
|
||||||
dst_ip,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut buf = Vec::with_capacity(Ipv4Header::MIN_LEN + UdpHeader::LEN + payload.len());
|
|
||||||
ip_hdr.write(&mut buf).unwrap();
|
|
||||||
udp_hdr.write(&mut buf).unwrap();
|
|
||||||
buf.extend_from_slice(payload);
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// before(include) a15e0b72bfc72cb032e67138070da01e325d66f8
|
|
||||||
/// sink_buf is used in `Stack` to hold a slot for sending any pkt
|
|
||||||
///
|
|
||||||
/// the original assumption is that the `poll_ready` -> `start_send` -> `poll_flush`
|
|
||||||
/// are called sequentially so the slot could be reused and will never get blocked.
|
|
||||||
///
|
|
||||||
/// but once the user calls `send_all` on `Stack`, which will not immediate flush the pkt(call `poll_flush`),
|
|
||||||
/// then `sink_buf` is could be Some(pkt), then it will trigger `Poll::Pending` branch in `Stack::poll_ready`,
|
|
||||||
/// who did not register the waker correctly, so it will got hanged forever.
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn bug1_poll_ready_waker_registered_via_send_all() {
|
|
||||||
let (mut stack, _runner, _udp_socket, _tcp) = StackBuilder::default()
|
|
||||||
.enable_udp(true)
|
|
||||||
.udp_buffer_size(64)
|
|
||||||
.stack_buffer_size(64)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let pkt1 = make_udp_ipv4([1, 2, 3, 4], 1111, [5, 6, 7, 8], 9999, b"first");
|
|
||||||
let pkt2 = make_udp_ipv4([1, 2, 3, 4], 1111, [5, 6, 7, 8], 9999, b"second");
|
|
||||||
|
|
||||||
let mut stream = futures::stream::iter([Ok(pkt1), Ok(pkt2)]);
|
|
||||||
|
|
||||||
let result = timeout(Duration::from_secs(1), stack.send_all(&mut stream)).await;
|
|
||||||
// should be ok after the fix
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
@ -10,12 +10,25 @@ use ostp_core::{NoiseRole, OstpEvent, PaddingStrategy, ProtocolAction, ProtocolC
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
use tokio::time::{interval, timeout, Instant};
|
use tokio::time::{interval, timeout, Instant, MissedTickBehavior};
|
||||||
|
|
||||||
use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
||||||
use crate::config::ClientConfig;
|
use crate::config::ClientConfig;
|
||||||
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||||
|
|
||||||
|
/// Per-address ceiling on the UoT/TCP connect attempt. Long enough that a
|
||||||
|
/// genuinely slow mobile path still completes its handshake, short enough that
|
||||||
|
/// a blackholed address (typically IPv6 advertised without a working route)
|
||||||
|
/// costs seconds instead of the kernel's full SYN-retry budget before the next
|
||||||
|
/// candidate address is tried.
|
||||||
|
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
|
/// How long to keep retrying a resume-triggered reconnect before handing the
|
||||||
|
/// problem back to the ordinary stall path. That path is what releases the
|
||||||
|
/// system proxy, so this is really a bound on how long the machine may be left
|
||||||
|
/// with no working internet at all after waking.
|
||||||
|
const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45);
|
||||||
|
|
||||||
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
pub fn set_socket_protector<F>(f: F)
|
pub fn set_socket_protector<F>(f: F)
|
||||||
|
|
@ -46,6 +59,56 @@ async fn send_datagram(socket: &crate::transport::Transport, frame: &Bytes, _web
|
||||||
struct SessionState {
|
struct SessionState {
|
||||||
socket: crate::transport::Transport,
|
socket: crate::transport::Transport,
|
||||||
machine: ProtocolMachine,
|
machine: ProtocolMachine,
|
||||||
|
/// Handle to this session's spawned receiver task. Held so the task is
|
||||||
|
/// aborted when the session is dropped (e.g. replaced on reconnect).
|
||||||
|
/// Otherwise, on a dead connection the task blocks forever in recv() while
|
||||||
|
/// keeping the old socket alive — leaking a task + socket on every
|
||||||
|
/// reconnect, which piles up across sleep/resume cycles.
|
||||||
|
rx_task: tokio::task::AbortHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SessionState {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.rx_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the per-session receiver loop that reads inbound datagrams from the
|
||||||
|
/// transport and forwards them to the bridge, returning an AbortHandle so the
|
||||||
|
/// task is torn down when its `SessionState` is dropped. Consolidates the three
|
||||||
|
/// previously-duplicated inline copies (initial connect, network-change, and
|
||||||
|
/// keepalive reconnect).
|
||||||
|
fn spawn_session_receiver(
|
||||||
|
socket: crate::transport::Transport,
|
||||||
|
session_index: usize,
|
||||||
|
udp_tx: mpsc::Sender<(usize, Bytes)>,
|
||||||
|
) -> tokio::task::AbortHandle {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0_u8; 65535];
|
||||||
|
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
|
||||||
|
loop {
|
||||||
|
match socket.recv(&mut buf).await {
|
||||||
|
Ok(n) => {
|
||||||
|
let inbound = Bytes::copy_from_slice(&buf[..n]);
|
||||||
|
if udp_tx.send((session_index, inbound)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if is_uot {
|
||||||
|
// TCP transport is dead; exit so the bridge sees the
|
||||||
|
// channel close and reconnects.
|
||||||
|
tracing::debug!("UoT session {} disconnected: {}", session_index, e);
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.abort_handle()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Bridge {
|
pub struct Bridge {
|
||||||
|
|
@ -65,7 +128,6 @@ pub struct Bridge {
|
||||||
pub mux_sessions: usize,
|
pub mux_sessions: usize,
|
||||||
|
|
||||||
pub transport_mode: String,
|
pub transport_mode: String,
|
||||||
pub stealth_sni: String,
|
|
||||||
pub tcp_fragmentation: bool,
|
pub tcp_fragmentation: bool,
|
||||||
pub frag_chunk: usize,
|
pub frag_chunk: usize,
|
||||||
pub frag_sleep: u64,
|
pub frag_sleep: u64,
|
||||||
|
|
@ -81,6 +143,21 @@ pub struct Bridge {
|
||||||
last_rtt_ms: f64,
|
last_rtt_ms: f64,
|
||||||
last_sample_at: Instant,
|
last_sample_at: Instant,
|
||||||
last_valid_recv: Instant,
|
last_valid_recv: Instant,
|
||||||
|
/// Set when a suspend/resume is detected, cleared once a reconnect actually
|
||||||
|
/// succeeds. Waking is precisely when the network is least likely to be
|
||||||
|
/// ready — Wi-Fi has not reassociated yet — so a single attempt fired
|
||||||
|
/// milliseconds after resume usually fails, and a one-shot forced reconnect
|
||||||
|
/// then fell back to the ordinary 25s stall heuristic. That heuristic keys
|
||||||
|
/// off a monotonic clock which does not advance while the machine is
|
||||||
|
/// asleep, so it could take a further 25s of real uptime to fire, or not
|
||||||
|
/// fire at all. Retrying until success removes the dependency on either.
|
||||||
|
forced_reconnect_pending: bool,
|
||||||
|
last_forced_reconnect_try: Instant,
|
||||||
|
/// Wall-clock start of the current resume-reconnect campaign, used to bound
|
||||||
|
/// it. Wall clock rather than Instant because the monotonic clock does not
|
||||||
|
/// advance across suspend on Windows, so it cannot measure anything that
|
||||||
|
/// begins at wake.
|
||||||
|
forced_reconnect_started: Option<SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bridge {
|
impl Bridge {
|
||||||
|
|
@ -102,7 +179,6 @@ impl Bridge {
|
||||||
mux_sessions: config.multiplex.sessions.max(1),
|
mux_sessions: config.multiplex.sessions.max(1),
|
||||||
|
|
||||||
transport_mode: config.transport.mode.clone(),
|
transport_mode: config.transport.mode.clone(),
|
||||||
stealth_sni: config.transport.stealth_sni.clone(),
|
|
||||||
tcp_fragmentation: config.transport.tcp_fragmentation,
|
tcp_fragmentation: config.transport.tcp_fragmentation,
|
||||||
frag_chunk: config.transport.frag_chunk,
|
frag_chunk: config.transport.frag_chunk,
|
||||||
frag_sleep: config.transport.frag_sleep,
|
frag_sleep: config.transport.frag_sleep,
|
||||||
|
|
@ -118,6 +194,9 @@ impl Bridge {
|
||||||
last_rtt_ms: 0.0,
|
last_rtt_ms: 0.0,
|
||||||
last_sample_at: Instant::now(),
|
last_sample_at: Instant::now(),
|
||||||
last_valid_recv: Instant::now(),
|
last_valid_recv: Instant::now(),
|
||||||
|
forced_reconnect_pending: false,
|
||||||
|
last_forced_reconnect_try: Instant::now(),
|
||||||
|
forced_reconnect_started: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +212,21 @@ impl Bridge {
|
||||||
let mut metrics_tick = interval(Duration::from_millis(500));
|
let mut metrics_tick = interval(Duration::from_millis(500));
|
||||||
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(self.keepalive_interval_sec.max(1)));
|
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(self.keepalive_interval_sec.max(1)));
|
||||||
let mut retransmit_tick = tokio::time::interval(Duration::from_millis(10));
|
let mut retransmit_tick = tokio::time::interval(Duration::from_millis(10));
|
||||||
|
// CRITICAL for suspend/resume: the default MissedTickBehavior is `Burst`,
|
||||||
|
// which after a laptop sleep or a phone backgrounding the app fires ALL
|
||||||
|
// the ticks that "should" have happened during the gap back-to-back. For
|
||||||
|
// the 10ms retransmit tick that is tens of thousands of instant ticks on
|
||||||
|
// resume — a CPU storm that hangs the bridge and manifests as the app
|
||||||
|
// freezing or getting stuck "Connecting". Skip missed ticks instead.
|
||||||
|
metrics_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
keepalive_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
retransmit_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||||
|
|
||||||
|
// Wall-clock anchor for suspend/resume detection. tokio's timers run on a
|
||||||
|
// monotonic clock; comparing it against wall-clock lets us notice that
|
||||||
|
// the machine slept (or the app was frozen in the background) and force
|
||||||
|
// one clean reconnect instead of trying to resume a long-dead session.
|
||||||
|
let mut last_wall_check = SystemTime::now();
|
||||||
let init_msg = if self.mode == "tun" {
|
let init_msg = if self.mode == "tun" {
|
||||||
"Bridge initialized (TUN mode)".to_string()
|
"Bridge initialized (TUN mode)".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -168,18 +262,89 @@ impl Bridge {
|
||||||
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
|
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
|
||||||
}
|
}
|
||||||
cmd = bridge_rx.recv() => {
|
cmd = bridge_rx.recv() => {
|
||||||
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
|
if !self.handle_bridge_cmd(cmd, &mut bridge_rx, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = metrics_tick.tick() => {
|
_ = metrics_tick.tick() => {
|
||||||
|
// Suspend/resume detection: the wall clock jumps forward on
|
||||||
|
// wake even when the monotonic timer clock does not, so a
|
||||||
|
// large gap here means the machine slept / the app was frozen.
|
||||||
|
// The session is almost certainly dead (the server evicts
|
||||||
|
// idle sessions after 10 min), so force one clean reconnect
|
||||||
|
// rather than waiting on stale-session heuristics.
|
||||||
|
let wall_gap = last_wall_check.elapsed().unwrap_or_default();
|
||||||
|
last_wall_check = SystemTime::now();
|
||||||
|
if self.running && wall_gap > Duration::from_secs(15) {
|
||||||
|
let _ = tx.send(UiEvent::Log(format!(
|
||||||
|
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
|
||||||
|
))).await;
|
||||||
|
self.forced_reconnect_pending = true;
|
||||||
|
self.forced_reconnect_started = Some(SystemTime::now());
|
||||||
|
self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give up if resume reconnects keep failing. Retrying forever
|
||||||
|
// looks harmless but is not: the system proxy stays pointed at
|
||||||
|
// our local listener the whole time, so the machine has NO
|
||||||
|
// working internet — not merely no tunnel — while the UI sits
|
||||||
|
// on "connecting". Handing the retry to the ordinary keepalive
|
||||||
|
// path restores the proxy through its hard-timeout branch,
|
||||||
|
// which force=true deliberately skips.
|
||||||
|
//
|
||||||
|
// Measured on the wall clock: Instant does not advance across
|
||||||
|
// suspend on Windows (QPC stops), so a monotonic deadline can
|
||||||
|
// not bound anything that starts at wake.
|
||||||
|
if self.forced_reconnect_pending {
|
||||||
|
let pending_for = self
|
||||||
|
.forced_reconnect_started
|
||||||
|
.and_then(|t| t.elapsed().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if pending_for > RESUME_RECONNECT_GIVE_UP {
|
||||||
|
self.forced_reconnect_pending = false;
|
||||||
|
self.forced_reconnect_started = None;
|
||||||
|
let _ = tx.send(UiEvent::Log(format!(
|
||||||
|
"Reconnect after suspend failed for {}s — releasing the system \
|
||||||
|
proxy so normal traffic works; will keep retrying in the \
|
||||||
|
background",
|
||||||
|
pending_for.as_secs()
|
||||||
|
))).await;
|
||||||
|
// Make the ordinary stall path fire on the next
|
||||||
|
// keepalive tick: it is the one that tears the proxy
|
||||||
|
// back down (or, with kill switch on, deliberately
|
||||||
|
// keeps blocking).
|
||||||
|
self.last_valid_recv = Instant::now()
|
||||||
|
.checked_sub(Duration::from_secs(3600))
|
||||||
|
.unwrap_or_else(Instant::now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep retrying a resume-triggered reconnect until one lands.
|
||||||
|
// The first attempt fires within half a second of waking, when
|
||||||
|
// the NIC is typically still reassociating, so treating it as
|
||||||
|
// one-shot left the tunnel dead until some other timer noticed.
|
||||||
|
if self.running
|
||||||
|
&& self.forced_reconnect_pending
|
||||||
|
&& self.last_forced_reconnect_try.elapsed() >= Duration::from_secs(3)
|
||||||
|
{
|
||||||
|
self.last_forced_reconnect_try = Instant::now();
|
||||||
|
self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||||
|
// handle_keepalive refreshes last_valid_recv only when a
|
||||||
|
// session was actually established, so this is a real
|
||||||
|
// success check rather than "we tried".
|
||||||
|
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
|
||||||
|
self.forced_reconnect_pending = false;
|
||||||
|
self.forced_reconnect_started = None;
|
||||||
|
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.running {
|
if self.running {
|
||||||
self.emit_metrics(&tx).await;
|
self.emit_metrics(&tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = keepalive_tick.tick() => {
|
_ = keepalive_tick.tick() => {
|
||||||
if self.running {
|
if self.running {
|
||||||
self.handle_keepalive(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
self.handle_keepalive(false, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = retransmit_tick.tick() => {
|
_ = retransmit_tick.tick() => {
|
||||||
|
|
@ -188,7 +353,20 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
|
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
|
||||||
s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
|
// Upper bound matches MAX_CWND_PACKETS in ostp-core's congestion
|
||||||
|
// controller. The old 16384 ceiling let ~20 MB sit in flight,
|
||||||
|
// which on a mobile uplink is minutes of buffered queue rather
|
||||||
|
// than throughput — the app kept handing over data long after
|
||||||
|
// the path had stopped draining it.
|
||||||
|
// Two independent gates. cwnd bounds how much may be in
|
||||||
|
// flight; pacing bounds how FAST it is released. Without the
|
||||||
|
// second, a full window goes out back-to-back and lands in
|
||||||
|
// the bottleneck's buffer as standing queue rather than
|
||||||
|
// throughput — the thing that produced multi-second RTT.
|
||||||
|
s.iter().any(|ses| {
|
||||||
|
ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 1024)
|
||||||
|
&& ses.machine.can_pace_packet()
|
||||||
|
})
|
||||||
}).unwrap_or(true) => {
|
}).unwrap_or(true) => {
|
||||||
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
||||||
}
|
}
|
||||||
|
|
@ -211,8 +389,8 @@ impl Bridge {
|
||||||
) {
|
) {
|
||||||
match udp_msg {
|
match udp_msg {
|
||||||
Some((session_index, inbound)) => {
|
Some((session_index, inbound)) => {
|
||||||
|
// Raw byte counter — every datagram that reached the socket counts.
|
||||||
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
||||||
self.last_valid_recv = Instant::now();
|
|
||||||
if let Some(sessions) = sessions_opt.as_mut() {
|
if let Some(sessions) = sessions_opt.as_mut() {
|
||||||
if session_index < sessions.len() {
|
if session_index < sessions.len() {
|
||||||
let session = &mut sessions[session_index];
|
let session = &mut sessions[session_index];
|
||||||
|
|
@ -225,6 +403,22 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only NOW, after the datagram actually authenticated and
|
||||||
|
// decrypted, does it count as a sign of life. This used to
|
||||||
|
// be set above, before any validation — so a datagram that
|
||||||
|
// failed to decrypt still reset the stall detector on its
|
||||||
|
// way to the `return` above. Anything arriving at this port
|
||||||
|
// (frames from a session the server already evicted, stale
|
||||||
|
// retransmits, or plain garbage from an off-path source that
|
||||||
|
// knows the ip:port) kept the client convinced the tunnel
|
||||||
|
// was healthy: the 25s background reconnect in
|
||||||
|
// handle_keepalive never fired and the tunnel sat dead at
|
||||||
|
// 0 b/s until the user reconnected by hand. It also made
|
||||||
|
// `is_healthy` (see emit_metrics) lie in the UI, and handed
|
||||||
|
// any off-path sender a trivial way to pin a client in a
|
||||||
|
// dead session indefinitely.
|
||||||
|
self.last_valid_recv = Instant::now();
|
||||||
|
|
||||||
let mut actions_queue = std::collections::VecDeque::new();
|
let mut actions_queue = std::collections::VecDeque::new();
|
||||||
actions_queue.push_back(initial_action);
|
actions_queue.push_back(initial_action);
|
||||||
|
|
||||||
|
|
@ -297,6 +491,7 @@ impl Bridge {
|
||||||
async fn handle_bridge_cmd(
|
async fn handle_bridge_cmd(
|
||||||
&mut self,
|
&mut self,
|
||||||
cmd: Option<BridgeCommand>,
|
cmd: Option<BridgeCommand>,
|
||||||
|
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
|
||||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||||
|
|
@ -333,35 +528,9 @@ impl Bridge {
|
||||||
match self.perform_handshake_with_id(&tx, session_id).await {
|
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||||
Ok((sock, mach, rtt)) => {
|
Ok((sock, mach, rtt)) => {
|
||||||
let session_index = sessions.len();
|
let session_index = sessions.len();
|
||||||
let socket_clone = sock.clone();
|
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||||
let udp_tx_clone = udp_tx.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||||
let mut buf = vec![0_u8; 65535];
|
|
||||||
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
|
|
||||||
loop {
|
|
||||||
match socket_clone.recv(&mut buf).await {
|
|
||||||
Ok(n) => {
|
|
||||||
let inbound = Bytes::copy_from_slice(&buf[..n]);
|
|
||||||
if udp_tx_clone.send((session_index, inbound)).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if is_uot {
|
|
||||||
// TCP is dead — drop sender to signal bridge via channel close
|
|
||||||
tracing::debug!("UoT session {} disconnected: {}", session_index, e);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sessions.push(SessionState { socket: sock, machine: mach });
|
|
||||||
rtt_sum += rtt;
|
rtt_sum += rtt;
|
||||||
successful_sessions += 1;
|
successful_sessions += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -414,6 +583,32 @@ impl Bridge {
|
||||||
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
||||||
}
|
}
|
||||||
Some(BridgeCommand::NetworkChanged) => {
|
Some(BridgeCommand::NetworkChanged) => {
|
||||||
|
// A real network handoff (Wi-Fi <-> cellular) commonly fires
|
||||||
|
// onLost + onAvailable within milliseconds of each other on
|
||||||
|
// Android, queuing several NetworkChanged commands back to
|
||||||
|
// back. Each reconnect below is a full sequential handshake
|
||||||
|
// (up to ~1.2s x 4 attempts x mux_sessions) run synchronously
|
||||||
|
// in this select-loop iteration, so without coalescing, the
|
||||||
|
// first attempt often races the OS's own network switch and
|
||||||
|
// fails on the now-dead interface, then the SECOND queued
|
||||||
|
// NetworkChanged only starts its own full reconnect after
|
||||||
|
// that first one finishes - multiplying a sub-second handoff
|
||||||
|
// into many seconds of extra outage. Drain same-kind repeats
|
||||||
|
// so a burst collapses into one reconnect on the freshest
|
||||||
|
// signal; a different command found while draining is
|
||||||
|
// handled immediately rather than dropped.
|
||||||
|
while let Ok(next) = bridge_rx.try_recv() {
|
||||||
|
if !matches!(next, BridgeCommand::NetworkChanged) {
|
||||||
|
let more = Box::pin(self.handle_bridge_cmd(
|
||||||
|
Some(next), bridge_rx, sessions_opt, udp_rx_opt, proxy_guard, stream_map, tx, proxy_tx,
|
||||||
|
)).await;
|
||||||
|
if !more {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.running {
|
if self.running {
|
||||||
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
||||||
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
||||||
|
|
@ -430,31 +625,8 @@ impl Bridge {
|
||||||
match self.perform_handshake_with_id(&tx, session_id).await {
|
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||||
Ok((sock, mach, rtt)) => {
|
Ok((sock, mach, rtt)) => {
|
||||||
let session_index = new_sessions.len();
|
let session_index = new_sessions.len();
|
||||||
let socket_clone = sock.clone();
|
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||||
let udp_tx_clone = udp_tx.clone();
|
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buf = vec![0_u8; 65535];
|
|
||||||
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
|
|
||||||
loop {
|
|
||||||
match socket_clone.recv(&mut buf).await {
|
|
||||||
Ok(n) => {
|
|
||||||
let inbound = Bytes::copy_from_slice(&buf[..n]);
|
|
||||||
if udp_tx_clone.send((session_index, inbound)).await.is_err() { break; }
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if is_uot {
|
|
||||||
tracing::debug!("UoT network-change session {} disconnected: {}", session_index, e);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
new_sessions.push(SessionState { socket: sock, machine: mach });
|
|
||||||
rtt_sum += rtt;
|
rtt_sum += rtt;
|
||||||
successful_sessions += 1;
|
successful_sessions += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -525,6 +697,7 @@ impl Bridge {
|
||||||
|
|
||||||
async fn handle_keepalive(
|
async fn handle_keepalive(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
force: bool,
|
||||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||||
|
|
@ -533,9 +706,12 @@ impl Bridge {
|
||||||
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
|
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
|
||||||
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
|
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
|
||||||
) {
|
) {
|
||||||
if self.last_valid_recv.elapsed().as_secs() > 25 {
|
if force || self.last_valid_recv.elapsed().as_secs() > 25 {
|
||||||
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
||||||
if elapsed > 180 {
|
// On a forced (post-resume) reconnect the monotonic clock may not
|
||||||
|
// have advanced, so `elapsed` can be small — never treat a forced
|
||||||
|
// reconnect as a hard timeout; we specifically want to re-establish.
|
||||||
|
if !force && elapsed > 180 {
|
||||||
if self.kill_switch {
|
if self.kill_switch {
|
||||||
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
|
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -566,34 +742,9 @@ impl Bridge {
|
||||||
match self.perform_handshake_with_id(&tx, session_id).await {
|
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||||
Ok((sock, mach, rtt)) => {
|
Ok((sock, mach, rtt)) => {
|
||||||
let session_index = new_sessions.len();
|
let session_index = new_sessions.len();
|
||||||
let socket_clone = sock.clone();
|
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||||
let udp_tx_clone = udp_tx.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||||
let mut buf = vec![0_u8; 65535];
|
|
||||||
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
|
|
||||||
loop {
|
|
||||||
match socket_clone.recv(&mut buf).await {
|
|
||||||
Ok(n) => {
|
|
||||||
let inbound = Bytes::copy_from_slice(&buf[..n]);
|
|
||||||
if udp_tx_clone.send((session_index, inbound)).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if is_uot {
|
|
||||||
tracing::debug!("UoT reconnect session {} disconnected: {}", session_index, e);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tracing::warn!("UDP socket recv error (reconnect session {}): {}", session_index, e);
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
new_sessions.push(SessionState { socket: sock, machine: mach });
|
|
||||||
rtt_sum += rtt;
|
rtt_sum += rtt;
|
||||||
successful_sessions += 1;
|
successful_sessions += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -869,7 +1020,21 @@ impl Bridge {
|
||||||
Ok(addrs) => addrs.collect(),
|
Ok(addrs) => addrs.collect(),
|
||||||
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
||||||
};
|
};
|
||||||
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
|
// IPv4 first. Addresses are tried strictly in order, each burning its
|
||||||
|
// full retry budget before the next is touched, so this ordering decides
|
||||||
|
// how long a bad family stalls the whole connect. Mobile carriers
|
||||||
|
// routinely hand out IPv6 with no working route and BLACKHOLE it rather
|
||||||
|
// than rejecting, so every IPv6 candidate costs the full timeout budget
|
||||||
|
// — with several AAAA records the working IPv4 address was not reached
|
||||||
|
// for tens of seconds. (The same ordering bug was already fixed on the
|
||||||
|
// server's outbound path and in the UoT connect.)
|
||||||
|
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 1 } else { 0 });
|
||||||
|
|
||||||
|
// NAT64 is a fallback for IPv6-only networks. Retrying it per failing
|
||||||
|
// address multiplied an already-long connect: each attempt re-runs a DNS
|
||||||
|
// lookup and another full round of handshake retries, for a path that
|
||||||
|
// either works for the whole network or for none of it.
|
||||||
|
let mut nat64_attempted = false;
|
||||||
|
|
||||||
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
||||||
|
|
||||||
|
|
@ -882,7 +1047,8 @@ impl Bridge {
|
||||||
let socket = match self.try_connect_transport(target_ip, port).await {
|
let socket = match self.try_connect_transport(target_ip, port).await {
|
||||||
Ok(sock) => sock,
|
Ok(sock) => sock,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||||
|
nat64_attempted = true;
|
||||||
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
||||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||||
|
|
@ -963,7 +1129,8 @@ impl Bridge {
|
||||||
let (final_socket, size) = if success {
|
let (final_socket, size) = if success {
|
||||||
(socket, size)
|
(socket, size)
|
||||||
} else {
|
} else {
|
||||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||||
|
nat64_attempted = true;
|
||||||
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
||||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||||
|
|
@ -1033,7 +1200,6 @@ impl Bridge {
|
||||||
self.mux_enabled = cfg.multiplex.enabled;
|
self.mux_enabled = cfg.multiplex.enabled;
|
||||||
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
||||||
self.transport_mode = cfg.transport.mode.clone();
|
self.transport_mode = cfg.transport.mode.clone();
|
||||||
self.stealth_sni = cfg.transport.stealth_sni.clone();
|
|
||||||
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
|
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
|
||||||
self.frag_chunk = cfg.transport.frag_chunk.max(1);
|
self.frag_chunk = cfg.transport.frag_chunk.max(1);
|
||||||
self.frag_sleep = cfg.transport.frag_sleep;
|
self.frag_sleep = cfg.transport.frag_sleep;
|
||||||
|
|
@ -1051,7 +1217,27 @@ impl Bridge {
|
||||||
) -> Result<crate::transport::Transport> {
|
) -> Result<crate::transport::Transport> {
|
||||||
let mode = self.transport_mode.to_lowercase();
|
let mode = self.transport_mode.to_lowercase();
|
||||||
if mode == "uot" || mode == "tcp" {
|
if mode == "uot" || mode == "tcp" {
|
||||||
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
// Bound the TCP connect. Without this it inherits the kernel's SYN
|
||||||
|
// retry budget, which is tens of seconds (and can reach ~2 minutes).
|
||||||
|
// That is exactly what made UoT appear to hang on mobile: callers
|
||||||
|
// resolve every address for the server and try IPv6 first (see the
|
||||||
|
// sort in perform_handshake_with_id), and a mobile network that
|
||||||
|
// advertises IPv6 without a working route blackholes the SYN rather
|
||||||
|
// than rejecting it — so the client sat through the full retry
|
||||||
|
// budget before it ever reached the IPv4 address that would have
|
||||||
|
// connected immediately. UDP never showed this because connect() on
|
||||||
|
// a UDP socket only sets the default peer and returns at once.
|
||||||
|
let stream = tokio::time::timeout(
|
||||||
|
UOT_CONNECT_TIMEOUT,
|
||||||
|
tokio::net::TcpStream::connect((target_ip, port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"TCP connect to {target_ip}:{port} timed out after {:?}",
|
||||||
|
UOT_CONNECT_TIMEOUT
|
||||||
|
)
|
||||||
|
})??;
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
let (mut read_half, mut write_half) = stream.into_split();
|
let (mut read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
|
|
@ -1060,9 +1246,14 @@ impl Bridge {
|
||||||
let frag_sleep = self.frag_sleep;
|
let frag_sleep = self.frag_sleep;
|
||||||
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
let [junk_pc_min, junk_pc_max] = self.junk_pc;
|
||||||
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
let [junk_ps_min, junk_ps_max] = self.junk_ps;
|
||||||
// Per-key junk marker (derived from the access key) — NOT a global
|
// Time-rotating per-key junk marker — NOT a global constant and NOT
|
||||||
// constant, so junk frames carry no universal DPI signature.
|
// even a static per-user value: it changes every window, so junk
|
||||||
let junk_marker = ostp_core::crypto::derive_all_secrets(&self.access_key).junk_marker;
|
// carries no fixed DPI signature on the wire. All frames in this
|
||||||
|
// burst are sent within milliseconds, so one window applies to all.
|
||||||
|
let junk_marker = ostp_core::crypto::derive_junk_marker(
|
||||||
|
&self.access_key,
|
||||||
|
ostp_core::crypto::current_junk_window(),
|
||||||
|
);
|
||||||
|
|
||||||
{
|
{
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
@ -1183,8 +1374,19 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
||||||
|
// Well-known prefix (RFC 6052), used if discovery doesn't answer in time.
|
||||||
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
|
// Bound the discovery lookup. This runs on exactly the networks that are
|
||||||
|
// already misbehaving, where the resolver can hang for tens of seconds
|
||||||
|
// before giving up — unbounded, it was a large part of why connecting over
|
||||||
|
// a broken mobile network took minutes. Falling back to the well-known
|
||||||
|
// prefix is strictly better than waiting.
|
||||||
|
let discovery = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
tokio::net::lookup_host("ipv4only.arpa:80"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Ok(Ok(addrs)) = discovery {
|
||||||
for addr in addrs {
|
for addr in addrs {
|
||||||
if let std::net::SocketAddr::V6(v6) = addr {
|
if let std::net::SocketAddr::V6(v6) = addr {
|
||||||
let octets = v6.ip().octets();
|
let octets = v6.ip().octets();
|
||||||
|
|
|
||||||
|
|
@ -70,15 +70,13 @@ pub struct LocalProxyConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport layer configuration.
|
/// Transport layer configuration.
|
||||||
/// `mode` = "udp" (default) or "uot" (UDP over TCP с xHTTP-транспортом).
|
/// `mode` = "udp" (default) or "uot" (UDP over TCP, no protocol mimicry —
|
||||||
|
/// zapret-like: no recognizable header at all, not a fake TLS/HTTP shell).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TransportConfig {
|
pub struct TransportConfig {
|
||||||
/// "udp" or "uot"
|
/// "udp" or "uot"
|
||||||
#[serde(default = "default_transport_mode")]
|
#[serde(default = "default_transport_mode")]
|
||||||
pub mode: String,
|
pub mode: String,
|
||||||
/// TLS SNI and HTTP Host for xHTTP routing
|
|
||||||
#[serde(default)]
|
|
||||||
pub stealth_sni: String,
|
|
||||||
/// Split the first UoT/TCP packet (handshake) into tiny TCP segments to
|
/// 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.
|
/// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP.
|
||||||
pub tcp_fragmentation: bool,
|
pub tcp_fragmentation: bool,
|
||||||
|
|
@ -106,7 +104,6 @@ impl Default for TransportConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode: default_transport_mode(),
|
mode: default_transport_mode(),
|
||||||
stealth_sni: String::new(),
|
|
||||||
tcp_fragmentation: false,
|
tcp_fragmentation: false,
|
||||||
frag_chunk: default_frag_chunk(),
|
frag_chunk: default_frag_chunk(),
|
||||||
frag_sleep: default_frag_sleep(),
|
frag_sleep: default_frag_sleep(),
|
||||||
|
|
@ -192,7 +189,6 @@ struct RawUnifiedConfig {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawTransportSection {
|
struct RawTransportSection {
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
stealth_sni: Option<String>,
|
|
||||||
tcp_fragmentation: Option<bool>,
|
tcp_fragmentation: Option<bool>,
|
||||||
frag_chunk: Option<usize>,
|
frag_chunk: Option<usize>,
|
||||||
frag_sleep: Option<u64>,
|
frag_sleep: Option<u64>,
|
||||||
|
|
@ -270,7 +266,6 @@ impl ClientConfig {
|
||||||
},
|
},
|
||||||
transport: TransportConfig {
|
transport: TransportConfig {
|
||||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
||||||
stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(),
|
|
||||||
tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false),
|
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_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),
|
frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep),
|
||||||
|
|
@ -423,19 +418,22 @@ pub struct RelayServerConfig {
|
||||||
pub upstream_tcp: String,
|
pub upstream_tcp: String,
|
||||||
/// Upstream address for UDP traffic
|
/// Upstream address for UDP traffic
|
||||||
pub upstream_udp: String,
|
pub upstream_udp: String,
|
||||||
/// Target server's API URL, for key sync
|
// ── 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,
|
pub upstream_api_url: String,
|
||||||
/// Bearer token for the target server's API
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub upstream_api_token: String,
|
pub upstream_api_token: String,
|
||||||
/// Key sync interval in seconds (default 30)
|
#[serde(default)]
|
||||||
#[serde(default = "default_sync_interval")]
|
|
||||||
pub sync_interval_secs: u64,
|
pub sync_interval_secs: u64,
|
||||||
pub debug: Option<bool>,
|
pub debug: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sync_interval() -> u64 { 30 }
|
|
||||||
|
|
||||||
/// Supports both a single string "0.0.0.0:50000" and an array
|
/// Supports both a single string "0.0.0.0:50000" and an array
|
||||||
/// ["0.0.0.0:50000", "[::]:50000"].
|
/// ["0.0.0.0:50000", "[::]:50000"].
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
@ -495,7 +493,6 @@ pub struct ClientFileConfig {
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct TransportConfigRaw {
|
pub struct TransportConfigRaw {
|
||||||
pub mode: Option<String>,
|
pub mode: Option<String>,
|
||||||
pub stealth_sni: Option<String>,
|
|
||||||
pub tcp_fragmentation: Option<bool>,
|
pub tcp_fragmentation: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ pub fn detect_kind(json: &Value) -> Option<ConfigKind> {
|
||||||
/// never user-meaningful data) and `transport.wss` (the WSS framing
|
/// never user-meaningful data) and `transport.wss` (the WSS framing
|
||||||
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
|
/// feature removed entirely in the 0.4.0 rebuild) — both dropped with an
|
||||||
/// explicit note; everything else maps 1:1, nothing to convert.
|
/// 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`.
|
/// - **current flat schema**: no-op, `changed = false`.
|
||||||
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
||||||
let mut report = MigrationReport::default();
|
let mut report = MigrationReport::default();
|
||||||
|
|
@ -106,6 +111,14 @@ pub fn migrate_client_json(json: Value) -> (Value, MigrationReport) {
|
||||||
.to_string(),
|
.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)
|
(out, report)
|
||||||
|
|
@ -199,12 +212,14 @@ fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Val
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("udp")
|
.unwrap_or("udp")
|
||||||
.to_string();
|
.to_string();
|
||||||
let stealth_sni = primary
|
if let Some(sni) = primary.get("transport").and_then(|t| t.get("stealth_sni")).and_then(|v| v.as_str()) {
|
||||||
.get("transport")
|
if !sni.is_empty() {
|
||||||
.and_then(|t| t.get("stealth_sni"))
|
report.note(format!(
|
||||||
.and_then(|v| v.as_str())
|
"Dropped transport.stealth_sni ({sni:?}) — never actually used to construct \
|
||||||
.unwrap_or("")
|
any wire bytes; unused config plumbing with no successor field."
|
||||||
.to_string();
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
let tcp_fragmentation = primary
|
let tcp_fragmentation = primary
|
||||||
.get("transport")
|
.get("transport")
|
||||||
.and_then(|t| t.get("tcp_fragmentation"))
|
.and_then(|t| t.get("tcp_fragmentation"))
|
||||||
|
|
@ -287,7 +302,6 @@ fn migrate_client_from_modular(json: Value, mut report: MigrationReport) -> (Val
|
||||||
},
|
},
|
||||||
"transport": {
|
"transport": {
|
||||||
"mode": transport_type,
|
"mode": transport_type,
|
||||||
"stealth_sni": stealth_sni,
|
|
||||||
"tcp_fragmentation": tcp_fragmentation,
|
"tcp_fragmentation": tcp_fragmentation,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -388,13 +402,15 @@ mod tests {
|
||||||
assert_eq!(new["debug"], true);
|
assert_eq!(new["debug"], true);
|
||||||
assert_eq!(new["tun"]["enable"], true);
|
assert_eq!(new["tun"]["enable"], true);
|
||||||
assert_eq!(new["transport"]["mode"], "uot");
|
assert_eq!(new["transport"]["mode"], "uot");
|
||||||
assert_eq!(new["transport"]["stealth_sni"], "vk.com");
|
|
||||||
assert_eq!(new["transport"]["tcp_fragmentation"], true);
|
assert_eq!(new["transport"]["tcp_fragmentation"], true);
|
||||||
assert_eq!(new["mux"]["enabled"], true);
|
assert_eq!(new["mux"]["enabled"], true);
|
||||||
assert_eq!(new["mux"]["sessions"], 4);
|
assert_eq!(new["mux"]["sessions"], 4);
|
||||||
assert_eq!(new["exclude"]["domains"], json!(["local.lan", "internal.corp"]));
|
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"]["ips"], json!(["192.168.0.0/16"]));
|
||||||
assert_eq!(new["exclude"]["processes"], json!(["steam.exe"]));
|
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) —
|
/// Old modular configs that had MULTIPLE ostp outbounds (multi-server) —
|
||||||
|
|
@ -423,9 +439,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-0.3.1 flat config carrying fields that no longer exist
|
/// Pre-0.3.1 flat config carrying fields that no longer exist
|
||||||
/// (tun.wintun_path, tun.ipv4_address, transport.wss) — those get
|
/// (tun.wintun_path, tun.ipv4_address, transport.wss, transport.stealth_sni)
|
||||||
/// dropped with a note; every field that's still meaningful passes
|
/// — those get dropped with a note; every field that's still meaningful
|
||||||
/// through untouched, byte for byte.
|
/// passes through untouched, byte for byte.
|
||||||
#[test]
|
#[test]
|
||||||
fn flat_legacy_drops_only_dead_fields() {
|
fn flat_legacy_drops_only_dead_fields() {
|
||||||
let old = json!({
|
let old = json!({
|
||||||
|
|
@ -455,15 +471,16 @@ mod tests {
|
||||||
assert_eq!(new["tun"]["dns"], "1.1.1.1");
|
assert_eq!(new["tun"]["dns"], "1.1.1.1");
|
||||||
assert_eq!(new["tun"]["kill_switch"], true);
|
assert_eq!(new["tun"]["kill_switch"], true);
|
||||||
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
|
assert_eq!(new["exclude"]["domains"], json!(["a.com"]));
|
||||||
assert_eq!(new["transport"]["stealth_sni"], "bing.com");
|
|
||||||
// Dead fields are gone...
|
// Dead fields are gone...
|
||||||
assert!(new["tun"].get("wintun_path").is_none());
|
assert!(new["tun"].get("wintun_path").is_none());
|
||||||
assert!(new["tun"].get("ipv4_address").is_none());
|
assert!(new["tun"].get("ipv4_address").is_none());
|
||||||
assert!(new["transport"].get("wss").is_none());
|
assert!(new["transport"].get("wss").is_none());
|
||||||
|
assert!(new["transport"].get("stealth_sni").is_none());
|
||||||
// ...and their removal was reported, not silent.
|
// ...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("wintun_path")));
|
||||||
assert!(report.notes.iter().any(|n| n.contains("ipv4_address")));
|
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("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
|
/// A config already in the current shape must be a true no-op: report
|
||||||
|
|
@ -476,7 +493,7 @@ mod tests {
|
||||||
"tun": { "enable": false, "dns": null, "kill_switch": false },
|
"tun": { "enable": false, "dns": null, "kill_switch": false },
|
||||||
"exclude": { "domains": [], "ips": [], "processes": [] },
|
"exclude": { "domains": [], "ips": [], "processes": [] },
|
||||||
"mux": { "enabled": false, "sessions": 1 },
|
"mux": { "enabled": false, "sessions": 1 },
|
||||||
"transport": { "mode": "udp", "stealth_sni": "", "tcp_fragmentation": false }
|
"transport": { "mode": "udp", "tcp_fragmentation": false }
|
||||||
});
|
});
|
||||||
let (new, report) = migrate_client_json(current.clone());
|
let (new, report) = migrate_client_json(current.clone());
|
||||||
assert!(!report.changed);
|
assert!(!report.changed);
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ pub fn enable_system_proxy(proxy_addr: &str) {
|
||||||
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
println!("OSTP Local Proxy is running at socks5://{}", proxy_addr);
|
||||||
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
println!("Since you are in a headless/terminal environment, OSTP cannot automatically");
|
||||||
println!("configure your system proxy. To route traffic from this terminal, run:");
|
println!("configure your system proxy. To route traffic from this terminal, run:");
|
||||||
println!("\n eval $(ostp --proxy-env)\n");
|
println!("\n eval $(ostp proxy-env)\n");
|
||||||
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
println!("Or configure your application (e.g. curl -x socks5://{})", proxy_addr);
|
||||||
println!("===================================================================\n");
|
println!("===================================================================\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,10 @@ async fn handle_udp_associate(
|
||||||
|
|
||||||
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
||||||
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
||||||
|
// Held only to keep the direct-UDP readers' cancellation senders alive;
|
||||||
|
// dropping this (on every return path from this function) is what tells
|
||||||
|
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
|
||||||
|
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
|
||||||
|
|
||||||
let mut tcp_buf = [0u8; 1];
|
let mut tcp_buf = [0u8; 1];
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -432,7 +436,9 @@ async fn handle_udp_associate(
|
||||||
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
let s_arc = Arc::new(s);
|
let s_arc = Arc::new(s);
|
||||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||||
|
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||||
|
direct_udp_cancel_txs.push(cancel_tx);
|
||||||
direct_udp_v6 = Some(s_arc);
|
direct_udp_v6 = Some(s_arc);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -446,7 +452,9 @@ async fn handle_udp_associate(
|
||||||
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
let s_arc = Arc::new(s);
|
let s_arc = Arc::new(s);
|
||||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||||
|
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||||
|
direct_udp_cancel_txs.push(cancel_tx);
|
||||||
direct_udp_v4 = Some(s_arc);
|
direct_udp_v4 = Some(s_arc);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -520,11 +528,24 @@ fn spawn_direct_udp_reader(
|
||||||
sock_tx: Arc<UdpSocket>,
|
sock_tx: Arc<UdpSocket>,
|
||||||
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
||||||
_debug: bool,
|
_debug: bool,
|
||||||
|
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
match direct_socket.recv_from(&mut buf).await {
|
let recv_result = tokio::select! {
|
||||||
|
// Fires as soon as the sender half (held by handle_udp_associate
|
||||||
|
// for exactly this reason) is dropped - which happens the
|
||||||
|
// instant that function returns, on every exit path, with no
|
||||||
|
// explicit signaling needed. Without this, a UDP-associate
|
||||||
|
// session that ever bypassed traffic direct (excluded IP/
|
||||||
|
// domain) leaked this socket + task for the rest of the
|
||||||
|
// process's life once the session ended: nothing else ever
|
||||||
|
// stopped this loop.
|
||||||
|
_ = &mut cancel_rx => break,
|
||||||
|
res = direct_socket.recv_from(&mut buf) => res,
|
||||||
|
};
|
||||||
|
match recv_result {
|
||||||
Ok((len, target_addr)) => {
|
Ok((len, target_addr)) => {
|
||||||
let client_addr = {
|
let client_addr = {
|
||||||
let guard = client_udp_addr.lock().unwrap();
|
let guard = client_udp_addr.lock().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -138,27 +138,34 @@ async fn start_udp_bypass_session(
|
||||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket = Arc::new(socket);
|
// A single select! loop over both directions, rather than spawning a
|
||||||
let socket_rx = socket.clone();
|
// separate task for the read side, so the whole session - physical
|
||||||
|
// socket included - is torn down the moment this function returns
|
||||||
// Spawn a task to read from physical socket and send back to smoltcp
|
// (e.g. when session_rx closes). The previous spawned-task version left
|
||||||
let tx_clone = smoltcp_tx.clone();
|
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
||||||
tokio::spawn(async move {
|
// alive) running forever after this function returned: nothing ever
|
||||||
use futures::SinkExt;
|
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
|
||||||
let mut buf = [0u8; 65536];
|
// mode) leaked one socket + one task for the lifetime of the process.
|
||||||
loop {
|
use futures::SinkExt;
|
||||||
match socket_rx.recv_from(&mut buf).await {
|
let mut buf = [0u8; 65536];
|
||||||
Ok((n, peer)) => {
|
loop {
|
||||||
let mut lock = tx_clone.lock().await;
|
tokio::select! {
|
||||||
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
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,
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
while let Some((payload, dst)) = session_rx.recv().await {
|
|
||||||
socket.send_to(&payload, dst).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,18 @@ pub struct CongestionController {
|
||||||
loss_count: u32,
|
loss_count: u32,
|
||||||
/// Pacing rate: bytes per second
|
/// Pacing rate: bytes per second
|
||||||
pacing_rate: u64,
|
pacing_rate: u64,
|
||||||
|
/// Token-bucket allowance for pacing, in bytes.
|
||||||
|
pacing_tokens: f64,
|
||||||
|
pacing_last_refill: Instant,
|
||||||
/// MTU estimate (used for cwnd → packet count conversion)
|
/// MTU estimate (used for cwnd → packet count conversion)
|
||||||
mtu: u64,
|
mtu: u64,
|
||||||
/// Min RTT expiry: re-probe after 10 seconds
|
/// Min RTT expiry: re-probe after 10 seconds
|
||||||
min_rtt_stamp: Instant,
|
min_rtt_stamp: Instant,
|
||||||
|
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
|
||||||
|
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
|
||||||
|
slow_start_losses: u32,
|
||||||
|
/// Start of the current loss-tolerance window.
|
||||||
|
slow_start_loss_window_start: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -60,6 +68,20 @@ const MIN_CWND_PACKETS: u64 = 2;
|
||||||
/// Min RTT expiry window (after which we re-probe)
|
/// Min RTT expiry window (after which we re-probe)
|
||||||
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||||
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
/// 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);
|
const RTO_MIN: Duration = Duration::from_millis(50);
|
||||||
/// Maximum RTO
|
/// Maximum RTO
|
||||||
const RTO_MAX: Duration = Duration::from_secs(16);
|
const RTO_MAX: Duration = Duration::from_secs(16);
|
||||||
|
|
@ -67,6 +89,24 @@ const RTO_MAX: Duration = Duration::from_secs(16);
|
||||||
/// Will be replaced by first real measurement within milliseconds.
|
/// Will be replaced by first real measurement within milliseconds.
|
||||||
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
||||||
|
|
||||||
|
/// Isolated packet loss during slow start (a single dropped frame from
|
||||||
|
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
|
||||||
|
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
|
||||||
|
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
|
||||||
|
/// on any link with a non-zero background loss rate permanently downgrades
|
||||||
|
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
|
||||||
|
/// growth within the first few RTTs - turning what should be a sub-second
|
||||||
|
/// ramp-up into tens of seconds to minutes before throughput opens up
|
||||||
|
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
|
||||||
|
/// claws back up). Only treat loss as a real congestion signal - and pay
|
||||||
|
/// the full slow-start-exit + halving cost - once this many losses land
|
||||||
|
/// within SLOW_START_LOSS_WINDOW.
|
||||||
|
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
|
||||||
|
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
|
||||||
|
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
|
||||||
|
/// well-connected link, generous on a slow one.
|
||||||
|
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
impl CongestionController {
|
impl CongestionController {
|
||||||
pub fn new(mtu: u64) -> Self {
|
pub fn new(mtu: u64) -> Self {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
@ -88,9 +128,52 @@ impl CongestionController {
|
||||||
pacing_rate: initial_pacing,
|
pacing_rate: initial_pacing,
|
||||||
mtu,
|
mtu,
|
||||||
min_rtt_stamp: now,
|
min_rtt_stamp: now,
|
||||||
|
slow_start_losses: 0,
|
||||||
|
slow_start_loss_window_start: now,
|
||||||
|
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
|
||||||
|
pacing_last_refill: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes of pacing allowance available right now, without consuming any.
|
||||||
|
///
|
||||||
|
/// Read-only so the send path can use it as an admission check before it
|
||||||
|
/// commits to building a datagram.
|
||||||
|
pub fn pacing_available(&self) -> f64 {
|
||||||
|
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
|
||||||
|
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether at least one full-size packet may be released right now.
|
||||||
|
pub fn can_pace_packet(&self) -> bool {
|
||||||
|
self.pacing_available() >= self.mtu as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ceiling on accumulated allowance.
|
||||||
|
///
|
||||||
|
/// Pacing intervals here are fractions of a millisecond, so releasing
|
||||||
|
/// strictly one packet at a time would need a sub-millisecond timer per
|
||||||
|
/// packet. Instead we allow a short burst — the same trade every real
|
||||||
|
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
|
||||||
|
/// can still saturate the configured rate, with a small floor so a
|
||||||
|
/// cold/low estimate can never wedge sending entirely.
|
||||||
|
fn pacing_burst(&self) -> f64 {
|
||||||
|
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
|
||||||
|
by_rate.max((self.mtu * 4) as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refill from elapsed time and deduct `bytes`. Called on the real send
|
||||||
|
/// path; allowance is permitted to go negative so an oversized packet still
|
||||||
|
/// pays for itself rather than being released for free.
|
||||||
|
fn consume_pacing(&mut self, bytes: u64) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
|
||||||
|
self.pacing_last_refill = now;
|
||||||
|
self.pacing_tokens =
|
||||||
|
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||||
|
- bytes as f64;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the current congestion window in bytes.
|
/// Returns the current congestion window in bytes.
|
||||||
pub fn cwnd(&self) -> u64 {
|
pub fn cwnd(&self) -> u64 {
|
||||||
self.cwnd
|
self.cwnd
|
||||||
|
|
@ -142,6 +225,24 @@ impl CongestionController {
|
||||||
/// Record that we sent `bytes` of data.
|
/// Record that we sent `bytes` of data.
|
||||||
pub fn on_send(&mut self, bytes: u64) {
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
||||||
|
// Charge the pacing bucket here rather than at the admission check, so
|
||||||
|
// every byte that actually reaches the wire is paid for exactly once —
|
||||||
|
// including retransmits, which are precisely what must not be allowed
|
||||||
|
// to bypass the rate limit and pile into an already-full queue.
|
||||||
|
self.consume_pacing(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
|
||||||
|
/// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids
|
||||||
|
/// measuring RTT from it). The window still advances; only the RTT estimator
|
||||||
|
/// is left untouched.
|
||||||
|
pub fn on_ack_no_rtt(&mut self, bytes: u64) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||||
|
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||||
|
self.grow_window(bytes);
|
||||||
|
self.update_pacing_rate();
|
||||||
|
self.last_ack_time = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that `bytes` were acknowledged with the given RTT sample.
|
/// Record that `bytes` were acknowledged with the given RTT sample.
|
||||||
|
|
@ -153,9 +254,53 @@ impl CongestionController {
|
||||||
// Update RTT measurements
|
// Update RTT measurements
|
||||||
self.update_rtt(rtt, now);
|
self.update_rtt(rtt, now);
|
||||||
|
|
||||||
// State machine
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
Phase::SlowStart => {
|
Phase::SlowStart => {
|
||||||
|
// 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)
|
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
||||||
self.cwnd = self.cwnd.saturating_add(bytes);
|
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||||
if self.cwnd >= self.ssthresh {
|
if self.cwnd >= self.ssthresh {
|
||||||
|
|
@ -169,8 +314,20 @@ impl CongestionController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.update_pacing_rate();
|
self.clamp_cwnd();
|
||||||
self.last_ack_time = now;
|
}
|
||||||
|
|
||||||
|
/// Hard ceiling on the congestion window.
|
||||||
|
///
|
||||||
|
/// Independent of any estimate: no real path this protocol runs over has a
|
||||||
|
/// bandwidth-delay product anywhere near this, so a window above it is
|
||||||
|
/// buffered queue rather than data in transit. Without it, slow start on a
|
||||||
|
/// buffer that never drops could grow the window into the tens of megabytes.
|
||||||
|
fn clamp_cwnd(&mut self) {
|
||||||
|
let ceiling = MAX_CWND_PACKETS.saturating_mul(self.mtu);
|
||||||
|
if self.cwnd > ceiling {
|
||||||
|
self.cwnd = ceiling;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a loss event.
|
/// Record a loss event.
|
||||||
|
|
@ -180,11 +337,28 @@ impl CongestionController {
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
Phase::SlowStart => {
|
Phase::SlowStart => {
|
||||||
// Exit slow start, set ssthresh to half of cwnd
|
let now = Instant::now();
|
||||||
self.ssthresh = self.cwnd / 2;
|
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
|
||||||
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
// Previous window's losses have aged out - this loss starts a fresh count.
|
||||||
self.phase = Phase::ProbeBandwidth;
|
self.slow_start_losses = 0;
|
||||||
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
|
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 => {
|
Phase::ProbeBandwidth => {
|
||||||
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||||
|
|
@ -273,6 +447,138 @@ mod tests {
|
||||||
assert!(cc.cwnd() < initial);
|
assert!(cc.cwnd() < initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
|
||||||
|
/// ever lost, but the standing queue inflates RTT. A loss-only controller
|
||||||
|
/// grows cwnd forever here — which is how a session ends up reporting
|
||||||
|
/// multi-second RTT and stalling video.
|
||||||
|
#[test]
|
||||||
|
fn test_rtt_inflation_halts_growth_without_any_loss() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
|
||||||
|
// Establish a low path floor; this becomes min_rtt.
|
||||||
|
for _ in 0..4 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let cwnd_before = cc.cwnd();
|
||||||
|
|
||||||
|
// Queue builds: RTT climbs far above the floor, still zero loss.
|
||||||
|
for _ in 0..20 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(400));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cc.cwnd() <= cwnd_before,
|
||||||
|
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
|
||||||
|
cwnd_before,
|
||||||
|
cc.cwnd()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pacing must actually bound the release rate: draining the bucket has to
|
||||||
|
/// deny the next packet. Without this the congestion window alone decides,
|
||||||
|
/// and a whole window leaves back-to-back.
|
||||||
|
#[test]
|
||||||
|
fn test_pacing_bucket_denies_once_drained() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
|
||||||
|
|
||||||
|
// Spend well beyond one burst allowance.
|
||||||
|
let burst_bytes = cc.pacing_available();
|
||||||
|
let mut spent = 0.0;
|
||||||
|
while spent <= burst_bytes + 1200.0 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
spent += 1200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!cc.can_pace_packet(),
|
||||||
|
"pacing allowed unbounded sending: {} bytes still available after spending {}",
|
||||||
|
cc.pacing_available(),
|
||||||
|
spent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The allowance must refill over time, or sending would stall permanently
|
||||||
|
/// once the first burst is spent.
|
||||||
|
#[test]
|
||||||
|
fn test_pacing_bucket_refills_over_time() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
while cc.can_pace_packet() {
|
||||||
|
cc.on_send(1200);
|
||||||
|
}
|
||||||
|
assert!(!cc.can_pace_packet());
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
assert!(
|
||||||
|
cc.can_pace_packet(),
|
||||||
|
"pacing bucket never refilled; sending would be stuck forever"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cwnd must never exceed the absolute ceiling, however long slow start
|
||||||
|
/// runs unopposed — above it the window is buffered queue, not throughput.
|
||||||
|
#[test]
|
||||||
|
fn test_cwnd_never_exceeds_absolute_ceiling() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Constant RTT: no inflation signal, so only the hard cap can stop this.
|
||||||
|
for _ in 0..5000 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(30));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
|
||||||
|
"cwnd {} exceeded the {}-packet ceiling",
|
||||||
|
cc.cwnd(),
|
||||||
|
MAX_CWND_PACKETS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
||||||
|
// A single dropped packet (wireless noise, a brief handover blip) is
|
||||||
|
// normal on real links and must not permanently downgrade the
|
||||||
|
// session from exponential to linear growth.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
|
||||||
|
|
||||||
|
// It should still shrink the window somewhat (not ignored entirely),
|
||||||
|
// just far less punishing than the sustained-congestion case.
|
||||||
|
let after_one = cc.cwnd();
|
||||||
|
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sustained_slow_start_loss_exits_slow_start() {
|
||||||
|
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
|
||||||
|
// a real congestion signal and must still trigger the harsher
|
||||||
|
// exit-slow-start + halve response.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
for _ in 0..SLOW_START_LOSS_TOLERANCE {
|
||||||
|
cc.on_loss(1200);
|
||||||
|
}
|
||||||
|
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slow_start_loss_window_resets_after_expiry() {
|
||||||
|
// Two losses far enough apart (window expired between them) must
|
||||||
|
// each be treated as isolated, not accumulated toward the sustained-
|
||||||
|
// loss threshold.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart);
|
||||||
|
|
||||||
|
// Simulate the window having expired by resetting its start
|
||||||
|
// directly (std::thread::sleep in a unit test would be flaky/slow).
|
||||||
|
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
|
||||||
|
assert_eq!(cc.slow_start_losses, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_can_send_limits() {
|
fn test_can_send_limits() {
|
||||||
let mut cc = CongestionController::new(1200);
|
let mut cc = CongestionController::new(1200);
|
||||||
|
|
@ -313,6 +619,23 @@ mod tests {
|
||||||
assert_eq!(rto, Duration::from_millis(150));
|
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]
|
#[test]
|
||||||
fn test_rto_clamp_min() {
|
fn test_rto_clamp_min() {
|
||||||
let cc = CongestionController::new(1200);
|
let cc = CongestionController::new(1200);
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ pub use noise::{NoiseRole, NoiseSession};
|
||||||
pub use obfuscation::{
|
pub use obfuscation::{
|
||||||
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
deobfuscate_header_inplace, deobfuscate_packet_inplace, obfuscate_packet_inplace,
|
||||||
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
derive_obfuscation_key, derive_psk, derive_all_secrets, DerivedSecrets,
|
||||||
|
derive_junk_marker, current_junk_window, JUNK_MARKER_WINDOW_SECS,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use snow::{Builder, HandshakeState, TransportState};
|
use snow::{Builder, HandshakeState};
|
||||||
|
|
||||||
use crate::protocol::ProtocolError;
|
use crate::protocol::ProtocolError;
|
||||||
|
|
||||||
|
|
@ -10,9 +10,15 @@ pub enum NoiseRole {
|
||||||
Responder,
|
Responder,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum NoiseSession {
|
/// A Noise handshake in progress. OSTP does not use snow's transport mode: once
|
||||||
Handshake(Box<HandshakeState>),
|
/// the handshake finishes we extract the raw Split() keys (see [`raw_split`])
|
||||||
Transport(TransportState),
|
/// and drive our own out-of-order AEAD (see `crypto::aead`), because the wire
|
||||||
|
/// protocol needs explicit per-frame nonces for reordering that snow's internal
|
||||||
|
/// nonce counter can't express.
|
||||||
|
///
|
||||||
|
/// [`raw_split`]: NoiseSession::raw_split
|
||||||
|
pub struct NoiseSession {
|
||||||
|
handshake: Box<HandshakeState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NoiseSession {
|
impl NoiseSession {
|
||||||
|
|
@ -36,50 +42,92 @@ impl NoiseSession {
|
||||||
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
|
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self::Handshake(Box::new(handshake)))
|
Ok(Self { handshake: Box::new(handshake) })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||||
match self {
|
self.handshake
|
||||||
NoiseSession::Handshake(hs) => hs
|
.write_message(payload, out)
|
||||||
.write_message(payload, out)
|
.map_err(|_| ProtocolError::Crypto("noise-write".to_string()))
|
||||||
.map_err(|_| ProtocolError::Crypto("noise-write".to_string())),
|
|
||||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||||
match self {
|
self.handshake
|
||||||
NoiseSession::Handshake(hs) => hs
|
.read_message(input, out)
|
||||||
.read_message(input, out)
|
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e)))
|
||||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))),
|
|
||||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> {
|
/// Derive the two directional transport keys via Noise's Split().
|
||||||
match self {
|
///
|
||||||
NoiseSession::Handshake(hs) => {
|
/// SECURITY: keys are taken from the final chaining key `ck` (which absorbs
|
||||||
let hash = hs.get_handshake_hash();
|
/// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h`
|
||||||
if out.len() != hash.len() {
|
/// (which only absorbs public transcript data — ephemeral pubkeys and
|
||||||
return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string()));
|
/// ciphertexts — and never the DH secret). Deriving from `ck` is what gives
|
||||||
}
|
/// the session forward secrecy: an adversary who later learns the PSK still
|
||||||
out.copy_from_slice(hash);
|
/// cannot recompute these keys without the ephemeral private keys, which are
|
||||||
Ok(())
|
/// discarded after the handshake.
|
||||||
}
|
///
|
||||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
/// Must only be called once the handshake is finished (both messages of the
|
||||||
}
|
/// NNpsk0 exchange processed); at that point `ck` is final. Returns
|
||||||
}
|
/// `(send_key, recv_key)` for the given role, matching snow's TransportState
|
||||||
|
/// direction mapping: split output `.0` is initiator→responder, `.1` is
|
||||||
pub fn into_transport(self) -> Result<Self, ProtocolError> {
|
/// responder→initiator.
|
||||||
match self {
|
pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> {
|
||||||
NoiseSession::Handshake(hs) => {
|
if !self.handshake.is_handshake_finished() {
|
||||||
let transport = hs
|
return Err(ProtocolError::State("handshake not finished at key split".to_string()));
|
||||||
.into_transport_mode()
|
|
||||||
.map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?;
|
|
||||||
Ok(NoiseSession::Transport(transport))
|
|
||||||
}
|
|
||||||
NoiseSession::Transport(_) => Ok(self),
|
|
||||||
}
|
}
|
||||||
|
let (k0, k1) = self.handshake.dangerously_get_raw_split();
|
||||||
|
Ok(match role {
|
||||||
|
// Initiator sends on .0 (i→r), receives on .1 (r→i).
|
||||||
|
NoiseRole::Initiator => (k0, k1),
|
||||||
|
// Responder is the mirror image.
|
||||||
|
NoiseRole::Responder => (k1, k0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Drive a full NNpsk0 handshake and confirm both sides derive matching
|
||||||
|
/// directional keys. This guards the .0/.1 → send/recv role mapping in
|
||||||
|
/// `raw_split`: if it were wrong, the two sides' send/recv keys wouldn't
|
||||||
|
/// cross-match and the transport channel would silently fail to decrypt.
|
||||||
|
#[test]
|
||||||
|
fn raw_split_keys_agree_across_roles() {
|
||||||
|
let psk = [7u8; 32];
|
||||||
|
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
|
||||||
|
let mut responder = NoiseSession::new(NoiseRole::Responder, &psk).unwrap();
|
||||||
|
|
||||||
|
// msg1: initiator -> responder
|
||||||
|
let mut buf1 = [0u8; 1024];
|
||||||
|
let n1 = initiator.write_handshake(&[], &mut buf1).unwrap();
|
||||||
|
let mut tmp = [0u8; 1024];
|
||||||
|
responder.read_handshake(&buf1[..n1], &mut tmp).unwrap();
|
||||||
|
|
||||||
|
// msg2: responder -> initiator
|
||||||
|
let mut buf2 = [0u8; 1024];
|
||||||
|
let n2 = responder.write_handshake(&[], &mut buf2).unwrap();
|
||||||
|
initiator.read_handshake(&buf2[..n2], &mut tmp).unwrap();
|
||||||
|
|
||||||
|
let (i_send, i_recv) = initiator.raw_split(NoiseRole::Initiator).unwrap();
|
||||||
|
let (r_send, r_recv) = responder.raw_split(NoiseRole::Responder).unwrap();
|
||||||
|
|
||||||
|
// What the initiator sends with, the responder must receive with.
|
||||||
|
assert_eq!(i_send, r_recv, "initiator send key must equal responder recv key");
|
||||||
|
assert_eq!(r_send, i_recv, "responder send key must equal initiator recv key");
|
||||||
|
// The two directions use distinct keys.
|
||||||
|
assert_ne!(i_send, i_recv, "the two directions must not share a key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// raw_split must refuse to hand out keys before the handshake is complete —
|
||||||
|
/// keys taken from a half-mixed chaining key would be wrong and insecure.
|
||||||
|
#[test]
|
||||||
|
fn raw_split_rejected_before_handshake_finishes() {
|
||||||
|
let psk = [9u8; 32];
|
||||||
|
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
|
||||||
|
// No messages exchanged yet: handshake not finished.
|
||||||
|
assert!(initiator.raw_split(NoiseRole::Initiator).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,16 @@ fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec<u8> {
|
||||||
/// The derivation uses the access key as both IKM and salt material,
|
/// The derivation uses the access key as both IKM and salt material,
|
||||||
/// split into two halves. No fixed strings are used — the access key
|
/// split into two halves. No fixed strings are used — the access key
|
||||||
/// alone determines all derived values.
|
/// alone determines all derived values.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct DerivedSecrets {
|
pub struct DerivedSecrets {
|
||||||
pub obfuscation_key: [u8; 8],
|
pub obfuscation_key: [u8; 8],
|
||||||
pub psk: [u8; 32],
|
pub psk: [u8; 32],
|
||||||
pub handshake_pad_min: usize,
|
pub handshake_pad_min: usize,
|
||||||
pub handshake_pad_max: usize,
|
pub handshake_pad_max: usize,
|
||||||
/// Per-key 4-byte prefix stamped on junk frames so the server can drop them
|
|
||||||
/// without a GLOBAL constant marker (which would be a universal DPI signature
|
|
||||||
/// for all OSTP users — exactly what the version gate avoids for the handshake).
|
|
||||||
pub junk_marker: [u8; 4],
|
|
||||||
}
|
}
|
||||||
|
// NOTE: the junk marker is NOT part of DerivedSecrets — it is time-rotating and
|
||||||
|
// derived separately per window via `derive_junk_marker` (see below), so it
|
||||||
|
// carries no static per-user signature.
|
||||||
|
|
||||||
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
/// OSTP wire protocol version. Mixed into key derivation (NOT sent on the
|
||||||
/// wire) so peers running incompatible versions derive entirely different
|
/// wire) so peers running incompatible versions derive entirely different
|
||||||
|
|
@ -75,8 +75,11 @@ pub struct DerivedSecrets {
|
||||||
/// without a version) produces a different obfuscation key, so a 0.4.0 server
|
/// 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.
|
/// 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.
|
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4;
|
||||||
pub const PROTOCOL_VERSION: u8 = 4;
|
/// version 5 (0.4.x hardening) moved transport keys from the handshake hash to
|
||||||
|
/// Noise's Split() output — a wire-breaking crypto change, so old peers must not
|
||||||
|
/// interop (they would derive different session keys and fail decryption).
|
||||||
|
pub const PROTOCOL_VERSION: u8 = 5;
|
||||||
|
|
||||||
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||||
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
|
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
|
||||||
|
|
@ -129,25 +132,61 @@ pub(crate) fn derive_all_secrets_versioned(access_key: &[u8], version: u8) -> De
|
||||||
let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
|
let pad_min = 16 + (pad_bytes[0] as usize % 64); // 16-79
|
||||||
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175
|
let pad_max = pad_min + 48 + (pad_bytes[1] as usize % 128); // +48..+175
|
||||||
|
|
||||||
// Derive junk marker (4 bytes) — info = key_hash[16..] || 0x04.
|
|
||||||
// Per-key: to an outsider it is indistinguishable from the random junk
|
|
||||||
// payload, so there is no cross-user signature; the server, knowing the key,
|
|
||||||
// derives the same marker and drops the junk silently.
|
|
||||||
let mut junk_info = info_base.to_vec();
|
|
||||||
junk_info.push(0x04);
|
|
||||||
let junk_bytes = hkdf_expand(&prk, &junk_info, 4);
|
|
||||||
let mut junk_marker = [0u8; 4];
|
|
||||||
junk_marker.copy_from_slice(&junk_bytes);
|
|
||||||
|
|
||||||
DerivedSecrets {
|
DerivedSecrets {
|
||||||
obfuscation_key,
|
obfuscation_key,
|
||||||
psk,
|
psk,
|
||||||
handshake_pad_min: pad_min,
|
handshake_pad_min: pad_min,
|
||||||
handshake_pad_max: pad_max,
|
handshake_pad_max: pad_max,
|
||||||
junk_marker,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Window length (seconds) for the rotating junk marker. The marker changes
|
||||||
|
/// every window, so junk carries no static per-user fingerprint on the wire;
|
||||||
|
/// the server checks the current and previous window to absorb clock skew.
|
||||||
|
pub const JUNK_MARKER_WINDOW_SECS: u64 = 60;
|
||||||
|
|
||||||
|
/// The current junk-marker time window (unix seconds / window length).
|
||||||
|
pub fn current_junk_window() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() / JUNK_MARKER_WINDOW_SECS)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the 4-byte junk marker for a given time `window`.
|
||||||
|
///
|
||||||
|
/// Uses the same version-gated HKDF scheme as [`derive_all_secrets`], with the
|
||||||
|
/// window folded into the `info` (label byte `0x04`). Folding in the window
|
||||||
|
/// makes the marker rotate: to an on-path observer the junk prefix changes every
|
||||||
|
/// window (no fixed signature), and a captured marker is only valid for ~1
|
||||||
|
/// window. Only a holder of the access key can compute it, so an outsider cannot
|
||||||
|
/// forge a silently-dropped junk packet.
|
||||||
|
pub fn derive_junk_marker(access_key: &[u8], window: u64) -> [u8; 4] {
|
||||||
|
derive_junk_marker_versioned(access_key, window, PROTOCOL_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn derive_junk_marker_versioned(access_key: &[u8], window: u64, version: u8) -> [u8; 4] {
|
||||||
|
use sha2::Digest;
|
||||||
|
let key_hash = sha2::Sha256::digest(access_key);
|
||||||
|
let salt = &key_hash[..16];
|
||||||
|
let info_base = &key_hash[16..];
|
||||||
|
|
||||||
|
let mut ikm = Vec::with_capacity(access_key.len() + 1);
|
||||||
|
ikm.extend_from_slice(access_key);
|
||||||
|
ikm.push(version);
|
||||||
|
let prk = hkdf_extract(salt, &ikm);
|
||||||
|
|
||||||
|
// info = key_hash[16..] || 0x04 || window(LE) — same label byte as before,
|
||||||
|
// now parameterised by the time window.
|
||||||
|
let mut info = info_base.to_vec();
|
||||||
|
info.push(0x04);
|
||||||
|
info.extend_from_slice(&window.to_le_bytes());
|
||||||
|
let bytes = hkdf_expand(&prk, &info, 4);
|
||||||
|
let mut marker = [0u8; 4];
|
||||||
|
marker.copy_from_slice(&bytes);
|
||||||
|
marker
|
||||||
|
}
|
||||||
|
|
||||||
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
// ── Legacy API (delegates to derive_all_secrets) ─────────────────────────────
|
||||||
|
|
||||||
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
pub fn derive_obfuscation_key(access_key: &[u8]) -> [u8; 8] {
|
||||||
|
|
|
||||||
|
|
@ -191,4 +191,29 @@ mod tests {
|
||||||
assert_eq!(recovered_nonce, nonce);
|
assert_eq!(recovered_nonce, nonce);
|
||||||
assert_eq!(&packet[12..], &ciphertext);
|
assert_eq!(&packet[12..], &ciphertext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The junk marker must: be stable within a window (client and server agree),
|
||||||
|
/// rotate across windows (no static on-wire fingerprint), and differ per key
|
||||||
|
/// (one user's marker never silently-drops on another user's flow).
|
||||||
|
#[test]
|
||||||
|
fn test_junk_marker_rotation() {
|
||||||
|
let key_a = b"access-key-alpha";
|
||||||
|
let key_b = b"access-key-bravo";
|
||||||
|
|
||||||
|
// Stable within a window.
|
||||||
|
assert_eq!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1000));
|
||||||
|
|
||||||
|
// Rotates across adjacent windows.
|
||||||
|
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 1001));
|
||||||
|
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_a, 999));
|
||||||
|
|
||||||
|
// Distinct per key within the same window.
|
||||||
|
assert_ne!(derive_junk_marker(key_a, 1000), derive_junk_marker(key_b, 1000));
|
||||||
|
|
||||||
|
// A different protocol version yields a different marker (version gate).
|
||||||
|
assert_ne!(
|
||||||
|
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION),
|
||||||
|
derive_junk_marker_versioned(key_a, 1000, PROTOCOL_VERSION.wrapping_add(1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ pub enum FrameKind {
|
||||||
KeepAlive = 4,
|
KeepAlive = 4,
|
||||||
Nack = 5,
|
Nack = 5,
|
||||||
Ack = 6,
|
Ack = 6,
|
||||||
/// 0-RTT session resumption: client sends ticket + early data
|
|
||||||
Resume = 7,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<u8> for FrameKind {
|
impl TryFrom<u8> for FrameKind {
|
||||||
|
|
@ -28,7 +26,6 @@ impl TryFrom<u8> for FrameKind {
|
||||||
4 => Ok(Self::KeepAlive),
|
4 => Ok(Self::KeepAlive),
|
||||||
5 => Ok(Self::Nack),
|
5 => Ok(Self::Nack),
|
||||||
6 => Ok(Self::Ack),
|
6 => Ok(Self::Ack),
|
||||||
7 => Ok(Self::Resume),
|
|
||||||
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
|
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +101,15 @@ impl FramedPacket {
|
||||||
let payload_len = header.payload_len as usize;
|
let payload_len = header.payload_len as usize;
|
||||||
let pad_len = header.pad_len as usize;
|
let pad_len = header.pad_len as usize;
|
||||||
|
|
||||||
let expected = FRAME_HEADER_LEN + payload_len + pad_len;
|
// Use checked arithmetic: payload_len is a u32 from the (decrypted, but
|
||||||
|
// still to-be-trusted) header, and on 32-bit targets — MIPS/ARMv7
|
||||||
|
// routers are supported build targets — header+payload+pad can overflow
|
||||||
|
// usize and wrap to a small value that spuriously passes the length
|
||||||
|
// check, causing an out-of-range slice below.
|
||||||
|
let expected = FRAME_HEADER_LEN
|
||||||
|
.checked_add(payload_len)
|
||||||
|
.and_then(|v| v.checked_add(pad_len))
|
||||||
|
.ok_or_else(|| ProtocolError::Framing("frame length overflow".to_string()))?;
|
||||||
if buf.len() < expected {
|
if buf.len() < expected {
|
||||||
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ pub mod crypto;
|
||||||
pub mod framing;
|
pub mod framing;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
pub mod resumption;
|
|
||||||
|
|
||||||
pub use crypto::NoiseRole;
|
pub use crypto::NoiseRole;
|
||||||
pub use framing::{TrafficProfile, PaddingStrategy};
|
pub use framing::{TrafficProfile, PaddingStrategy};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Upper bound on a single frame's retransmit timer, after exponential backoff
|
||||||
|
/// is applied to the adaptive RTO. Past this the session is dead from the
|
||||||
|
/// user's point of view, and waiting longer only delays recovery.
|
||||||
|
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
use crate::congestion::CongestionController;
|
use crate::congestion::CongestionController;
|
||||||
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
||||||
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
||||||
|
|
@ -103,6 +107,17 @@ pub struct ProtocolMachine {
|
||||||
_mtu: usize,
|
_mtu: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gap recovery (see `ProtocolMachine::recover_stalled_gap`) ────────────────
|
||||||
|
// How long the receive sequence may sit stuck behind a missing frame, with
|
||||||
|
// later frames already buffered, before that frame is declared unrecoverable
|
||||||
|
// and skipped. Derived from the live RTO so it scales with the path instead of
|
||||||
|
// guessing, then clamped: the floor keeps a fast link from discarding a frame
|
||||||
|
// that is merely late, the ceiling bounds how long a stall can be visible to
|
||||||
|
// the user before the tunnel unblocks itself.
|
||||||
|
const GAP_RECOVERY_RTO_MULTIPLIER: u32 = 8;
|
||||||
|
const GAP_RECOVERY_MIN: Duration = Duration::from_secs(2);
|
||||||
|
const GAP_RECOVERY_MAX: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct SentFrame {
|
struct SentFrame {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
|
|
@ -156,10 +171,33 @@ impl ProtocolMachine {
|
||||||
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sum of retry counters across in-flight frames. Test-only: lets a test
|
||||||
|
/// assert the core retransmit invariant (a retry is only ever charged to a
|
||||||
|
/// frame that was actually put on the wire) without needing to advance the
|
||||||
|
/// clock through several seconds of exponential backoff.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn total_retries(&self) -> usize {
|
||||||
|
self.sent_history
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.is_retransmittable)
|
||||||
|
.map(|f| f.retries as usize)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cwnd_packets(&self) -> usize {
|
pub fn cwnd_packets(&self) -> usize {
|
||||||
self.cc.cwnd_packets() as usize
|
self.cc.cwnd_packets() as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the pacing bucket currently allows releasing another packet.
|
||||||
|
///
|
||||||
|
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
|
||||||
|
/// nothing about how fast that window is emptied onto the wire. Sending a
|
||||||
|
/// whole window back-to-back is what drives a deep buffer into standing
|
||||||
|
/// queue, so admission is gated on both.
|
||||||
|
pub fn can_pace_packet(&self) -> bool {
|
||||||
|
self.cc.can_pace_packet()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_send(&mut self, bytes: u64) {
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
self.cc.on_send(bytes);
|
self.cc.on_send(bytes);
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +275,9 @@ impl ProtocolMachine {
|
||||||
|
|
||||||
let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]);
|
let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]);
|
||||||
if session_id != self.session_id {
|
if session_id != self.session_id {
|
||||||
tracing::error!("session id mismatch! expected={:#010x}, got={:#010x}, is_handshake={}, raw_len={}", self.session_id, session_id, is_handshake, raw_vec.len());
|
// Per-packet, attacker-triggerable event: keep at debug and don't
|
||||||
|
// dump internal session ids (log-flood + info-leak surface).
|
||||||
|
tracing::debug!("session id mismatch (is_handshake={})", is_handshake);
|
||||||
return Err(ProtocolError::State("session id mismatch".to_string()));
|
return Err(ProtocolError::State("session id mismatch".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,8 +303,7 @@ impl ProtocolMachine {
|
||||||
noise_len, raw_vec.len() - 6
|
noise_len, raw_vec.len() - 6
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
tracing::info!("handle_inbound: raw_vec.len()={}, noise_len={}, raw_vec[0..6]={:?}", raw_vec.len(), noise_len, &raw_vec[0..6]);
|
|
||||||
|
|
||||||
let mut read_out = vec![0_u8; 1024];
|
let mut read_out = vec![0_u8; 1024];
|
||||||
let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
|
let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
|
||||||
ProtocolError::Crypto(format!("noise-read: {:?} (raw_len={}, noise_len={})", e, raw_vec.len(), noise_len))
|
ProtocolError::Crypto(format!("noise-read: {:?} (raw_len={}, noise_len={})", e, raw_vec.len(), noise_len))
|
||||||
|
|
@ -281,9 +320,12 @@ impl ProtocolMachine {
|
||||||
NoiseRole::Initiator => None,
|
NoiseRole::Initiator => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut key = [0_u8; 32];
|
// Transport keys come from Noise's Split() over the final chaining key,
|
||||||
self.noise.handshake_hash(&mut key)?;
|
// so they depend on the ephemeral `ee` DH secret and give the session
|
||||||
let (send_key, recv_key) = derive_split_keys(&key, self.role);
|
// forward secrecy. (Previously these were derived from the handshake
|
||||||
|
// hash, which never absorbs the DH result — see raw_split's SECURITY
|
||||||
|
// note. That is the wire-breaking change gated by PROTOCOL_VERSION.)
|
||||||
|
let (send_key, recv_key) = self.noise.raw_split(self.role)?;
|
||||||
self.send_cipher = Some(SessionCipher::new(&send_key));
|
self.send_cipher = Some(SessionCipher::new(&send_key));
|
||||||
self.recv_cipher = Some(SessionCipher::new(&recv_key));
|
self.recv_cipher = Some(SessionCipher::new(&recv_key));
|
||||||
self.state = OstpState::Established;
|
self.state = OstpState::Established;
|
||||||
|
|
@ -293,7 +335,107 @@ impl ProtocolMachine {
|
||||||
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores liveness when the receive sequence is stuck behind a frame that
|
||||||
|
/// can never arrive.
|
||||||
|
///
|
||||||
|
/// Delivery is gated on `expected_recv_nonce`, so a single missing frame
|
||||||
|
/// holds back every later frame. That is correct *while the sender can still
|
||||||
|
/// retransmit* — but the sender drops a frame from `sent_history` once it
|
||||||
|
/// exceeds `max_retries + 2` attempts (see the zombie eviction in
|
||||||
|
/// `handle_tick`). After that the frame is gone for good and the two sides
|
||||||
|
/// deadlock: the receiver buffers forever and NACKs a nonce nobody can
|
||||||
|
/// resend.
|
||||||
|
///
|
||||||
|
/// That deadlock is invisible to the keepalive watchdog, which is why it
|
||||||
|
/// presented as a hard freeze rather than a reconnect: retransmits, ACKs and
|
||||||
|
/// NACKs keep flowing, so the client's `last_valid_recv` keeps refreshing and
|
||||||
|
/// its stall detector never fires. The RTT readout freezes at its last value
|
||||||
|
/// for the same reason — Pong rides in a Data frame stuck behind the gap.
|
||||||
|
///
|
||||||
|
/// So: once we have been stuck long enough that retransmission has provably
|
||||||
|
/// given up, skip to the lowest buffered nonce and drain. This drops the
|
||||||
|
/// missing frame's payload (one RelayMessage — a chunk of one stream), which
|
||||||
|
/// is a real cost, but the alternative is a permanently dead tunnel.
|
||||||
|
fn recover_stalled_gap(&mut self) -> Vec<ProtocolAction> {
|
||||||
|
let mut recovered = Vec::new();
|
||||||
|
if self.reorder_buffer.is_empty() {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait out the sender's full retransmit budget before giving up, so a
|
||||||
|
// frame that is merely late is never discarded. The sender backs off
|
||||||
|
// exponentially, so key this off the live RTO estimate rather than a
|
||||||
|
// flat constant, with a floor that keeps low-RTT links from skipping
|
||||||
|
// too eagerly and a ceiling that bounds the visible freeze.
|
||||||
|
let timeout = self
|
||||||
|
.cc
|
||||||
|
.rto()
|
||||||
|
.saturating_mul(GAP_RECOVERY_RTO_MULTIPLIER)
|
||||||
|
.clamp(GAP_RECOVERY_MIN, GAP_RECOVERY_MAX);
|
||||||
|
if self.last_recv_advance.elapsed() < timeout {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(&resume_at) = self.reorder_buffer.keys().next() else {
|
||||||
|
return recovered;
|
||||||
|
};
|
||||||
|
let skipped = resume_at.saturating_sub(self.expected_recv_nonce);
|
||||||
|
tracing::warn!(
|
||||||
|
"Gap recovery: no progress for {:?}; skipping {} unrecoverable frame(s) \
|
||||||
|
(nonce {} -> {}) to unblock the session",
|
||||||
|
self.last_recv_advance.elapsed(),
|
||||||
|
skipped,
|
||||||
|
self.expected_recv_nonce,
|
||||||
|
resume_at
|
||||||
|
);
|
||||||
|
|
||||||
|
self.expected_recv_nonce = resume_at;
|
||||||
|
while let Some(buffered) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
||||||
|
recovered.push(buffered);
|
||||||
|
match self.expected_recv_nonce.checked_add(1) {
|
||||||
|
Some(next) => self.expected_recv_nonce = next,
|
||||||
|
// u64 nonce space exhausted: stop draining rather than wrap.
|
||||||
|
// The session is finished either way; the caller's next decrypt
|
||||||
|
// will fail and tear it down.
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_recv_advance = Instant::now();
|
||||||
|
// The peer must learn the sequence moved on, or it will keep
|
||||||
|
// retransmitting into the void.
|
||||||
|
self.ack_pending = true;
|
||||||
|
|
||||||
|
recovered
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
|
// Check for a stalled gap before classifying this frame, so the rest of
|
||||||
|
// the function sees an already-advanced `expected_recv_nonce`. Runs here
|
||||||
|
// rather than on Tick because both tick handlers discard DeliverApp
|
||||||
|
// actions, and because inbound frames keep arriving throughout the stall
|
||||||
|
// (retransmits/ACKs/NACKs/keepalives) — so this path is reliably reached.
|
||||||
|
let recovered = self.recover_stalled_gap();
|
||||||
|
let result = self.handle_data_inbound_frame(raw_vec)?;
|
||||||
|
if recovered.is_empty() {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovered payloads are older than anything this frame produces, so
|
||||||
|
// they go first to preserve delivery order.
|
||||||
|
let mut all = recovered;
|
||||||
|
match result {
|
||||||
|
ProtocolAction::Noop => {}
|
||||||
|
ProtocolAction::Multiple(list) => all.extend(list),
|
||||||
|
single => all.push(single),
|
||||||
|
}
|
||||||
|
Ok(if all.len() == 1 {
|
||||||
|
all.pop().unwrap()
|
||||||
|
} else {
|
||||||
|
ProtocolAction::Multiple(all)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_data_inbound_frame(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
if raw_vec.len() < 12 {
|
if raw_vec.len() < 12 {
|
||||||
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -358,13 +500,8 @@ impl ProtocolMachine {
|
||||||
FrameKind::Data => {
|
FrameKind::Data => {
|
||||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
||||||
}
|
}
|
||||||
FrameKind::Resume => {
|
|
||||||
// 0-RTT: treat early data as application data
|
|
||||||
tracing::info!("0-RTT Resume frame received, processing early data");
|
|
||||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
|
||||||
}
|
|
||||||
FrameKind::Close => {
|
FrameKind::Close => {
|
||||||
tracing::info!("Received Close frame, terminating session");
|
tracing::debug!("Received Close frame, terminating session");
|
||||||
self.state = OstpState::Closed;
|
self.state = OstpState::Closed;
|
||||||
ProtocolAction::Noop
|
ProtocolAction::Noop
|
||||||
}
|
}
|
||||||
|
|
@ -545,18 +682,39 @@ impl ProtocolMachine {
|
||||||
if !frame.is_retransmittable {
|
if !frame.is_retransmittable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Out of budget for this tick — stop scanning rather than walking the
|
||||||
|
// rest of the queue. sent_history is in send order, so everything we
|
||||||
|
// skip is strictly newer than what we already handled; deferring it to
|
||||||
|
// the next tick preserves oldest-first retransmit priority.
|
||||||
|
if retransmit_budget == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
||||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor))
|
||||||
|
.min(MAX_EFFECTIVE_RTO);
|
||||||
|
|
||||||
if now.duration_since(frame.last_sent) >= effective_rto {
|
if now.duration_since(frame.last_sent) >= effective_rto {
|
||||||
|
// Only burn the retry counter and reset the RTO timer when the
|
||||||
|
// frame is ACTUALLY put on the wire. Doing it unconditionally
|
||||||
|
// meant that whenever the per-tick budget ran out — which is
|
||||||
|
// exactly when loss is heavy and retransmits matter most —
|
||||||
|
// frames accumulated "phantom retries" they never actually got,
|
||||||
|
// and the zombie eviction above then silently dropped them after
|
||||||
|
// `grace` such rounds. The peer never received that data and
|
||||||
|
// never would: that stream stalls forever while the session
|
||||||
|
// itself stays healthy, which is precisely the reported "tunnel
|
||||||
|
// frozen at 0 b/s but the session still up" symptom.
|
||||||
frame.last_sent = now;
|
frame.last_sent = now;
|
||||||
frame.retries = frame.retries.saturating_add(1);
|
frame.retries = frame.retries.saturating_add(1);
|
||||||
|
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||||
if retransmit_budget > 0 {
|
retransmit_budget -= 1;
|
||||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
|
||||||
retransmit_budget -= 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -685,24 +843,34 @@ impl ProtocolMachine {
|
||||||
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut acked_bytes = 0u64;
|
let mut acked_bytes = 0u64;
|
||||||
let mut min_rtt = Duration::from_secs(60);
|
let mut min_rtt: Option<Duration> = None;
|
||||||
|
|
||||||
// Compute RTT from the oldest acked frame's send timestamp
|
|
||||||
for frame in self.sent_history.iter() {
|
for frame in self.sent_history.iter() {
|
||||||
if nonce_in_ranges(frame.nonce, ranges) {
|
if nonce_in_ranges(frame.nonce, ranges) {
|
||||||
acked_bytes += frame.bytes.len() as u64;
|
acked_bytes += frame.bytes.len() as u64;
|
||||||
let rtt = now.duration_since(frame.last_sent);
|
// Karn's algorithm: never take an RTT sample from a frame that
|
||||||
if rtt < min_rtt {
|
// was retransmitted. `last_sent` is bumped on every retransmit,
|
||||||
min_rtt = rtt;
|
// 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));
|
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
|
||||||
|
|
||||||
// Notify congestion controller
|
// Notify congestion controller. Feed an RTT sample only when we had at
|
||||||
|
// least one unambiguous ACK; otherwise update the window without
|
||||||
|
// polluting the RTT estimator.
|
||||||
if acked_bytes > 0 {
|
if acked_bytes > 0 {
|
||||||
self.cc.on_ack(acked_bytes, min_rtt);
|
match min_rtt {
|
||||||
|
Some(rtt) => self.cc.on_ack(acked_bytes, rtt),
|
||||||
|
None => self.cc.on_ack_no_rtt(acked_bytes),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -732,26 +900,6 @@ fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool {
|
||||||
ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end)
|
ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_split_keys(base_key: &[u8; 32], role: NoiseRole) -> ([u8; 32], [u8; 32]) {
|
|
||||||
let mut initiator_key = [0u8; 32];
|
|
||||||
let mut responder_key = [0u8; 32];
|
|
||||||
|
|
||||||
let mut h1 = Sha256::new();
|
|
||||||
h1.update(base_key);
|
|
||||||
h1.update(b"ostp-initiator");
|
|
||||||
initiator_key.copy_from_slice(&h1.finalize());
|
|
||||||
|
|
||||||
let mut h2 = Sha256::new();
|
|
||||||
h2.update(base_key);
|
|
||||||
h2.update(b"ostp-responder");
|
|
||||||
responder_key.copy_from_slice(&h2.finalize());
|
|
||||||
|
|
||||||
match role {
|
|
||||||
NoiseRole::Initiator => (initiator_key, responder_key),
|
|
||||||
NoiseRole::Responder => (responder_key, initiator_key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -983,4 +1131,154 @@ mod tests {
|
||||||
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
||||||
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A retry may only be charged to a frame that was actually retransmitted.
|
||||||
|
///
|
||||||
|
/// The retransmit loop is budget-limited per tick. It used to bump
|
||||||
|
/// `retries` and reset `last_sent` for every due frame regardless of
|
||||||
|
/// whether the budget allowed it to actually send — so under heavy loss
|
||||||
|
/// (exactly when the budget runs out) frames racked up retries they never
|
||||||
|
/// received, and the zombie eviction dropped them after `max_retries + 2`
|
||||||
|
/// such rounds. That data was never delivered and never would be: the
|
||||||
|
/// stream stalls permanently while the session itself stays up.
|
||||||
|
#[test]
|
||||||
|
fn test_retransmit_budget_charges_retries_only_for_frames_actually_sent() {
|
||||||
|
let (mut client, _server) = do_handshake();
|
||||||
|
|
||||||
|
// Queue far more in-flight frames than a single tick's budget allows.
|
||||||
|
const FRAMES: usize = 40;
|
||||||
|
for i in 0..FRAMES {
|
||||||
|
let payload = Bytes::from(vec![i as u8; 200]);
|
||||||
|
client.on_event(OstpEvent::Outbound(1, payload)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(client.in_flight_count(), FRAMES);
|
||||||
|
assert_eq!(client.total_retries(), 0, "nothing retransmitted yet");
|
||||||
|
|
||||||
|
// Let every frame's RTO lapse so that on the next tick all FRAMES frames
|
||||||
|
// are due at once and the per-tick budget is guaranteed to run out. The
|
||||||
|
// effective RTO here is max(cc.rto(), config rto_ms) = 100ms at retries=0.
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
|
||||||
|
let sent = count_datagrams(&client.on_event(OstpEvent::Tick).unwrap());
|
||||||
|
|
||||||
|
assert!(sent > 0, "expected some retransmits after the RTO lapsed");
|
||||||
|
assert!(
|
||||||
|
sent < FRAMES,
|
||||||
|
"budget should have capped this tick below the {FRAMES} due frames, got {sent}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.total_retries(),
|
||||||
|
sent,
|
||||||
|
"charged {} retries but only put {} frames on the wire — the \
|
||||||
|
difference is phantom retries that will silently evict live data",
|
||||||
|
client.total_retries(),
|
||||||
|
sent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.in_flight_count(),
|
||||||
|
FRAMES,
|
||||||
|
"nothing was acked, so no frame may be evicted yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many datagrams an action tree actually puts on the wire.
|
||||||
|
fn count_datagrams(action: &ProtocolAction) -> usize {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::SendDatagram(_) => 1,
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().map(count_datagrams).sum(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many application payloads an action tree actually delivers.
|
||||||
|
fn delivered_payloads(action: &ProtocolAction) -> Vec<Bytes> {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::DeliverApp(_, data) => vec![data.clone()],
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().flat_map(delivered_payloads).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `count` data frames on `client`, returning them without delivering
|
||||||
|
/// any — lets a test choose which ones to "lose" in transit.
|
||||||
|
fn make_data_frames(client: &mut ProtocolMachine, count: u8) -> Vec<Bytes> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| {
|
||||||
|
let payload = Bytes::from(vec![i; 32]);
|
||||||
|
match client.on_event(OstpEvent::Outbound(1, payload)).unwrap() {
|
||||||
|
ProtocolAction::SendDatagram(d) => d,
|
||||||
|
_ => panic!("expected SendDatagram for frame {i}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The freeze this fixes: a frame is lost, the sender eventually stops
|
||||||
|
/// retransmitting it, and the receiver — which gates delivery on
|
||||||
|
/// `expected_recv_nonce` — waits for it forever. Every later frame piles up
|
||||||
|
/// undelivered while the transport itself stays healthy, so nothing upstream
|
||||||
|
/// notices. Recovery must eventually skip the hole and release the backlog.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_releases_permanently_stalled_frames() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 4);
|
||||||
|
|
||||||
|
// Frame 0 arrives in order and is delivered straight through.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert_eq!(delivered_payloads(&action).len(), 1, "in-order frame should deliver");
|
||||||
|
|
||||||
|
// Frame 1 is lost. 2 and 3 arrive but must be held back — delivering them
|
||||||
|
// now would reorder the stream.
|
||||||
|
for idx in [2usize, 3] {
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[idx].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"frame {idx} must stay buffered behind the missing frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand in for "the sender exhausted its retries and dropped frame 1":
|
||||||
|
// the sequence has not advanced for longer than the recovery timeout.
|
||||||
|
server.last_recv_advance = Instant::now() - GAP_RECOVERY_MAX - Duration::from_secs(1);
|
||||||
|
|
||||||
|
// The next inbound frame (a retransmitted duplicate, which is exactly what
|
||||||
|
// a real stalled session keeps receiving) must unblock the backlog.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(
|
||||||
|
delivered.len(),
|
||||||
|
2,
|
||||||
|
"both buffered frames must be released once the gap is declared unrecoverable"
|
||||||
|
);
|
||||||
|
// ...and in order: frame 2 before frame 3.
|
||||||
|
assert_eq!(delivered[0][0], 2);
|
||||||
|
assert_eq!(delivered[1][0], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovery must not be trigger-happy: a frame that is merely late still has
|
||||||
|
/// to be waited for, or we would discard data the sender is about to resend.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_does_not_fire_before_timeout() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 3);
|
||||||
|
|
||||||
|
server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[2].clone())).unwrap();
|
||||||
|
assert!(delivered_payloads(&action).is_empty());
|
||||||
|
|
||||||
|
// Well inside the timeout — the gap must still be respected.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"must keep waiting while retransmission is still plausible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And once the genuinely-late frame shows up, normal in-order delivery
|
||||||
|
// resumes with nothing dropped.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[1].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(delivered.len(), 2, "late frame plus the buffered one");
|
||||||
|
assert_eq!(delivered[0][0], 1);
|
||||||
|
assert_eq!(delivered[1][0], 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
//! 0-RTT Session Resumption for OSTP.
|
|
||||||
//!
|
|
||||||
//! When a client has previously connected to a server, it can cache
|
|
||||||
//! a "session ticket" that allows it to send encrypted data in the
|
|
||||||
//! very first packet — eliminating the handshake round-trip entirely.
|
|
||||||
//!
|
|
||||||
//! How it works:
|
|
||||||
//! 1. After a successful handshake, the server issues a SessionTicket
|
|
||||||
//! containing enough state to resume the session.
|
|
||||||
//! 2. The client stores the ticket locally (encrypted with the PSK).
|
|
||||||
//! 3. On reconnection, the client sends a ResumptionRequest with the
|
|
||||||
//! ticket + early data in the first packet.
|
|
||||||
//! 4. The server validates the ticket and immediately begins processing
|
|
||||||
//! data, achieving 0-RTT.
|
|
||||||
//!
|
|
||||||
//! Security considerations:
|
|
||||||
//! - Tickets have a TTL (default 3600s) to limit replay window.
|
|
||||||
//! - The server maintains a ticket nonce set to prevent replay.
|
|
||||||
//! - Early data is idempotent by protocol design (relay CONNECT is safe
|
|
||||||
//! because duplicate CONNECTs to the same target are no-ops).
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
/// A session ticket that allows 0-RTT resumption.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SessionTicket {
|
|
||||||
/// Unique ticket identifier (prevents replay)
|
|
||||||
pub ticket_id: [u8; 16],
|
|
||||||
/// Server session ID to resume
|
|
||||||
pub session_id: u32,
|
|
||||||
/// Derived cipher key for early data
|
|
||||||
pub cipher_key: [u8; 32],
|
|
||||||
/// Timestamp of issuance (seconds since epoch)
|
|
||||||
pub issued_at: u64,
|
|
||||||
/// Time-to-live in seconds
|
|
||||||
pub ttl: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum ticket age (1 hour default)
|
|
||||||
const DEFAULT_TICKET_TTL: u64 = 3600;
|
|
||||||
/// Maximum tickets in the anti-replay set
|
|
||||||
const MAX_REPLAY_SET: usize = 10000;
|
|
||||||
|
|
||||||
impl SessionTicket {
|
|
||||||
/// Create a new session ticket from the transport key material.
|
|
||||||
pub fn new(session_id: u32, transport_key: &[u8; 32], psk: &[u8; 32]) -> Self {
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Derive ticket ID from key material + timestamp
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(transport_key);
|
|
||||||
hasher.update(now.to_be_bytes());
|
|
||||||
hasher.update(b"ostp-ticket-id");
|
|
||||||
let hash = hasher.finalize();
|
|
||||||
let mut ticket_id = [0u8; 16];
|
|
||||||
ticket_id.copy_from_slice(&hash[..16]);
|
|
||||||
|
|
||||||
// Derive cipher key for early data from PSK + ticket
|
|
||||||
let mut key_hasher = Sha256::new();
|
|
||||||
key_hasher.update(psk);
|
|
||||||
key_hasher.update(ticket_id);
|
|
||||||
key_hasher.update(b"ostp-early-data-key");
|
|
||||||
let cipher_key_hash = key_hasher.finalize();
|
|
||||||
let mut cipher_key = [0u8; 32];
|
|
||||||
cipher_key.copy_from_slice(&cipher_key_hash);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
ticket_id,
|
|
||||||
session_id,
|
|
||||||
cipher_key,
|
|
||||||
issued_at: now,
|
|
||||||
ttl: DEFAULT_TICKET_TTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the ticket has expired.
|
|
||||||
pub fn is_expired(&self) -> bool {
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
now > self.issued_at + self.ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize the ticket to bytes for storage/transmission.
|
|
||||||
/// Wire format: [ticket_id:16][session_id:4][cipher_key:32][issued_at:8][ttl:8]
|
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(68);
|
|
||||||
out.extend_from_slice(&self.ticket_id);
|
|
||||||
out.extend_from_slice(&self.session_id.to_be_bytes());
|
|
||||||
out.extend_from_slice(&self.cipher_key);
|
|
||||||
out.extend_from_slice(&self.issued_at.to_be_bytes());
|
|
||||||
out.extend_from_slice(&self.ttl.to_be_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize a ticket from bytes.
|
|
||||||
pub fn from_bytes(data: &[u8]) -> Option<Self> {
|
|
||||||
if data.len() < 68 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut ticket_id = [0u8; 16];
|
|
||||||
ticket_id.copy_from_slice(&data[0..16]);
|
|
||||||
|
|
||||||
let session_id = u32::from_be_bytes(data[16..20].try_into().ok()?);
|
|
||||||
|
|
||||||
let mut cipher_key = [0u8; 32];
|
|
||||||
cipher_key.copy_from_slice(&data[20..52]);
|
|
||||||
|
|
||||||
let issued_at = u64::from_be_bytes(data[52..60].try_into().ok()?);
|
|
||||||
let ttl = u64::from_be_bytes(data[60..68].try_into().ok()?);
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
ticket_id,
|
|
||||||
session_id,
|
|
||||||
cipher_key,
|
|
||||||
issued_at,
|
|
||||||
ttl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypt the ticket with a PSK for client-side storage.
|
|
||||||
/// Uses a simple XOR cipher with HMAC-SHA256 derived key.
|
|
||||||
pub fn encrypt(&self, psk: &[u8; 32]) -> Vec<u8> {
|
|
||||||
let raw = self.to_bytes();
|
|
||||||
let mut enc_key_hasher = Sha256::new();
|
|
||||||
enc_key_hasher.update(psk);
|
|
||||||
enc_key_hasher.update(b"ostp-ticket-encryption");
|
|
||||||
let enc_key = enc_key_hasher.finalize();
|
|
||||||
|
|
||||||
let mut encrypted = raw.clone();
|
|
||||||
for (i, byte) in encrypted.iter_mut().enumerate() {
|
|
||||||
*byte ^= enc_key[i % 32];
|
|
||||||
}
|
|
||||||
encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt a ticket from encrypted bytes.
|
|
||||||
pub fn decrypt(encrypted: &[u8], psk: &[u8; 32]) -> Option<Self> {
|
|
||||||
let mut enc_key_hasher = Sha256::new();
|
|
||||||
enc_key_hasher.update(psk);
|
|
||||||
enc_key_hasher.update(b"ostp-ticket-encryption");
|
|
||||||
let enc_key = enc_key_hasher.finalize();
|
|
||||||
|
|
||||||
let mut decrypted = encrypted.to_vec();
|
|
||||||
for (i, byte) in decrypted.iter_mut().enumerate() {
|
|
||||||
*byte ^= enc_key[i % 32];
|
|
||||||
}
|
|
||||||
Self::from_bytes(&decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Server-side anti-replay guard for session tickets.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct TicketValidator {
|
|
||||||
/// Set of consumed ticket IDs (prevents replay)
|
|
||||||
consumed: HashSet<[u8; 16]>,
|
|
||||||
/// PSK for ticket validation
|
|
||||||
psk: [u8; 32],
|
|
||||||
/// Maximum age for tickets
|
|
||||||
max_age: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketValidator {
|
|
||||||
pub fn new(psk: [u8; 32]) -> Self {
|
|
||||||
Self {
|
|
||||||
consumed: HashSet::new(),
|
|
||||||
psk,
|
|
||||||
max_age: Duration::from_secs(DEFAULT_TICKET_TTL),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate a ticket from the client. Returns the ticket if valid,
|
|
||||||
/// or None if expired, replayed, or invalid.
|
|
||||||
pub fn validate(&mut self, encrypted_ticket: &[u8]) -> Option<SessionTicket> {
|
|
||||||
let ticket = SessionTicket::decrypt(encrypted_ticket, &self.psk)?;
|
|
||||||
|
|
||||||
// Check expiry
|
|
||||||
if ticket.is_expired() {
|
|
||||||
tracing::debug!("0-RTT ticket rejected: expired");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check replay
|
|
||||||
if self.consumed.contains(&ticket.ticket_id) {
|
|
||||||
tracing::warn!("0-RTT ticket rejected: replay detected");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept and mark as consumed
|
|
||||||
self.consumed.insert(ticket.ticket_id);
|
|
||||||
|
|
||||||
// Garbage collection: remove old entries when set grows too large
|
|
||||||
if self.consumed.len() > MAX_REPLAY_SET {
|
|
||||||
// Simple strategy: clear the entire set. This is safe because
|
|
||||||
// expired tickets would fail the expiry check anyway.
|
|
||||||
self.consumed.clear();
|
|
||||||
self.consumed.insert(ticket.ticket_id);
|
|
||||||
tracing::debug!("0-RTT replay set cleared (overflow)");
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::debug!("0-RTT ticket accepted: session_id={}", ticket.session_id);
|
|
||||||
Some(ticket)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Issue a new ticket for a completed session.
|
|
||||||
pub fn issue_ticket(&self, session_id: u32, transport_key: &[u8; 32]) -> SessionTicket {
|
|
||||||
SessionTicket::new(session_id, transport_key, &self.psk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_serialize_roundtrip() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(12345, &key, &psk);
|
|
||||||
|
|
||||||
let bytes = ticket.to_bytes();
|
|
||||||
let restored = SessionTicket::from_bytes(&bytes).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(ticket.ticket_id, restored.ticket_id);
|
|
||||||
assert_eq!(ticket.session_id, restored.session_id);
|
|
||||||
assert_eq!(ticket.cipher_key, restored.cipher_key);
|
|
||||||
assert_eq!(ticket.issued_at, restored.issued_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_encrypt_decrypt() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(99, &key, &psk);
|
|
||||||
|
|
||||||
let encrypted = ticket.encrypt(&psk);
|
|
||||||
let decrypted = SessionTicket::decrypt(&encrypted, &psk).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(ticket.ticket_id, decrypted.ticket_id);
|
|
||||||
assert_eq!(ticket.session_id, decrypted.session_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_wrong_psk_fails() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let wrong_psk = [99u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(1, &key, &psk);
|
|
||||||
let encrypted = ticket.encrypt(&psk);
|
|
||||||
|
|
||||||
// Decrypting with wrong PSK produces garbage, from_bytes should
|
|
||||||
// still return Some but ticket_id won't match
|
|
||||||
let decrypted = SessionTicket::decrypt(&encrypted, &wrong_psk);
|
|
||||||
// It may parse but the data will be wrong
|
|
||||||
if let Some(d) = decrypted {
|
|
||||||
assert_ne!(d.ticket_id, ticket.ticket_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ticket_not_expired() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let ticket = SessionTicket::new(1, &key, &psk);
|
|
||||||
assert!(!ticket.is_expired());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validator_replay_protection() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let key = [1u8; 32];
|
|
||||||
let mut validator = TicketValidator::new(psk);
|
|
||||||
|
|
||||||
let ticket = validator.issue_ticket(1, &key);
|
|
||||||
let encrypted = ticket.encrypt(&psk);
|
|
||||||
|
|
||||||
// First use should succeed
|
|
||||||
assert!(validator.validate(&encrypted).is_some());
|
|
||||||
|
|
||||||
// Replay should fail
|
|
||||||
assert!(validator.validate(&encrypted).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validator_different_tickets() {
|
|
||||||
let psk = [42u8; 32];
|
|
||||||
let mut validator = TicketValidator::new(psk);
|
|
||||||
|
|
||||||
let ticket1 = validator.issue_ticket(1, &[1u8; 32]);
|
|
||||||
let ticket2 = validator.issue_ticket(2, &[2u8; 32]);
|
|
||||||
|
|
||||||
assert!(validator.validate(&ticket1.encrypt(&psk)).is_some());
|
|
||||||
assert!(validator.validate(&ticket2.encrypt(&psk)).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_truncated_ticket_fails() {
|
|
||||||
assert!(SessionTicket::from_bytes(&[0u8; 10]).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
|
|
@ -5,6 +8,37 @@ plugins {
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Release signing material ────────────────────────────────────────────────
|
||||||
|
// Supplied out-of-band and never committed: either an `android/key.properties`
|
||||||
|
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
|
||||||
|
//
|
||||||
|
// This exists because the release build used to be signed with the DEBUG
|
||||||
|
// keystore (the stock Flutter template TODO). Android identifies an app by
|
||||||
|
// applicationId + signing key, and refuses to update across a key change. The
|
||||||
|
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
|
||||||
|
// so every published build carried a different random key — which is why
|
||||||
|
// updating on top of a previous install failed with "App not installed" /
|
||||||
|
// "unable to parse the package" and only a full uninstall+reinstall worked.
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propsFile = rootProject.file("key.properties")
|
||||||
|
if (propsFile.exists()) {
|
||||||
|
FileInputStream(propsFile).use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank counts as absent. GitHub Actions substitutes an EMPTY STRING (not an
|
||||||
|
// unset variable) for a secret that doesn't exist, so `getenv(...) ?: fallback`
|
||||||
|
// silently kept the empty value — the elvis operator only catches null. That is
|
||||||
|
// how an unset ANDROID_KEY_PASSWORD ended up being used as the literal key
|
||||||
|
// password instead of falling back to the store password, producing Gradle's
|
||||||
|
// "Get Key failed: Given final block not properly padded".
|
||||||
|
fun signingSetting(propKey: String, envKey: String): String? =
|
||||||
|
(keystoreProperties.getProperty(propKey) ?: System.getenv(envKey))
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
|
||||||
|
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.ospab.ostp_client"
|
namespace = "com.ospab.ostp_client"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|
@ -34,11 +68,43 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
val store = signingSetting("storePassword", "OSTP_KEYSTORE_PASSWORD")
|
||||||
|
storeFile = file(releaseStorePath!!)
|
||||||
|
storePassword = store
|
||||||
|
keyAlias = signingSetting("keyAlias", "OSTP_KEY_ALIAS")
|
||||||
|
// PKCS12 (the keytool default since Java 9, and what our upload
|
||||||
|
// keystore is) cannot hold a key password that differs from the
|
||||||
|
// store password — the format simply has no place to put one. So
|
||||||
|
// treat a missing key password as "same as the store password"
|
||||||
|
// instead of demanding a secret that, for this keystore, can only
|
||||||
|
// ever be a duplicate. An explicit value still wins, for the older
|
||||||
|
// JKS format where the two genuinely can differ.
|
||||||
|
keyPassword = signingSetting("keyPassword", "OSTP_KEY_PASSWORD") ?: store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Use the real upload key when one was supplied; otherwise fall back to
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// the debug keystore so a plain local `flutter build apk --release`
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// 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")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,28 +92,46 @@ class MainActivity : FlutterActivity() {
|
||||||
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
val metrics = net.ostp.client.OstpClientSdk.getMetrics()
|
||||||
result.success(metrics ?: "{}")
|
result.success(metrics ?: "{}")
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
// Surfaced into the in-app log viewer (not just logcat) so a
|
||||||
|
// broken traffic counter is diagnosable from a user's bug
|
||||||
|
// report without adb access.
|
||||||
|
android.util.Log.e("MainActivity", "getMetrics failed", e)
|
||||||
|
try {
|
||||||
|
net.ostp.client.OstpClientSdk.addLog("getMetrics failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
} catch (_: Throwable) {}
|
||||||
result.error("ERROR", e.message, null)
|
result.error("ERROR", e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"getInstalledApps" -> {
|
"getInstalledApps" -> {
|
||||||
try {
|
// MethodChannel handlers run on the main/UI thread by default.
|
||||||
val pm = packageManager
|
// Enumerating every installed package AND decoding+re-encoding
|
||||||
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
|
// each one's icon to PNG/base64 is expensive (100+ apps is
|
||||||
val list = apps.map { app ->
|
// common) — done inline here it blocked the main thread for
|
||||||
val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
|
// 10-15s, during which Flutter couldn't render ANY frame, not
|
||||||
(pm.getLaunchIntentForPackage(app.packageName) == null)
|
// even the "loading" spinner, so the screen just appeared to
|
||||||
val iconBase64 = getAppIconBase64(pm, app)
|
// hang before jumping straight to the fully-loaded list.
|
||||||
mapOf(
|
// Do the work on a background thread; only the final
|
||||||
"name" to pm.getApplicationLabel(app).toString(),
|
// `result.success(...)` needs to hop back onto the UI thread.
|
||||||
"package" to app.packageName,
|
val pm = packageManager
|
||||||
"isSystem" to isSystem,
|
Thread {
|
||||||
"icon" to (iconBase64 ?: "")
|
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)
|
}.start()
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("ERROR", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
|
@ -26,11 +26,11 @@ class OstpApp extends StatelessWidget {
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
scaffoldBackgroundColor: const Color(0xFF08080F),
|
scaffoldBackgroundColor: const Color(0xFF000000),
|
||||||
colorScheme: const ColorScheme.dark(
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFF6C72FF),
|
primary: Color(0xFFFFFFFF),
|
||||||
secondary: Color(0xFF22D3A5),
|
secondary: Color(0xFFAAAAAA),
|
||||||
surface: Color(0xFF151522),
|
surface: Color(0xFF111111),
|
||||||
),
|
),
|
||||||
fontFamily: 'Inter',
|
fontFamily: 'Inter',
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// A saved server profile. Field shape mirrors the desktop GUI's profile
|
||||||
|
/// object (ostp-gui/src/main.js) 1:1 — server/key/transport/tcp_fragmentation/
|
||||||
|
/// frag_chunk/frag_sleep/junk_pc/junk_ps — so behavior matches across
|
||||||
|
/// platforms. `wss` was dropped: the core no longer supports TLS-mimicry
|
||||||
|
/// transports (only plain UDP / UoT), so there is nothing left to carry it.
|
||||||
|
class OstpProfile {
|
||||||
|
String id;
|
||||||
|
String name;
|
||||||
|
String serverAddr;
|
||||||
|
String accessKey;
|
||||||
|
String transportMode; // 'udp' | 'uot'
|
||||||
|
bool active;
|
||||||
|
|
||||||
|
// Junk packets + TCP fragmentation — per-profile, exactly like ostp-gui's
|
||||||
|
// profile editor. Defaults match ostp_client::config::TransportConfig's
|
||||||
|
// own defaults (frag_chunk=2, frag_sleep=2, junk_pc=[2,5], junk_ps=[100,1000]).
|
||||||
|
bool tcpFragmentation;
|
||||||
|
int fragChunk;
|
||||||
|
int fragSleep;
|
||||||
|
int junkPcMin;
|
||||||
|
int junkPcMax;
|
||||||
|
int junkPsMin;
|
||||||
|
int junkPsMax;
|
||||||
|
|
||||||
|
OstpProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.serverAddr,
|
||||||
|
required this.accessKey,
|
||||||
|
this.transportMode = 'udp',
|
||||||
|
this.active = false,
|
||||||
|
this.tcpFragmentation = false,
|
||||||
|
this.fragChunk = 2,
|
||||||
|
this.fragSleep = 2,
|
||||||
|
this.junkPcMin = 2,
|
||||||
|
this.junkPcMax = 5,
|
||||||
|
this.junkPsMin = 100,
|
||||||
|
this.junkPsMax = 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'serverAddr': serverAddr,
|
||||||
|
'accessKey': accessKey,
|
||||||
|
'transportMode': transportMode,
|
||||||
|
'active': active,
|
||||||
|
'tcpFragmentation': tcpFragmentation,
|
||||||
|
'fragChunk': fragChunk,
|
||||||
|
'fragSleep': fragSleep,
|
||||||
|
'junkPcMin': junkPcMin,
|
||||||
|
'junkPcMax': junkPcMax,
|
||||||
|
'junkPsMin': junkPsMin,
|
||||||
|
'junkPsMax': junkPsMax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory OstpProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OstpProfile(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? 'Unnamed Profile',
|
||||||
|
serverAddr: json['serverAddr'] as String? ?? '',
|
||||||
|
accessKey: json['accessKey'] as String? ?? '',
|
||||||
|
transportMode: json['transportMode'] as String? ?? 'udp',
|
||||||
|
active: json['active'] as bool? ?? false,
|
||||||
|
tcpFragmentation: json['tcpFragmentation'] as bool? ?? false,
|
||||||
|
fragChunk: json['fragChunk'] as int? ?? 2,
|
||||||
|
fragSleep: json['fragSleep'] as int? ?? 2,
|
||||||
|
junkPcMin: json['junkPcMin'] as int? ?? 2,
|
||||||
|
junkPcMax: json['junkPcMax'] as int? ?? 5,
|
||||||
|
junkPsMin: json['junkPsMin'] as int? ?? 100,
|
||||||
|
junkPsMax: json['junkPsMax'] as int? ?? 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OstpProfile> decodeProfiles(String? json) {
|
||||||
|
if (json == null || json.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final List<dynamic> decoded = jsonDecode(json);
|
||||||
|
return decoded.map((e) => OstpProfile.fromJson(e)).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodeProfiles(List<OstpProfile> profiles) =>
|
||||||
|
jsonEncode(profiles.map((e) => e.toJson()).toList());
|
||||||
|
|
@ -15,6 +15,13 @@ class AppRoutingScreen extends StatefulWidget {
|
||||||
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
|
State<AppRoutingScreen> createState() => _AppRoutingScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picks readable black/white text for a given (opaque) background color.
|
||||||
|
/// The monochrome theme's `primary` is pure white — hardcoded white text on
|
||||||
|
/// top of it was invisible; this picks the contrasting color instead.
|
||||||
|
Color _onColor(Color bg) {
|
||||||
|
return ThemeData.estimateBrightnessForColor(bg) == Brightness.light ? Colors.black : Colors.white;
|
||||||
|
}
|
||||||
|
|
||||||
class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
|
||||||
|
|
@ -154,10 +161,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
|
color: _routingMode == 'bypass' ? theme.colorScheme.primary : Colors.white.withOpacity(0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Bypass Mode',
|
'Bypass Mode',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _routingMode == 'bypass' ? _onColor(theme.colorScheme.primary) : Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -181,10 +191,13 @@ class _AppRoutingScreenState extends State<AppRoutingScreen> {
|
||||||
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
|
color: _routingMode == 'proxy' ? theme.colorScheme.secondary : Colors.white.withOpacity(0.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Proxy Mode',
|
'Proxy Mode',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _routingMode == 'proxy' ? _onColor(theme.colorScheme.secondary) : Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
||||||
import '../models/connection_state_enum.dart';
|
import '../models/connection_state_enum.dart';
|
||||||
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'logs_screen.dart';
|
|
||||||
import 'app_routing_screen.dart';
|
/// Success green for the "connected" state — the button aura/border/icon and
|
||||||
import 'qr_scanner_screen.dart';
|
/// the top-bar status dot. The theme's `secondary` (#AAAAAA) reads as plain
|
||||||
|
/// white here, which gave no visual confirmation that the tunnel actually came
|
||||||
|
/// up. Reuses the same green already used for a healthy ping value, so
|
||||||
|
/// "green = good" stays consistent across the UI.
|
||||||
|
const Color kConnectedGreen = Color(0xFF22D3A5);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
|
|
@ -22,23 +25,34 @@ class HomeScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
static const platform = MethodChannel('com.ospab.ostp/vpn');
|
||||||
|
|
||||||
ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
|
ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
Timer? _uptimeTimer;
|
Timer? _uptimeTimer;
|
||||||
int _uptimeSecs = 0;
|
int _uptimeSecs = 0;
|
||||||
|
|
||||||
String _serverAddr = '127.0.0.1:443';
|
// Single active profile — the core only ever connects to one server at a
|
||||||
String _accessKey = 'default_key';
|
// time (no multi-server/urltest failover since the 0.4.x flat config),
|
||||||
|
// matching how the desktop GUI picks exactly one profile as `activeId`.
|
||||||
|
OstpProfile? _activeProfile;
|
||||||
|
|
||||||
String _download = '0 B';
|
String _download = '0 B';
|
||||||
String _upload = '0 B';
|
String _upload = '0 B';
|
||||||
|
|
||||||
|
// Live throughput (bytes/sec, computed from deltas between polls) and RTT
|
||||||
|
// are optional, same as the desktop GUI's "Show Speed" / "Show RTT" toggles
|
||||||
|
// in client settings — default on, persisted in prefs.
|
||||||
|
bool _showSpeed = true;
|
||||||
|
bool _showRtt = true;
|
||||||
|
String _downSpeed = '0 B/s';
|
||||||
|
String _upSpeed = '0 B/s';
|
||||||
|
int _prevBytesRecv = 0;
|
||||||
|
int _prevBytesSent = 0;
|
||||||
|
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late AnimationController _spinController;
|
late AnimationController _spinController;
|
||||||
|
|
||||||
bool _isCheckingPing = false;
|
String _pingText = '-- ms';
|
||||||
String _pingText = 'Target Ping: -- ms';
|
|
||||||
Color _pingColor = Colors.white54;
|
Color _pingColor = Colors.white54;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -67,40 +81,46 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
debugPrint("Failed to check initial state: $e");
|
debugPrint("Failed to check initial state: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadSettings() {
|
void _loadSettings() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
|
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
|
||||||
_accessKey = widget.prefs.getString('access_key') ?? '';
|
// Single-select: if more than one is somehow marked active (shouldn't
|
||||||
|
// happen — the editor enforces exclusivity — but don't crash on stale data).
|
||||||
|
final actives = profiles.where((p) => p.active).toList();
|
||||||
|
_activeProfile = actives.isNotEmpty ? actives.first : null;
|
||||||
|
_showSpeed = widget.prefs.getBool('show_speed') ?? true;
|
||||||
|
_showRtt = widget.prefs.getBool('show_rtt') ?? true;
|
||||||
});
|
});
|
||||||
_updateLatestConfigJson();
|
_updateLatestConfigJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateLatestConfigJson() {
|
/// Builds the exact JSON the native core (ostp-jni) deserializes as
|
||||||
|
/// `ostp_client::config::ClientConfig`. Field names/nesting must match that
|
||||||
|
/// struct precisely — unknown keys are silently ignored by serde, so a typo
|
||||||
|
/// here doesn't fail loudly, it just quietly does nothing.
|
||||||
|
Map<String, dynamic> _buildConfigMap() {
|
||||||
|
final p = _activeProfile;
|
||||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
||||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
||||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
|
||||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
||||||
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
|
||||||
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
|
||||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
||||||
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
||||||
final dnsServer = widget.prefs.getString('dns_server');
|
final dnsServer = widget.prefs.getString('dns_server');
|
||||||
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
||||||
final tunStack = 'ostp';
|
const tunStack = 'ostp';
|
||||||
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
||||||
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
||||||
|
|
||||||
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
||||||
final configMap = {
|
|
||||||
|
return {
|
||||||
"mode": "client",
|
"mode": "client",
|
||||||
"debug": debugMode,
|
"debug": debugMode,
|
||||||
"ostp": {
|
"ostp": {
|
||||||
"server_addr": _serverAddr,
|
"server_addr": p?.serverAddr ?? '',
|
||||||
"local_bind_addr": "0.0.0.0:0",
|
"local_bind_addr": "0.0.0.0:0",
|
||||||
"access_key": _accessKey,
|
"access_key": p?.accessKey ?? '',
|
||||||
"handshake_timeout_ms": 10000,
|
"handshake_timeout_ms": 10000,
|
||||||
"io_timeout_ms": 5000,
|
"io_timeout_ms": 5000,
|
||||||
"mtu": int.tryParse(mtu) ?? 1140,
|
"mtu": int.tryParse(mtu) ?? 1140,
|
||||||
|
|
@ -109,34 +129,40 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
"bind_addr": localBind,
|
"bind_addr": localBind,
|
||||||
"connect_timeout_ms": 15000,
|
"connect_timeout_ms": 15000,
|
||||||
},
|
},
|
||||||
|
// Junk packets + TCP fragmentation are per-profile settings — same
|
||||||
|
// shape as the desktop GUI's profile object — not global toggles.
|
||||||
"transport": {
|
"transport": {
|
||||||
"mode": transportMode,
|
"mode": p?.transportMode ?? 'udp',
|
||||||
"stealth_sni": stealthSni,
|
"tcp_fragmentation": p?.tcpFragmentation ?? false,
|
||||||
|
"frag_chunk": p?.fragChunk ?? 2,
|
||||||
|
"frag_sleep": p?.fragSleep ?? 2,
|
||||||
|
"junk_pc": [p?.junkPcMin ?? 2, p?.junkPcMax ?? 5],
|
||||||
|
"junk_ps": [p?.junkPsMin ?? 100, p?.junkPsMax ?? 1000],
|
||||||
},
|
},
|
||||||
"multiplex": {
|
"multiplex": {
|
||||||
"enabled": muxEnabled,
|
"enabled": muxEnabled,
|
||||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
"sessions": int.tryParse(muxSessions) ?? 2,
|
||||||
},
|
},
|
||||||
"tun": {
|
|
||||||
"enable": true,
|
|
||||||
"stack": tunStack
|
|
||||||
},
|
|
||||||
"exclusions": {
|
"exclusions": {
|
||||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
||||||
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
// No per-process exclusion field on mobile — Android's per-app
|
||||||
|
// selection (app_rules below) is the equivalent, and correct, control.
|
||||||
|
"processes": const [],
|
||||||
},
|
},
|
||||||
"app_rules": {
|
"app_rules": {
|
||||||
"mode": appRoutingMode,
|
"mode": appRoutingMode,
|
||||||
"packages": appRoutingPackages,
|
"packages": appRoutingPackages,
|
||||||
},
|
},
|
||||||
"dns_server": effectiveDnsServer,
|
"dns_server": effectiveDnsServer,
|
||||||
"tun_stack": tunStack
|
"tun_stack": tunStack,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateLatestConfigJson() {
|
||||||
|
final configMap = _buildConfigMap();
|
||||||
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
||||||
platform.invokeMethod('saveConfig', {
|
platform.invokeMethod('saveConfig', {"configJson": jsonEncode(configMap)});
|
||||||
"configJson": jsonEncode(configMap)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -150,87 +176,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Future<void> _toggleConnection() async {
|
Future<void> _toggleConnection() async {
|
||||||
if (_state == ConnectionStateEnum.disconnected) {
|
if (_state == ConnectionStateEnum.disconnected) {
|
||||||
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
if (_activeProfile == null || _activeProfile!.serverAddr.isEmpty || _activeProfile!.accessKey.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please configure Server and Key in Settings')),
|
const SnackBar(content: Text('Please select or add a profile in Settings')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_state = ConnectionStateEnum.connecting;
|
_state = ConnectionStateEnum.connecting;
|
||||||
});
|
});
|
||||||
_pulseController.repeat(reverse: true);
|
_pulseController.repeat(reverse: true);
|
||||||
_spinController.repeat();
|
_spinController.repeat();
|
||||||
|
|
||||||
final dnsServer = widget.prefs.getString('dns_server');
|
final configMap = _buildConfigMap();
|
||||||
final effectiveDnsServer = (dnsServer == null || dnsServer.isEmpty) ? '1.1.1.1' : dnsServer;
|
final configStr = jsonEncode(configMap);
|
||||||
final exDomains = widget.prefs.getString('ex_domains') ?? '';
|
widget.prefs.setString('latest_config_json', configStr);
|
||||||
final exIps = widget.prefs.getString('ex_ips') ?? '';
|
|
||||||
final exProcesses = widget.prefs.getString('ex_processes') ?? '';
|
|
||||||
final debugMode = widget.prefs.getBool('debug_mode') ?? false;
|
|
||||||
final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
|
|
||||||
final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
|
|
||||||
final mtu = widget.prefs.getString('mtu') ?? '1140';
|
|
||||||
final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
|
|
||||||
final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
|
|
||||||
final tunStack = 'ostp';
|
|
||||||
|
|
||||||
final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
|
|
||||||
final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
|
|
||||||
|
|
||||||
final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
|
|
||||||
final configMap = {
|
|
||||||
"mode": "client",
|
|
||||||
"debug": debugMode,
|
|
||||||
"ostp": {
|
|
||||||
"server_addr": _serverAddr,
|
|
||||||
"local_bind_addr": "0.0.0.0:0",
|
|
||||||
"access_key": _accessKey,
|
|
||||||
"handshake_timeout_ms": 10000,
|
|
||||||
"io_timeout_ms": 5000,
|
|
||||||
"mtu": int.tryParse(mtu) ?? 1140,
|
|
||||||
},
|
|
||||||
"local_proxy": {
|
|
||||||
"bind_addr": localBind,
|
|
||||||
"connect_timeout_ms": 15000,
|
|
||||||
},
|
|
||||||
"transport": {
|
|
||||||
"mode": transportMode,
|
|
||||||
"stealth_sni": stealthSni,
|
|
||||||
},
|
|
||||||
"multiplex": {
|
|
||||||
"enabled": muxEnabled,
|
|
||||||
"sessions": int.tryParse(muxSessions) ?? 2,
|
|
||||||
},
|
|
||||||
"tun": {
|
|
||||||
"enable": true,
|
|
||||||
"stack": tunStack
|
|
||||||
},
|
|
||||||
"exclusions": {
|
|
||||||
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
|
||||||
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
|
||||||
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
|
|
||||||
},
|
|
||||||
"app_rules": {
|
|
||||||
"mode": appRoutingMode,
|
|
||||||
"packages": appRoutingPackages,
|
|
||||||
},
|
|
||||||
"dns_server": dnsServer,
|
|
||||||
"tun_stack": tunStack
|
|
||||||
};
|
|
||||||
|
|
||||||
widget.prefs.setString('latest_config_json', jsonEncode(configMap));
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('saveConfig', {
|
await platform.invokeMethod('saveConfig', {"configJson": configStr});
|
||||||
"configJson": jsonEncode(configMap)
|
await platform.invokeMethod('startTunnel', {"configJson": configStr});
|
||||||
});
|
|
||||||
await platform.invokeMethod('startTunnel', {
|
|
||||||
"configJson": jsonEncode(configMap)
|
|
||||||
});
|
|
||||||
|
|
||||||
bool started = false;
|
bool started = false;
|
||||||
for (int i = 0; i < 10; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
@ -240,7 +206,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (started) {
|
if (started) {
|
||||||
_setConnected();
|
_setConnected();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -289,30 +255,34 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cycles transport mode x MTU to find a working combination against the
|
||||||
|
/// active profile's server. WSS/Reality are gone (the core dropped
|
||||||
|
/// TLS-mimicry transports entirely — see §A), so this only has udp/uot x
|
||||||
|
/// MTU left to probe; junk/frag stay at whatever the active profile has set.
|
||||||
Future<void> _runAutoMode() async {
|
Future<void> _runAutoMode() async {
|
||||||
final mtus = [1500, 1350, 1280, 1140];
|
final mtus = [1500, 1350, 1280, 1140];
|
||||||
final modes = [
|
final modes = ['udp', 'uot'];
|
||||||
{'t': 'udp'},
|
|
||||||
{'t': 'uot'},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (_serverAddr.isEmpty || _accessKey.isEmpty) {
|
final active = _activeProfile;
|
||||||
|
if (active == null || active.serverAddr.isEmpty || active.accessKey.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please configure Server and Key first')),
|
const SnackBar(content: Text('Please select a profile with a server and key first')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var mode in modes) {
|
final originalMode = active.transportMode;
|
||||||
for (var mtu in mtus) {
|
final originalMtu = widget.prefs.getString('mtu') ?? '1140';
|
||||||
|
|
||||||
|
for (final mode in modes) {
|
||||||
|
for (final mtu in mtus) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Testing: ${mode['t']} | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
SnackBar(content: Text('Testing: $mode | MTU: $mtu'), duration: const Duration(seconds: 2)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update prefs
|
|
||||||
await widget.prefs.setString('mtu', mtu.toString());
|
await widget.prefs.setString('mtu', mtu.toString());
|
||||||
await widget.prefs.setString('transport_mode', mode['t'] as String);
|
active.transportMode = mode;
|
||||||
_updateLatestConfigJson();
|
_updateLatestConfigJson();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -337,7 +307,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
if (started) {
|
if (started) {
|
||||||
_setConnected();
|
_setConnected();
|
||||||
// Wait to see if connection is stable and ping is successful
|
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
try {
|
try {
|
||||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
final metricsJson = await platform.invokeMethod('getMetrics');
|
||||||
|
|
@ -345,30 +314,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||||
if (rttMs > 0) {
|
if (rttMs > 0) {
|
||||||
|
// Working combo found — persist it onto the profile.
|
||||||
|
_persistActiveProfile();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Success! Found working config: ${mode['t']} (MTU $mtu)')),
|
SnackBar(content: Text('Success! Found working config: $mode (MTU $mtu)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return; // Stop on first working config
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
// Ignore metrics error
|
// Ignore metrics error, fall through to try next combo.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection seems unstable or no ping, stop and try next
|
|
||||||
await platform.invokeMethod('stopTunnel');
|
await platform.invokeMethod('stopTunnel');
|
||||||
_setDisconnected();
|
_setDisconnected();
|
||||||
} else {
|
} else {
|
||||||
_setDisconnected();
|
_setDisconnected();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
_setDisconnected();
|
_setDisconnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No working combo found — revert the active profile/mtu to what they
|
||||||
|
// were before probing so we don't leave it on a broken guess.
|
||||||
|
active.transportMode = originalMode;
|
||||||
|
await widget.prefs.setString('mtu', originalMtu);
|
||||||
|
_updateLatestConfigJson();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Auto search finished. No working config found.')),
|
const SnackBar(content: Text('Auto search finished. No working config found.')),
|
||||||
|
|
@ -376,14 +352,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _persistActiveProfile() {
|
||||||
|
final active = _activeProfile;
|
||||||
|
if (active == null) return;
|
||||||
|
final profiles = decodeProfiles(widget.prefs.getString('profiles_json'));
|
||||||
|
final idx = profiles.indexWhere((p) => p.id == active.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
profiles[idx] = active;
|
||||||
|
widget.prefs.setString('profiles_json', encodeProfiles(profiles));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _setConnected() {
|
void _setConnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_state = ConnectionStateEnum.connected;
|
_state = ConnectionStateEnum.connected;
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 1.0;
|
_pulseController.value = 1.0;
|
||||||
|
|
||||||
_uptimeSecs = 0;
|
_uptimeSecs = 0;
|
||||||
_uptimeTimer?.cancel();
|
_uptimeTimer?.cancel();
|
||||||
_uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
_uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
|
@ -398,7 +385,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
final isRunning = await platform.invokeMethod('isRunning');
|
final isRunning = await platform.invokeMethod('isRunning');
|
||||||
|
|
||||||
if (isRunning == true && _state == ConnectionStateEnum.disconnected) {
|
if (isRunning == true && _state == ConnectionStateEnum.disconnected) {
|
||||||
_setConnected();
|
_setConnected();
|
||||||
} else if (isRunning == false && _state == ConnectionStateEnum.connected) {
|
} else if (isRunning == false && _state == ConnectionStateEnum.connected) {
|
||||||
|
|
@ -413,7 +400,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
|
final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
|
||||||
final connState = parsed['connection_state'] as int? ?? 2;
|
final connState = parsed['connection_state'] as int? ?? 2;
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
||||||
|
|
||||||
if (connState == 0) {
|
if (connState == 0) {
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('stopTunnel');
|
await platform.invokeMethod('stopTunnel');
|
||||||
|
|
@ -428,13 +415,19 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_download = _formatBytes(bytesRecv);
|
_download = _formatBytes(bytesRecv);
|
||||||
_upload = _formatBytes(bytesSent);
|
_upload = _formatBytes(bytesSent);
|
||||||
if (rttMs > 0 && !_isCheckingPing) {
|
final dRecv = bytesRecv > _prevBytesRecv ? bytesRecv - _prevBytesRecv : 0;
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
final dSent = bytesSent > _prevBytesSent ? bytesSent - _prevBytesSent : 0;
|
||||||
|
_prevBytesRecv = bytesRecv;
|
||||||
|
_prevBytesSent = bytesSent;
|
||||||
|
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||||
|
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||||
|
if (rttMs > 0) {
|
||||||
|
_pingText = '$rttMs ms';
|
||||||
if (rttMs < 100) {
|
if (rttMs < 100) {
|
||||||
_pingColor = const Color(0xFF22D3A5);
|
_pingColor = const Color(0xFF22D3A5);
|
||||||
} else if (rttMs < 250) {
|
} else if (rttMs < 250) {
|
||||||
|
|
@ -460,33 +453,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkConnectionLatency() async {
|
|
||||||
if (_state != ConnectionStateEnum.connected) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = true;
|
|
||||||
_pingText = 'Updating...';
|
|
||||||
_pingColor = Colors.white70;
|
|
||||||
});
|
|
||||||
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setDisconnected() {
|
void _setDisconnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_state = ConnectionStateEnum.disconnected;
|
_state = ConnectionStateEnum.disconnected;
|
||||||
_download = '0 B';
|
_download = '0 B';
|
||||||
_upload = '0 B';
|
_upload = '0 B';
|
||||||
_pingText = 'Target Ping: -- ms';
|
_downSpeed = '0 B/s';
|
||||||
|
_upSpeed = '0 B/s';
|
||||||
|
_prevBytesRecv = 0;
|
||||||
|
_prevBytesSent = 0;
|
||||||
|
_pingText = '-- ms';
|
||||||
_pingColor = Colors.white54;
|
_pingColor = Colors.white54;
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 0.0;
|
_pulseController.value = 0.0;
|
||||||
|
|
@ -506,39 +484,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned.fill(
|
||||||
top: -150, right: -100,
|
child: Opacity(
|
||||||
child: Container(
|
opacity: 0.1,
|
||||||
width: 400, height: 400,
|
child: Center(
|
||||||
decoration: BoxDecoration(
|
child: Image.asset(
|
||||||
shape: BoxShape.circle,
|
'assets/logo.png',
|
||||||
color: theme.colorScheme.primary.withOpacity(0.15),
|
width: MediaQuery.of(context).size.shortestSide * 0.6,
|
||||||
),
|
// No color tint needed — the asset now carries real alpha
|
||||||
child: BackdropFilter(
|
// (background pixels' luminance was baked into alpha, see
|
||||||
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
// git history), so it's already a pure white silhouette.
|
||||||
child: Container(),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
|
||||||
bottom: -100, left: -100,
|
|
||||||
child: Container(
|
|
||||||
width: 350, height: 350,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: theme.colorScheme.secondary.withOpacity(0.1),
|
|
||||||
),
|
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
|
|
||||||
child: Container(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
@ -577,13 +541,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
width: 12, height: 12,
|
width: 12, height: 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary
|
? kConnectedGreen
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary.withOpacity(0.5)
|
? kConnectedGreen.withOpacity(0.5)
|
||||||
: theme.colorScheme.primary.withOpacity(0.5),
|
: theme.colorScheme.primary.withOpacity(0.5),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
)
|
)
|
||||||
|
|
@ -637,7 +601,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Widget _buildStage(ThemeData theme) {
|
Widget _buildStage(ThemeData theme) {
|
||||||
Color getAccentColor() {
|
Color getAccentColor() {
|
||||||
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
|
if (_state == ConnectionStateEnum.connected) return kConnectedGreen;
|
||||||
return theme.colorScheme.primary;
|
return theme.colorScheme.primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -677,7 +641,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _pulseController,
|
animation: _pulseController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
|
@ -722,9 +686,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
_state == ConnectionStateEnum.disconnected ? 'Disconnected' :
|
_state == ConnectionStateEnum.disconnected ? 'Disconnected' :
|
||||||
_state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
|
_state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
|
||||||
|
|
@ -742,105 +706,64 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
color: Colors.white54,
|
color: Colors.white54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
AnimatedOpacity(
|
Container(
|
||||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
duration: const Duration(milliseconds: 300),
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
color: Colors.white.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.15)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
const SizedBox(width: 10),
|
||||||
decoration: BoxDecoration(
|
Text(
|
||||||
color: Colors.white.withOpacity(0.08),
|
_activeProfile?.name ?? 'No profile selected',
|
||||||
borderRadius: BorderRadius.circular(30),
|
style: const TextStyle(
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.15)),
|
fontFamily: 'monospace',
|
||||||
),
|
fontSize: 15,
|
||||||
child: Row(
|
fontWeight: FontWeight.w600,
|
||||||
mainAxisSize: MainAxisSize.min,
|
color: Colors.white70,
|
||||||
children: [
|
|
||||||
const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
_serverAddr,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.03),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'CONNECTION TEST',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white38,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_pingText,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: _pingColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_isCheckingPing
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
|
||||||
)
|
|
||||||
: TextButton.icon(
|
|
||||||
onPressed: _checkConnectionLatency,
|
|
||||||
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
|
||||||
label: Text(
|
|
||||||
'Test Ping',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_showRtt)
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.03),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_pingText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _pingColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -856,15 +779,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary),
|
_buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary, _showSpeed ? _downSpeed : null),
|
||||||
Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)),
|
Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)),
|
||||||
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary),
|
_buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary, _showSpeed ? _upSpeed : null),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetricItem(IconData icon, String label, String value, Color color) {
|
Widget _buildMetricItem(IconData icon, String label, String value, Color color, [String? speed]) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|
@ -903,6 +826,19 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (speed != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
speed,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -911,4 +847,3 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.4.5+17
|
version: 0.4.4+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
@ -58,7 +58,7 @@ dev_dependencies:
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
ios: false
|
ios: false
|
||||||
image_path: "../icons/sqare.png"
|
image_path: "../icons/logo_new.png"
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
@ -72,9 +72,8 @@ flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
assets:
|
||||||
# - images/a_dot_burr.jpeg
|
- assets/logo.png
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "ostp-gui",
|
"name": "ostp-gui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.5",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.5"
|
version = "0.4.4"
|
||||||
description = "A Tauri App"
|
description = "OSTP desktop GUI"
|
||||||
authors = ["you"]
|
authors = ["ospab"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |