Compare commits
74 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
cf14a4243c | |
|
|
66368c9d0f | |
|
|
bc61b47817 | |
|
|
5a33ed69c4 | |
|
|
c5e703c144 | |
|
|
05f25155bd | |
|
|
e6e0a7b28c | |
|
|
8f0ffd08c0 | |
|
|
8a1426ecf5 | |
|
|
e483af541f | |
|
|
df1a14d15c | |
|
|
d915efc715 | |
|
|
b673219894 | |
|
|
a1c146aff3 | |
|
|
365b4ccbf5 | |
|
|
4a3fb8b944 | |
|
|
f789167a22 | |
|
|
108bab6a90 | |
|
|
f7e9215331 | |
|
|
ebfc751471 | |
|
|
3cda1a9bd4 | |
|
|
77a45d7642 | |
|
|
6abae68f35 | |
|
|
cb57347d51 | |
|
|
32c36afc3b | |
|
|
a8aba8f4b8 | |
|
|
2ede607027 | |
|
|
0c69617725 | |
|
|
88e0634f09 | |
|
|
7473278cc2 | |
|
|
77e42b77f7 | |
|
|
e7a4f2b4a4 | |
|
|
6bc646c8a5 | |
|
|
d9fe749cd4 | |
|
|
cdfd2babc0 | |
|
|
2092e22a7c | |
|
|
5278f58903 | |
|
|
340819745a | |
|
|
e31c4b2268 | |
|
|
e46c863ef0 | |
|
|
cddd623ad0 | |
|
|
9a891310f9 | |
|
|
d9686c9344 | |
|
|
dbf923fb16 | |
|
|
51b947e6ff | |
|
|
f01ed4ec25 | |
|
|
c2a1a53b4d | |
|
|
cd12b01bc3 | |
|
|
de5cee103b | |
|
|
c523b083cb | |
|
|
c6a130673d | |
|
|
c756e02b63 | |
|
|
70a669d3c6 | |
|
|
66a1e97840 | |
|
|
b6bdd53066 | |
|
|
1c291d9c88 | |
|
|
2660a37249 | |
|
|
108ab8468b | |
|
|
5fcc0ba7f4 | |
|
|
7e3ada8d4d | |
|
|
4fd4f7d435 | |
|
|
b166f13d59 | |
|
|
a69ffae750 | |
|
|
90a919df59 | |
|
|
4ac2e79e14 | |
|
|
a9509a235d | |
|
|
5754689e09 | |
|
|
b5735fe8c2 | |
|
|
f904695760 | |
|
|
29554a71f1 | |
|
|
271a39c664 | |
|
|
44f9067222 | |
|
|
1b536e9cf3 | |
|
|
5883f5105b |
|
|
@ -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
|
||||||
|
|
@ -89,7 +89,7 @@ jobs:
|
||||||
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
CHANNEL="${{ github.event.inputs.channel }}"
|
CHANNEL="${{ github.event.inputs.channel }}"
|
||||||
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
|
elif [ "${{ github.ref_name }}" = "beta" ]; then
|
||||||
CHANNEL="beta"
|
CHANNEL="beta"
|
||||||
else
|
else
|
||||||
CHANNEL="alpha"
|
CHANNEL="alpha"
|
||||||
|
|
@ -284,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 }}
|
||||||
|
|
@ -370,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
|
||||||
|
|
@ -452,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
|
||||||
|
|
@ -514,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
|
||||||
|
|
@ -579,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
|
||||||
|
|
|
||||||
|
|
@ -26,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
|
||||||
|
|
@ -39,6 +46,7 @@ turn-harvesting-idea.md
|
||||||
|
|
||||||
# Private tooling (closed-source)
|
# Private tooling (closed-source)
|
||||||
ostp-prober/
|
ostp-prober/
|
||||||
|
ostp-lab/
|
||||||
|
|
||||||
ostp-brain/
|
ostp-brain/
|
||||||
|
|
||||||
|
|
@ -47,3 +55,5 @@ ostp-control/
|
||||||
|
|
||||||
.agents/
|
.agents/
|
||||||
netstack-smoltcp/
|
netstack-smoltcp/
|
||||||
|
dnstt/
|
||||||
|
ostp-web/
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"target_version": "0.4.1",
|
"target_version": "0.4.4",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_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",
|
||||||
|
|
@ -1384,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1398,6 +1400,7 @@ dependencies = [
|
||||||
"rlimit",
|
"rlimit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -1406,7 +1409,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1437,7 +1440,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1471,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1494,6 +1497,7 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
"simple-dns",
|
"simple-dns",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -1503,7 +1507,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1515,7 +1519,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -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.1"
|
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" }
|
|
||||||
|
|
|
||||||
61
README.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -185,7 +192,7 @@ 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
|
||||||
proxy-env Print shell export commands for the local SOCKS proxy
|
proxy-env Print shell export commands for the local SOCKS proxy
|
||||||
|
|
|
||||||
59
README.ru.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -172,7 +181,7 @@ 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 Принудительно мигрировать конфиг к текущему формату
|
||||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||||
|
|
|
||||||
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).
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -80,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 {
|
||||||
|
|
@ -116,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,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 {
|
||||||
|
|
@ -166,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() => {
|
||||||
|
|
@ -186,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;
|
||||||
}
|
}
|
||||||
|
|
@ -209,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];
|
||||||
|
|
@ -223,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);
|
||||||
|
|
||||||
|
|
@ -295,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>,
|
||||||
|
|
@ -331,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;
|
||||||
}
|
}
|
||||||
|
|
@ -412,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);
|
||||||
|
|
@ -428,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;
|
||||||
}
|
}
|
||||||
|
|
@ -523,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>,
|
||||||
|
|
@ -531,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 {
|
||||||
|
|
@ -564,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;
|
||||||
}
|
}
|
||||||
|
|
@ -867,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);
|
||||||
|
|
||||||
|
|
@ -880,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 {
|
||||||
|
|
@ -961,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 {
|
||||||
|
|
@ -1048,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();
|
||||||
|
|
||||||
|
|
@ -1185,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();
|
||||||
|
|
|
||||||
|
|
@ -418,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)]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,6 +54,7 @@ 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],
|
||||||
|
|
@ -74,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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
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: 5.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.3 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: 3.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.9 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: 7.3 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.9 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: 12 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 15 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: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 769 KiB |
|
|
@ -8,6 +8,13 @@ import '../models/connection_state_enum.dart';
|
||||||
import '../models/ostp_profile.dart';
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Success green for the "connected" state — the button aura/border/icon and
|
||||||
|
/// 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;
|
||||||
const HomeScreen({super.key, required this.prefs});
|
const HomeScreen({super.key, required this.prefs});
|
||||||
|
|
@ -45,8 +52,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
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
|
||||||
|
|
@ -420,8 +426,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_prevBytesSent = bytesSent;
|
_prevBytesSent = bytesSent;
|
||||||
_downSpeed = '${_formatBytes(dRecv)}/s';
|
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||||
_upSpeed = '${_formatBytes(dSent)}/s';
|
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||||
if (rttMs > 0 && !_isCheckingPing) {
|
if (rttMs > 0) {
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
_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) {
|
||||||
|
|
@ -447,47 +453,6 @@ 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
|
||||||
if (metricsJson != null && metricsJson.isNotEmpty) {
|
|
||||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
if (rttMs > 0) {
|
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
|
||||||
_pingColor = rttMs < 100
|
|
||||||
? const Color(0xFF22D3A5)
|
|
||||||
: rttMs < 250
|
|
||||||
? Colors.amberAccent
|
|
||||||
: Colors.redAccent;
|
|
||||||
} else {
|
|
||||||
_pingText = 'Server Ping: -- ms';
|
|
||||||
_pingColor = Colors.white54;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Failed to check latency: $e");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setDisconnected() {
|
void _setDisconnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -498,9 +463,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_upSpeed = '0 B/s';
|
_upSpeed = '0 B/s';
|
||||||
_prevBytesRecv = 0;
|
_prevBytesRecv = 0;
|
||||||
_prevBytesSent = 0;
|
_prevBytesSent = 0;
|
||||||
_pingText = 'Target Ping: -- ms';
|
_pingText = '-- ms';
|
||||||
_pingColor = Colors.white54;
|
_pingColor = Colors.white54;
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 0.0;
|
_pulseController.value = 0.0;
|
||||||
|
|
@ -578,12 +542,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,67 +739,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.03),
|
color: Colors.white.withOpacity(0.03),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
|
||||||
child: Column(
|
const SizedBox(width: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
_pingText,
|
||||||
const Text(
|
style: TextStyle(
|
||||||
'CONNECTION TEST',
|
fontSize: 13,
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 10,
|
color: _pingColor,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -265,39 +265,42 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
if (v != null) setDialogState(() => transportMode = v);
|
if (v != null) setDialogState(() => transportMode = v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 32),
|
// Junk packets and TCP fragmentation only take effect on the
|
||||||
// Junk packets + TCP fragmentation moved into their own
|
// UoT (TCP) transport — the UDP path applies neither — so the
|
||||||
// modals (tap to configure) — this dialog was carrying too
|
// whole section is hidden under UDP instead of shown with a
|
||||||
// many fields at once; these two are advanced/occasional
|
// "UoT only" caveat. Reactive: switching Transport above calls
|
||||||
// settings, not something every profile edit needs to see.
|
// setDialogState, which rebuilds this and shows/hides it.
|
||||||
const Text('DPI OBFUSCATION', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)),
|
if (transportMode == 'uot') ...[
|
||||||
const SizedBox(height: 12),
|
const Divider(height: 32),
|
||||||
Row(
|
const Text('DPI OBFUSCATION', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)),
|
||||||
children: [
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Row(
|
||||||
child: OutlinedButton.icon(
|
children: [
|
||||||
icon: const Icon(Icons.shuffle_rounded, size: 18),
|
Expanded(
|
||||||
label: const Text('Junk Packets'),
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _showJunkPacketsModal(
|
icon: const Icon(Icons.shuffle_rounded, size: 18),
|
||||||
context, junkPcMinCtrl, junkPcMaxCtrl, junkPsMinCtrl, junkPsMaxCtrl,
|
label: const Text('Junk Packets'),
|
||||||
|
onPressed: () => _showJunkPacketsModal(
|
||||||
|
context, junkPcMinCtrl, junkPcMaxCtrl, junkPsMinCtrl, junkPsMaxCtrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 12),
|
Expanded(
|
||||||
Expanded(
|
child: OutlinedButton.icon(
|
||||||
child: OutlinedButton.icon(
|
icon: Icon(tcpFragmentation ? Icons.call_split_rounded : Icons.horizontal_rule_rounded, size: 18),
|
||||||
icon: Icon(tcpFragmentation ? Icons.call_split_rounded : Icons.horizontal_rule_rounded, size: 18),
|
label: Text(tcpFragmentation ? 'TCP Frag: On' : 'TCP Frag: Off'),
|
||||||
label: Text(tcpFragmentation ? 'TCP Frag: On' : 'TCP Frag: Off'),
|
onPressed: () => _showTcpFragModal(
|
||||||
onPressed: () => _showTcpFragModal(
|
context,
|
||||||
context,
|
tcpFragmentation,
|
||||||
tcpFragmentation,
|
(v) => setDialogState(() => tcpFragmentation = v),
|
||||||
(v) => setDialogState(() => tcpFragmentation = v),
|
fragChunkCtrl, fragSleepCtrl,
|
||||||
fragChunkCtrl, fragSleepCtrl,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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.1+19
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "ostp-gui",
|
"name": "ostp-gui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.1",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|
|
||||||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2696,7 +2696,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"json_comments",
|
"json_comments",
|
||||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.1"
|
version = "0.4.4"
|
||||||
description = "OSTP desktop GUI"
|
description = "OSTP desktop GUI"
|
||||||
authors = ["ospab"]
|
authors = ["ospab"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
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 |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.2 KiB |