Compare commits
No commits in common. "master" and "v0.4.1" have entirely different histories.
|
|
@ -4,7 +4,7 @@ name: CI/CD
|
|||
# `run-name` is evaluated at workflow-start, BEFORE any job runs - it cannot
|
||||
# see resolve-channel's computed tag_name (e.g. "0.4.3-alpha"), only the
|
||||
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
|
||||
# the bare branch name ("alpha"/"beta") for every run, which reads
|
||||
# the bare branch name ("alpha"/"pre-release") for every run, which reads
|
||||
# exactly like a literal release tag and caused real confusion - the actual
|
||||
# release tag has been correct (versioned) all along; only this label lied
|
||||
# about it. Spell out "channel" so nobody mistakes one for the other again.
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
# Tag shape:
|
||||
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
|
||||
# - push to `alpha` -> "{version}-alpha" (rolling, same tag every push)
|
||||
# - push to `beta` -> "{version}-beta" (rolling, same tag every push)
|
||||
# - push to `pre-release` -> "{version}-beta" (rolling, same tag every push)
|
||||
# - workflow_dispatch -> forced by the `channel` input (alpha|beta only)
|
||||
resolve-channel:
|
||||
name: Resolve release channel
|
||||
|
|
@ -89,7 +89,7 @@ jobs:
|
|||
# channel, then synthesize the rolling tag from Cargo.toml's version.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
elif [ "${{ github.ref_name }}" = "beta" ]; then
|
||||
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
|
||||
CHANNEL="beta"
|
||||
else
|
||||
CHANNEL="alpha"
|
||||
|
|
@ -284,15 +284,7 @@ jobs:
|
|||
|
||||
- name: Install cross (if not cached)
|
||||
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
||||
# 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
|
||||
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
||||
|
||||
- name: Build (cross)
|
||||
if: ${{ matrix.use_cross }}
|
||||
|
|
@ -378,15 +370,7 @@ jobs:
|
|||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
# Without a prefix fallback this cache NEVER restored on a release:
|
||||
# cutting a release rewrites every Cargo.lock (version bump), which
|
||||
# changes hashFiles(), which misses the exact key — so each release
|
||||
# rebuilt every dependency from scratch. That is why the GUI jobs ran
|
||||
# 2-4x longer than the plain release targets, which had this all along.
|
||||
restore-keys: |
|
||||
cargo-windows-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Download wintun
|
||||
shell: pwsh
|
||||
|
|
@ -468,28 +452,18 @@ jobs:
|
|||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-linux-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
run: |
|
||||
npm install
|
||||
# TUN mode shells out to this helper, elevated via pkexec. Only the
|
||||
# Windows job used to build it, so the Linux package shipped without
|
||||
# it and TUN could never start.
|
||||
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
|
||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||
|
||||
- name: Package Portable Tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir ostp-linux-gui-${{ matrix.arch }}
|
||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
||||
# The GUI looks for the helper next to its own executable first.
|
||||
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
|
||||
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
|
|
@ -540,10 +514,7 @@ jobs:
|
|||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
ostp-gui/src-tauri/target/
|
||||
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-macos-gui-${{ matrix.target }}-
|
||||
|
||||
- name: Build Tauri App
|
||||
working-directory: ostp-gui
|
||||
|
|
@ -608,107 +579,27 @@ jobs:
|
|||
with:
|
||||
ndk-version: r26b
|
||||
|
||||
# The Android jobs had no Rust caching at all, so every release recompiled
|
||||
# the whole ostp-jni dependency graph from scratch — the main reason these
|
||||
# were among the slowest jobs in the matrix.
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: cargo-android-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-android-${{ matrix.arch }}-
|
||||
|
||||
# cargo-ndk was built from source on every run. Cache the binary the same
|
||||
# way the cross-compilation jobs already cache `cross`.
|
||||
- name: Restore cargo-ndk binary cache
|
||||
id: cargo-ndk-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/bin/cargo-ndk
|
||||
key: cargo-ndk-bin-${{ runner.os }}-v1
|
||||
|
||||
- name: Install cargo-ndk (if not cached)
|
||||
if: steps.cargo-ndk-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-ndk --locked
|
||||
- name: Install cargo-ndk
|
||||
run: cargo install cargo-ndk
|
||||
|
||||
- name: Build Android APK
|
||||
shell: bash
|
||||
working-directory: ostp-flutter
|
||||
env:
|
||||
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
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
|
||||
# 1. Compile JNI
|
||||
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
||||
|
||||
cd ../ostp-jni
|
||||
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
||||
cd ../ostp-flutter
|
||||
|
||||
|
||||
|
||||
# 3. Build Flutter APK
|
||||
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
||||
|
||||
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
|
||||
# than shipping another un-updatable build.
|
||||
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
|
||||
| grep -qi "CN=Android Debug"; then
|
||||
echo "::error::APK is signed with the Android debug certificate - aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Copy to output
|
||||
cp "$APK" ostp-android-${{ matrix.arch }}.apk
|
||||
# 4. Copy to output
|
||||
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
|
|
|||
|
|
@ -26,13 +26,6 @@ test_route.ps1
|
|||
config.json
|
||||
wintun.dll
|
||||
|
||||
# Android signing keys. The upload keystore is the ONE key every published APK
|
||||
# must be signed with (Android refuses to update an app across a key change),
|
||||
# so losing or leaking it is unrecoverable — it can never be committed.
|
||||
*.jks
|
||||
*.keystore
|
||||
key.properties
|
||||
|
||||
# Server runtime cache (public IP autodetect) — must never be committed,
|
||||
# it's regenerated locally and leaks whatever host it ran on last.
|
||||
.ostp_public_ip
|
||||
|
|
@ -46,7 +39,6 @@ turn-harvesting-idea.md
|
|||
|
||||
# Private tooling (closed-source)
|
||||
ostp-prober/
|
||||
ostp-lab/
|
||||
|
||||
ostp-brain/
|
||||
|
||||
|
|
@ -55,5 +47,3 @@ ostp-control/
|
|||
|
||||
.agents/
|
||||
netstack-smoltcp/
|
||||
dnstt/
|
||||
ostp-web/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"target_version": "0.4.4",
|
||||
"target_version": "0.4.1",
|
||||
"branch": "master",
|
||||
"alpha_iteration": 0,
|
||||
"beta_iteration": 0
|
||||
|
|
|
|||
|
|
@ -74,10 +74,10 @@ The repository runs three long-lived branches, in increasing order of stability:
|
|||
| Branch | Role |
|
||||
|---|---|
|
||||
| `alpha` | Active development. All feature work and fixes land here first. |
|
||||
| `beta` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
|
||||
| `master` | Fast-forwarded from `beta` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
|
||||
| `pre-release` | 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. |
|
||||
|
||||
`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`.
|
||||
`pre-release` and `master` are **never** committed to directly - they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `pre-release` or `master`.
|
||||
|
||||
**Contributor PRs target `alpha`**, not `master`.
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ Multiple unrelated changes belong in separate commits, not one bundled commit -
|
|||
```bash
|
||||
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 `beta`, never direct PRs).
|
||||
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).
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -74,10 +74,10 @@
|
|||
| Ветка | Роль |
|
||||
|---|---|
|
||||
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
|
||||
| `beta` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||
| `master` | Перематывается вперёд от `beta`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
|
||||
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
|
||||
|
||||
В `beta` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `beta` или `master`.
|
||||
В `pre-release` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
|
||||
|
||||
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ obfuscation_key/psk), чтобы он был индивидуальным для
|
|||
```bash
|
||||
git push origin feat/имя-вашей-фичи
|
||||
```
|
||||
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `beta`, PR туда не принимаются напрямую).
|
||||
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
|
||||
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
|
||||
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.
|
||||
|
||||
|
|
|
|||
|
|
@ -1316,9 +1316,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "netstack-smoltcp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c38f66cdd673ff0e760752f27c6d34a7e3a140f0b1eea9efae3c46d8867c83d"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"etherparse",
|
||||
"futures",
|
||||
|
|
@ -1386,7 +1384,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||
|
||||
[[package]]
|
||||
name = "ostp"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1400,7 +1398,6 @@ dependencies = [
|
|||
"rlimit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
@ -1409,7 +1406,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -1440,7 +1437,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -1474,7 +1471,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-server"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -1497,7 +1494,6 @@ dependencies = [
|
|||
"sha2",
|
||||
"simple-dns",
|
||||
"socket2",
|
||||
"subtle",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
|
|
@ -1507,7 +1503,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
@ -1519,7 +1515,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun-helper"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -12,17 +12,20 @@ resolver = "2"
|
|||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0"
|
||||
bytes = "1.6"
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
snow = { version = "0.9", features = ["risky-raw-split"] }
|
||||
snow = "0.9"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
|
||||
tracing = "0.1"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
portable-atomic = "1.10"
|
||||
|
||||
[patch.crates-io]
|
||||
netstack-smoltcp = { path = "netstack-smoltcp" }
|
||||
|
|
|
|||
53
README.md
|
|
@ -56,42 +56,35 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
|
|||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Styles
|
||||
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||
graph TD
|
||||
subgraph Client ["Client"]
|
||||
A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
||||
TUN[TUN Interface] -->|IP Packets| B
|
||||
|
||||
subgraph Local["💻 Client Device"]
|
||||
Apps["Web Browser / Apps"]:::userApp
|
||||
Socks["SOCKS5 / HTTP Proxy"]:::ostpCore
|
||||
Tun["Global TUN (VPN)"]:::ostpCore
|
||||
Client["OSTP Client Protocol Engine\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||
|
||||
Apps -->|TCP/UDP| Socks
|
||||
Apps -->|IP Packets| Tun
|
||||
Socks --> Client
|
||||
Tun --> Client
|
||||
subgraph OSTPCoreClient ["OSTP Core Protocol"]
|
||||
B --> C{Protocol Machine}
|
||||
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
||||
D -->|Obfuscated UDP Payload| E((UDP Socket))
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
|
||||
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
|
||||
E <==>|Encrypted & Obfuscated UDP Tunnel| F
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
||||
FB -->|Forward| NGINX[nginx / Caddy]
|
||||
|
||||
Server -->|Decrypted Traffic| Relay
|
||||
Server -->|Active Probe / Scanner| Fallback
|
||||
Relay -->|Clear Traffic| Target
|
||||
H -->|Stats & Traffic| API[Management API]
|
||||
|
||||
I -->|Outbound| WWW((Internet))
|
||||
end
|
||||
|
||||
Client <==> Tunnel <==> Server
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -192,7 +185,7 @@ Commands:
|
|||
links Print client share links from the server config
|
||||
import <URL> Import a share link into the config file
|
||||
update Update OSTP to the latest release
|
||||
-b, --branch <NAME> Release channel: stable, beta, alpha (default: stable)
|
||||
-b, --branch <NAME> Release channel: stable, pre-release, alpha (default: stable)
|
||||
-v, --version <VER> Update to an exact version instead of the channel's latest
|
||||
migrate Force-migrate the configuration file to the current format
|
||||
proxy-env Print shell export commands for the local SOCKS proxy
|
||||
|
|
|
|||
51
README.ru.md
|
|
@ -35,42 +35,33 @@
|
|||
## Архитектура
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Styles
|
||||
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
|
||||
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
|
||||
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
|
||||
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
|
||||
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
|
||||
graph TD
|
||||
subgraph Client ["Клиент"]
|
||||
A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
|
||||
TUN[TUN Интерфейс] -->|IP Пакеты| B
|
||||
|
||||
subgraph Local["💻 Устройство клиента"]
|
||||
Apps["Браузер / Приложения"]:::userApp
|
||||
Socks["SOCKS5 / HTTP Прокси"]:::ostpCore
|
||||
Tun["Global TUN (VPN)"]:::ostpCore
|
||||
Client["OSTP Клиент\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
|
||||
|
||||
Apps -->|TCP/UDP| Socks
|
||||
Apps -->|IP Пакеты| Tun
|
||||
Socks --> Client
|
||||
Tun --> Client
|
||||
subgraph OSTPCoreClient ["OSTP Core Протокол"]
|
||||
B --> C{Protocol Machine}
|
||||
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
|
||||
D -->|Обфусцированный UDP| E((UDP Сокет))
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
|
||||
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
|
||||
E <==>|Зашифрованный UDP Туннель| F
|
||||
|
||||
subgraph Server ["Сервер"]
|
||||
F((UDP Сокет)) --> G{Dispatcher}
|
||||
|
||||
subgraph OSTPCoreServer ["OSTP Core Backend"]
|
||||
G -->|Auth & Decrypt| H[Session & State Guard]
|
||||
H -->|TCP Поток| I[Relay Loop]
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ Удаленный сервер (VPS)"]
|
||||
Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore
|
||||
Relay["Мультиплексор соединений"]:::ostpCore
|
||||
Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback
|
||||
Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external
|
||||
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
|
||||
FB -->|Перенаправление| NGINX[nginx / Caddy]
|
||||
|
||||
Server -->|Расшифрованный трафик| Relay
|
||||
Server -->|Сканеры цензоров| Fallback
|
||||
Relay -->|Чистый трафик| Target
|
||||
I -->|Outbound| WWW((Интернет))
|
||||
end
|
||||
|
||||
Client <==> Tunnel <==> Server
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -181,7 +172,7 @@ ostp [--config <PATH>] [КОМАНДА]
|
|||
links Вывести client-share-ссылки из серверного конфига
|
||||
import <URL> Импортировать share-ссылку в конфиг
|
||||
update Обновить OSTP до актуального релиза
|
||||
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
|
||||
-b, --branch <NAME> Канал релиза: stable, pre-release, alpha (по умолчанию stable)
|
||||
-v, --version <VER> Обновиться на точную версию вместо последней в канале
|
||||
migrate Принудительно мигрировать конфиг к текущему формату
|
||||
proxy-env Вывести shell-команды для локального SOCKS-прокси
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 0c5c52a57d899c05428c116898941761a2ed83c2
|
||||
|
|
@ -90,22 +90,11 @@ Because the `Nonce` is unique per packet, the mask is cryptographically independ
|
|||
|
||||
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
|
||||
|
||||
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via HKDF-SHA-256.
|
||||
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via SHA-256.
|
||||
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
|
||||
3. Ephemeral Curve25519 key exchange (`ee`) is evaluated, and the two directional transport keys are taken from Noise's `Split()` over the final chaining key `ck`.
|
||||
3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels.
|
||||
|
||||
> **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.
|
||||
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -137,5 +126,4 @@ The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to
|
|||
|
||||
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
|
||||
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
|
||||
* **Handshake-trial CPU DoS:** Because there is no cleartext key identifier on the wire (a deliberate stealth property), a datagram from an unknown source must be trial-decrypted against every registered key. Servers MUST bound this work: OSTP caches each key's derived secrets and time-windowed junk markers (so a trial is a cheap comparison plus one AEAD attempt per key, not a fresh HKDF/HMAC), and gates the trial path behind a global token bucket (default 100/s) so a spoofed-source flood cannot force unbounded per-packet crypto. The established-session fast path and IP-roaming path are not subject to this bucket.
|
||||
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).
|
||||
|
|
|
|||
|
|
@ -20,15 +20,9 @@
|
|||
// Адрес следующего узла в цепочке — UDP
|
||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||
|
||||
// URL API конечного (целевого) сервера для синхронизации access_keys.
|
||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
|
||||
//
|
||||
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
|
||||
// сервера). Management API смонтирован ВНУТРИ этого пути — именно он скрывает
|
||||
// панель от сканеров, — поэтому голый host:port попадает в несуществующий
|
||||
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
|
||||
// Это тот же адрес, по которому вы открываете веб-панель.
|
||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
|
||||
// URL API конечного (целевого) сервера для синхронизации access_keys
|
||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
|
||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
|
||||
|
||||
// Bearer-токен для доступа к API целевого сервера
|
||||
// Должен совпадать с api.token в конфиге target-сервера
|
||||
|
|
|
|||
|
|
@ -90,23 +90,11 @@ OSTP поддерживает **внутреннее криптографиче
|
|||
|
||||
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
|
||||
|
||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256.
|
||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия.
|
||||
3. Выполняется эфемерный обмен ключами Curve25519 (`ee`), и два однонаправленных транспортных ключа берутся из `Split()` протокола Noise над финальным chaining key `ck`.
|
||||
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256.
|
||||
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
|
||||
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
|
||||
|
||||
> **Прямая секретность (Forward Secrecy).** Транспортные ключи выводятся из
|
||||
> chaining key `ck`, который вбирает результат эфемерного обмена Диффи-Хеллмана
|
||||
> `ee`. Они **не** выводятся из handshake hash `h` протокола Noise: `h` вбирает
|
||||
> только публичные данные транскрипта (эфемерные публичные ключи и шифртексты с
|
||||
> провода) и никогда — сам DH-секрет, поэтому ключи, выведенные из `h`, дали бы
|
||||
> держателю PSK возможность расшифровать любую записанную сессию. Вывод из `ck`
|
||||
> привязывает каждую сессию к её эфемерным приватным ключам, которые
|
||||
> уничтожаются после рукопожатия: злоумышленник, скомпрометировавший PSK позже,
|
||||
> всё равно не сможет расшифровать прошлый трафик. Это свойство ломает
|
||||
> совместимость и защищено внутренней версией протокола (сейчас 5): узлы более
|
||||
> старой версии выводят другие ключи и не могут взаимодействовать.
|
||||
|
||||
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд, 5 минут) и дополнительно фиксирует принятые рукопожатия в множестве защиты от повтора на время этого окна.
|
||||
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -131,5 +119,4 @@ OSTP обеспечивает надежную доставку поверх UDP
|
|||
|
||||
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
|
||||
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
|
||||
* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают.
|
||||
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{"v":1}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"git": {
|
||||
"sha1": "702f6dfe124c5e4d343cfd3ca5a3efe0446cf6f0"
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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 }}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/target
|
||||
/Cargo.lock
|
||||
|
||||
.idea
|
||||
.VSCodeCounter/
|
||||
.vscode
|
||||
.DS_Store
|
||||
*.iml
|
||||
**/*.log
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
# 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"]
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
[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" }
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
# 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)
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
#[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();
|
||||
}
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
#!/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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
#!/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
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
#!/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
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
#__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
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
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<()>>;
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,564 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
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}")))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
//! 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,25 +10,12 @@ use ostp_core::{NoiseRole, OstpEvent, PaddingStrategy, ProtocolAction, ProtocolC
|
|||
use rand::Rng;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::time::{interval, timeout, Instant, MissedTickBehavior};
|
||||
use tokio::time::{interval, timeout, Instant};
|
||||
|
||||
use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
||||
use crate::config::ClientConfig;
|
||||
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();
|
||||
|
||||
pub fn set_socket_protector<F>(f: F)
|
||||
|
|
@ -59,56 +46,6 @@ async fn send_datagram(socket: &crate::transport::Transport, frame: &Bytes, _web
|
|||
struct SessionState {
|
||||
socket: crate::transport::Transport,
|
||||
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 {
|
||||
|
|
@ -143,21 +80,6 @@ pub struct Bridge {
|
|||
last_rtt_ms: f64,
|
||||
last_sample_at: 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 {
|
||||
|
|
@ -194,9 +116,6 @@ impl Bridge {
|
|||
last_rtt_ms: 0.0,
|
||||
last_sample_at: Instant::now(),
|
||||
last_valid_recv: Instant::now(),
|
||||
forced_reconnect_pending: false,
|
||||
last_forced_reconnect_try: Instant::now(),
|
||||
forced_reconnect_started: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -212,21 +131,6 @@ impl Bridge {
|
|||
let mut metrics_tick = interval(Duration::from_millis(500));
|
||||
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(self.keepalive_interval_sec.max(1)));
|
||||
let mut retransmit_tick = tokio::time::interval(Duration::from_millis(10));
|
||||
// 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" {
|
||||
"Bridge initialized (TUN mode)".to_string()
|
||||
} else {
|
||||
|
|
@ -262,89 +166,18 @@ 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;
|
||||
}
|
||||
cmd = bridge_rx.recv() => {
|
||||
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 {
|
||||
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = metrics_tick.tick() => {
|
||||
// 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 {
|
||||
self.emit_metrics(&tx).await;
|
||||
}
|
||||
}
|
||||
_ = keepalive_tick.tick() => {
|
||||
if self.running {
|
||||
self.handle_keepalive(false, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||
self.handle_keepalive(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||
}
|
||||
}
|
||||
_ = retransmit_tick.tick() => {
|
||||
|
|
@ -353,20 +186,7 @@ impl Bridge {
|
|||
}
|
||||
}
|
||||
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
|
||||
// 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()
|
||||
})
|
||||
s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
|
||||
}).unwrap_or(true) => {
|
||||
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
||||
}
|
||||
|
|
@ -389,8 +209,8 @@ impl Bridge {
|
|||
) {
|
||||
match udp_msg {
|
||||
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.last_valid_recv = Instant::now();
|
||||
if let Some(sessions) = sessions_opt.as_mut() {
|
||||
if session_index < sessions.len() {
|
||||
let session = &mut sessions[session_index];
|
||||
|
|
@ -403,22 +223,6 @@ 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();
|
||||
actions_queue.push_back(initial_action);
|
||||
|
||||
|
|
@ -491,7 +295,6 @@ impl Bridge {
|
|||
async fn handle_bridge_cmd(
|
||||
&mut self,
|
||||
cmd: Option<BridgeCommand>,
|
||||
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
|
||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||
|
|
@ -528,9 +331,35 @@ impl Bridge {
|
|||
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||
Ok((sock, mach, rtt)) => {
|
||||
let session_index = sessions.len();
|
||||
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||
let socket_clone = sock.clone();
|
||||
let udp_tx_clone = udp_tx.clone();
|
||||
|
||||
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 {
|
||||
// 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;
|
||||
successful_sessions += 1;
|
||||
}
|
||||
|
|
@ -583,32 +412,6 @@ impl Bridge {
|
|||
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
||||
}
|
||||
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 {
|
||||
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
||||
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
||||
|
|
@ -625,8 +428,31 @@ impl Bridge {
|
|||
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||
Ok((sock, mach, rtt)) => {
|
||||
let session_index = new_sessions.len();
|
||||
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
|
||||
let socket_clone = sock.clone();
|
||||
let udp_tx_clone = udp_tx.clone();
|
||||
|
||||
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;
|
||||
successful_sessions += 1;
|
||||
}
|
||||
|
|
@ -697,7 +523,6 @@ impl Bridge {
|
|||
|
||||
async fn handle_keepalive(
|
||||
&mut self,
|
||||
force: bool,
|
||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||
|
|
@ -706,12 +531,9 @@ impl Bridge {
|
|||
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
|
||||
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
|
||||
) {
|
||||
if force || self.last_valid_recv.elapsed().as_secs() > 25 {
|
||||
if self.last_valid_recv.elapsed().as_secs() > 25 {
|
||||
let elapsed = self.last_valid_recv.elapsed().as_secs();
|
||||
// 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 elapsed > 180 {
|
||||
if self.kill_switch {
|
||||
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
|
||||
} else {
|
||||
|
|
@ -742,9 +564,34 @@ impl Bridge {
|
|||
match self.perform_handshake_with_id(&tx, session_id).await {
|
||||
Ok((sock, mach, rtt)) => {
|
||||
let session_index = new_sessions.len();
|
||||
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
|
||||
let socket_clone = sock.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 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;
|
||||
successful_sessions += 1;
|
||||
}
|
||||
|
|
@ -1020,21 +867,7 @@ impl Bridge {
|
|||
Ok(addrs) => addrs.collect(),
|
||||
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
||||
};
|
||||
// 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;
|
||||
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
|
||||
|
||||
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
||||
|
||||
|
|
@ -1047,8 +880,7 @@ impl Bridge {
|
|||
let socket = match self.try_connect_transport(target_ip, port).await {
|
||||
Ok(sock) => sock,
|
||||
Err(e) => {
|
||||
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||
nat64_attempted = true;
|
||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
||||
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||
|
|
@ -1129,8 +961,7 @@ impl Bridge {
|
|||
let (final_socket, size) = if success {
|
||||
(socket, size)
|
||||
} else {
|
||||
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||
nat64_attempted = true;
|
||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
||||
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||
|
|
@ -1217,27 +1048,7 @@ impl Bridge {
|
|||
) -> Result<crate::transport::Transport> {
|
||||
let mode = self.transport_mode.to_lowercase();
|
||||
if mode == "uot" || mode == "tcp" {
|
||||
// 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 = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (mut read_half, mut write_half) = stream.into_split();
|
||||
|
||||
|
|
@ -1374,19 +1185,8 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
|
|||
}
|
||||
|
||||
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];
|
||||
// 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 {
|
||||
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
|
||||
for addr in addrs {
|
||||
if let std::net::SocketAddr::V6(v6) = addr {
|
||||
let octets = v6.ip().octets();
|
||||
|
|
|
|||
|
|
@ -418,22 +418,19 @@ pub struct RelayServerConfig {
|
|||
pub upstream_tcp: String,
|
||||
/// Upstream address for UDP traffic
|
||||
pub upstream_udp: String,
|
||||
// ── Deprecated ──────────────────────────────────────────────────────────
|
||||
// The relay used to authenticate clients itself and pulled the access-key
|
||||
// list from the target server's management API to do it. It no longer does:
|
||||
// sessions are authenticated end-to-end by the target server, and a relay
|
||||
// that re-checks credentials only adds a weaker second gate plus a copy of
|
||||
// the key list on a machine that does not need one. These are kept solely
|
||||
// so existing relay configs still parse; they are ignored.
|
||||
#[serde(default)]
|
||||
/// Target server's API URL, for key sync
|
||||
pub upstream_api_url: String,
|
||||
/// Bearer token for the target server's API
|
||||
#[serde(default)]
|
||||
pub upstream_api_token: String,
|
||||
#[serde(default)]
|
||||
/// Key sync interval in seconds (default 30)
|
||||
#[serde(default = "default_sync_interval")]
|
||||
pub sync_interval_secs: u64,
|
||||
pub debug: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_sync_interval() -> u64 { 30 }
|
||||
|
||||
/// Supports both a single string "0.0.0.0:50000" and an array
|
||||
/// ["0.0.0.0:50000", "[::]:50000"].
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
|
|
|
|||
|
|
@ -361,10 +361,6 @@ async fn handle_udp_associate(
|
|||
|
||||
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
||||
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
||||
// Held only to keep the direct-UDP readers' cancellation senders alive;
|
||||
// dropping this (on every return path from this function) is what tells
|
||||
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
|
||||
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
|
||||
|
||||
let mut tcp_buf = [0u8; 1];
|
||||
loop {
|
||||
|
|
@ -436,9 +432,7 @@ async fn handle_udp_associate(
|
|||
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||
Ok(s) => {
|
||||
let s_arc = Arc::new(s);
|
||||
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);
|
||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
||||
direct_udp_v6 = Some(s_arc);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -452,9 +446,7 @@ async fn handle_udp_associate(
|
|||
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||
Ok(s) => {
|
||||
let s_arc = Arc::new(s);
|
||||
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);
|
||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
||||
direct_udp_v4 = Some(s_arc);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -528,24 +520,11 @@ fn spawn_direct_udp_reader(
|
|||
sock_tx: Arc<UdpSocket>,
|
||||
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
||||
_debug: bool,
|
||||
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 65536];
|
||||
loop {
|
||||
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 {
|
||||
match direct_socket.recv_from(&mut buf).await {
|
||||
Ok((len, target_addr)) => {
|
||||
let client_addr = {
|
||||
let guard = client_udp_addr.lock().unwrap();
|
||||
|
|
|
|||
|
|
@ -138,34 +138,27 @@ async fn start_udp_bypass_session(
|
|||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||
}
|
||||
|
||||
// A single select! loop over both directions, rather than spawning a
|
||||
// separate task for the read side, so the whole session - physical
|
||||
// socket included - is torn down the moment this function returns
|
||||
// (e.g. when session_rx closes). The previous spawned-task version left
|
||||
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
||||
// alive) running forever after this function returned: nothing ever
|
||||
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
|
||||
// mode) leaked one socket + one task for the lifetime of the process.
|
||||
let socket = Arc::new(socket);
|
||||
let socket_rx = socket.clone();
|
||||
|
||||
// Spawn a task to read from physical socket and send back to smoltcp
|
||||
let tx_clone = smoltcp_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
use futures::SinkExt;
|
||||
let mut buf = [0u8; 65536];
|
||||
loop {
|
||||
tokio::select! {
|
||||
outbound = session_rx.recv() => {
|
||||
match outbound {
|
||||
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
inbound = socket.recv_from(&mut buf) => {
|
||||
match inbound {
|
||||
match socket_rx.recv_from(&mut buf).await {
|
||||
Ok((n, peer)) => {
|
||||
let mut lock = smoltcp_tx.lock().await;
|
||||
let mut lock = tx_clone.lock().await;
|
||||
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some((payload, dst)) = session_rx.recv().await {
|
||||
socket.send_to(&payload, dst).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -39,18 +39,10 @@ pub struct CongestionController {
|
|||
loss_count: u32,
|
||||
/// Pacing rate: bytes per second
|
||||
pacing_rate: u64,
|
||||
/// Token-bucket allowance for pacing, in bytes.
|
||||
pacing_tokens: f64,
|
||||
pacing_last_refill: Instant,
|
||||
/// MTU estimate (used for cwnd → packet count conversion)
|
||||
mtu: u64,
|
||||
/// Min RTT expiry: re-probe after 10 seconds
|
||||
min_rtt_stamp: Instant,
|
||||
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
|
||||
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
|
||||
slow_start_losses: u32,
|
||||
/// Start of the current loss-tolerance window.
|
||||
slow_start_loss_window_start: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -68,20 +60,6 @@ const MIN_CWND_PACKETS: u64 = 2;
|
|||
/// Min RTT expiry window (after which we re-probe)
|
||||
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
||||
/// Absolute ceiling on the congestion window, in packets. At a ~1200-byte MTU
|
||||
/// this is roughly 1.2 MB in flight — already far above the bandwidth-delay
|
||||
/// product of any link this protocol realistically runs over, so anything
|
||||
/// beyond it is standing queue, not throughput. The client previously allowed
|
||||
/// up to 16384 packets (~20 MB), which on a mobile uplink is minutes of buffer.
|
||||
const MAX_CWND_PACKETS: u64 = 1024;
|
||||
/// SRTT/min_rtt ratio at which slow start stops. Doubling is what fills a deep
|
||||
/// buffer fastest, so growth must end when the queue starts building rather
|
||||
/// than waiting for a loss that a deep buffer may never produce.
|
||||
const RTT_INFLATION_EXIT_SLOW_START: f64 = 2.0;
|
||||
/// SRTT/min_rtt ratio treated as a standing queue that must be actively drained.
|
||||
const RTT_INFLATION_BACKOFF: f64 = 4.0;
|
||||
/// How much pacing allowance may accumulate, expressed as time-at-rate.
|
||||
const PACING_BURST: Duration = Duration::from_millis(10);
|
||||
const RTO_MIN: Duration = Duration::from_millis(50);
|
||||
/// Maximum RTO
|
||||
const RTO_MAX: Duration = Duration::from_secs(16);
|
||||
|
|
@ -89,24 +67,6 @@ const RTO_MAX: Duration = Duration::from_secs(16);
|
|||
/// Will be replaced by first real measurement within milliseconds.
|
||||
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
||||
|
||||
/// Isolated packet loss during slow start (a single dropped frame from
|
||||
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
|
||||
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
|
||||
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
|
||||
/// on any link with a non-zero background loss rate permanently downgrades
|
||||
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
|
||||
/// growth within the first few RTTs - turning what should be a sub-second
|
||||
/// ramp-up into tens of seconds to minutes before throughput opens up
|
||||
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
|
||||
/// claws back up). Only treat loss as a real congestion signal - and pay
|
||||
/// the full slow-start-exit + halving cost - once this many losses land
|
||||
/// within SLOW_START_LOSS_WINDOW.
|
||||
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
|
||||
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
|
||||
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
|
||||
/// well-connected link, generous on a slow one.
|
||||
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
|
||||
|
||||
impl CongestionController {
|
||||
pub fn new(mtu: u64) -> Self {
|
||||
let now = Instant::now();
|
||||
|
|
@ -128,52 +88,9 @@ impl CongestionController {
|
|||
pacing_rate: initial_pacing,
|
||||
mtu,
|
||||
min_rtt_stamp: now,
|
||||
slow_start_losses: 0,
|
||||
slow_start_loss_window_start: now,
|
||||
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
|
||||
pacing_last_refill: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of pacing allowance available right now, without consuming any.
|
||||
///
|
||||
/// Read-only so the send path can use it as an admission check before it
|
||||
/// commits to building a datagram.
|
||||
pub fn pacing_available(&self) -> f64 {
|
||||
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
|
||||
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||
}
|
||||
|
||||
/// Whether at least one full-size packet may be released right now.
|
||||
pub fn can_pace_packet(&self) -> bool {
|
||||
self.pacing_available() >= self.mtu as f64
|
||||
}
|
||||
|
||||
/// Ceiling on accumulated allowance.
|
||||
///
|
||||
/// Pacing intervals here are fractions of a millisecond, so releasing
|
||||
/// strictly one packet at a time would need a sub-millisecond timer per
|
||||
/// packet. Instead we allow a short burst — the same trade every real
|
||||
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
|
||||
/// can still saturate the configured rate, with a small floor so a
|
||||
/// cold/low estimate can never wedge sending entirely.
|
||||
fn pacing_burst(&self) -> f64 {
|
||||
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
|
||||
by_rate.max((self.mtu * 4) as f64)
|
||||
}
|
||||
|
||||
/// Refill from elapsed time and deduct `bytes`. Called on the real send
|
||||
/// path; allowance is permitted to go negative so an oversized packet still
|
||||
/// pays for itself rather than being released for free.
|
||||
fn consume_pacing(&mut self, bytes: u64) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
|
||||
self.pacing_last_refill = now;
|
||||
self.pacing_tokens =
|
||||
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||
- bytes as f64;
|
||||
}
|
||||
|
||||
/// Returns the current congestion window in bytes.
|
||||
pub fn cwnd(&self) -> u64 {
|
||||
self.cwnd
|
||||
|
|
@ -225,24 +142,6 @@ impl CongestionController {
|
|||
/// Record that we sent `bytes` of data.
|
||||
pub fn on_send(&mut self, bytes: u64) {
|
||||
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
||||
// Charge the pacing bucket here rather than at the admission check, so
|
||||
// every byte that actually reaches the wire is paid for exactly once —
|
||||
// including retransmits, which are precisely what must not be allowed
|
||||
// to bypass the rate limit and pile into an already-full queue.
|
||||
self.consume_pacing(bytes);
|
||||
}
|
||||
|
||||
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
|
||||
/// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids
|
||||
/// measuring RTT from it). The window still advances; only the RTT estimator
|
||||
/// is left untouched.
|
||||
pub fn on_ack_no_rtt(&mut self, bytes: u64) {
|
||||
let now = Instant::now();
|
||||
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
|
||||
self.total_acked = self.total_acked.saturating_add(bytes);
|
||||
self.grow_window(bytes);
|
||||
self.update_pacing_rate();
|
||||
self.last_ack_time = now;
|
||||
}
|
||||
|
||||
/// Record that `bytes` were acknowledged with the given RTT sample.
|
||||
|
|
@ -254,53 +153,9 @@ impl CongestionController {
|
|||
// Update RTT measurements
|
||||
self.update_rtt(rtt, now);
|
||||
|
||||
self.grow_window(bytes);
|
||||
self.update_pacing_rate();
|
||||
self.last_ack_time = now;
|
||||
}
|
||||
|
||||
/// Congestion-window growth shared by both ACK paths (slow start / probe).
|
||||
fn grow_window(&mut self, bytes: u64) {
|
||||
// ── Delay-based congestion signal ────────────────────────────────────
|
||||
// A loss-only controller is blind on a deeply-buffered path, and mobile
|
||||
// carrier buffers are very deep: they absorb a burst instead of dropping
|
||||
// it, so no loss is ever signalled and cwnd keeps growing. The queue —
|
||||
// not the link — is what grows, and the standing delay it adds shows up
|
||||
// as RTT inflating far above the path's floor. Left unchecked this is a
|
||||
// positive feedback loop: bigger queue -> larger RTT samples -> larger
|
||||
// SRTT -> larger RTO -> retransmits pile on -> bigger queue, which is
|
||||
// how a session ends up reporting multi-second (even multi-minute) RTT
|
||||
// and stalls video until the buffer finally drains or the user
|
||||
// reconnects. Treat sustained RTT inflation as congestion in its own
|
||||
// right, exactly as it is.
|
||||
let inflation = if self.rtt_initialized && !self.min_rtt.is_zero() {
|
||||
self.srtt.as_secs_f64() / self.min_rtt.as_secs_f64()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if inflation >= RTT_INFLATION_BACKOFF {
|
||||
// Standing queue is severe — actively drain it.
|
||||
self.cwnd = (self.cwnd / 2).max(MIN_CWND_PACKETS * self.mtu);
|
||||
self.ssthresh = self.cwnd;
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: draining standing queue");
|
||||
self.clamp_cwnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// State machine
|
||||
match self.phase {
|
||||
Phase::SlowStart => {
|
||||
// Exponential doubling is what fills a deep buffer fastest, so
|
||||
// leave slow start as soon as the queue starts to build rather
|
||||
// than waiting for the loss that may never come.
|
||||
if inflation >= RTT_INFLATION_EXIT_SLOW_START {
|
||||
self.ssthresh = self.cwnd;
|
||||
self.phase = Phase::ProbeBandwidth;
|
||||
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: RTT inflation ended slow start");
|
||||
self.clamp_cwnd();
|
||||
return;
|
||||
}
|
||||
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
||||
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||
if self.cwnd >= self.ssthresh {
|
||||
|
|
@ -314,20 +169,8 @@ impl CongestionController {
|
|||
}
|
||||
}
|
||||
|
||||
self.clamp_cwnd();
|
||||
}
|
||||
|
||||
/// Hard ceiling on the congestion window.
|
||||
///
|
||||
/// Independent of any estimate: no real path this protocol runs over has a
|
||||
/// bandwidth-delay product anywhere near this, so a window above it is
|
||||
/// buffered queue rather than data in transit. Without it, slow start on a
|
||||
/// buffer that never drops could grow the window into the tens of megabytes.
|
||||
fn clamp_cwnd(&mut self) {
|
||||
let ceiling = MAX_CWND_PACKETS.saturating_mul(self.mtu);
|
||||
if self.cwnd > ceiling {
|
||||
self.cwnd = ceiling;
|
||||
}
|
||||
self.update_pacing_rate();
|
||||
self.last_ack_time = now;
|
||||
}
|
||||
|
||||
/// Record a loss event.
|
||||
|
|
@ -337,28 +180,11 @@ impl CongestionController {
|
|||
|
||||
match self.phase {
|
||||
Phase::SlowStart => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
|
||||
// Previous window's losses have aged out - this loss starts a fresh count.
|
||||
self.slow_start_losses = 0;
|
||||
self.slow_start_loss_window_start = now;
|
||||
}
|
||||
self.slow_start_losses += 1;
|
||||
|
||||
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
|
||||
// Sustained loss within the window: treat as real congestion.
|
||||
// Exit slow start, set ssthresh to half of cwnd.
|
||||
// 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");
|
||||
}
|
||||
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
|
||||
}
|
||||
Phase::ProbeBandwidth => {
|
||||
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||
|
|
@ -447,138 +273,6 @@ mod tests {
|
|||
assert!(cc.cwnd() < initial);
|
||||
}
|
||||
|
||||
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
|
||||
/// ever lost, but the standing queue inflates RTT. A loss-only controller
|
||||
/// grows cwnd forever here — which is how a session ends up reporting
|
||||
/// multi-second RTT and stalling video.
|
||||
#[test]
|
||||
fn test_rtt_inflation_halts_growth_without_any_loss() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
|
||||
// Establish a low path floor; this becomes min_rtt.
|
||||
for _ in 0..4 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(20));
|
||||
}
|
||||
let cwnd_before = cc.cwnd();
|
||||
|
||||
// Queue builds: RTT climbs far above the floor, still zero loss.
|
||||
for _ in 0..20 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(400));
|
||||
}
|
||||
|
||||
assert!(
|
||||
cc.cwnd() <= cwnd_before,
|
||||
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
|
||||
cwnd_before,
|
||||
cc.cwnd()
|
||||
);
|
||||
}
|
||||
|
||||
/// Pacing must actually bound the release rate: draining the bucket has to
|
||||
/// deny the next packet. Without this the congestion window alone decides,
|
||||
/// and a whole window leaves back-to-back.
|
||||
#[test]
|
||||
fn test_pacing_bucket_denies_once_drained() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
|
||||
|
||||
// Spend well beyond one burst allowance.
|
||||
let burst_bytes = cc.pacing_available();
|
||||
let mut spent = 0.0;
|
||||
while spent <= burst_bytes + 1200.0 {
|
||||
cc.on_send(1200);
|
||||
spent += 1200.0;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!cc.can_pace_packet(),
|
||||
"pacing allowed unbounded sending: {} bytes still available after spending {}",
|
||||
cc.pacing_available(),
|
||||
spent
|
||||
);
|
||||
}
|
||||
|
||||
/// The allowance must refill over time, or sending would stall permanently
|
||||
/// once the first burst is spent.
|
||||
#[test]
|
||||
fn test_pacing_bucket_refills_over_time() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
while cc.can_pace_packet() {
|
||||
cc.on_send(1200);
|
||||
}
|
||||
assert!(!cc.can_pace_packet());
|
||||
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
assert!(
|
||||
cc.can_pace_packet(),
|
||||
"pacing bucket never refilled; sending would be stuck forever"
|
||||
);
|
||||
}
|
||||
|
||||
/// cwnd must never exceed the absolute ceiling, however long slow start
|
||||
/// runs unopposed — above it the window is buffered queue, not throughput.
|
||||
#[test]
|
||||
fn test_cwnd_never_exceeds_absolute_ceiling() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Constant RTT: no inflation signal, so only the hard cap can stop this.
|
||||
for _ in 0..5000 {
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(30));
|
||||
}
|
||||
assert!(
|
||||
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
|
||||
"cwnd {} exceeded the {}-packet ceiling",
|
||||
cc.cwnd(),
|
||||
MAX_CWND_PACKETS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
||||
// A single dropped packet (wireless noise, a brief handover blip) is
|
||||
// normal on real links and must not permanently downgrade the
|
||||
// session from exponential to linear growth.
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_loss(1200);
|
||||
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
|
||||
|
||||
// It should still shrink the window somewhat (not ignored entirely),
|
||||
// just far less punishing than the sustained-congestion case.
|
||||
let after_one = cc.cwnd();
|
||||
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sustained_slow_start_loss_exits_slow_start() {
|
||||
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
|
||||
// a real congestion signal and must still trigger the harsher
|
||||
// exit-slow-start + halve response.
|
||||
let mut cc = CongestionController::new(1200);
|
||||
for _ in 0..SLOW_START_LOSS_TOLERANCE {
|
||||
cc.on_loss(1200);
|
||||
}
|
||||
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slow_start_loss_window_resets_after_expiry() {
|
||||
// Two losses far enough apart (window expired between them) must
|
||||
// each be treated as isolated, not accumulated toward the sustained-
|
||||
// loss threshold.
|
||||
let mut cc = CongestionController::new(1200);
|
||||
cc.on_loss(1200);
|
||||
assert_eq!(cc.phase, Phase::SlowStart);
|
||||
|
||||
// Simulate the window having expired by resetting its start
|
||||
// directly (std::thread::sleep in a unit test would be flaky/slow).
|
||||
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
|
||||
cc.on_loss(1200);
|
||||
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
|
||||
assert_eq!(cc.slow_start_losses, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_send_limits() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
|
|
@ -619,23 +313,6 @@ mod tests {
|
|||
assert_eq!(rto, Duration::from_millis(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_on_ack_no_rtt_grows_window_without_touching_srtt() {
|
||||
let mut cc = CongestionController::new(1200);
|
||||
// Establish a known SRTT with a real sample.
|
||||
cc.on_send(1200);
|
||||
cc.on_ack(1200, Duration::from_millis(40));
|
||||
let srtt_before = cc.smoothed_rtt();
|
||||
let cwnd_before = cc.cwnd();
|
||||
|
||||
// A Karn's-algorithm ACK (all acked frames were retransmitted): window
|
||||
// must advance, RTT estimate must be untouched.
|
||||
cc.on_send(1200);
|
||||
cc.on_ack_no_rtt(1200);
|
||||
assert!(cc.cwnd() > cwnd_before, "cwnd should still grow on a no-RTT ack");
|
||||
assert_eq!(cc.smoothed_rtt(), srtt_before, "SRTT must not move on a no-RTT ack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rto_clamp_min() {
|
||||
let cc = CongestionController::new(1200);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use snow::{Builder, HandshakeState};
|
||||
use snow::{Builder, HandshakeState, TransportState};
|
||||
|
||||
use crate::protocol::ProtocolError;
|
||||
|
||||
|
|
@ -10,15 +10,9 @@ pub enum NoiseRole {
|
|||
Responder,
|
||||
}
|
||||
|
||||
/// A Noise handshake in progress. OSTP does not use snow's transport mode: once
|
||||
/// the handshake finishes we extract the raw Split() keys (see [`raw_split`])
|
||||
/// and drive our own out-of-order AEAD (see `crypto::aead`), because the wire
|
||||
/// protocol needs explicit per-frame nonces for reordering that snow's internal
|
||||
/// nonce counter can't express.
|
||||
///
|
||||
/// [`raw_split`]: NoiseSession::raw_split
|
||||
pub struct NoiseSession {
|
||||
handshake: Box<HandshakeState>,
|
||||
pub enum NoiseSession {
|
||||
Handshake(Box<HandshakeState>),
|
||||
Transport(TransportState),
|
||||
}
|
||||
|
||||
impl NoiseSession {
|
||||
|
|
@ -42,92 +36,50 @@ impl NoiseSession {
|
|||
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
|
||||
};
|
||||
|
||||
Ok(Self { handshake: Box::new(handshake) })
|
||||
Ok(Self::Handshake(Box::new(handshake)))
|
||||
}
|
||||
|
||||
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
|
||||
self.handshake
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => hs
|
||||
.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> {
|
||||
self.handshake
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => hs
|
||||
.read_message(input, out)
|
||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e)))
|
||||
}
|
||||
|
||||
/// Derive the two directional transport keys via Noise's Split().
|
||||
///
|
||||
/// SECURITY: keys are taken from the final chaining key `ck` (which absorbs
|
||||
/// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h`
|
||||
/// (which only absorbs public transcript data — ephemeral pubkeys and
|
||||
/// ciphertexts — and never the DH secret). Deriving from `ck` is what gives
|
||||
/// the session forward secrecy: an adversary who later learns the PSK still
|
||||
/// cannot recompute these keys without the ephemeral private keys, which are
|
||||
/// discarded after the handshake.
|
||||
///
|
||||
/// Must only be called once the handshake is finished (both messages of the
|
||||
/// NNpsk0 exchange processed); at that point `ck` is final. Returns
|
||||
/// `(send_key, recv_key)` for the given role, matching snow's TransportState
|
||||
/// direction mapping: split output `.0` is initiator→responder, `.1` is
|
||||
/// responder→initiator.
|
||||
pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> {
|
||||
if !self.handshake.is_handshake_finished() {
|
||||
return Err(ProtocolError::State("handshake not finished at key split".to_string()));
|
||||
}
|
||||
let (k0, k1) = self.handshake.dangerously_get_raw_split();
|
||||
Ok(match role {
|
||||
// Initiator sends on .0 (i→r), receives on .1 (r→i).
|
||||
NoiseRole::Initiator => (k0, k1),
|
||||
// Responder is the mirror image.
|
||||
NoiseRole::Responder => (k1, k0),
|
||||
})
|
||||
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))),
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => {
|
||||
let hash = hs.get_handshake_hash();
|
||||
if out.len() != hash.len() {
|
||||
return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string()));
|
||||
}
|
||||
out.copy_from_slice(hash);
|
||||
Ok(())
|
||||
}
|
||||
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_transport(self) -> Result<Self, ProtocolError> {
|
||||
match self {
|
||||
NoiseSession::Handshake(hs) => {
|
||||
let transport = hs
|
||||
.into_transport_mode()
|
||||
.map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?;
|
||||
Ok(NoiseSession::Transport(transport))
|
||||
}
|
||||
NoiseSession::Transport(_) => Ok(self),
|
||||
}
|
||||
|
||||
/// 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,7 +54,6 @@ fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec<u8> {
|
|||
/// The derivation uses the access key as both IKM and salt material,
|
||||
/// split into two halves. No fixed strings are used — the access key
|
||||
/// alone determines all derived values.
|
||||
#[derive(Clone)]
|
||||
pub struct DerivedSecrets {
|
||||
pub obfuscation_key: [u8; 8],
|
||||
pub psk: [u8; 32],
|
||||
|
|
@ -75,11 +74,8 @@ pub struct DerivedSecrets {
|
|||
/// without a version) produces a different obfuscation key, so a 0.4.0 server
|
||||
/// cannot recover its handshake header and rejects it as an unauthorized probe.
|
||||
///
|
||||
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4;
|
||||
/// version 5 (0.4.x hardening) moved transport keys from the handshake hash to
|
||||
/// Noise's Split() output — a wire-breaking crypto change, so old peers must not
|
||||
/// interop (they would derive different session keys and fail decryption).
|
||||
pub const PROTOCOL_VERSION: u8 = 5;
|
||||
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4.
|
||||
pub const PROTOCOL_VERSION: u8 = 4;
|
||||
|
||||
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
|
||||
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ pub enum FrameKind {
|
|||
KeepAlive = 4,
|
||||
Nack = 5,
|
||||
Ack = 6,
|
||||
/// 0-RTT session resumption: client sends ticket + early data
|
||||
Resume = 7,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for FrameKind {
|
||||
|
|
@ -26,6 +28,7 @@ impl TryFrom<u8> for FrameKind {
|
|||
4 => Ok(Self::KeepAlive),
|
||||
5 => Ok(Self::Nack),
|
||||
6 => Ok(Self::Ack),
|
||||
7 => Ok(Self::Resume),
|
||||
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
|
||||
}
|
||||
}
|
||||
|
|
@ -101,15 +104,7 @@ impl FramedPacket {
|
|||
let payload_len = header.payload_len as usize;
|
||||
let pad_len = header.pad_len as usize;
|
||||
|
||||
// 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()))?;
|
||||
let expected = FRAME_HEADER_LEN + payload_len + pad_len;
|
||||
if buf.len() < expected {
|
||||
return Err(ProtocolError::Framing("frame body truncated".to_string()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod crypto;
|
|||
pub mod framing;
|
||||
pub mod protocol;
|
||||
pub mod relay;
|
||||
pub mod resumption;
|
||||
|
||||
pub use crypto::NoiseRole;
|
||||
pub use framing::{TrafficProfile, PaddingStrategy};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
use bytes::Bytes;
|
||||
use rand::Rng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Upper bound on a single frame's retransmit timer, after exponential backoff
|
||||
/// is applied to the adaptive RTO. Past this the session is dead from the
|
||||
/// user's point of view, and waiting longer only delays recovery.
|
||||
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
|
||||
|
||||
use crate::congestion::CongestionController;
|
||||
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
||||
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
||||
|
|
@ -107,17 +103,6 @@ pub struct ProtocolMachine {
|
|||
_mtu: usize,
|
||||
}
|
||||
|
||||
// ── Gap recovery (see `ProtocolMachine::recover_stalled_gap`) ────────────────
|
||||
// How long the receive sequence may sit stuck behind a missing frame, with
|
||||
// later frames already buffered, before that frame is declared unrecoverable
|
||||
// and skipped. Derived from the live RTO so it scales with the path instead of
|
||||
// guessing, then clamped: the floor keeps a fast link from discarding a frame
|
||||
// that is merely late, the ceiling bounds how long a stall can be visible to
|
||||
// the user before the tunnel unblocks itself.
|
||||
const GAP_RECOVERY_RTO_MULTIPLIER: u32 = 8;
|
||||
const GAP_RECOVERY_MIN: Duration = Duration::from_secs(2);
|
||||
const GAP_RECOVERY_MAX: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SentFrame {
|
||||
nonce: u64,
|
||||
|
|
@ -171,33 +156,10 @@ impl ProtocolMachine {
|
|||
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
||||
}
|
||||
|
||||
/// Sum of retry counters across in-flight frames. Test-only: lets a test
|
||||
/// assert the core retransmit invariant (a retry is only ever charged to a
|
||||
/// frame that was actually put on the wire) without needing to advance the
|
||||
/// clock through several seconds of exponential backoff.
|
||||
#[cfg(test)]
|
||||
fn total_retries(&self) -> usize {
|
||||
self.sent_history
|
||||
.iter()
|
||||
.filter(|f| f.is_retransmittable)
|
||||
.map(|f| f.retries as usize)
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn cwnd_packets(&self) -> usize {
|
||||
self.cc.cwnd_packets() as usize
|
||||
}
|
||||
|
||||
/// Whether the pacing bucket currently allows releasing another packet.
|
||||
///
|
||||
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
|
||||
/// nothing about how fast that window is emptied onto the wire. Sending a
|
||||
/// whole window back-to-back is what drives a deep buffer into standing
|
||||
/// queue, so admission is gated on both.
|
||||
pub fn can_pace_packet(&self) -> bool {
|
||||
self.cc.can_pace_packet()
|
||||
}
|
||||
|
||||
pub fn on_send(&mut self, bytes: u64) {
|
||||
self.cc.on_send(bytes);
|
||||
}
|
||||
|
|
@ -275,9 +237,7 @@ impl ProtocolMachine {
|
|||
|
||||
let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]);
|
||||
if session_id != self.session_id {
|
||||
// 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);
|
||||
tracing::error!("session id mismatch! expected={:#010x}, got={:#010x}, is_handshake={}, raw_len={}", self.session_id, session_id, is_handshake, raw_vec.len());
|
||||
return Err(ProtocolError::State("session id mismatch".to_string()));
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +263,7 @@ impl ProtocolMachine {
|
|||
noise_len, raw_vec.len() - 6
|
||||
)));
|
||||
}
|
||||
tracing::info!("handle_inbound: raw_vec.len()={}, noise_len={}, raw_vec[0..6]={:?}", raw_vec.len(), noise_len, &raw_vec[0..6]);
|
||||
|
||||
let mut read_out = vec![0_u8; 1024];
|
||||
let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
|
||||
|
|
@ -320,12 +281,9 @@ impl ProtocolMachine {
|
|||
NoiseRole::Initiator => None,
|
||||
};
|
||||
|
||||
// Transport keys come from Noise's Split() over the final chaining key,
|
||||
// so they depend on the ephemeral `ee` DH secret and give the session
|
||||
// forward secrecy. (Previously these were derived from the handshake
|
||||
// hash, which never absorbs the DH result — see raw_split's SECURITY
|
||||
// note. That is the wire-breaking change gated by PROTOCOL_VERSION.)
|
||||
let (send_key, recv_key) = self.noise.raw_split(self.role)?;
|
||||
let mut key = [0_u8; 32];
|
||||
self.noise.handshake_hash(&mut key)?;
|
||||
let (send_key, recv_key) = derive_split_keys(&key, self.role);
|
||||
self.send_cipher = Some(SessionCipher::new(&send_key));
|
||||
self.recv_cipher = Some(SessionCipher::new(&recv_key));
|
||||
self.state = OstpState::Established;
|
||||
|
|
@ -335,107 +293,7 @@ impl ProtocolMachine {
|
|||
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
||||
}
|
||||
|
||||
/// Restores liveness when the receive sequence is stuck behind a frame that
|
||||
/// can never arrive.
|
||||
///
|
||||
/// Delivery is gated on `expected_recv_nonce`, so a single missing frame
|
||||
/// holds back every later frame. That is correct *while the sender can still
|
||||
/// retransmit* — but the sender drops a frame from `sent_history` once it
|
||||
/// exceeds `max_retries + 2` attempts (see the zombie eviction in
|
||||
/// `handle_tick`). After that the frame is gone for good and the two sides
|
||||
/// deadlock: the receiver buffers forever and NACKs a nonce nobody can
|
||||
/// resend.
|
||||
///
|
||||
/// That deadlock is invisible to the keepalive watchdog, which is why it
|
||||
/// presented as a hard freeze rather than a reconnect: retransmits, ACKs and
|
||||
/// NACKs keep flowing, so the client's `last_valid_recv` keeps refreshing and
|
||||
/// its stall detector never fires. The RTT readout freezes at its last value
|
||||
/// for the same reason — Pong rides in a Data frame stuck behind the gap.
|
||||
///
|
||||
/// So: once we have been stuck long enough that retransmission has provably
|
||||
/// given up, skip to the lowest buffered nonce and drain. This drops the
|
||||
/// missing frame's payload (one RelayMessage — a chunk of one stream), which
|
||||
/// is a real cost, but the alternative is a permanently dead tunnel.
|
||||
fn recover_stalled_gap(&mut self) -> Vec<ProtocolAction> {
|
||||
let mut recovered = Vec::new();
|
||||
if self.reorder_buffer.is_empty() {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
// Wait out the sender's full retransmit budget before giving up, so a
|
||||
// frame that is merely late is never discarded. The sender backs off
|
||||
// exponentially, so key this off the live RTO estimate rather than a
|
||||
// flat constant, with a floor that keeps low-RTT links from skipping
|
||||
// too eagerly and a ceiling that bounds the visible freeze.
|
||||
let timeout = self
|
||||
.cc
|
||||
.rto()
|
||||
.saturating_mul(GAP_RECOVERY_RTO_MULTIPLIER)
|
||||
.clamp(GAP_RECOVERY_MIN, GAP_RECOVERY_MAX);
|
||||
if self.last_recv_advance.elapsed() < timeout {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
let Some(&resume_at) = self.reorder_buffer.keys().next() else {
|
||||
return recovered;
|
||||
};
|
||||
let skipped = resume_at.saturating_sub(self.expected_recv_nonce);
|
||||
tracing::warn!(
|
||||
"Gap recovery: no progress for {:?}; skipping {} unrecoverable frame(s) \
|
||||
(nonce {} -> {}) to unblock the session",
|
||||
self.last_recv_advance.elapsed(),
|
||||
skipped,
|
||||
self.expected_recv_nonce,
|
||||
resume_at
|
||||
);
|
||||
|
||||
self.expected_recv_nonce = resume_at;
|
||||
while let Some(buffered) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
||||
recovered.push(buffered);
|
||||
match self.expected_recv_nonce.checked_add(1) {
|
||||
Some(next) => self.expected_recv_nonce = next,
|
||||
// u64 nonce space exhausted: stop draining rather than wrap.
|
||||
// The session is finished either way; the caller's next decrypt
|
||||
// will fail and tear it down.
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.last_recv_advance = Instant::now();
|
||||
// The peer must learn the sequence moved on, or it will keep
|
||||
// retransmitting into the void.
|
||||
self.ack_pending = true;
|
||||
|
||||
recovered
|
||||
}
|
||||
|
||||
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||
// Check for a stalled gap before classifying this frame, so the rest of
|
||||
// the function sees an already-advanced `expected_recv_nonce`. Runs here
|
||||
// rather than on Tick because both tick handlers discard DeliverApp
|
||||
// actions, and because inbound frames keep arriving throughout the stall
|
||||
// (retransmits/ACKs/NACKs/keepalives) — so this path is reliably reached.
|
||||
let recovered = self.recover_stalled_gap();
|
||||
let result = self.handle_data_inbound_frame(raw_vec)?;
|
||||
if recovered.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Recovered payloads are older than anything this frame produces, so
|
||||
// they go first to preserve delivery order.
|
||||
let mut all = recovered;
|
||||
match result {
|
||||
ProtocolAction::Noop => {}
|
||||
ProtocolAction::Multiple(list) => all.extend(list),
|
||||
single => all.push(single),
|
||||
}
|
||||
Ok(if all.len() == 1 {
|
||||
all.pop().unwrap()
|
||||
} else {
|
||||
ProtocolAction::Multiple(all)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_data_inbound_frame(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||
if raw_vec.len() < 12 {
|
||||
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
||||
}
|
||||
|
|
@ -500,8 +358,13 @@ impl ProtocolMachine {
|
|||
FrameKind::Data => {
|
||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
||||
}
|
||||
FrameKind::Resume => {
|
||||
// 0-RTT: treat early data as application data
|
||||
tracing::info!("0-RTT Resume frame received, processing early data");
|
||||
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
|
||||
}
|
||||
FrameKind::Close => {
|
||||
tracing::debug!("Received Close frame, terminating session");
|
||||
tracing::info!("Received Close frame, terminating session");
|
||||
self.state = OstpState::Closed;
|
||||
ProtocolAction::Noop
|
||||
}
|
||||
|
|
@ -682,41 +545,20 @@ impl ProtocolMachine {
|
|||
if !frame.is_retransmittable {
|
||||
continue;
|
||||
}
|
||||
// Out of budget for this tick — stop scanning rather than walking the
|
||||
// rest of the queue. sent_history is in send order, so everything we
|
||||
// skip is strictly newer than what we already handled; deferring it to
|
||||
// the next tick preserves oldest-first retransmit priority.
|
||||
if retransmit_budget == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff, but bounded in absolute terms. base_rto is
|
||||
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
|
||||
// multiplying that by the 64x backoff cap yields a frame that sits
|
||||
// unretransmitted for ~17 MINUTES, long past the point where the
|
||||
// session is simply dead to the user. Cap the product so backoff
|
||||
// stays a backoff rather than an outage.
|
||||
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor))
|
||||
.min(MAX_EFFECTIVE_RTO);
|
||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
||||
|
||||
if now.duration_since(frame.last_sent) >= effective_rto {
|
||||
// Only burn the retry counter and reset the RTO timer when the
|
||||
// frame is ACTUALLY put on the wire. Doing it unconditionally
|
||||
// meant that whenever the per-tick budget ran out — which is
|
||||
// exactly when loss is heavy and retransmits matter most —
|
||||
// frames accumulated "phantom retries" they never actually got,
|
||||
// and the zombie eviction above then silently dropped them after
|
||||
// `grace` such rounds. The peer never received that data and
|
||||
// never would: that stream stalls forever while the session
|
||||
// itself stays healthy, which is precisely the reported "tunnel
|
||||
// frozen at 0 b/s but the session still up" symptom.
|
||||
frame.last_sent = now;
|
||||
frame.retries = frame.retries.saturating_add(1);
|
||||
|
||||
if retransmit_budget > 0 {
|
||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||
retransmit_budget -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if actions.is_empty() {
|
||||
Ok(ProtocolAction::Noop)
|
||||
|
|
@ -843,34 +685,24 @@ impl ProtocolMachine {
|
|||
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
|
||||
let now = Instant::now();
|
||||
let mut acked_bytes = 0u64;
|
||||
let mut min_rtt: Option<Duration> = None;
|
||||
let mut min_rtt = Duration::from_secs(60);
|
||||
|
||||
// Compute RTT from the oldest acked frame's send timestamp
|
||||
for frame in self.sent_history.iter() {
|
||||
if nonce_in_ranges(frame.nonce, ranges) {
|
||||
acked_bytes += frame.bytes.len() as u64;
|
||||
// Karn's algorithm: never take an RTT sample from a frame that
|
||||
// was retransmitted. `last_sent` is bumped on every retransmit,
|
||||
// so an ACK for the ORIGINAL transmission would be measured
|
||||
// against the retransmit time, yielding a spuriously small RTT
|
||||
// that drags SRTT/RTO down and triggers more spurious
|
||||
// retransmits. Only unambiguous (never-retried) frames qualify.
|
||||
if frame.retries == 0 {
|
||||
let rtt = now.duration_since(frame.last_sent);
|
||||
min_rtt = Some(min_rtt.map_or(rtt, |m| m.min(rtt)));
|
||||
if rtt < min_rtt {
|
||||
min_rtt = rtt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
|
||||
|
||||
// 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.
|
||||
// Notify congestion controller
|
||||
if acked_bytes > 0 {
|
||||
match min_rtt {
|
||||
Some(rtt) => self.cc.on_ack(acked_bytes, rtt),
|
||||
None => self.cc.on_ack_no_rtt(acked_bytes),
|
||||
}
|
||||
self.cc.on_ack(acked_bytes, min_rtt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -900,6 +732,26 @@ fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool {
|
|||
ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end)
|
||||
}
|
||||
|
||||
fn derive_split_keys(base_key: &[u8; 32], role: NoiseRole) -> ([u8; 32], [u8; 32]) {
|
||||
let mut initiator_key = [0u8; 32];
|
||||
let mut responder_key = [0u8; 32];
|
||||
|
||||
let mut h1 = Sha256::new();
|
||||
h1.update(base_key);
|
||||
h1.update(b"ostp-initiator");
|
||||
initiator_key.copy_from_slice(&h1.finalize());
|
||||
|
||||
let mut h2 = Sha256::new();
|
||||
h2.update(base_key);
|
||||
h2.update(b"ostp-responder");
|
||||
responder_key.copy_from_slice(&h2.finalize());
|
||||
|
||||
match role {
|
||||
NoiseRole::Initiator => (initiator_key, responder_key),
|
||||
NoiseRole::Responder => (responder_key, initiator_key),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1131,154 +983,4 @@ mod tests {
|
|||
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
||||
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
||||
}
|
||||
|
||||
/// A retry may only be charged to a frame that was actually retransmitted.
|
||||
///
|
||||
/// The retransmit loop is budget-limited per tick. It used to bump
|
||||
/// `retries` and reset `last_sent` for every due frame regardless of
|
||||
/// whether the budget allowed it to actually send — so under heavy loss
|
||||
/// (exactly when the budget runs out) frames racked up retries they never
|
||||
/// received, and the zombie eviction dropped them after `max_retries + 2`
|
||||
/// such rounds. That data was never delivered and never would be: the
|
||||
/// stream stalls permanently while the session itself stays up.
|
||||
#[test]
|
||||
fn test_retransmit_budget_charges_retries_only_for_frames_actually_sent() {
|
||||
let (mut client, _server) = do_handshake();
|
||||
|
||||
// Queue far more in-flight frames than a single tick's budget allows.
|
||||
const FRAMES: usize = 40;
|
||||
for i in 0..FRAMES {
|
||||
let payload = Bytes::from(vec![i as u8; 200]);
|
||||
client.on_event(OstpEvent::Outbound(1, payload)).unwrap();
|
||||
}
|
||||
assert_eq!(client.in_flight_count(), FRAMES);
|
||||
assert_eq!(client.total_retries(), 0, "nothing retransmitted yet");
|
||||
|
||||
// Let every frame's RTO lapse so that on the next tick all FRAMES frames
|
||||
// are due at once and the per-tick budget is guaranteed to run out. The
|
||||
// effective RTO here is max(cc.rto(), config rto_ms) = 100ms at retries=0.
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
|
||||
let sent = count_datagrams(&client.on_event(OstpEvent::Tick).unwrap());
|
||||
|
||||
assert!(sent > 0, "expected some retransmits after the RTO lapsed");
|
||||
assert!(
|
||||
sent < FRAMES,
|
||||
"budget should have capped this tick below the {FRAMES} due frames, got {sent}"
|
||||
);
|
||||
assert_eq!(
|
||||
client.total_retries(),
|
||||
sent,
|
||||
"charged {} retries but only put {} frames on the wire — the \
|
||||
difference is phantom retries that will silently evict live data",
|
||||
client.total_retries(),
|
||||
sent
|
||||
);
|
||||
assert_eq!(
|
||||
client.in_flight_count(),
|
||||
FRAMES,
|
||||
"nothing was acked, so no frame may be evicted yet"
|
||||
);
|
||||
}
|
||||
|
||||
/// Count how many datagrams an action tree actually puts on the wire.
|
||||
fn count_datagrams(action: &ProtocolAction) -> usize {
|
||||
match action {
|
||||
ProtocolAction::SendDatagram(_) => 1,
|
||||
ProtocolAction::Multiple(list) => list.iter().map(count_datagrams).sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Count how many application payloads an action tree actually delivers.
|
||||
fn delivered_payloads(action: &ProtocolAction) -> Vec<Bytes> {
|
||||
match action {
|
||||
ProtocolAction::DeliverApp(_, data) => vec![data.clone()],
|
||||
ProtocolAction::Multiple(list) => list.iter().flat_map(delivered_payloads).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `count` data frames on `client`, returning them without delivering
|
||||
/// any — lets a test choose which ones to "lose" in transit.
|
||||
fn make_data_frames(client: &mut ProtocolMachine, count: u8) -> Vec<Bytes> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let payload = Bytes::from(vec![i; 32]);
|
||||
match client.on_event(OstpEvent::Outbound(1, payload)).unwrap() {
|
||||
ProtocolAction::SendDatagram(d) => d,
|
||||
_ => panic!("expected SendDatagram for frame {i}"),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The freeze this fixes: a frame is lost, the sender eventually stops
|
||||
/// retransmitting it, and the receiver — which gates delivery on
|
||||
/// `expected_recv_nonce` — waits for it forever. Every later frame piles up
|
||||
/// undelivered while the transport itself stays healthy, so nothing upstream
|
||||
/// notices. Recovery must eventually skip the hole and release the backlog.
|
||||
#[test]
|
||||
fn test_gap_recovery_releases_permanently_stalled_frames() {
|
||||
let (mut client, mut server) = do_handshake();
|
||||
let frames = make_data_frames(&mut client, 4);
|
||||
|
||||
// Frame 0 arrives in order and is delivered straight through.
|
||||
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||
assert_eq!(delivered_payloads(&action).len(), 1, "in-order frame should deliver");
|
||||
|
||||
// Frame 1 is lost. 2 and 3 arrive but must be held back — delivering them
|
||||
// now would reorder the stream.
|
||||
for idx in [2usize, 3] {
|
||||
let action = server.on_event(OstpEvent::Inbound(frames[idx].clone())).unwrap();
|
||||
assert!(
|
||||
delivered_payloads(&action).is_empty(),
|
||||
"frame {idx} must stay buffered behind the missing frame"
|
||||
);
|
||||
}
|
||||
|
||||
// Stand in for "the sender exhausted its retries and dropped frame 1":
|
||||
// the sequence has not advanced for longer than the recovery timeout.
|
||||
server.last_recv_advance = Instant::now() - GAP_RECOVERY_MAX - Duration::from_secs(1);
|
||||
|
||||
// The next inbound frame (a retransmitted duplicate, which is exactly what
|
||||
// a real stalled session keeps receiving) must unblock the backlog.
|
||||
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||
let delivered = delivered_payloads(&action);
|
||||
assert_eq!(
|
||||
delivered.len(),
|
||||
2,
|
||||
"both buffered frames must be released once the gap is declared unrecoverable"
|
||||
);
|
||||
// ...and in order: frame 2 before frame 3.
|
||||
assert_eq!(delivered[0][0], 2);
|
||||
assert_eq!(delivered[1][0], 3);
|
||||
}
|
||||
|
||||
/// Recovery must not be trigger-happy: a frame that is merely late still has
|
||||
/// to be waited for, or we would discard data the sender is about to resend.
|
||||
#[test]
|
||||
fn test_gap_recovery_does_not_fire_before_timeout() {
|
||||
let (mut client, mut server) = do_handshake();
|
||||
let frames = make_data_frames(&mut client, 3);
|
||||
|
||||
server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||
let action = server.on_event(OstpEvent::Inbound(frames[2].clone())).unwrap();
|
||||
assert!(delivered_payloads(&action).is_empty());
|
||||
|
||||
// Well inside the timeout — the gap must still be respected.
|
||||
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||
assert!(
|
||||
delivered_payloads(&action).is_empty(),
|
||||
"must keep waiting while retransmission is still plausible"
|
||||
);
|
||||
|
||||
// And once the genuinely-late frame shows up, normal in-order delivery
|
||||
// resumes with nothing dropped.
|
||||
let action = server.on_event(OstpEvent::Inbound(frames[1].clone())).unwrap();
|
||||
let delivered = delivered_payloads(&action);
|
||||
assert_eq!(delivered.len(), 2, "late frame plus the buffered one");
|
||||
assert_eq!(delivered[0][0], 1);
|
||||
assert_eq!(delivered[1][0], 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
//! 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,6 +1,3 @@
|
|||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
|
|
@ -8,37 +5,6 @@ plugins {
|
|||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// ── Release signing material ────────────────────────────────────────────────
|
||||
// Supplied out-of-band and never committed: either an `android/key.properties`
|
||||
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
|
||||
//
|
||||
// This exists because the release build used to be signed with the DEBUG
|
||||
// keystore (the stock Flutter template TODO). Android identifies an app by
|
||||
// applicationId + signing key, and refuses to update across a key change. The
|
||||
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
|
||||
// so every published build carried a different random key — which is why
|
||||
// updating on top of a previous install failed with "App not installed" /
|
||||
// "unable to parse the package" and only a full uninstall+reinstall worked.
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propsFile = rootProject.file("key.properties")
|
||||
if (propsFile.exists()) {
|
||||
FileInputStream(propsFile).use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Blank counts as absent. GitHub Actions substitutes an EMPTY STRING (not an
|
||||
// unset variable) for a secret that doesn't exist, so `getenv(...) ?: fallback`
|
||||
// silently kept the empty value — the elvis operator only catches null. That is
|
||||
// how an unset ANDROID_KEY_PASSWORD ended up being used as the literal key
|
||||
// password instead of falling back to the store password, producing Gradle's
|
||||
// "Get Key failed: Given final block not properly padded".
|
||||
fun signingSetting(propKey: String, envKey: String): String? =
|
||||
(keystoreProperties.getProperty(propKey) ?: System.getenv(envKey))
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
|
||||
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
|
||||
|
||||
android {
|
||||
namespace = "com.ospab.ostp_client"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
|
@ -68,43 +34,11 @@ 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 {
|
||||
release {
|
||||
// Use the real upload key when one was supplied; otherwise fall back to
|
||||
// the debug keystore so a plain local `flutter build apk --release`
|
||||
// still works for development. Anything PUBLISHED must take the first
|
||||
// branch — a debug-signed build cannot be updated over, and its key is
|
||||
// machine-local, so it also can't be reproduced later.
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
logger.warn(
|
||||
"OSTP: no release keystore configured (android/key.properties or " +
|
||||
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
|
||||
"is for local use only: users cannot update over it, and the key is " +
|
||||
"not reproducible on another machine."
|
||||
)
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 769 KiB After Width: | Height: | Size: 11 KiB |
|
|
@ -8,13 +8,6 @@ import '../models/connection_state_enum.dart';
|
|||
import '../models/ostp_profile.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 {
|
||||
final SharedPreferences prefs;
|
||||
const HomeScreen({super.key, required this.prefs});
|
||||
|
|
@ -52,7 +45,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
late AnimationController _pulseController;
|
||||
late AnimationController _spinController;
|
||||
|
||||
String _pingText = '-- ms';
|
||||
bool _isCheckingPing = false;
|
||||
String _pingText = 'Target Ping: -- ms';
|
||||
Color _pingColor = Colors.white54;
|
||||
|
||||
@override
|
||||
|
|
@ -426,8 +420,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
_prevBytesSent = bytesSent;
|
||||
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||
if (rttMs > 0) {
|
||||
_pingText = '$rttMs ms';
|
||||
if (rttMs > 0 && !_isCheckingPing) {
|
||||
_pingText = 'Server Ping: $rttMs ms';
|
||||
if (rttMs < 100) {
|
||||
_pingColor = const Color(0xFF22D3A5);
|
||||
} else if (rttMs < 250) {
|
||||
|
|
@ -453,6 +447,47 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
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() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
@ -463,8 +498,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
_upSpeed = '0 B/s';
|
||||
_prevBytesRecv = 0;
|
||||
_prevBytesSent = 0;
|
||||
_pingText = '-- ms';
|
||||
_pingText = 'Target Ping: -- ms';
|
||||
_pingColor = Colors.white54;
|
||||
_isCheckingPing = false;
|
||||
});
|
||||
_pulseController.stop();
|
||||
_pulseController.value = 0.0;
|
||||
|
|
@ -542,12 +578,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: _state == ConnectionStateEnum.connected
|
||||
? kConnectedGreen
|
||||
? theme.colorScheme.secondary
|
||||
: theme.colorScheme.primary,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _state == ConnectionStateEnum.connected
|
||||
? kConnectedGreen.withOpacity(0.5)
|
||||
? theme.colorScheme.secondary.withOpacity(0.5)
|
||||
: theme.colorScheme.primary.withOpacity(0.5),
|
||||
blurRadius: 10,
|
||||
)
|
||||
|
|
@ -601,7 +637,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
|
||||
Widget _buildStage(ThemeData theme) {
|
||||
Color getAccentColor() {
|
||||
if (_state == ConnectionStateEnum.connected) return kConnectedGreen;
|
||||
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
|
||||
return theme.colorScheme.primary;
|
||||
}
|
||||
|
||||
|
|
@ -739,23 +775,37 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.03),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'CONNECTION TEST',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white38,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_pingText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _pingColor,
|
||||
),
|
||||
|
|
@ -763,6 +813,32 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
],
|
||||
),
|
||||
),
|
||||
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,13 +265,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
if (v != null) setDialogState(() => transportMode = v);
|
||||
},
|
||||
),
|
||||
// Junk packets and TCP fragmentation only take effect on the
|
||||
// UoT (TCP) transport — the UDP path applies neither — so the
|
||||
// whole section is hidden under UDP instead of shown with a
|
||||
// "UoT only" caveat. Reactive: switching Transport above calls
|
||||
// setDialogState, which rebuilds this and shows/hides it.
|
||||
if (transportMode == 'uot') ...[
|
||||
const Divider(height: 32),
|
||||
// Junk packets + TCP fragmentation moved into their own
|
||||
// modals (tap to configure) — this dialog was carrying too
|
||||
// many fields at once; these two are advanced/occasional
|
||||
// settings, not something every profile edit needs to see.
|
||||
const Text('DPI OBFUSCATION', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
|
|
@ -301,7 +299,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.4.4+31
|
||||
version: 0.4.1+19
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.4
|
||||
|
|
@ -58,7 +58,7 @@ dev_dependencies:
|
|||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
ios: false
|
||||
image_path: "../icons/logo_new.png"
|
||||
image_path: "../icons/sqare.png"
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "ostp-gui",
|
||||
"private": true,
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
|
|
|
|||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -2696,7 +2696,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-gui"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"json_comments",
|
||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ostp-gui"
|
||||
version = "0.4.4"
|
||||
version = "0.4.1"
|
||||
description = "OSTP desktop GUI"
|
||||
authors = ["ospab"]
|
||||
edition = "2021"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |