mirror of https://github.com/ospab/ostp.git
Compare commits
53 Commits
v0.4.2-bet
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
cf14a4243c | |
|
|
66368c9d0f | |
|
|
bc61b47817 | |
|
|
5a33ed69c4 | |
|
|
c5e703c144 | |
|
|
05f25155bd | |
|
|
e6e0a7b28c | |
|
|
8f0ffd08c0 | |
|
|
8a1426ecf5 | |
|
|
e483af541f | |
|
|
df1a14d15c | |
|
|
d915efc715 | |
|
|
b673219894 | |
|
|
a1c146aff3 | |
|
|
365b4ccbf5 | |
|
|
4a3fb8b944 | |
|
|
f789167a22 | |
|
|
108bab6a90 | |
|
|
f7e9215331 | |
|
|
ebfc751471 | |
|
|
3cda1a9bd4 | |
|
|
77a45d7642 | |
|
|
6abae68f35 | |
|
|
cb57347d51 | |
|
|
32c36afc3b | |
|
|
a8aba8f4b8 | |
|
|
2ede607027 | |
|
|
0c69617725 | |
|
|
88e0634f09 | |
|
|
7473278cc2 | |
|
|
77e42b77f7 | |
|
|
e7a4f2b4a4 | |
|
|
6bc646c8a5 | |
|
|
d9fe749cd4 | |
|
|
cdfd2babc0 | |
|
|
2092e22a7c | |
|
|
5278f58903 | |
|
|
340819745a | |
|
|
e31c4b2268 | |
|
|
e46c863ef0 | |
|
|
cddd623ad0 | |
|
|
9a891310f9 | |
|
|
d9686c9344 | |
|
|
dbf923fb16 | |
|
|
51b947e6ff | |
|
|
f01ed4ec25 | |
|
|
c2a1a53b4d | |
|
|
cd12b01bc3 | |
|
|
de5cee103b | |
|
|
c523b083cb | |
|
|
c6a130673d | |
|
|
c756e02b63 | |
|
|
70a669d3c6 |
|
|
@ -284,7 +284,15 @@ jobs:
|
||||||
|
|
||||||
- name: Install cross (if not cached)
|
- name: Install cross (if not cached)
|
||||||
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
|
||||||
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
# cross-rs's own source (not ours, not a dependency of ours) uses a
|
||||||
|
# macro-at-end-of-block pattern that trips rustc's
|
||||||
|
# semicolon_in_expressions_from_macros lint on current toolchains -
|
||||||
|
# harmless in cross's actual behavior, but `cargo install` compiles
|
||||||
|
# the installed package as the "local" crate, so dependency lint
|
||||||
|
# capping doesn't shield it. --cap-lints=warn is the standard escape
|
||||||
|
# hatch for building a third-party tool against a newer compiler than
|
||||||
|
# its own lint config assumed; it doesn't touch our own build.
|
||||||
|
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
|
||||||
|
|
||||||
- name: Build (cross)
|
- name: Build (cross)
|
||||||
if: ${{ matrix.use_cross }}
|
if: ${{ matrix.use_cross }}
|
||||||
|
|
@ -370,7 +378,15 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
# Without a prefix fallback this cache NEVER restored on a release:
|
||||||
|
# cutting a release rewrites every Cargo.lock (version bump), which
|
||||||
|
# changes hashFiles(), which misses the exact key — so each release
|
||||||
|
# rebuilt every dependency from scratch. That is why the GUI jobs ran
|
||||||
|
# 2-4x longer than the plain release targets, which had this all along.
|
||||||
|
restore-keys: |
|
||||||
|
cargo-windows-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Download wintun
|
- name: Download wintun
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
@ -452,18 +468,28 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-linux-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
|
# TUN mode shells out to this helper, elevated via pkexec. Only the
|
||||||
|
# Windows job used to build it, so the Linux package shipped without
|
||||||
|
# it and TUN could never start.
|
||||||
|
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }} --manifest-path ../Cargo.toml
|
||||||
npx tauri build --no-bundle --target ${{ matrix.target }}
|
npx tauri build --no-bundle --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Package Portable Tarball
|
- name: Package Portable Tarball
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
mkdir ostp-linux-gui-${{ matrix.arch }}
|
mkdir ostp-linux-gui-${{ matrix.arch }}
|
||||||
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
cp ostp-gui/src-tauri/target/${{ matrix.target }}/release/ostp-gui ostp-linux-gui-${{ matrix.arch }}/
|
||||||
|
# The GUI looks for the helper next to its own executable first.
|
||||||
|
cp target/${{ matrix.target }}/release/ostp-tun-helper ostp-linux-gui-${{ matrix.arch }}/
|
||||||
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
tar -czf ostp-linux-gui-${{ matrix.arch }}.tar.gz ostp-linux-gui-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
|
|
@ -514,7 +540,10 @@ jobs:
|
||||||
~/.cargo/registry/cache/
|
~/.cargo/registry/cache/
|
||||||
~/.cargo/git/db/
|
~/.cargo/git/db/
|
||||||
target/
|
target/
|
||||||
|
ostp-gui/src-tauri/target/
|
||||||
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-macos-gui-${{ matrix.target }}-
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
working-directory: ostp-gui
|
working-directory: ostp-gui
|
||||||
|
|
@ -579,27 +608,107 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ndk-version: r26b
|
ndk-version: r26b
|
||||||
|
|
||||||
- name: Install cargo-ndk
|
# The Android jobs had no Rust caching at all, so every release recompiled
|
||||||
run: cargo install cargo-ndk
|
# the whole ostp-jni dependency graph from scratch — the main reason these
|
||||||
|
# were among the slowest jobs in the matrix.
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index/
|
||||||
|
~/.cargo/registry/cache/
|
||||||
|
~/.cargo/git/db/
|
||||||
|
target/
|
||||||
|
key: cargo-android-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
cargo-android-${{ matrix.arch }}-
|
||||||
|
|
||||||
|
# cargo-ndk was built from source on every run. Cache the binary the same
|
||||||
|
# way the cross-compilation jobs already cache `cross`.
|
||||||
|
- name: Restore cargo-ndk binary cache
|
||||||
|
id: cargo-ndk-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-ndk
|
||||||
|
key: cargo-ndk-bin-${{ runner.os }}-v1
|
||||||
|
|
||||||
|
- name: Install cargo-ndk (if not cached)
|
||||||
|
if: steps.cargo-ndk-cache.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install cargo-ndk --locked
|
||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ostp-flutter
|
working-directory: ostp-flutter
|
||||||
|
env:
|
||||||
|
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
# 1. Compile JNI
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. Materialise the upload keystore from secrets. Android keys an app
|
||||||
|
# by applicationId + signing key and refuses to update across a key
|
||||||
|
# change, so every published build MUST use this one key. Releases
|
||||||
|
# used to fall through to the per-machine debug keystore, which on
|
||||||
|
# ephemeral CI runners meant a different random key every build -
|
||||||
|
# hence "App not installed" on upgrade.
|
||||||
|
if [ -z "${OSTP_KEYSTORE_B64:-}" ]; then
|
||||||
|
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set. Refusing to publish a"
|
||||||
|
echo "::error::debug-signed APK: users could not update over it and the key is"
|
||||||
|
echo "::error::not reproducible. See docs for the one-time keystore setup."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export OSTP_KEYSTORE_PATH="$RUNNER_TEMP/ostp-upload.jks"
|
||||||
|
# Strip any stray CR/LF before decoding: the secret is pasted from a
|
||||||
|
# shell whose line endings we don't control, and a single trailing \r
|
||||||
|
# is enough to corrupt the decode.
|
||||||
|
printf '%s' "$OSTP_KEYSTORE_B64" | tr -d '\r\n' | base64 -d > "$OSTP_KEYSTORE_PATH"
|
||||||
|
|
||||||
|
# Verify the keystore opens BEFORE spending four minutes on Gradle only
|
||||||
|
# to fail at the packaging step. The size/SHA-256 are safe to print (a
|
||||||
|
# hash reveals nothing) and let the operator compare against the local
|
||||||
|
# file to tell a transport problem apart from a wrong password.
|
||||||
|
echo "keystore: $(stat -c%s "$OSTP_KEYSTORE_PATH") bytes, sha256 $(sha256sum "$OSTP_KEYSTORE_PATH" | cut -d' ' -f1)"
|
||||||
|
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||||
|
-storepass "$OSTP_KEYSTORE_PASSWORD" >/dev/null 2>&1; then
|
||||||
|
echo "::error::The keystore did not open with ANDROID_KEYSTORE_PASSWORD."
|
||||||
|
echo "::error::If the SHA-256 above matches your local ostp-upload.jks, the file"
|
||||||
|
echo "::error::arrived intact and the password secret itself is wrong - note that"
|
||||||
|
echo "::error::PowerShell expands \$ inside double quotes, so a password containing"
|
||||||
|
echo "::error::one gets mangled unless it was set with single quotes."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! keytool -list -keystore "$OSTP_KEYSTORE_PATH" \
|
||||||
|
-storepass "$OSTP_KEYSTORE_PASSWORD" -alias "$OSTP_KEY_ALIAS" >/dev/null 2>&1; then
|
||||||
|
echo "::error::Keystore opened, but it has no key under ANDROID_KEY_ALIAS."
|
||||||
|
echo "::error::Aliases present in the keystore:"
|
||||||
|
keytool -list -keystore "$OSTP_KEYSTORE_PATH" -storepass "$OSTP_KEYSTORE_PASSWORD" \
|
||||||
|
| grep -i "PrivateKeyEntry" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Compile JNI
|
||||||
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
|
||||||
|
|
||||||
cd ../ostp-jni
|
cd ../ostp-jni
|
||||||
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
|
||||||
cd ../ostp-flutter
|
cd ../ostp-flutter
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 3. Build Flutter APK
|
# 3. Build Flutter APK
|
||||||
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
|
||||||
|
|
||||||
# 4. Copy to output
|
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
|
# than shipping another un-updatable build.
|
||||||
|
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
|
||||||
|
| grep -qi "CN=Android Debug"; then
|
||||||
|
echo "::error::APK is signed with the Android debug certificate - aborting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Copy to output
|
||||||
|
cp "$APK" ostp-android-${{ matrix.arch }}.apk
|
||||||
|
|
||||||
- name: Upload to GitHub Release
|
- name: Upload to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ test_route.ps1
|
||||||
config.json
|
config.json
|
||||||
wintun.dll
|
wintun.dll
|
||||||
|
|
||||||
|
# Android signing keys. The upload keystore is the ONE key every published APK
|
||||||
|
# must be signed with (Android refuses to update an app across a key change),
|
||||||
|
# so losing or leaking it is unrecoverable — it can never be committed.
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
key.properties
|
||||||
|
|
||||||
# Server runtime cache (public IP autodetect) — must never be committed,
|
# Server runtime cache (public IP autodetect) — must never be committed,
|
||||||
# it's regenerated locally and leaks whatever host it ran on last.
|
# it's regenerated locally and leaks whatever host it ran on last.
|
||||||
.ostp_public_ip
|
.ostp_public_ip
|
||||||
|
|
@ -39,6 +46,7 @@ turn-harvesting-idea.md
|
||||||
|
|
||||||
# Private tooling (closed-source)
|
# Private tooling (closed-source)
|
||||||
ostp-prober/
|
ostp-prober/
|
||||||
|
ostp-lab/
|
||||||
|
|
||||||
ostp-brain/
|
ostp-brain/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"target_version": "0.4.2",
|
"target_version": "0.4.4",
|
||||||
"branch": "beta",
|
"branch": "master",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_iteration": 1
|
"beta_iteration": 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1400,6 +1400,7 @@ dependencies = [
|
||||||
"rlimit",
|
"rlimit",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -1408,7 +1409,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1439,7 +1440,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1473,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1496,6 +1497,7 @@ dependencies = [
|
||||||
"sha2",
|
"sha2",
|
||||||
"simple-dns",
|
"simple-dns",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -1505,7 +1507,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1517,7 +1519,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ resolver = "2"
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,15 @@
|
||||||
// Адрес следующего узла в цепочке — UDP
|
// Адрес следующего узла в цепочке — UDP
|
||||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||||
|
|
||||||
// URL API конечного (целевого) сервера для синхронизации access_keys
|
// URL API конечного (целевого) сервера для синхронизации access_keys.
|
||||||
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель)
|
// Должен быть доступен с этого relay-сервера (можно через SSH-туннель).
|
||||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
|
//
|
||||||
|
// ВАЖНО: URL обязан включать секретный путь панели (api.webpath целевого
|
||||||
|
// сервера). Management API смонтирован ВНУТРИ этого пути — именно он скрывает
|
||||||
|
// панель от сканеров, — поэтому голый host:port попадает в несуществующий
|
||||||
|
// маршрут, и синхронизация падает с 404 ещё до проверки токена.
|
||||||
|
// Это тот же адрес, по которому вы открываете веб-панель.
|
||||||
|
"upstream_api_url": "http://TARGET_SERVER_IP:9090/TARGET_SERVER_WEBPATH",
|
||||||
|
|
||||||
// Bearer-токен для доступа к API целевого сервера
|
// Bearer-токен для доступа к API целевого сервера
|
||||||
// Должен совпадать с api.token в конфиге target-сервера
|
// Должен совпадать с api.token в конфиге target-сервера
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,19 @@ use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
|
||||||
use crate::config::ClientConfig;
|
use crate::config::ClientConfig;
|
||||||
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
|
||||||
|
|
||||||
|
/// Per-address ceiling on the UoT/TCP connect attempt. Long enough that a
|
||||||
|
/// genuinely slow mobile path still completes its handshake, short enough that
|
||||||
|
/// a blackholed address (typically IPv6 advertised without a working route)
|
||||||
|
/// costs seconds instead of the kernel's full SYN-retry budget before the next
|
||||||
|
/// candidate address is tried.
|
||||||
|
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
|
||||||
|
|
||||||
|
/// How long to keep retrying a resume-triggered reconnect before handing the
|
||||||
|
/// problem back to the ordinary stall path. That path is what releases the
|
||||||
|
/// system proxy, so this is really a bound on how long the machine may be left
|
||||||
|
/// with no working internet at all after waking.
|
||||||
|
const RESUME_RECONNECT_GIVE_UP: Duration = Duration::from_secs(45);
|
||||||
|
|
||||||
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
pub fn set_socket_protector<F>(f: F)
|
pub fn set_socket_protector<F>(f: F)
|
||||||
|
|
@ -130,6 +143,21 @@ pub struct Bridge {
|
||||||
last_rtt_ms: f64,
|
last_rtt_ms: f64,
|
||||||
last_sample_at: Instant,
|
last_sample_at: Instant,
|
||||||
last_valid_recv: Instant,
|
last_valid_recv: Instant,
|
||||||
|
/// Set when a suspend/resume is detected, cleared once a reconnect actually
|
||||||
|
/// succeeds. Waking is precisely when the network is least likely to be
|
||||||
|
/// ready — Wi-Fi has not reassociated yet — so a single attempt fired
|
||||||
|
/// milliseconds after resume usually fails, and a one-shot forced reconnect
|
||||||
|
/// then fell back to the ordinary 25s stall heuristic. That heuristic keys
|
||||||
|
/// off a monotonic clock which does not advance while the machine is
|
||||||
|
/// asleep, so it could take a further 25s of real uptime to fire, or not
|
||||||
|
/// fire at all. Retrying until success removes the dependency on either.
|
||||||
|
forced_reconnect_pending: bool,
|
||||||
|
last_forced_reconnect_try: Instant,
|
||||||
|
/// Wall-clock start of the current resume-reconnect campaign, used to bound
|
||||||
|
/// it. Wall clock rather than Instant because the monotonic clock does not
|
||||||
|
/// advance across suspend on Windows, so it cannot measure anything that
|
||||||
|
/// begins at wake.
|
||||||
|
forced_reconnect_started: Option<SystemTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bridge {
|
impl Bridge {
|
||||||
|
|
@ -166,6 +194,9 @@ impl Bridge {
|
||||||
last_rtt_ms: 0.0,
|
last_rtt_ms: 0.0,
|
||||||
last_sample_at: Instant::now(),
|
last_sample_at: Instant::now(),
|
||||||
last_valid_recv: Instant::now(),
|
last_valid_recv: Instant::now(),
|
||||||
|
forced_reconnect_pending: false,
|
||||||
|
last_forced_reconnect_try: Instant::now(),
|
||||||
|
forced_reconnect_started: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +262,7 @@ impl Bridge {
|
||||||
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
|
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
|
||||||
}
|
}
|
||||||
cmd = bridge_rx.recv() => {
|
cmd = bridge_rx.recv() => {
|
||||||
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
|
if !self.handle_bridge_cmd(cmd, &mut bridge_rx, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -248,7 +279,64 @@ impl Bridge {
|
||||||
let _ = tx.send(UiEvent::Log(format!(
|
let _ = tx.send(UiEvent::Log(format!(
|
||||||
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
|
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
|
||||||
))).await;
|
))).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;
|
self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
|
||||||
|
// handle_keepalive refreshes last_valid_recv only when a
|
||||||
|
// session was actually established, so this is a real
|
||||||
|
// success check rather than "we tried".
|
||||||
|
if self.last_valid_recv.elapsed() < Duration::from_secs(3) {
|
||||||
|
self.forced_reconnect_pending = false;
|
||||||
|
self.forced_reconnect_started = None;
|
||||||
|
let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if self.running {
|
if self.running {
|
||||||
self.emit_metrics(&tx).await;
|
self.emit_metrics(&tx).await;
|
||||||
|
|
@ -265,7 +353,20 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
|
proxy_ev = proxy_rx.recv(), if self.running && sessions_opt.as_ref().map(|s| {
|
||||||
s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
|
// Upper bound matches MAX_CWND_PACKETS in ostp-core's congestion
|
||||||
|
// controller. The old 16384 ceiling let ~20 MB sit in flight,
|
||||||
|
// which on a mobile uplink is minutes of buffered queue rather
|
||||||
|
// than throughput — the app kept handing over data long after
|
||||||
|
// the path had stopped draining it.
|
||||||
|
// Two independent gates. cwnd bounds how much may be in
|
||||||
|
// flight; pacing bounds how FAST it is released. Without the
|
||||||
|
// second, a full window goes out back-to-back and lands in
|
||||||
|
// the bottleneck's buffer as standing queue rather than
|
||||||
|
// throughput — the thing that produced multi-second RTT.
|
||||||
|
s.iter().any(|ses| {
|
||||||
|
ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 1024)
|
||||||
|
&& ses.machine.can_pace_packet()
|
||||||
|
})
|
||||||
}).unwrap_or(true) => {
|
}).unwrap_or(true) => {
|
||||||
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
self.handle_proxy_event(proxy_ev, &mut sessions_opt, &mut stream_map, &tx, &proxy_tx).await;
|
||||||
}
|
}
|
||||||
|
|
@ -288,8 +389,8 @@ impl Bridge {
|
||||||
) {
|
) {
|
||||||
match udp_msg {
|
match udp_msg {
|
||||||
Some((session_index, inbound)) => {
|
Some((session_index, inbound)) => {
|
||||||
|
// Raw byte counter — every datagram that reached the socket counts.
|
||||||
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
|
||||||
self.last_valid_recv = Instant::now();
|
|
||||||
if let Some(sessions) = sessions_opt.as_mut() {
|
if let Some(sessions) = sessions_opt.as_mut() {
|
||||||
if session_index < sessions.len() {
|
if session_index < sessions.len() {
|
||||||
let session = &mut sessions[session_index];
|
let session = &mut sessions[session_index];
|
||||||
|
|
@ -302,6 +403,22 @@ impl Bridge {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only NOW, after the datagram actually authenticated and
|
||||||
|
// decrypted, does it count as a sign of life. This used to
|
||||||
|
// be set above, before any validation — so a datagram that
|
||||||
|
// failed to decrypt still reset the stall detector on its
|
||||||
|
// way to the `return` above. Anything arriving at this port
|
||||||
|
// (frames from a session the server already evicted, stale
|
||||||
|
// retransmits, or plain garbage from an off-path source that
|
||||||
|
// knows the ip:port) kept the client convinced the tunnel
|
||||||
|
// was healthy: the 25s background reconnect in
|
||||||
|
// handle_keepalive never fired and the tunnel sat dead at
|
||||||
|
// 0 b/s until the user reconnected by hand. It also made
|
||||||
|
// `is_healthy` (see emit_metrics) lie in the UI, and handed
|
||||||
|
// any off-path sender a trivial way to pin a client in a
|
||||||
|
// dead session indefinitely.
|
||||||
|
self.last_valid_recv = Instant::now();
|
||||||
|
|
||||||
let mut actions_queue = std::collections::VecDeque::new();
|
let mut actions_queue = std::collections::VecDeque::new();
|
||||||
actions_queue.push_back(initial_action);
|
actions_queue.push_back(initial_action);
|
||||||
|
|
||||||
|
|
@ -374,6 +491,7 @@ impl Bridge {
|
||||||
async fn handle_bridge_cmd(
|
async fn handle_bridge_cmd(
|
||||||
&mut self,
|
&mut self,
|
||||||
cmd: Option<BridgeCommand>,
|
cmd: Option<BridgeCommand>,
|
||||||
|
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
|
||||||
sessions_opt: &mut Option<Vec<SessionState>>,
|
sessions_opt: &mut Option<Vec<SessionState>>,
|
||||||
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
|
||||||
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
|
||||||
|
|
@ -465,6 +583,32 @@ impl Bridge {
|
||||||
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
|
||||||
}
|
}
|
||||||
Some(BridgeCommand::NetworkChanged) => {
|
Some(BridgeCommand::NetworkChanged) => {
|
||||||
|
// A real network handoff (Wi-Fi <-> cellular) commonly fires
|
||||||
|
// onLost + onAvailable within milliseconds of each other on
|
||||||
|
// Android, queuing several NetworkChanged commands back to
|
||||||
|
// back. Each reconnect below is a full sequential handshake
|
||||||
|
// (up to ~1.2s x 4 attempts x mux_sessions) run synchronously
|
||||||
|
// in this select-loop iteration, so without coalescing, the
|
||||||
|
// first attempt often races the OS's own network switch and
|
||||||
|
// fails on the now-dead interface, then the SECOND queued
|
||||||
|
// NetworkChanged only starts its own full reconnect after
|
||||||
|
// that first one finishes - multiplying a sub-second handoff
|
||||||
|
// into many seconds of extra outage. Drain same-kind repeats
|
||||||
|
// so a burst collapses into one reconnect on the freshest
|
||||||
|
// signal; a different command found while draining is
|
||||||
|
// handled immediately rather than dropped.
|
||||||
|
while let Ok(next) = bridge_rx.try_recv() {
|
||||||
|
if !matches!(next, BridgeCommand::NetworkChanged) {
|
||||||
|
let more = Box::pin(self.handle_bridge_cmd(
|
||||||
|
Some(next), bridge_rx, sessions_opt, udp_rx_opt, proxy_guard, stream_map, tx, proxy_tx,
|
||||||
|
)).await;
|
||||||
|
if !more {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if self.running {
|
if self.running {
|
||||||
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
|
||||||
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
self.metrics.connection_state.store(1, Ordering::Relaxed);
|
||||||
|
|
@ -876,7 +1020,21 @@ impl Bridge {
|
||||||
Ok(addrs) => addrs.collect(),
|
Ok(addrs) => addrs.collect(),
|
||||||
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
Err(e) => return Err(anyhow::anyhow!("failed to resolve server address {}: {}", self.server_addr, e)),
|
||||||
};
|
};
|
||||||
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
|
// IPv4 first. Addresses are tried strictly in order, each burning its
|
||||||
|
// full retry budget before the next is touched, so this ordering decides
|
||||||
|
// how long a bad family stalls the whole connect. Mobile carriers
|
||||||
|
// routinely hand out IPv6 with no working route and BLACKHOLE it rather
|
||||||
|
// than rejecting, so every IPv6 candidate costs the full timeout budget
|
||||||
|
// — with several AAAA records the working IPv4 address was not reached
|
||||||
|
// for tens of seconds. (The same ordering bug was already fixed on the
|
||||||
|
// server's outbound path and in the UoT connect.)
|
||||||
|
resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 1 } else { 0 });
|
||||||
|
|
||||||
|
// NAT64 is a fallback for IPv6-only networks. Retrying it per failing
|
||||||
|
// address multiplied an already-long connect: each attempt re-runs a DNS
|
||||||
|
// lookup and another full round of handshake retries, for a path that
|
||||||
|
// either works for the whole network or for none of it.
|
||||||
|
let mut nat64_attempted = false;
|
||||||
|
|
||||||
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
let mut last_err = anyhow::anyhow!("no IP addresses resolved for {}", self.server_addr);
|
||||||
|
|
||||||
|
|
@ -889,7 +1047,8 @@ impl Bridge {
|
||||||
let socket = match self.try_connect_transport(target_ip, port).await {
|
let socket = match self.try_connect_transport(target_ip, port).await {
|
||||||
Ok(sock) => sock,
|
Ok(sock) => sock,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||||
|
nat64_attempted = true;
|
||||||
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
tx.send(UiEvent::Log(format!("Direct IPv4 connection failed: {}. Trying NAT64 fallback...", e))).await.ok();
|
||||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||||
|
|
@ -970,7 +1129,8 @@ impl Bridge {
|
||||||
let (final_socket, size) = if success {
|
let (final_socket, size) = if success {
|
||||||
(socket, size)
|
(socket, size)
|
||||||
} else {
|
} else {
|
||||||
if let std::net::IpAddr::V4(ipv4) = target_ip {
|
if let (std::net::IpAddr::V4(ipv4), false) = (target_ip, nat64_attempted) {
|
||||||
|
nat64_attempted = true;
|
||||||
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
tx.send(UiEvent::Log("Direct IPv4 handshake timed out. Trying NAT64 fallback...".to_string())).await.ok();
|
||||||
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
let nat64_ipv6 = synthesize_nat64(ipv4).await;
|
||||||
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
match self.try_connect_transport(std::net::IpAddr::V6(nat64_ipv6), port).await {
|
||||||
|
|
@ -1057,7 +1217,27 @@ impl Bridge {
|
||||||
) -> Result<crate::transport::Transport> {
|
) -> Result<crate::transport::Transport> {
|
||||||
let mode = self.transport_mode.to_lowercase();
|
let mode = self.transport_mode.to_lowercase();
|
||||||
if mode == "uot" || mode == "tcp" {
|
if mode == "uot" || mode == "tcp" {
|
||||||
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
|
// Bound the TCP connect. Without this it inherits the kernel's SYN
|
||||||
|
// retry budget, which is tens of seconds (and can reach ~2 minutes).
|
||||||
|
// That is exactly what made UoT appear to hang on mobile: callers
|
||||||
|
// resolve every address for the server and try IPv6 first (see the
|
||||||
|
// sort in perform_handshake_with_id), and a mobile network that
|
||||||
|
// advertises IPv6 without a working route blackholes the SYN rather
|
||||||
|
// than rejecting it — so the client sat through the full retry
|
||||||
|
// budget before it ever reached the IPv4 address that would have
|
||||||
|
// connected immediately. UDP never showed this because connect() on
|
||||||
|
// a UDP socket only sets the default peer and returns at once.
|
||||||
|
let stream = tokio::time::timeout(
|
||||||
|
UOT_CONNECT_TIMEOUT,
|
||||||
|
tokio::net::TcpStream::connect((target_ip, port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"TCP connect to {target_ip}:{port} timed out after {:?}",
|
||||||
|
UOT_CONNECT_TIMEOUT
|
||||||
|
)
|
||||||
|
})??;
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
let (mut read_half, mut write_half) = stream.into_split();
|
let (mut read_half, mut write_half) = stream.into_split();
|
||||||
|
|
||||||
|
|
@ -1194,8 +1374,19 @@ fn next_profile(current: TrafficProfile) -> TrafficProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
async fn synthesize_nat64(ip: std::net::Ipv4Addr) -> std::net::Ipv6Addr {
|
||||||
|
// Well-known prefix (RFC 6052), used if discovery doesn't answer in time.
|
||||||
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
let mut prefix = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
|
// Bound the discovery lookup. This runs on exactly the networks that are
|
||||||
|
// already misbehaving, where the resolver can hang for tens of seconds
|
||||||
|
// before giving up — unbounded, it was a large part of why connecting over
|
||||||
|
// a broken mobile network took minutes. Falling back to the well-known
|
||||||
|
// prefix is strictly better than waiting.
|
||||||
|
let discovery = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
tokio::net::lookup_host("ipv4only.arpa:80"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Ok(Ok(addrs)) = discovery {
|
||||||
for addr in addrs {
|
for addr in addrs {
|
||||||
if let std::net::SocketAddr::V6(v6) = addr {
|
if let std::net::SocketAddr::V6(v6) = addr {
|
||||||
let octets = v6.ip().octets();
|
let octets = v6.ip().octets();
|
||||||
|
|
|
||||||
|
|
@ -418,19 +418,22 @@ pub struct RelayServerConfig {
|
||||||
pub upstream_tcp: String,
|
pub upstream_tcp: String,
|
||||||
/// Upstream address for UDP traffic
|
/// Upstream address for UDP traffic
|
||||||
pub upstream_udp: String,
|
pub upstream_udp: String,
|
||||||
/// Target server's API URL, for key sync
|
// ── Deprecated ──────────────────────────────────────────────────────────
|
||||||
|
// The relay used to authenticate clients itself and pulled the access-key
|
||||||
|
// list from the target server's management API to do it. It no longer does:
|
||||||
|
// sessions are authenticated end-to-end by the target server, and a relay
|
||||||
|
// that re-checks credentials only adds a weaker second gate plus a copy of
|
||||||
|
// the key list on a machine that does not need one. These are kept solely
|
||||||
|
// so existing relay configs still parse; they are ignored.
|
||||||
|
#[serde(default)]
|
||||||
pub upstream_api_url: String,
|
pub upstream_api_url: String,
|
||||||
/// Bearer token for the target server's API
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub upstream_api_token: String,
|
pub upstream_api_token: String,
|
||||||
/// Key sync interval in seconds (default 30)
|
#[serde(default)]
|
||||||
#[serde(default = "default_sync_interval")]
|
|
||||||
pub sync_interval_secs: u64,
|
pub sync_interval_secs: u64,
|
||||||
pub debug: Option<bool>,
|
pub debug: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_sync_interval() -> u64 { 30 }
|
|
||||||
|
|
||||||
/// Supports both a single string "0.0.0.0:50000" and an array
|
/// Supports both a single string "0.0.0.0:50000" and an array
|
||||||
/// ["0.0.0.0:50000", "[::]:50000"].
|
/// ["0.0.0.0:50000", "[::]:50000"].
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,10 @@ async fn handle_udp_associate(
|
||||||
|
|
||||||
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
|
||||||
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
|
||||||
|
// Held only to keep the direct-UDP readers' cancellation senders alive;
|
||||||
|
// dropping this (on every return path from this function) is what tells
|
||||||
|
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
|
||||||
|
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
|
||||||
|
|
||||||
let mut tcp_buf = [0u8; 1];
|
let mut tcp_buf = [0u8; 1];
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -432,7 +436,9 @@ async fn handle_udp_associate(
|
||||||
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
let s_arc = Arc::new(s);
|
let s_arc = Arc::new(s);
|
||||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||||
|
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||||
|
direct_udp_cancel_txs.push(cancel_tx);
|
||||||
direct_udp_v6 = Some(s_arc);
|
direct_udp_v6 = Some(s_arc);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -446,7 +452,9 @@ async fn handle_udp_associate(
|
||||||
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
let s_arc = Arc::new(s);
|
let s_arc = Arc::new(s);
|
||||||
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
||||||
|
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
|
||||||
|
direct_udp_cancel_txs.push(cancel_tx);
|
||||||
direct_udp_v4 = Some(s_arc);
|
direct_udp_v4 = Some(s_arc);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -520,11 +528,24 @@ fn spawn_direct_udp_reader(
|
||||||
sock_tx: Arc<UdpSocket>,
|
sock_tx: Arc<UdpSocket>,
|
||||||
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
|
||||||
_debug: bool,
|
_debug: bool,
|
||||||
|
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = vec![0u8; 65536];
|
let mut buf = vec![0u8; 65536];
|
||||||
loop {
|
loop {
|
||||||
match direct_socket.recv_from(&mut buf).await {
|
let recv_result = tokio::select! {
|
||||||
|
// Fires as soon as the sender half (held by handle_udp_associate
|
||||||
|
// for exactly this reason) is dropped - which happens the
|
||||||
|
// instant that function returns, on every exit path, with no
|
||||||
|
// explicit signaling needed. Without this, a UDP-associate
|
||||||
|
// session that ever bypassed traffic direct (excluded IP/
|
||||||
|
// domain) leaked this socket + task for the rest of the
|
||||||
|
// process's life once the session ended: nothing else ever
|
||||||
|
// stopped this loop.
|
||||||
|
_ = &mut cancel_rx => break,
|
||||||
|
res = direct_socket.recv_from(&mut buf) => res,
|
||||||
|
};
|
||||||
|
match recv_result {
|
||||||
Ok((len, target_addr)) => {
|
Ok((len, target_addr)) => {
|
||||||
let client_addr = {
|
let client_addr = {
|
||||||
let guard = client_udp_addr.lock().unwrap();
|
let guard = client_udp_addr.lock().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -138,27 +138,34 @@ async fn start_udp_bypass_session(
|
||||||
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let socket = Arc::new(socket);
|
// A single select! loop over both directions, rather than spawning a
|
||||||
let socket_rx = socket.clone();
|
// separate task for the read side, so the whole session - physical
|
||||||
|
// socket included - is torn down the moment this function returns
|
||||||
// Spawn a task to read from physical socket and send back to smoltcp
|
// (e.g. when session_rx closes). The previous spawned-task version left
|
||||||
let tx_clone = smoltcp_tx.clone();
|
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
|
||||||
tokio::spawn(async move {
|
// alive) running forever after this function returned: nothing ever
|
||||||
use futures::SinkExt;
|
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
|
||||||
let mut buf = [0u8; 65536];
|
// mode) leaked one socket + one task for the lifetime of the process.
|
||||||
loop {
|
use futures::SinkExt;
|
||||||
match socket_rx.recv_from(&mut buf).await {
|
let mut buf = [0u8; 65536];
|
||||||
Ok((n, peer)) => {
|
loop {
|
||||||
let mut lock = tx_clone.lock().await;
|
tokio::select! {
|
||||||
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
outbound = session_rx.recv() => {
|
||||||
|
match outbound {
|
||||||
|
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inbound = socket.recv_from(&mut buf) => {
|
||||||
|
match inbound {
|
||||||
|
Ok((n, peer)) => {
|
||||||
|
let mut lock = smoltcp_tx.lock().await;
|
||||||
|
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
while let Some((payload, dst)) = session_rx.recv().await {
|
|
||||||
socket.send_to(&payload, dst).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,18 @@ pub struct CongestionController {
|
||||||
loss_count: u32,
|
loss_count: u32,
|
||||||
/// Pacing rate: bytes per second
|
/// Pacing rate: bytes per second
|
||||||
pacing_rate: u64,
|
pacing_rate: u64,
|
||||||
|
/// Token-bucket allowance for pacing, in bytes.
|
||||||
|
pacing_tokens: f64,
|
||||||
|
pacing_last_refill: Instant,
|
||||||
/// MTU estimate (used for cwnd → packet count conversion)
|
/// MTU estimate (used for cwnd → packet count conversion)
|
||||||
mtu: u64,
|
mtu: u64,
|
||||||
/// Min RTT expiry: re-probe after 10 seconds
|
/// Min RTT expiry: re-probe after 10 seconds
|
||||||
min_rtt_stamp: Instant,
|
min_rtt_stamp: Instant,
|
||||||
|
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
|
||||||
|
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
|
||||||
|
slow_start_losses: u32,
|
||||||
|
/// Start of the current loss-tolerance window.
|
||||||
|
slow_start_loss_window_start: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -60,6 +68,20 @@ const MIN_CWND_PACKETS: u64 = 2;
|
||||||
/// Min RTT expiry window (after which we re-probe)
|
/// Min RTT expiry window (after which we re-probe)
|
||||||
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
|
||||||
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
|
||||||
|
/// Absolute ceiling on the congestion window, in packets. At a ~1200-byte MTU
|
||||||
|
/// this is roughly 1.2 MB in flight — already far above the bandwidth-delay
|
||||||
|
/// product of any link this protocol realistically runs over, so anything
|
||||||
|
/// beyond it is standing queue, not throughput. The client previously allowed
|
||||||
|
/// up to 16384 packets (~20 MB), which on a mobile uplink is minutes of buffer.
|
||||||
|
const MAX_CWND_PACKETS: u64 = 1024;
|
||||||
|
/// SRTT/min_rtt ratio at which slow start stops. Doubling is what fills a deep
|
||||||
|
/// buffer fastest, so growth must end when the queue starts building rather
|
||||||
|
/// than waiting for a loss that a deep buffer may never produce.
|
||||||
|
const RTT_INFLATION_EXIT_SLOW_START: f64 = 2.0;
|
||||||
|
/// SRTT/min_rtt ratio treated as a standing queue that must be actively drained.
|
||||||
|
const RTT_INFLATION_BACKOFF: f64 = 4.0;
|
||||||
|
/// How much pacing allowance may accumulate, expressed as time-at-rate.
|
||||||
|
const PACING_BURST: Duration = Duration::from_millis(10);
|
||||||
const RTO_MIN: Duration = Duration::from_millis(50);
|
const RTO_MIN: Duration = Duration::from_millis(50);
|
||||||
/// Maximum RTO
|
/// Maximum RTO
|
||||||
const RTO_MAX: Duration = Duration::from_secs(16);
|
const RTO_MAX: Duration = Duration::from_secs(16);
|
||||||
|
|
@ -67,6 +89,24 @@ const RTO_MAX: Duration = Duration::from_secs(16);
|
||||||
/// Will be replaced by first real measurement within milliseconds.
|
/// Will be replaced by first real measurement within milliseconds.
|
||||||
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
const INITIAL_RTT: Duration = Duration::from_millis(30);
|
||||||
|
|
||||||
|
/// Isolated packet loss during slow start (a single dropped frame from
|
||||||
|
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
|
||||||
|
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
|
||||||
|
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
|
||||||
|
/// on any link with a non-zero background loss rate permanently downgrades
|
||||||
|
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
|
||||||
|
/// growth within the first few RTTs - turning what should be a sub-second
|
||||||
|
/// ramp-up into tens of seconds to minutes before throughput opens up
|
||||||
|
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
|
||||||
|
/// claws back up). Only treat loss as a real congestion signal - and pay
|
||||||
|
/// the full slow-start-exit + halving cost - once this many losses land
|
||||||
|
/// within SLOW_START_LOSS_WINDOW.
|
||||||
|
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
|
||||||
|
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
|
||||||
|
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
|
||||||
|
/// well-connected link, generous on a slow one.
|
||||||
|
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
impl CongestionController {
|
impl CongestionController {
|
||||||
pub fn new(mtu: u64) -> Self {
|
pub fn new(mtu: u64) -> Self {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
@ -88,9 +128,52 @@ impl CongestionController {
|
||||||
pacing_rate: initial_pacing,
|
pacing_rate: initial_pacing,
|
||||||
mtu,
|
mtu,
|
||||||
min_rtt_stamp: now,
|
min_rtt_stamp: now,
|
||||||
|
slow_start_losses: 0,
|
||||||
|
slow_start_loss_window_start: now,
|
||||||
|
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
|
||||||
|
pacing_last_refill: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes of pacing allowance available right now, without consuming any.
|
||||||
|
///
|
||||||
|
/// Read-only so the send path can use it as an admission check before it
|
||||||
|
/// commits to building a datagram.
|
||||||
|
pub fn pacing_available(&self) -> f64 {
|
||||||
|
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
|
||||||
|
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether at least one full-size packet may be released right now.
|
||||||
|
pub fn can_pace_packet(&self) -> bool {
|
||||||
|
self.pacing_available() >= self.mtu as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ceiling on accumulated allowance.
|
||||||
|
///
|
||||||
|
/// Pacing intervals here are fractions of a millisecond, so releasing
|
||||||
|
/// strictly one packet at a time would need a sub-millisecond timer per
|
||||||
|
/// packet. Instead we allow a short burst — the same trade every real
|
||||||
|
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
|
||||||
|
/// can still saturate the configured rate, with a small floor so a
|
||||||
|
/// cold/low estimate can never wedge sending entirely.
|
||||||
|
fn pacing_burst(&self) -> f64 {
|
||||||
|
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
|
||||||
|
by_rate.max((self.mtu * 4) as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refill from elapsed time and deduct `bytes`. Called on the real send
|
||||||
|
/// path; allowance is permitted to go negative so an oversized packet still
|
||||||
|
/// pays for itself rather than being released for free.
|
||||||
|
fn consume_pacing(&mut self, bytes: u64) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
|
||||||
|
self.pacing_last_refill = now;
|
||||||
|
self.pacing_tokens =
|
||||||
|
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
|
||||||
|
- bytes as f64;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the current congestion window in bytes.
|
/// Returns the current congestion window in bytes.
|
||||||
pub fn cwnd(&self) -> u64 {
|
pub fn cwnd(&self) -> u64 {
|
||||||
self.cwnd
|
self.cwnd
|
||||||
|
|
@ -142,6 +225,11 @@ impl CongestionController {
|
||||||
/// Record that we sent `bytes` of data.
|
/// Record that we sent `bytes` of data.
|
||||||
pub fn on_send(&mut self, bytes: u64) {
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
|
||||||
|
// Charge the pacing bucket here rather than at the admission check, so
|
||||||
|
// every byte that actually reaches the wire is paid for exactly once —
|
||||||
|
// including retransmits, which are precisely what must not be allowed
|
||||||
|
// to bypass the rate limit and pile into an already-full queue.
|
||||||
|
self.consume_pacing(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
|
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
|
||||||
|
|
@ -173,9 +261,46 @@ impl CongestionController {
|
||||||
|
|
||||||
/// Congestion-window growth shared by both ACK paths (slow start / probe).
|
/// Congestion-window growth shared by both ACK paths (slow start / probe).
|
||||||
fn grow_window(&mut self, bytes: u64) {
|
fn grow_window(&mut self, bytes: u64) {
|
||||||
// State machine
|
// ── Delay-based congestion signal ────────────────────────────────────
|
||||||
|
// A loss-only controller is blind on a deeply-buffered path, and mobile
|
||||||
|
// carrier buffers are very deep: they absorb a burst instead of dropping
|
||||||
|
// it, so no loss is ever signalled and cwnd keeps growing. The queue —
|
||||||
|
// not the link — is what grows, and the standing delay it adds shows up
|
||||||
|
// as RTT inflating far above the path's floor. Left unchecked this is a
|
||||||
|
// positive feedback loop: bigger queue -> larger RTT samples -> larger
|
||||||
|
// SRTT -> larger RTO -> retransmits pile on -> bigger queue, which is
|
||||||
|
// how a session ends up reporting multi-second (even multi-minute) RTT
|
||||||
|
// and stalls video until the buffer finally drains or the user
|
||||||
|
// reconnects. Treat sustained RTT inflation as congestion in its own
|
||||||
|
// right, exactly as it is.
|
||||||
|
let inflation = if self.rtt_initialized && !self.min_rtt.is_zero() {
|
||||||
|
self.srtt.as_secs_f64() / self.min_rtt.as_secs_f64()
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
if inflation >= RTT_INFLATION_BACKOFF {
|
||||||
|
// Standing queue is severe — actively drain it.
|
||||||
|
self.cwnd = (self.cwnd / 2).max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
self.ssthresh = self.cwnd;
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: draining standing queue");
|
||||||
|
self.clamp_cwnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
Phase::SlowStart => {
|
Phase::SlowStart => {
|
||||||
|
// Exponential doubling is what fills a deep buffer fastest, so
|
||||||
|
// leave slow start as soon as the queue starts to build rather
|
||||||
|
// than waiting for the loss that may never come.
|
||||||
|
if inflation >= RTT_INFLATION_EXIT_SLOW_START {
|
||||||
|
self.ssthresh = self.cwnd;
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: RTT inflation ended slow start");
|
||||||
|
self.clamp_cwnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
|
||||||
self.cwnd = self.cwnd.saturating_add(bytes);
|
self.cwnd = self.cwnd.saturating_add(bytes);
|
||||||
if self.cwnd >= self.ssthresh {
|
if self.cwnd >= self.ssthresh {
|
||||||
|
|
@ -188,6 +313,21 @@ impl CongestionController {
|
||||||
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
|
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.clamp_cwnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard ceiling on the congestion window.
|
||||||
|
///
|
||||||
|
/// Independent of any estimate: no real path this protocol runs over has a
|
||||||
|
/// bandwidth-delay product anywhere near this, so a window above it is
|
||||||
|
/// buffered queue rather than data in transit. Without it, slow start on a
|
||||||
|
/// buffer that never drops could grow the window into the tens of megabytes.
|
||||||
|
fn clamp_cwnd(&mut self) {
|
||||||
|
let ceiling = MAX_CWND_PACKETS.saturating_mul(self.mtu);
|
||||||
|
if self.cwnd > ceiling {
|
||||||
|
self.cwnd = ceiling;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a loss event.
|
/// Record a loss event.
|
||||||
|
|
@ -197,11 +337,28 @@ impl CongestionController {
|
||||||
|
|
||||||
match self.phase {
|
match self.phase {
|
||||||
Phase::SlowStart => {
|
Phase::SlowStart => {
|
||||||
// Exit slow start, set ssthresh to half of cwnd
|
let now = Instant::now();
|
||||||
self.ssthresh = self.cwnd / 2;
|
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
|
||||||
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
// Previous window's losses have aged out - this loss starts a fresh count.
|
||||||
self.phase = Phase::ProbeBandwidth;
|
self.slow_start_losses = 0;
|
||||||
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
|
self.slow_start_loss_window_start = now;
|
||||||
|
}
|
||||||
|
self.slow_start_losses += 1;
|
||||||
|
|
||||||
|
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
|
||||||
|
// Sustained loss within the window: treat as real congestion.
|
||||||
|
// Exit slow start, set ssthresh to half of cwnd.
|
||||||
|
self.ssthresh = self.cwnd / 2;
|
||||||
|
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
self.phase = Phase::ProbeBandwidth;
|
||||||
|
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: sustained loss during slow start, exiting");
|
||||||
|
} else {
|
||||||
|
// Isolated loss: likely non-congestive noise. Take a mild,
|
||||||
|
// temporary haircut but keep exponential growth going -
|
||||||
|
// don't throw away slow start over a single dropped frame.
|
||||||
|
self.cwnd = (self.cwnd * 8 / 10).max(MIN_CWND_PACKETS * self.mtu);
|
||||||
|
tracing::debug!(cwnd = self.cwnd, count = self.slow_start_losses, "congestion: isolated loss during slow start, staying in slow start");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Phase::ProbeBandwidth => {
|
Phase::ProbeBandwidth => {
|
||||||
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
|
||||||
|
|
@ -290,6 +447,138 @@ mod tests {
|
||||||
assert!(cc.cwnd() < initial);
|
assert!(cc.cwnd() < initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
|
||||||
|
/// ever lost, but the standing queue inflates RTT. A loss-only controller
|
||||||
|
/// grows cwnd forever here — which is how a session ends up reporting
|
||||||
|
/// multi-second RTT and stalling video.
|
||||||
|
#[test]
|
||||||
|
fn test_rtt_inflation_halts_growth_without_any_loss() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
|
||||||
|
// Establish a low path floor; this becomes min_rtt.
|
||||||
|
for _ in 0..4 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let cwnd_before = cc.cwnd();
|
||||||
|
|
||||||
|
// Queue builds: RTT climbs far above the floor, still zero loss.
|
||||||
|
for _ in 0..20 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(400));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cc.cwnd() <= cwnd_before,
|
||||||
|
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
|
||||||
|
cwnd_before,
|
||||||
|
cc.cwnd()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pacing must actually bound the release rate: draining the bucket has to
|
||||||
|
/// deny the next packet. Without this the congestion window alone decides,
|
||||||
|
/// and a whole window leaves back-to-back.
|
||||||
|
#[test]
|
||||||
|
fn test_pacing_bucket_denies_once_drained() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
|
||||||
|
|
||||||
|
// Spend well beyond one burst allowance.
|
||||||
|
let burst_bytes = cc.pacing_available();
|
||||||
|
let mut spent = 0.0;
|
||||||
|
while spent <= burst_bytes + 1200.0 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
spent += 1200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!cc.can_pace_packet(),
|
||||||
|
"pacing allowed unbounded sending: {} bytes still available after spending {}",
|
||||||
|
cc.pacing_available(),
|
||||||
|
spent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The allowance must refill over time, or sending would stall permanently
|
||||||
|
/// once the first burst is spent.
|
||||||
|
#[test]
|
||||||
|
fn test_pacing_bucket_refills_over_time() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
while cc.can_pace_packet() {
|
||||||
|
cc.on_send(1200);
|
||||||
|
}
|
||||||
|
assert!(!cc.can_pace_packet());
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
assert!(
|
||||||
|
cc.can_pace_packet(),
|
||||||
|
"pacing bucket never refilled; sending would be stuck forever"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cwnd must never exceed the absolute ceiling, however long slow start
|
||||||
|
/// runs unopposed — above it the window is buffered queue, not throughput.
|
||||||
|
#[test]
|
||||||
|
fn test_cwnd_never_exceeds_absolute_ceiling() {
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
// Constant RTT: no inflation signal, so only the hard cap can stop this.
|
||||||
|
for _ in 0..5000 {
|
||||||
|
cc.on_send(1200);
|
||||||
|
cc.on_ack(1200, Duration::from_millis(30));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
|
||||||
|
"cwnd {} exceeded the {}-packet ceiling",
|
||||||
|
cc.cwnd(),
|
||||||
|
MAX_CWND_PACKETS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
||||||
|
// A single dropped packet (wireless noise, a brief handover blip) is
|
||||||
|
// normal on real links and must not permanently downgrade the
|
||||||
|
// session from exponential to linear growth.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
|
||||||
|
|
||||||
|
// It should still shrink the window somewhat (not ignored entirely),
|
||||||
|
// just far less punishing than the sustained-congestion case.
|
||||||
|
let after_one = cc.cwnd();
|
||||||
|
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sustained_slow_start_loss_exits_slow_start() {
|
||||||
|
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
|
||||||
|
// a real congestion signal and must still trigger the harsher
|
||||||
|
// exit-slow-start + halve response.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
for _ in 0..SLOW_START_LOSS_TOLERANCE {
|
||||||
|
cc.on_loss(1200);
|
||||||
|
}
|
||||||
|
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slow_start_loss_window_resets_after_expiry() {
|
||||||
|
// Two losses far enough apart (window expired between them) must
|
||||||
|
// each be treated as isolated, not accumulated toward the sustained-
|
||||||
|
// loss threshold.
|
||||||
|
let mut cc = CongestionController::new(1200);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart);
|
||||||
|
|
||||||
|
// Simulate the window having expired by resetting its start
|
||||||
|
// directly (std::thread::sleep in a unit test would be flaky/slow).
|
||||||
|
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
|
||||||
|
cc.on_loss(1200);
|
||||||
|
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
|
||||||
|
assert_eq!(cc.slow_start_losses, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_can_send_limits() {
|
fn test_can_send_limits() {
|
||||||
let mut cc = CongestionController::new(1200);
|
let mut cc = CongestionController::new(1200);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ use thiserror::Error;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Upper bound on a single frame's retransmit timer, after exponential backoff
|
||||||
|
/// is applied to the adaptive RTO. Past this the session is dead from the
|
||||||
|
/// user's point of view, and waiting longer only delays recovery.
|
||||||
|
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
use crate::congestion::CongestionController;
|
use crate::congestion::CongestionController;
|
||||||
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
|
||||||
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
|
||||||
|
|
@ -102,6 +107,17 @@ pub struct ProtocolMachine {
|
||||||
_mtu: usize,
|
_mtu: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gap recovery (see `ProtocolMachine::recover_stalled_gap`) ────────────────
|
||||||
|
// How long the receive sequence may sit stuck behind a missing frame, with
|
||||||
|
// later frames already buffered, before that frame is declared unrecoverable
|
||||||
|
// and skipped. Derived from the live RTO so it scales with the path instead of
|
||||||
|
// guessing, then clamped: the floor keeps a fast link from discarding a frame
|
||||||
|
// that is merely late, the ceiling bounds how long a stall can be visible to
|
||||||
|
// the user before the tunnel unblocks itself.
|
||||||
|
const GAP_RECOVERY_RTO_MULTIPLIER: u32 = 8;
|
||||||
|
const GAP_RECOVERY_MIN: Duration = Duration::from_secs(2);
|
||||||
|
const GAP_RECOVERY_MAX: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct SentFrame {
|
struct SentFrame {
|
||||||
nonce: u64,
|
nonce: u64,
|
||||||
|
|
@ -155,10 +171,33 @@ impl ProtocolMachine {
|
||||||
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sum of retry counters across in-flight frames. Test-only: lets a test
|
||||||
|
/// assert the core retransmit invariant (a retry is only ever charged to a
|
||||||
|
/// frame that was actually put on the wire) without needing to advance the
|
||||||
|
/// clock through several seconds of exponential backoff.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn total_retries(&self) -> usize {
|
||||||
|
self.sent_history
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.is_retransmittable)
|
||||||
|
.map(|f| f.retries as usize)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cwnd_packets(&self) -> usize {
|
pub fn cwnd_packets(&self) -> usize {
|
||||||
self.cc.cwnd_packets() as usize
|
self.cc.cwnd_packets() as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the pacing bucket currently allows releasing another packet.
|
||||||
|
///
|
||||||
|
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
|
||||||
|
/// nothing about how fast that window is emptied onto the wire. Sending a
|
||||||
|
/// whole window back-to-back is what drives a deep buffer into standing
|
||||||
|
/// queue, so admission is gated on both.
|
||||||
|
pub fn can_pace_packet(&self) -> bool {
|
||||||
|
self.cc.can_pace_packet()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_send(&mut self, bytes: u64) {
|
pub fn on_send(&mut self, bytes: u64) {
|
||||||
self.cc.on_send(bytes);
|
self.cc.on_send(bytes);
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +335,107 @@ impl ProtocolMachine {
|
||||||
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restores liveness when the receive sequence is stuck behind a frame that
|
||||||
|
/// can never arrive.
|
||||||
|
///
|
||||||
|
/// Delivery is gated on `expected_recv_nonce`, so a single missing frame
|
||||||
|
/// holds back every later frame. That is correct *while the sender can still
|
||||||
|
/// retransmit* — but the sender drops a frame from `sent_history` once it
|
||||||
|
/// exceeds `max_retries + 2` attempts (see the zombie eviction in
|
||||||
|
/// `handle_tick`). After that the frame is gone for good and the two sides
|
||||||
|
/// deadlock: the receiver buffers forever and NACKs a nonce nobody can
|
||||||
|
/// resend.
|
||||||
|
///
|
||||||
|
/// That deadlock is invisible to the keepalive watchdog, which is why it
|
||||||
|
/// presented as a hard freeze rather than a reconnect: retransmits, ACKs and
|
||||||
|
/// NACKs keep flowing, so the client's `last_valid_recv` keeps refreshing and
|
||||||
|
/// its stall detector never fires. The RTT readout freezes at its last value
|
||||||
|
/// for the same reason — Pong rides in a Data frame stuck behind the gap.
|
||||||
|
///
|
||||||
|
/// So: once we have been stuck long enough that retransmission has provably
|
||||||
|
/// given up, skip to the lowest buffered nonce and drain. This drops the
|
||||||
|
/// missing frame's payload (one RelayMessage — a chunk of one stream), which
|
||||||
|
/// is a real cost, but the alternative is a permanently dead tunnel.
|
||||||
|
fn recover_stalled_gap(&mut self) -> Vec<ProtocolAction> {
|
||||||
|
let mut recovered = Vec::new();
|
||||||
|
if self.reorder_buffer.is_empty() {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait out the sender's full retransmit budget before giving up, so a
|
||||||
|
// frame that is merely late is never discarded. The sender backs off
|
||||||
|
// exponentially, so key this off the live RTO estimate rather than a
|
||||||
|
// flat constant, with a floor that keeps low-RTT links from skipping
|
||||||
|
// too eagerly and a ceiling that bounds the visible freeze.
|
||||||
|
let timeout = self
|
||||||
|
.cc
|
||||||
|
.rto()
|
||||||
|
.saturating_mul(GAP_RECOVERY_RTO_MULTIPLIER)
|
||||||
|
.clamp(GAP_RECOVERY_MIN, GAP_RECOVERY_MAX);
|
||||||
|
if self.last_recv_advance.elapsed() < timeout {
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(&resume_at) = self.reorder_buffer.keys().next() else {
|
||||||
|
return recovered;
|
||||||
|
};
|
||||||
|
let skipped = resume_at.saturating_sub(self.expected_recv_nonce);
|
||||||
|
tracing::warn!(
|
||||||
|
"Gap recovery: no progress for {:?}; skipping {} unrecoverable frame(s) \
|
||||||
|
(nonce {} -> {}) to unblock the session",
|
||||||
|
self.last_recv_advance.elapsed(),
|
||||||
|
skipped,
|
||||||
|
self.expected_recv_nonce,
|
||||||
|
resume_at
|
||||||
|
);
|
||||||
|
|
||||||
|
self.expected_recv_nonce = resume_at;
|
||||||
|
while let Some(buffered) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
|
||||||
|
recovered.push(buffered);
|
||||||
|
match self.expected_recv_nonce.checked_add(1) {
|
||||||
|
Some(next) => self.expected_recv_nonce = next,
|
||||||
|
// u64 nonce space exhausted: stop draining rather than wrap.
|
||||||
|
// The session is finished either way; the caller's next decrypt
|
||||||
|
// will fail and tear it down.
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_recv_advance = Instant::now();
|
||||||
|
// The peer must learn the sequence moved on, or it will keep
|
||||||
|
// retransmitting into the void.
|
||||||
|
self.ack_pending = true;
|
||||||
|
|
||||||
|
recovered
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
|
// Check for a stalled gap before classifying this frame, so the rest of
|
||||||
|
// the function sees an already-advanced `expected_recv_nonce`. Runs here
|
||||||
|
// rather than on Tick because both tick handlers discard DeliverApp
|
||||||
|
// actions, and because inbound frames keep arriving throughout the stall
|
||||||
|
// (retransmits/ACKs/NACKs/keepalives) — so this path is reliably reached.
|
||||||
|
let recovered = self.recover_stalled_gap();
|
||||||
|
let result = self.handle_data_inbound_frame(raw_vec)?;
|
||||||
|
if recovered.is_empty() {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovered payloads are older than anything this frame produces, so
|
||||||
|
// they go first to preserve delivery order.
|
||||||
|
let mut all = recovered;
|
||||||
|
match result {
|
||||||
|
ProtocolAction::Noop => {}
|
||||||
|
ProtocolAction::Multiple(list) => all.extend(list),
|
||||||
|
single => all.push(single),
|
||||||
|
}
|
||||||
|
Ok(if all.len() == 1 {
|
||||||
|
all.pop().unwrap()
|
||||||
|
} else {
|
||||||
|
ProtocolAction::Multiple(all)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_data_inbound_frame(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
|
||||||
if raw_vec.len() < 12 {
|
if raw_vec.len() < 12 {
|
||||||
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
return Err(ProtocolError::Framing("data datagram too short".to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -543,18 +682,39 @@ impl ProtocolMachine {
|
||||||
if !frame.is_retransmittable {
|
if !frame.is_retransmittable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Out of budget for this tick — stop scanning rather than walking the
|
||||||
|
// rest of the queue. sent_history is in send order, so everything we
|
||||||
|
// skip is strictly newer than what we already handled; deferring it to
|
||||||
|
// the next tick preserves oldest-first retransmit priority.
|
||||||
|
if retransmit_budget == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff, but bounded in absolute terms. base_rto is
|
||||||
|
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
|
||||||
|
// multiplying that by the 64x backoff cap yields a frame that sits
|
||||||
|
// unretransmitted for ~17 MINUTES, long past the point where the
|
||||||
|
// session is simply dead to the user. Cap the product so backoff
|
||||||
|
// stays a backoff rather than an outage.
|
||||||
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
|
||||||
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
|
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor))
|
||||||
|
.min(MAX_EFFECTIVE_RTO);
|
||||||
|
|
||||||
if now.duration_since(frame.last_sent) >= effective_rto {
|
if now.duration_since(frame.last_sent) >= effective_rto {
|
||||||
|
// Only burn the retry counter and reset the RTO timer when the
|
||||||
|
// frame is ACTUALLY put on the wire. Doing it unconditionally
|
||||||
|
// meant that whenever the per-tick budget ran out — which is
|
||||||
|
// exactly when loss is heavy and retransmits matter most —
|
||||||
|
// frames accumulated "phantom retries" they never actually got,
|
||||||
|
// and the zombie eviction above then silently dropped them after
|
||||||
|
// `grace` such rounds. The peer never received that data and
|
||||||
|
// never would: that stream stalls forever while the session
|
||||||
|
// itself stays healthy, which is precisely the reported "tunnel
|
||||||
|
// frozen at 0 b/s but the session still up" symptom.
|
||||||
frame.last_sent = now;
|
frame.last_sent = now;
|
||||||
frame.retries = frame.retries.saturating_add(1);
|
frame.retries = frame.retries.saturating_add(1);
|
||||||
|
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
||||||
if retransmit_budget > 0 {
|
retransmit_budget -= 1;
|
||||||
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
|
|
||||||
retransmit_budget -= 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -971,4 +1131,154 @@ mod tests {
|
||||||
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
let _ = client.on_event(OstpEvent::Tick).unwrap();
|
||||||
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
let _ = server.on_event(OstpEvent::Tick).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A retry may only be charged to a frame that was actually retransmitted.
|
||||||
|
///
|
||||||
|
/// The retransmit loop is budget-limited per tick. It used to bump
|
||||||
|
/// `retries` and reset `last_sent` for every due frame regardless of
|
||||||
|
/// whether the budget allowed it to actually send — so under heavy loss
|
||||||
|
/// (exactly when the budget runs out) frames racked up retries they never
|
||||||
|
/// received, and the zombie eviction dropped them after `max_retries + 2`
|
||||||
|
/// such rounds. That data was never delivered and never would be: the
|
||||||
|
/// stream stalls permanently while the session itself stays up.
|
||||||
|
#[test]
|
||||||
|
fn test_retransmit_budget_charges_retries_only_for_frames_actually_sent() {
|
||||||
|
let (mut client, _server) = do_handshake();
|
||||||
|
|
||||||
|
// Queue far more in-flight frames than a single tick's budget allows.
|
||||||
|
const FRAMES: usize = 40;
|
||||||
|
for i in 0..FRAMES {
|
||||||
|
let payload = Bytes::from(vec![i as u8; 200]);
|
||||||
|
client.on_event(OstpEvent::Outbound(1, payload)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(client.in_flight_count(), FRAMES);
|
||||||
|
assert_eq!(client.total_retries(), 0, "nothing retransmitted yet");
|
||||||
|
|
||||||
|
// Let every frame's RTO lapse so that on the next tick all FRAMES frames
|
||||||
|
// are due at once and the per-tick budget is guaranteed to run out. The
|
||||||
|
// effective RTO here is max(cc.rto(), config rto_ms) = 100ms at retries=0.
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
|
||||||
|
let sent = count_datagrams(&client.on_event(OstpEvent::Tick).unwrap());
|
||||||
|
|
||||||
|
assert!(sent > 0, "expected some retransmits after the RTO lapsed");
|
||||||
|
assert!(
|
||||||
|
sent < FRAMES,
|
||||||
|
"budget should have capped this tick below the {FRAMES} due frames, got {sent}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.total_retries(),
|
||||||
|
sent,
|
||||||
|
"charged {} retries but only put {} frames on the wire — the \
|
||||||
|
difference is phantom retries that will silently evict live data",
|
||||||
|
client.total_retries(),
|
||||||
|
sent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.in_flight_count(),
|
||||||
|
FRAMES,
|
||||||
|
"nothing was acked, so no frame may be evicted yet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many datagrams an action tree actually puts on the wire.
|
||||||
|
fn count_datagrams(action: &ProtocolAction) -> usize {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::SendDatagram(_) => 1,
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().map(count_datagrams).sum(),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many application payloads an action tree actually delivers.
|
||||||
|
fn delivered_payloads(action: &ProtocolAction) -> Vec<Bytes> {
|
||||||
|
match action {
|
||||||
|
ProtocolAction::DeliverApp(_, data) => vec![data.clone()],
|
||||||
|
ProtocolAction::Multiple(list) => list.iter().flat_map(delivered_payloads).collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build `count` data frames on `client`, returning them without delivering
|
||||||
|
/// any — lets a test choose which ones to "lose" in transit.
|
||||||
|
fn make_data_frames(client: &mut ProtocolMachine, count: u8) -> Vec<Bytes> {
|
||||||
|
(0..count)
|
||||||
|
.map(|i| {
|
||||||
|
let payload = Bytes::from(vec![i; 32]);
|
||||||
|
match client.on_event(OstpEvent::Outbound(1, payload)).unwrap() {
|
||||||
|
ProtocolAction::SendDatagram(d) => d,
|
||||||
|
_ => panic!("expected SendDatagram for frame {i}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The freeze this fixes: a frame is lost, the sender eventually stops
|
||||||
|
/// retransmitting it, and the receiver — which gates delivery on
|
||||||
|
/// `expected_recv_nonce` — waits for it forever. Every later frame piles up
|
||||||
|
/// undelivered while the transport itself stays healthy, so nothing upstream
|
||||||
|
/// notices. Recovery must eventually skip the hole and release the backlog.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_releases_permanently_stalled_frames() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 4);
|
||||||
|
|
||||||
|
// Frame 0 arrives in order and is delivered straight through.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert_eq!(delivered_payloads(&action).len(), 1, "in-order frame should deliver");
|
||||||
|
|
||||||
|
// Frame 1 is lost. 2 and 3 arrive but must be held back — delivering them
|
||||||
|
// now would reorder the stream.
|
||||||
|
for idx in [2usize, 3] {
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[idx].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"frame {idx} must stay buffered behind the missing frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand in for "the sender exhausted its retries and dropped frame 1":
|
||||||
|
// the sequence has not advanced for longer than the recovery timeout.
|
||||||
|
server.last_recv_advance = Instant::now() - GAP_RECOVERY_MAX - Duration::from_secs(1);
|
||||||
|
|
||||||
|
// The next inbound frame (a retransmitted duplicate, which is exactly what
|
||||||
|
// a real stalled session keeps receiving) must unblock the backlog.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(
|
||||||
|
delivered.len(),
|
||||||
|
2,
|
||||||
|
"both buffered frames must be released once the gap is declared unrecoverable"
|
||||||
|
);
|
||||||
|
// ...and in order: frame 2 before frame 3.
|
||||||
|
assert_eq!(delivered[0][0], 2);
|
||||||
|
assert_eq!(delivered[1][0], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovery must not be trigger-happy: a frame that is merely late still has
|
||||||
|
/// to be waited for, or we would discard data the sender is about to resend.
|
||||||
|
#[test]
|
||||||
|
fn test_gap_recovery_does_not_fire_before_timeout() {
|
||||||
|
let (mut client, mut server) = do_handshake();
|
||||||
|
let frames = make_data_frames(&mut client, 3);
|
||||||
|
|
||||||
|
server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[2].clone())).unwrap();
|
||||||
|
assert!(delivered_payloads(&action).is_empty());
|
||||||
|
|
||||||
|
// Well inside the timeout — the gap must still be respected.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
|
||||||
|
assert!(
|
||||||
|
delivered_payloads(&action).is_empty(),
|
||||||
|
"must keep waiting while retransmission is still plausible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And once the genuinely-late frame shows up, normal in-order delivery
|
||||||
|
// resumes with nothing dropped.
|
||||||
|
let action = server.on_event(OstpEvent::Inbound(frames[1].clone())).unwrap();
|
||||||
|
let delivered = delivered_payloads(&action);
|
||||||
|
assert_eq!(delivered.len(), 2, "late frame plus the buffered one");
|
||||||
|
assert_eq!(delivered[0][0], 1);
|
||||||
|
assert_eq!(delivered[1][0], 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
|
|
@ -5,6 +8,37 @@ plugins {
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Release signing material ────────────────────────────────────────────────
|
||||||
|
// Supplied out-of-band and never committed: either an `android/key.properties`
|
||||||
|
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
|
||||||
|
//
|
||||||
|
// This exists because the release build used to be signed with the DEBUG
|
||||||
|
// keystore (the stock Flutter template TODO). Android identifies an app by
|
||||||
|
// applicationId + signing key, and refuses to update across a key change. The
|
||||||
|
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
|
||||||
|
// so every published build carried a different random key — which is why
|
||||||
|
// updating on top of a previous install failed with "App not installed" /
|
||||||
|
// "unable to parse the package" and only a full uninstall+reinstall worked.
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propsFile = rootProject.file("key.properties")
|
||||||
|
if (propsFile.exists()) {
|
||||||
|
FileInputStream(propsFile).use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank counts as absent. GitHub Actions substitutes an EMPTY STRING (not an
|
||||||
|
// unset variable) for a secret that doesn't exist, so `getenv(...) ?: fallback`
|
||||||
|
// silently kept the empty value — the elvis operator only catches null. That is
|
||||||
|
// how an unset ANDROID_KEY_PASSWORD ended up being used as the literal key
|
||||||
|
// password instead of falling back to the store password, producing Gradle's
|
||||||
|
// "Get Key failed: Given final block not properly padded".
|
||||||
|
fun signingSetting(propKey: String, envKey: String): String? =
|
||||||
|
(keystoreProperties.getProperty(propKey) ?: System.getenv(envKey))
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
|
||||||
|
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.ospab.ostp_client"
|
namespace = "com.ospab.ostp_client"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|
@ -34,11 +68,43 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
val store = signingSetting("storePassword", "OSTP_KEYSTORE_PASSWORD")
|
||||||
|
storeFile = file(releaseStorePath!!)
|
||||||
|
storePassword = store
|
||||||
|
keyAlias = signingSetting("keyAlias", "OSTP_KEY_ALIAS")
|
||||||
|
// PKCS12 (the keytool default since Java 9, and what our upload
|
||||||
|
// keystore is) cannot hold a key password that differs from the
|
||||||
|
// store password — the format simply has no place to put one. So
|
||||||
|
// treat a missing key password as "same as the store password"
|
||||||
|
// instead of demanding a secret that, for this keystore, can only
|
||||||
|
// ever be a duplicate. An explicit value still wins, for the older
|
||||||
|
// JKS format where the two genuinely can differ.
|
||||||
|
keyPassword = signingSetting("keyPassword", "OSTP_KEY_PASSWORD") ?: store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// Use the real upload key when one was supplied; otherwise fall back to
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// the debug keystore so a plain local `flutter build apk --release`
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// still works for development. Anything PUBLISHED must take the first
|
||||||
|
// branch — a debug-signed build cannot be updated over, and its key is
|
||||||
|
// machine-local, so it also can't be reproduced later.
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"OSTP: no release keystore configured (android/key.properties or " +
|
||||||
|
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
|
||||||
|
"is for local use only: users cannot update over it, and the key is " +
|
||||||
|
"not reproducible on another machine."
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ import '../models/connection_state_enum.dart';
|
||||||
import '../models/ostp_profile.dart';
|
import '../models/ostp_profile.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Success green for the "connected" state — the button aura/border/icon and
|
||||||
|
/// the top-bar status dot. The theme's `secondary` (#AAAAAA) reads as plain
|
||||||
|
/// white here, which gave no visual confirmation that the tunnel actually came
|
||||||
|
/// up. Reuses the same green already used for a healthy ping value, so
|
||||||
|
/// "green = good" stays consistent across the UI.
|
||||||
|
const Color kConnectedGreen = Color(0xFF22D3A5);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
const HomeScreen({super.key, required this.prefs});
|
const HomeScreen({super.key, required this.prefs});
|
||||||
|
|
@ -45,8 +52,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late AnimationController _spinController;
|
late AnimationController _spinController;
|
||||||
|
|
||||||
bool _isCheckingPing = false;
|
String _pingText = '-- ms';
|
||||||
String _pingText = 'Target Ping: -- ms';
|
|
||||||
Color _pingColor = Colors.white54;
|
Color _pingColor = Colors.white54;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -420,8 +426,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_prevBytesSent = bytesSent;
|
_prevBytesSent = bytesSent;
|
||||||
_downSpeed = '${_formatBytes(dRecv)}/s';
|
_downSpeed = '${_formatBytes(dRecv)}/s';
|
||||||
_upSpeed = '${_formatBytes(dSent)}/s';
|
_upSpeed = '${_formatBytes(dSent)}/s';
|
||||||
if (rttMs > 0 && !_isCheckingPing) {
|
if (rttMs > 0) {
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
_pingText = '$rttMs ms';
|
||||||
if (rttMs < 100) {
|
if (rttMs < 100) {
|
||||||
_pingColor = const Color(0xFF22D3A5);
|
_pingColor = const Color(0xFF22D3A5);
|
||||||
} else if (rttMs < 250) {
|
} else if (rttMs < 250) {
|
||||||
|
|
@ -447,47 +453,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkConnectionLatency() async {
|
|
||||||
if (_state != ConnectionStateEnum.connected) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = true;
|
|
||||||
_pingText = 'Updating...';
|
|
||||||
_pingColor = Colors.white70;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final metricsJson = await platform.invokeMethod('getMetrics');
|
|
||||||
if (metricsJson != null && metricsJson.isNotEmpty) {
|
|
||||||
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
|
|
||||||
final rttMs = parsed['rtt_ms'] as int? ?? 0;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
if (rttMs > 0) {
|
|
||||||
_pingText = 'Server Ping: $rttMs ms';
|
|
||||||
_pingColor = rttMs < 100
|
|
||||||
? const Color(0xFF22D3A5)
|
|
||||||
: rttMs < 250
|
|
||||||
? Colors.amberAccent
|
|
||||||
: Colors.redAccent;
|
|
||||||
} else {
|
|
||||||
_pingText = 'Server Ping: -- ms';
|
|
||||||
_pingColor = Colors.white54;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Failed to check latency: $e");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setDisconnected() {
|
void _setDisconnected() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -498,9 +463,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
_upSpeed = '0 B/s';
|
_upSpeed = '0 B/s';
|
||||||
_prevBytesRecv = 0;
|
_prevBytesRecv = 0;
|
||||||
_prevBytesSent = 0;
|
_prevBytesSent = 0;
|
||||||
_pingText = 'Target Ping: -- ms';
|
_pingText = '-- ms';
|
||||||
_pingColor = Colors.white54;
|
_pingColor = Colors.white54;
|
||||||
_isCheckingPing = false;
|
|
||||||
});
|
});
|
||||||
_pulseController.stop();
|
_pulseController.stop();
|
||||||
_pulseController.value = 0.0;
|
_pulseController.value = 0.0;
|
||||||
|
|
@ -578,12 +542,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary
|
? kConnectedGreen
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _state == ConnectionStateEnum.connected
|
color: _state == ConnectionStateEnum.connected
|
||||||
? theme.colorScheme.secondary.withOpacity(0.5)
|
? kConnectedGreen.withOpacity(0.5)
|
||||||
: theme.colorScheme.primary.withOpacity(0.5),
|
: theme.colorScheme.primary.withOpacity(0.5),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
)
|
)
|
||||||
|
|
@ -637,7 +601,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
|
|
||||||
Widget _buildStage(ThemeData theme) {
|
Widget _buildStage(ThemeData theme) {
|
||||||
Color getAccentColor() {
|
Color getAccentColor() {
|
||||||
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
|
if (_state == ConnectionStateEnum.connected) return kConnectedGreen;
|
||||||
return theme.colorScheme.primary;
|
return theme.colorScheme.primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,67 +739,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.03),
|
color: Colors.white.withOpacity(0.03),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
border: Border.all(color: Colors.white.withOpacity(0.06)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
|
||||||
child: Column(
|
const SizedBox(width: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
_pingText,
|
||||||
const Text(
|
style: TextStyle(
|
||||||
'CONNECTION TEST',
|
fontSize: 13,
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 10,
|
color: _pingColor,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white38,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_pingText,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: _pingColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
_isCheckingPing
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
|
|
||||||
)
|
|
||||||
: TextButton.icon(
|
|
||||||
onPressed: _checkConnectionLatency,
|
|
||||||
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
|
|
||||||
label: Text(
|
|
||||||
'Test Ping',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.4.2+20
|
version: 0.4.4+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "ostp-gui",
|
"name": "ostp-gui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.2",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
|
|
||||||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|
@ -2696,7 +2696,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2713,7 +2713,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"json_comments",
|
"json_comments",
|
||||||
|
|
@ -2733,7 +2733,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ostp-gui"
|
name = "ostp-gui"
|
||||||
version = "0.4.2"
|
version = "0.4.4"
|
||||||
description = "OSTP desktop GUI"
|
description = "OSTP desktop GUI"
|
||||||
authors = ["ospab"]
|
authors = ["ospab"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -204,19 +204,34 @@ fn get_wintun_install_path() -> String {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `Command` for a console program, with the console window suppressed.
|
||||||
|
///
|
||||||
|
/// The GUI is a windowed-subsystem binary, so every console child it spawns
|
||||||
|
/// pops up a console window for as long as that child runs. With `reg`,
|
||||||
|
/// `tasklist` and `schtasks` all being invoked from here, that surfaced as
|
||||||
|
/// windows flashing on screen — worst while polling for the scheduled task,
|
||||||
|
/// which could spawn twenty of them in a row.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn quiet_command(program: &str) -> std::process::Command {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
let mut cmd = std::process::Command::new(program);
|
||||||
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets or removes the app from Windows startup (HKCU\...\Run).
|
/// Sets or removes the app from Windows startup (HKCU\...\Run).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_autostart(enable: bool) -> Result<(), String> {
|
fn set_autostart(enable: bool) -> Result<(), String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
|
||||||
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
let app_name = "OSTP";
|
let app_name = "OSTP";
|
||||||
if enable {
|
if enable {
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe()
|
||||||
.map_err(|e| format!("Cannot get exe path: {}", e))?;
|
.map_err(|e| format!("Cannot get exe path: {}", e))?;
|
||||||
let exe_str = format!("\"{}\"", exe.to_string_lossy());
|
let exe_str = format!("\"{}\"", exe.to_string_lossy());
|
||||||
let out = Command::new("reg")
|
let out = quiet_command("reg")
|
||||||
.args(["add", key, "/v", app_name, "/t", "REG_SZ", "/d", &exe_str, "/f"])
|
.args(["add", key, "/v", app_name, "/t", "REG_SZ", "/d", &exe_str, "/f"])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("reg add failed: {}", e))?;
|
.map_err(|e| format!("reg add failed: {}", e))?;
|
||||||
|
|
@ -224,28 +239,71 @@ fn set_autostart(enable: bool) -> Result<(), String> {
|
||||||
return Err(String::from_utf8_lossy(&out.stderr).to_string());
|
return Err(String::from_utf8_lossy(&out.stderr).to_string());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = Command::new("reg")
|
let _ = quiet_command("reg")
|
||||||
.args(["delete", key, "/v", app_name, "/f"])
|
.args(["delete", key, "/v", app_name, "/f"])
|
||||||
.output();
|
.output();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// XDG autostart: desktop environments launch every .desktop file in
|
||||||
|
// ~/.config/autostart on login. This is the portable equivalent of the
|
||||||
|
// HKCU Run key above and needs no elevation.
|
||||||
|
let path = linux_autostart_path().ok_or("Cannot determine the autostart directory")?;
|
||||||
|
if enable {
|
||||||
|
let exe = std::env::current_exe().map_err(|e| format!("Cannot get exe path: {}", e))?;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| format!("Cannot create {}: {}", dir.display(), e))?;
|
||||||
|
}
|
||||||
|
let entry = format!(
|
||||||
|
"[Desktop Entry]\n\
|
||||||
|
Type=Application\n\
|
||||||
|
Name=OSTP\n\
|
||||||
|
Exec=\"{}\"\n\
|
||||||
|
Terminal=false\n\
|
||||||
|
X-GNOME-Autostart-enabled=true\n",
|
||||||
|
exe.display()
|
||||||
|
);
|
||||||
|
std::fs::write(&path, entry)
|
||||||
|
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
||||||
|
} else if path.exists() {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Cannot remove {}: {}", path.display(), e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path of the XDG autostart entry, honouring XDG_CONFIG_HOME.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_autostart_path() -> Option<PathBuf> {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| !p.as_os_str().is_empty())
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
|
||||||
|
Some(base.join("autostart").join("ostp.desktop"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Checks if the app is currently in Windows startup.
|
/// Checks if the app is currently in Windows startup.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_autostart() -> bool {
|
fn get_autostart() -> bool {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
|
||||||
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
let out = Command::new("reg")
|
let out = quiet_command("reg")
|
||||||
.args(["query", key, "/v", "OSTP"])
|
.args(["query", key, "/v", "OSTP"])
|
||||||
.output();
|
.output();
|
||||||
if let Ok(o) = out {
|
if let Ok(o) = out {
|
||||||
return o.status.success();
|
return o.status.success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(path) = linux_autostart_path() {
|
||||||
|
return path.exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,8 +312,7 @@ fn get_autostart() -> bool {
|
||||||
fn list_running_processes() -> Vec<String> {
|
fn list_running_processes() -> Vec<String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use std::process::Command;
|
if let Ok(out) = quiet_command("tasklist")
|
||||||
if let Ok(out) = Command::new("tasklist")
|
|
||||||
.args(["/FO", "CSV", "/NH"])
|
.args(["/FO", "CSV", "/NH"])
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
|
|
@ -625,13 +682,18 @@ async fn start_tun_via_helper(
|
||||||
raw: &ClientConfigRaw,
|
raw: &ClientConfigRaw,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
|
// TUN goes through a privileged helper. Elevation is implemented for
|
||||||
|
// Windows (UAC) and Linux (polkit/pkexec); anywhere else launch_as_admin
|
||||||
|
// reports that plainly rather than letting this fail later as a confusing
|
||||||
|
// missing-file error.
|
||||||
let port = {
|
let port = {
|
||||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Bind error: {}", e))?;
|
||||||
listener.local_addr().unwrap().port()
|
listener.local_addr().unwrap().port()
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_token = rand::random::<u64>().to_string();
|
let auth_token = rand::random::<u64>().to_string();
|
||||||
let helper_exe = find_helper_exe().ok_or_else(|| "ostp-tun-helper.exe not found.".to_string())?;
|
let helper_exe = find_helper_exe()
|
||||||
|
.ok_or_else(|| format!("{HELPER_EXE_NAME} not found next to the app or in target/."))?;
|
||||||
launch_as_admin(&helper_exe, &auth_token, port).map_err(|e| format!("Failed to launch helper: {}", e))?;
|
launch_as_admin(&helper_exe, &auth_token, port).map_err(|e| format!("Failed to launch helper: {}", e))?;
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||||
|
|
||||||
|
|
@ -705,11 +767,22 @@ struct HelperPipeState {
|
||||||
error_msg: Option<String>,
|
error_msg: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Executable name of the TUN helper for the current platform.
|
||||||
|
///
|
||||||
|
/// The ".exe" suffix was hardcoded, so on Linux every lookup below searched for
|
||||||
|
/// a file that cannot exist and the GUI reported the helper as missing on a
|
||||||
|
/// platform where it ships without an extension.
|
||||||
|
const HELPER_EXE_NAME: &str = if cfg!(windows) {
|
||||||
|
"ostp-tun-helper.exe"
|
||||||
|
} else {
|
||||||
|
"ostp-tun-helper"
|
||||||
|
};
|
||||||
|
|
||||||
fn find_helper_exe() -> Option<PathBuf> {
|
fn find_helper_exe() -> Option<PathBuf> {
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
// 1. Release/Production adjacent
|
// 1. Release/Production adjacent
|
||||||
let candidate = dir.join("ostp-tun-helper.exe");
|
let candidate = dir.join(HELPER_EXE_NAME);
|
||||||
if candidate.exists() { return Some(candidate); }
|
if candidate.exists() { return Some(candidate); }
|
||||||
|
|
||||||
// 2. Tauri target directory fallback
|
// 2. Tauri target directory fallback
|
||||||
|
|
@ -717,9 +790,9 @@ fn find_helper_exe() -> Option<PathBuf> {
|
||||||
let mut parent = dir;
|
let mut parent = dir;
|
||||||
while let Some(p) = parent.parent() {
|
while let Some(p) = parent.parent() {
|
||||||
if p.file_name().map(|n| n == "target").unwrap_or(false) {
|
if p.file_name().map(|n| n == "target").unwrap_or(false) {
|
||||||
let deb = p.join("debug").join("ostp-tun-helper.exe");
|
let deb = p.join("debug").join(HELPER_EXE_NAME);
|
||||||
if deb.exists() { return Some(deb); }
|
if deb.exists() { return Some(deb); }
|
||||||
let rel = p.join("release").join("ostp-tun-helper.exe");
|
let rel = p.join("release").join(HELPER_EXE_NAME);
|
||||||
if rel.exists() { return Some(rel); }
|
if rel.exists() { return Some(rel); }
|
||||||
}
|
}
|
||||||
parent = p;
|
parent = p;
|
||||||
|
|
@ -729,13 +802,13 @@ fn find_helper_exe() -> Option<PathBuf> {
|
||||||
// 3. Current working directory target fallback
|
// 3. Current working directory target fallback
|
||||||
let cwd = std::env::current_dir().unwrap_or_default();
|
let cwd = std::env::current_dir().unwrap_or_default();
|
||||||
let candidates = [
|
let candidates = [
|
||||||
cwd.join("ostp-tun-helper.exe"),
|
cwd.join(HELPER_EXE_NAME),
|
||||||
cwd.join("target").join("debug").join("ostp-tun-helper.exe"),
|
cwd.join("target").join("debug").join(HELPER_EXE_NAME),
|
||||||
cwd.join("target").join("release").join("ostp-tun-helper.exe"),
|
cwd.join("target").join("release").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("target").join("debug").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("target").join("release").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("target").join("release").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("..").join("target").join("debug").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("..").join("target").join("debug").join(HELPER_EXE_NAME),
|
||||||
cwd.join("..").join("..").join("target").join("release").join("ostp-tun-helper.exe"),
|
cwd.join("..").join("..").join("target").join("release").join(HELPER_EXE_NAME),
|
||||||
];
|
];
|
||||||
for path in &candidates {
|
for path in &candidates {
|
||||||
if path.exists() { return Some(path.clone()); }
|
if path.exists() { return Some(path.clone()); }
|
||||||
|
|
@ -743,8 +816,272 @@ fn find_helper_exe() -> Option<PathBuf> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Name of the Scheduled Task that runs the helper elevated without a prompt.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const HELPER_TASK_NAME: &str = "OSTP TUN Helper";
|
||||||
|
|
||||||
|
/// Fixed path the GUI writes launch parameters to, and the task's command line
|
||||||
|
/// reads them from.
|
||||||
|
///
|
||||||
|
/// A Scheduled Task stores a FIXED command line, so the per-launch port and
|
||||||
|
/// token cannot travel as arguments. The file lives under the user's own
|
||||||
|
/// LOCALAPPDATA: the helper runs elevated but as the SAME user, so this keeps
|
||||||
|
/// the token inside the trust boundary it already had — no other user can read
|
||||||
|
/// it, which would not be true of a shared location.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn helper_args_file() -> PathBuf {
|
||||||
|
let base = std::env::var_os("LOCALAPPDATA")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
base.join("OSTP").join("helper-args.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal XML text escaping for the values interpolated into the task
|
||||||
|
/// definition. Paths and usernames are attacker-irrelevant here but can easily
|
||||||
|
/// contain `&`, which would otherwise produce invalid XML and a confusing
|
||||||
|
/// schtasks parse failure.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse of [`xml_escape`]. `&` must be undone last or `&lt;` would
|
||||||
|
/// come back as `<`.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn xml_unescape(s: &str) -> String {
|
||||||
|
s.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exe path currently baked into the registered task, if any.
|
||||||
|
///
|
||||||
|
/// Queried as XML rather than `/FO LIST /V`: the list format's field labels are
|
||||||
|
/// localized (on a Russian Windows "Task To Run" is "Задача для запуска"),
|
||||||
|
/// whereas XML tag names are fixed. schtasks writes UTF-16LE with a BOM here,
|
||||||
|
/// but tolerate UTF-8 in case that ever changes.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn helper_task_command() -> Option<String> {
|
||||||
|
let out = quiet_command("schtasks")
|
||||||
|
.args(["/Query", "/TN", HELPER_TASK_NAME, "/XML"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = if out.stdout.starts_with(&[0xFF, 0xFE]) {
|
||||||
|
let units: Vec<u16> = out.stdout[2..]
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&units)
|
||||||
|
} else {
|
||||||
|
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = text.find("<Command>")? + "<Command>".len();
|
||||||
|
let end = text[start..].find("</Command>")? + start;
|
||||||
|
Some(xml_unescape(text[start..end].trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a task is registered AND still points at the exe we are about to run.
|
||||||
|
///
|
||||||
|
/// The path matters as much as the name. A task registered by a dev build (or
|
||||||
|
/// by an install that has since moved) keeps its original `<Command>`, and
|
||||||
|
/// `schtasks /Run` reports success merely for *accepting* the request — a task
|
||||||
|
/// whose exe no longer exists fails asynchronously and silently. Trusting the
|
||||||
|
/// name alone therefore bought a 60-second "Timeout connecting to helper" on
|
||||||
|
/// every single connect, permanently, until the task was deleted by hand.
|
||||||
|
/// Re-registering costs one consent prompt and fixes it for good.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn helper_task_matches(exe: &std::path::Path) -> bool {
|
||||||
|
let Some(registered) = helper_task_command() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let registered = registered.trim().trim_matches('"');
|
||||||
|
|
||||||
|
// Canonicalize both sides when possible so `..`, short 8.3 names and
|
||||||
|
// casing differences do not read as a mismatch. A missing file cannot be
|
||||||
|
// canonicalized — which is itself a mismatch worth re-registering over.
|
||||||
|
match (
|
||||||
|
std::fs::canonicalize(registered),
|
||||||
|
std::fs::canonicalize(exe),
|
||||||
|
) {
|
||||||
|
(Ok(a), Ok(b)) => a == b,
|
||||||
|
_ => registered.eq_ignore_ascii_case(&exe.display().to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the Scheduled Task. This is the ONLY step that needs elevation, and
|
||||||
|
/// it happens once per machine; every later tunnel start reuses the task.
|
||||||
|
///
|
||||||
|
/// RunLevel=HIGHEST makes the task run elevated, and because a task launch is
|
||||||
|
/// not an elevation request, Windows shows no consent dialog for it.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn install_helper_task(exe: &std::path::Path) -> anyhow::Result<()> {
|
||||||
|
let args_file = helper_args_file();
|
||||||
|
if let Some(dir) = args_file.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register from an XML definition rather than /TR. The command line would
|
||||||
|
// otherwise need the exe path and the args path quoted INSIDE an already
|
||||||
|
// quoted /TR value, escaped again through ShellExecuteW — a notoriously
|
||||||
|
// brittle chain when either path contains a space, which both of these do
|
||||||
|
// by default (Program Files, and usernames with spaces). XML also lets the
|
||||||
|
// battery and time-limit settings below be stated explicitly.
|
||||||
|
let user = format!(
|
||||||
|
"{}\\{}",
|
||||||
|
std::env::var("USERDOMAIN").unwrap_or_else(|_| "%COMPUTERNAME%".into()),
|
||||||
|
std::env::var("USERNAME").unwrap_or_default()
|
||||||
|
);
|
||||||
|
let xml = format!(
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-16"?>
|
||||||
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||||
|
<RegistrationInfo>
|
||||||
|
<Description>Runs the OSTP TUN helper elevated so enabling the tunnel does not prompt for consent every time.</Description>
|
||||||
|
</RegistrationInfo>
|
||||||
|
<Principals>
|
||||||
|
<Principal id="Author">
|
||||||
|
<UserId>{user}</UserId>
|
||||||
|
<LogonType>InteractiveToken</LogonType>
|
||||||
|
<RunLevel>HighestAvailable</RunLevel>
|
||||||
|
</Principal>
|
||||||
|
</Principals>
|
||||||
|
<Settings>
|
||||||
|
<MultipleInstancesPolicy>Parallel</MultipleInstancesPolicy>
|
||||||
|
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
||||||
|
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||||
|
<StartWhenAvailable>false</StartWhenAvailable>
|
||||||
|
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
||||||
|
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<Hidden>false</Hidden>
|
||||||
|
<AllowHardTerminate>true</AllowHardTerminate>
|
||||||
|
</Settings>
|
||||||
|
<Actions Context="Author">
|
||||||
|
<Exec>
|
||||||
|
<Command>{exe}</Command>
|
||||||
|
<Arguments>--args-file "{args}"</Arguments>
|
||||||
|
</Exec>
|
||||||
|
</Actions>
|
||||||
|
</Task>
|
||||||
|
"#,
|
||||||
|
user = xml_escape(&user),
|
||||||
|
exe = xml_escape(&exe.display().to_string()),
|
||||||
|
args = xml_escape(&args_file.display().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// schtasks /Create /XML expects UTF-16LE with a BOM.
|
||||||
|
let xml_path = std::env::temp_dir().join(format!("ostp_task_{}.xml", rand::random::<u32>()));
|
||||||
|
let mut utf16: Vec<u8> = vec![0xFF, 0xFE];
|
||||||
|
for unit in xml.encode_utf16() {
|
||||||
|
utf16.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
std::fs::write(&xml_path, &utf16)?;
|
||||||
|
|
||||||
|
// Registering a HighestAvailable task is itself privileged: this is the one
|
||||||
|
// prompt, and it happens once per machine.
|
||||||
|
//
|
||||||
|
// Elevate through PowerShell's Start-Process -Wait rather than
|
||||||
|
// ShellExecuteW. ShellExecuteW returns as soon as the elevated process is
|
||||||
|
// LAUNCHED, so the XML below was being deleted while schtasks was still
|
||||||
|
// starting up — registration then failed, leaving the user with a consent
|
||||||
|
// prompt that accomplished nothing, followed by a second prompt from the
|
||||||
|
// fallback path. -Wait makes the deletion safe and lets the exit code be
|
||||||
|
// checked instead of guessed at by polling.
|
||||||
|
//
|
||||||
|
// ArgumentList takes an array, so the task name and XML path never need
|
||||||
|
// quoting or escaping through a command line, only PowerShell's own
|
||||||
|
// single-quote doubling.
|
||||||
|
let ps = format!(
|
||||||
|
"$p = Start-Process -FilePath 'schtasks.exe' -Verb RunAs -Wait -PassThru \
|
||||||
|
-WindowStyle Hidden -ArgumentList @('/Create','/TN','{}','/XML','{}','/F'); \
|
||||||
|
exit $p.ExitCode",
|
||||||
|
ps_quote(HELPER_TASK_NAME),
|
||||||
|
ps_quote(&xml_path.display().to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = quiet_command("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &ps])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
// schtasks has exited by now, so this is safe.
|
||||||
|
let _ = std::fs::remove_file(&xml_path);
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(s) if s.success() => {}
|
||||||
|
Ok(s) => anyhow::bail!(
|
||||||
|
"registering the scheduled task failed (exit code {:?}). A declined consent prompt \
|
||||||
|
reports 1223.",
|
||||||
|
s.code()
|
||||||
|
),
|
||||||
|
Err(e) => anyhow::bail!("could not run powershell to register the task: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if helper_task_matches(exe) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("schtasks reported success but the task does not point at {}", exe.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape a value for embedding in a PowerShell single-quoted string.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn ps_quote(s: &str) -> String {
|
||||||
|
s.replace('\'', "''")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
|
fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
|
||||||
|
// Preferred path: hand the parameters over in a file and trigger the
|
||||||
|
// pre-registered task, which runs elevated with no prompt. Falls back to a
|
||||||
|
// direct elevated launch when the task is absent (first ever run, or the
|
||||||
|
// user removed it) — and that first run is also where the task gets created,
|
||||||
|
// so the prompt appears once rather than on every connect.
|
||||||
|
let args_file = helper_args_file();
|
||||||
|
if let Some(dir) = args_file.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
let payload = serde_json::json!({ "port": port, "token": token });
|
||||||
|
let wrote_args = std::fs::write(&args_file, payload.to_string()).is_ok();
|
||||||
|
|
||||||
|
if wrote_args {
|
||||||
|
if !helper_task_matches(exe) {
|
||||||
|
if let Err(e) = install_helper_task(exe) {
|
||||||
|
eprintln!("[OSTP] could not register the helper task ({e}); falling back to a direct elevated launch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if helper_task_matches(exe) {
|
||||||
|
let run = quiet_command("schtasks")
|
||||||
|
.args(["/Run", "/TN", HELPER_TASK_NAME])
|
||||||
|
.output();
|
||||||
|
match run {
|
||||||
|
Ok(o) if o.status.success() => return Ok(()),
|
||||||
|
Ok(o) => eprintln!(
|
||||||
|
"[OSTP] schtasks /Run failed: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
),
|
||||||
|
Err(e) => eprintln!("[OSTP] schtasks /Run could not start: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Falling through: remove the file so a stale token is not left behind.
|
||||||
|
let _ = std::fs::remove_file(&args_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_as_admin_direct(exe, token, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The original one-prompt-per-launch path, kept as the fallback.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn launch_as_admin_direct(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::Result<()> {
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
use std::ptr::null_mut;
|
use std::ptr::null_mut;
|
||||||
|
|
@ -797,8 +1134,50 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(target_os = "linux")]
|
||||||
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> { anyhow::bail!("Windows only."); }
|
fn launch_as_admin(exe: &PathBuf, token: &str, port: u16) -> Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// Same shape as the Windows path: the token goes through a file rather than
|
||||||
|
// argv, so it never shows up in the process list.
|
||||||
|
let token_file = std::env::temp_dir().join(format!("ostp_auth_{}.tmp", rand::random::<u32>()));
|
||||||
|
std::fs::write(&token_file, token)?;
|
||||||
|
// Unlike Windows, /tmp is world-readable here, and this token authenticates
|
||||||
|
// control of the privileged tunnel helper — restrict it to the owner.
|
||||||
|
let _ = std::fs::set_permissions(&token_file, std::fs::Permissions::from_mode(0o600));
|
||||||
|
|
||||||
|
// pkexec is polkit's front-end: in a desktop session it raises a graphical
|
||||||
|
// authentication dialog. sudo is not an option from a GUI process, which has
|
||||||
|
// no terminal to prompt on.
|
||||||
|
match Command::new("pkexec")
|
||||||
|
.arg(exe)
|
||||||
|
.arg("--port")
|
||||||
|
.arg(port.to_string())
|
||||||
|
.arg("--token-file")
|
||||||
|
.arg(&token_file)
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
let _ = std::fs::remove_file(&token_file);
|
||||||
|
anyhow::bail!(
|
||||||
|
"pkexec was not found, so the TUN helper cannot be granted the privileges it \
|
||||||
|
needs. Install polkit (package \"policykit-1\" on Debian/Ubuntu, \"polkit\" on \
|
||||||
|
Fedora/Arch), or use proxy mode, which needs no elevation."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&token_file);
|
||||||
|
Err(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
|
fn launch_as_admin(_exe: &PathBuf, _token: &str, _port: u16) -> Result<()> {
|
||||||
|
anyhow::bail!("TUN mode needs a privileged helper, which is implemented on Windows and Linux only. Use proxy mode on this platform.");
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn show_error_dialog(msg: &str) {
|
fn show_error_dialog(msg: &str) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ostp-gui",
|
"productName": "ostp-gui",
|
||||||
"version": "0.4.2",
|
"version": "0.4.4",
|
||||||
"identifier": "com.ospab.ostp",
|
"identifier": "com.ospab.ostp",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "OSTP",
|
"title": "OSTP",
|
||||||
"width": 360,
|
"width": 400,
|
||||||
"height": 680,
|
"height": 720,
|
||||||
"resizable": false
|
"minWidth": 360,
|
||||||
|
"minHeight": 560,
|
||||||
|
"resizable": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|
|
||||||
|
|
@ -402,55 +402,6 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced TCP/UoT Settings (visible only if uot is selected) -->
|
|
||||||
<div id="pm-tcp-settings" style="display:none; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 15px;">
|
|
||||||
<div class="toggle-row" style="padding:0; border:none; margin-bottom:10px;">
|
|
||||||
<div class="toggle-text">
|
|
||||||
<span class="toggle-name">TCP Fragmentation</span>
|
|
||||||
<span class="toggle-hint">Split handshake to bypass DPI</span>
|
|
||||||
</div>
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" id="pm-tcp-frag" />
|
|
||||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="pm-frag-details" style="display:none;">
|
|
||||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
|
||||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
|
||||||
<span class="field-label">Chunk Size</span>
|
|
||||||
<input id="pm-frag-chunk" class="field-input compact" type="number" placeholder="2" min="1" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
|
||||||
<span class="field-label">Sleep (ms)</span>
|
|
||||||
<input id="pm-frag-sleep" class="field-input compact" type="number" placeholder="2" min="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-divider-mini" style="margin-top:0;"><span>Junk Packets</span></div>
|
|
||||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
|
||||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
|
||||||
<span class="field-label">Count (Min)</span>
|
|
||||||
<input id="pm-junk-pc-min" class="field-input compact" type="number" placeholder="2" min="0" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
|
||||||
<span class="field-label">Count (Max)</span>
|
|
||||||
<input id="pm-junk-pc-max" class="field-input compact" type="number" placeholder="5" min="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:10px;">
|
|
||||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
|
||||||
<span class="field-label">Size (Min)</span>
|
|
||||||
<input id="pm-junk-ps-min" class="field-input compact" type="number" placeholder="100" min="0" />
|
|
||||||
</div>
|
|
||||||
<div class="inline-field" style="padding:0; border:none; flex:1;">
|
|
||||||
<span class="field-label">Size (Max)</span>
|
|
||||||
<input id="pm-junk-ps-max" class="field-input compact" type="number" placeholder="1000" min="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="btn-profile-cancel" class="btn secondary">Cancel</button>
|
<button id="btn-profile-cancel" class="btn secondary">Cancel</button>
|
||||||
<button id="btn-profile-delete" class="btn danger" style="display:none;">Delete</button>
|
<button id="btn-profile-delete" class="btn danger" style="display:none;">Delete</button>
|
||||||
|
|
|
||||||
|
|
@ -113,15 +113,6 @@ const pmName = $('pm-name');
|
||||||
const pmServer = $('pm-server');
|
const pmServer = $('pm-server');
|
||||||
const pmKey = $('pm-key');
|
const pmKey = $('pm-key');
|
||||||
const pmTransport = $('pm-transport');
|
const pmTransport = $('pm-transport');
|
||||||
const pmTcpFrag = $('pm-tcp-frag');
|
|
||||||
const pmFragChunk = $('pm-frag-chunk');
|
|
||||||
const pmFragSleep = $('pm-frag-sleep');
|
|
||||||
const pmJunkPcMin = $('pm-junk-pc-min');
|
|
||||||
const pmJunkPcMax = $('pm-junk-pc-max');
|
|
||||||
const pmJunkPsMin = $('pm-junk-ps-min');
|
|
||||||
const pmJunkPsMax = $('pm-junk-ps-max');
|
|
||||||
const pmTcpSettings = $('pm-tcp-settings');
|
|
||||||
const pmFragDetails = $('pm-frag-details');
|
|
||||||
const btnProfileCancel = $('btn-profile-cancel');
|
const btnProfileCancel = $('btn-profile-cancel');
|
||||||
const btnProfileSave = $('btn-profile-save');
|
const btnProfileSave = $('btn-profile-save');
|
||||||
const btnProfileDelete = $('btn-profile-delete');
|
const btnProfileDelete = $('btn-profile-delete');
|
||||||
|
|
@ -522,31 +513,15 @@ function openProfileEditor(id) {
|
||||||
pmServer.value = p.server || '';
|
pmServer.value = p.server || '';
|
||||||
pmKey.value = p.key || '';
|
pmKey.value = p.key || '';
|
||||||
pmTransport.value = p.transport || 'udp';
|
pmTransport.value = p.transport || 'udp';
|
||||||
pmTcpFrag.checked = !!p.tcp_fragmentation;
|
|
||||||
pmFragChunk.value = p.frag_chunk || 2;
|
|
||||||
pmFragSleep.value = p.frag_sleep || 2;
|
|
||||||
pmJunkPcMin.value = p.junk_pc ? p.junk_pc[0] : 2;
|
|
||||||
pmJunkPcMax.value = p.junk_pc ? p.junk_pc[1] : 5;
|
|
||||||
pmJunkPsMin.value = p.junk_ps ? p.junk_ps[0] : 100;
|
|
||||||
pmJunkPsMax.value = p.junk_ps ? p.junk_ps[1] : 1000;
|
|
||||||
btnProfileDelete.style.display = '';
|
btnProfileDelete.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
profileModalTitle.textContent = 'New Profile';
|
profileModalTitle.textContent = 'New Profile';
|
||||||
pmName.value = pmServer.value = pmKey.value = '';
|
pmName.value = pmServer.value = pmKey.value = '';
|
||||||
pmTransport.value = 'udp';
|
pmTransport.value = 'udp';
|
||||||
pmTcpFrag.checked = false;
|
|
||||||
pmFragChunk.value = 2;
|
|
||||||
pmFragSleep.value = 2;
|
|
||||||
pmJunkPcMin.value = 2;
|
|
||||||
pmJunkPcMax.value = 5;
|
|
||||||
pmJunkPsMin.value = 100;
|
|
||||||
pmJunkPsMax.value = 1000;
|
|
||||||
btnProfileDelete.style.display = 'none';
|
btnProfileDelete.style.display = 'none';
|
||||||
}
|
}
|
||||||
pmKey.type = 'password';
|
pmKey.type = 'password';
|
||||||
profileModal.classList.remove('hidden');
|
profileModal.classList.remove('hidden');
|
||||||
pmTransport.dispatchEvent(new Event('change'));
|
|
||||||
pmTcpFrag.dispatchEvent(new Event('change'));
|
|
||||||
setTimeout(() => pmName.focus(), 80);
|
setTimeout(() => pmName.focus(), 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,11 +539,6 @@ function saveProfileFromEditor() {
|
||||||
server,
|
server,
|
||||||
key,
|
key,
|
||||||
transport: pmTransport.value,
|
transport: pmTransport.value,
|
||||||
tcp_fragmentation: pmTcpFrag.checked,
|
|
||||||
frag_chunk: parseInt(pmFragChunk.value) || 2,
|
|
||||||
frag_sleep: parseInt(pmFragSleep.value) || 2,
|
|
||||||
junk_pc: [parseInt(pmJunkPcMin.value)||2, parseInt(pmJunkPcMax.value)||5],
|
|
||||||
junk_ps: [parseInt(pmJunkPsMin.value)||100, parseInt(pmJunkPsMax.value)||1000],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -578,11 +548,6 @@ function saveProfileFromEditor() {
|
||||||
server,
|
server,
|
||||||
key,
|
key,
|
||||||
transport: pmTransport.value,
|
transport: pmTransport.value,
|
||||||
tcp_fragmentation: pmTcpFrag.checked,
|
|
||||||
frag_chunk: parseInt(pmFragChunk.value) || 2,
|
|
||||||
frag_sleep: parseInt(pmFragSleep.value) || 2,
|
|
||||||
junk_pc: [parseInt(pmJunkPcMin.value)||2, parseInt(pmJunkPcMax.value)||5],
|
|
||||||
junk_ps: [parseInt(pmJunkPsMin.value)||100, parseInt(pmJunkPsMax.value)||1000],
|
|
||||||
};
|
};
|
||||||
profiles.push(p);
|
profiles.push(p);
|
||||||
if (!activeId) { activeId = p.id; saveActiveId(activeId); }
|
if (!activeId) { activeId = p.id; saveActiveId(activeId); }
|
||||||
|
|
@ -695,6 +660,13 @@ function loadSettingsIntoForm() {
|
||||||
updateClientVisibility();
|
updateClientVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last values actually pushed to the OS / backend, so repeated saves that did
|
||||||
|
// not change them stay free. Undefined until the first save, which is correct:
|
||||||
|
// the first one should apply.
|
||||||
|
let lastAppliedAutostart;
|
||||||
|
let lastAppliedTunnelConfig;
|
||||||
|
let hotReloadTimer;
|
||||||
|
|
||||||
function collectAndSaveSettings() {
|
function collectAndSaveSettings() {
|
||||||
const s = {
|
const s = {
|
||||||
tun: inTun.checked,
|
tun: inTun.checked,
|
||||||
|
|
@ -721,19 +693,41 @@ function collectAndSaveSettings() {
|
||||||
fragChunk: parseInt(inFragChunk.value) || 2,
|
fragChunk: parseInt(inFragChunk.value) || 2,
|
||||||
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
|
fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2,
|
||||||
};
|
};
|
||||||
|
// Cheap and local: safe to run on every debounced keystroke.
|
||||||
saveClientSettings(s);
|
saveClientSettings(s);
|
||||||
updateClientVisibility();
|
updateClientVisibility();
|
||||||
|
|
||||||
// Set autostart
|
// Everything below talks to the OS or restarts the tunnel. Running it per
|
||||||
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
|
// keystroke is what made typing in the exclusion fields lag by seconds: the
|
||||||
|
// 400ms debounce fires during natural pauses in typing, and each firing hit
|
||||||
|
// the Windows registry and then tore down and rebuilt the tunnel.
|
||||||
|
|
||||||
// Hot-reload exclusions if connected
|
// Only touch autostart when it actually changed — this is a registry write.
|
||||||
|
if (s.launchStartup !== lastAppliedAutostart) {
|
||||||
|
lastAppliedAutostart = s.launchStartup;
|
||||||
|
invoke('set_autostart', { enable: s.launchStartup }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot-reload the tunnel only when something it actually reads has changed,
|
||||||
|
// and on a much longer debounce: a reload is disruptive, so it should land
|
||||||
|
// once the user has stopped editing rather than between keystrokes.
|
||||||
if (appState === 'connected') {
|
if (appState === 'connected') {
|
||||||
const cfg = buildConfig();
|
const tunnelRelevant = JSON.stringify([
|
||||||
if (cfg) {
|
s.tun, s.killSwitch, s.mux, s.muxSessions, s.mtu, s.dns, s.socks,
|
||||||
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
|
s.exDomains, s.exIps, s.exProcs, s.junkEnabled, s.junkPcMin, s.junkPcMax,
|
||||||
.then(() => invoke('reload_tunnel'))
|
s.junkPsMin, s.junkPsMax, s.tcpFrag, s.fragChunk, s.fragSleep,
|
||||||
.catch(() => {});
|
]);
|
||||||
|
if (tunnelRelevant !== lastAppliedTunnelConfig) {
|
||||||
|
clearTimeout(hotReloadTimer);
|
||||||
|
hotReloadTimer = setTimeout(() => {
|
||||||
|
lastAppliedTunnelConfig = tunnelRelevant;
|
||||||
|
const cfg = buildConfig();
|
||||||
|
if (cfg) {
|
||||||
|
invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) })
|
||||||
|
.then(() => invoke('reload_tunnel'))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -875,12 +869,6 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||||
btnProfileCancel.addEventListener('click', () => profileModal.classList.add('hidden'));
|
btnProfileCancel.addEventListener('click', () => profileModal.classList.add('hidden'));
|
||||||
btnProfileSave.addEventListener('click', saveProfileFromEditor);
|
btnProfileSave.addEventListener('click', saveProfileFromEditor);
|
||||||
btnProfileDelete.addEventListener('click', deleteEditingProfile);
|
btnProfileDelete.addEventListener('click', deleteEditingProfile);
|
||||||
pmTransport.addEventListener('change', () => {
|
|
||||||
pmTcpSettings.style.display = pmTransport.value === 'uot' ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
pmTcpFrag.addEventListener('change', () => {
|
|
||||||
pmFragDetails.style.display = pmTcpFrag.checked ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
btnPeekPm.addEventListener('click', () => {
|
btnPeekPm.addEventListener('click', () => {
|
||||||
pmKey.type = pmKey.type === 'password' ? 'text' : 'password';
|
pmKey.type = pmKey.type === 'password' ? 'text' : 'password';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@
|
||||||
--c-accent-dim: rgba(var(--c-fg-rgb),0.08);
|
--c-accent-dim: rgba(var(--c-fg-rgb),0.08);
|
||||||
--c-accent-glow: rgba(var(--c-fg-rgb),0.18);
|
--c-accent-glow: rgba(var(--c-fg-rgb),0.18);
|
||||||
|
|
||||||
/* Green only for "connected" state */
|
/* Green only for "connected" state — the one deliberate break from the
|
||||||
--c-green: #e8e8e8;
|
monochrome palette, so a successful connection reads at a glance. */
|
||||||
--c-green-glow: rgba(232,232,232,0.25);
|
--c-green-rgb: 46, 230, 109;
|
||||||
--c-green-dim: rgba(232,232,232,0.07);
|
--c-green: #2ee66d;
|
||||||
|
--c-green-glow: rgba(var(--c-green-rgb),0.28);
|
||||||
|
--c-green-dim: rgba(var(--c-green-rgb),0.09);
|
||||||
|
|
||||||
--c-red: #ff5f5f;
|
--c-red: #ff5f5f;
|
||||||
--c-amber: #f0b840;
|
--c-amber: #f0b840;
|
||||||
|
|
@ -55,9 +57,11 @@
|
||||||
--c-accent: #18181b;
|
--c-accent: #18181b;
|
||||||
--c-accent-dim: rgba(0,0,0,0.08);
|
--c-accent-dim: rgba(0,0,0,0.08);
|
||||||
--c-accent-glow: rgba(0,0,0,0.14);
|
--c-accent-glow: rgba(0,0,0,0.14);
|
||||||
--c-green: #18181b;
|
/* Deeper green so it stays legible against the light background. */
|
||||||
--c-green-glow: rgba(0,0,0,0.16);
|
--c-green-rgb: 22, 163, 74;
|
||||||
--c-green-dim: rgba(0,0,0,0.05);
|
--c-green: #16a34a;
|
||||||
|
--c-green-glow: rgba(var(--c-green-rgb),0.22);
|
||||||
|
--c-green-dim: rgba(var(--c-green-rgb),0.08);
|
||||||
--c-red: #dc2626;
|
--c-red: #dc2626;
|
||||||
--c-amber: #d97706;
|
--c-amber: #d97706;
|
||||||
--c-txt-1: #18181b;
|
--c-txt-1: #18181b;
|
||||||
|
|
@ -95,6 +99,13 @@ a { text-decoration: none; }
|
||||||
.app-root {
|
.app-root {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* The window is resizable so users on desktops where the toolkit does not
|
||||||
|
apply our DPI scaling (WebKitGTK on HiDPI Linux renders the configured
|
||||||
|
size as raw pixels, giving a postage-stamp window) can size it themselves.
|
||||||
|
Capping and centring the column keeps the intended narrow layout instead of
|
||||||
|
stretching controls across a wide window. */
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -155,7 +166,7 @@ a { text-decoration: none; }
|
||||||
transition: background var(--t-med), box-shadow var(--t-med);
|
transition: background var(--t-med), box-shadow var(--t-med);
|
||||||
}
|
}
|
||||||
.brand-dot.connecting { animation: dot-blink 1.4s infinite ease-in-out; background: var(--c-accent); }
|
.brand-dot.connecting { animation: dot-blink 1.4s infinite ease-in-out; background: var(--c-accent); }
|
||||||
.brand-dot.connected { background: var(--c-accent); box-shadow: 0 0 10px var(--c-accent-glow); }
|
.brand-dot.connected { background: var(--c-green); box-shadow: 0 0 10px var(--c-green-glow); }
|
||||||
|
|
||||||
@keyframes dot-blink {
|
@keyframes dot-blink {
|
||||||
0%,100% { opacity: 1; }
|
0%,100% { opacity: 1; }
|
||||||
|
|
@ -233,11 +244,11 @@ a { text-decoration: none; }
|
||||||
|
|
||||||
.orbit-wrap.connected .orbit {
|
.orbit-wrap.connected .orbit {
|
||||||
animation: orbit-spin 4s linear infinite;
|
animation: orbit-spin 4s linear infinite;
|
||||||
border-color: rgba(var(--c-fg-rgb),0.14);
|
border-color: rgba(var(--c-green-rgb),0.30);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(var(--c-fg-rgb),0.08); }
|
.orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(var(--c-green-rgb),0.18); }
|
||||||
.orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(var(--c-fg-rgb),0.04); }
|
.orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(var(--c-green-rgb),0.10); }
|
||||||
|
|
||||||
@keyframes orbit-spin {
|
@keyframes orbit-spin {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
|
|
@ -270,9 +281,9 @@ a { text-decoration: none; }
|
||||||
animation: btn-breathe 2s infinite ease-in-out;
|
animation: btn-breathe 2s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
.power-btn.connected {
|
.power-btn.connected {
|
||||||
border-color: rgba(var(--c-fg-rgb),0.8);
|
border-color: var(--c-green);
|
||||||
color: var(--c-txt-1);
|
color: var(--c-green);
|
||||||
box-shadow: 0 0 0 8px rgba(var(--c-fg-rgb),0.04), 0 0 50px rgba(var(--c-fg-rgb),0.12), 0 8px 32px rgba(0,0,0,0.5);
|
box-shadow: 0 0 0 8px var(--c-green-dim), 0 0 50px var(--c-green-glow), 0 8px 32px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
.power-btn.error {
|
.power-btn.error {
|
||||||
border-color: var(--c-red);
|
border-color: var(--c-red);
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,4 @@ hex = "0.4.3"
|
||||||
chacha20poly1305.workspace = true
|
chacha20poly1305.workspace = true
|
||||||
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
|
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
|
subtle = "2.6"
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,18 @@ pub async fn start_api_server(
|
||||||
|
|
||||||
// ── Middleware: token check ──────────────────────────────────────────────────
|
// ── Middleware: token check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Constant-time string equality for secrets (tokens, password hashes).
|
||||||
|
/// Plain `==` short-circuits on the first differing byte, which leaks how
|
||||||
|
/// many leading bytes an attacker's guess got right through response
|
||||||
|
/// timing - a classic remote timing side-channel against exactly the kind
|
||||||
|
/// of long-lived bearer/session secrets compared here. `subtle` is already
|
||||||
|
/// pulled in transitively (chacha20poly1305 etc.); pinning it as a direct
|
||||||
|
/// dependency here makes that guarantee explicit for this call site.
|
||||||
|
fn secure_eq(a: &str, b: &str) -> bool {
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
a.as_bytes().ct_eq(b.as_bytes()).into()
|
||||||
|
}
|
||||||
|
|
||||||
fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
|
fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
|
||||||
// Both session token (for web UI) and static API token (for relays) are checked
|
// Both session token (for web UI) and static API token (for relays) are checked
|
||||||
let mut allowed = false;
|
let mut allowed = false;
|
||||||
|
|
@ -332,19 +344,19 @@ fn check_token(state: &ApiState, headers: &axum::http::HeaderMap) -> bool {
|
||||||
if let Some(token) = val.strip_prefix("Bearer ") {
|
if let Some(token) = val.strip_prefix("Bearer ") {
|
||||||
let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone();
|
let current_session = state.session_token.read().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
if let Some(session) = current_session {
|
if let Some(session) = current_session {
|
||||||
if token == session {
|
if secure_eq(token, &session) {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref api_tok) = state.api_token {
|
if let Some(ref api_tok) = state.api_token {
|
||||||
if token == api_tok {
|
if secure_eq(token, api_tok) {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(ref api_tok) = state.api_token {
|
if let Some(ref api_tok) = state.api_token {
|
||||||
if val == api_tok {
|
if secure_eq(val, api_tok) {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -371,7 +383,7 @@ async fn handle_login(
|
||||||
let hash = sha2::Sha256::digest(password.as_bytes());
|
let hash = sha2::Sha256::digest(password.as_bytes());
|
||||||
let hash_hex = format!("{:x}", hash);
|
let hash_hex = format!("{:x}", hash);
|
||||||
|
|
||||||
if hash_hex == state.password_hash {
|
if secure_eq(&hash_hex, &state.password_hash) {
|
||||||
let token = uuid::Uuid::new_v4().to_string();
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
*state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone());
|
*state.session_token.write().unwrap_or_else(|e| e.into_inner()) = Some(token.clone());
|
||||||
(StatusCode::OK, ApiResponse::success(LoginResponse { token }))
|
(StatusCode::OK, ApiResponse::success(LoginResponse { token }))
|
||||||
|
|
@ -881,15 +893,91 @@ mod tests {
|
||||||
let state = make_test_state("");
|
let state = make_test_state("");
|
||||||
let _router = create_api_router(state);
|
let _router = create_api_router(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_secure_eq_matches_and_rejects() {
|
||||||
|
assert!(secure_eq("same-secret", "same-secret"));
|
||||||
|
assert!(!secure_eq("same-secret", "different"));
|
||||||
|
assert!(!secure_eq("short", "much-longer-value"));
|
||||||
|
assert!(secure_eq("", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers_with_bearer(token: &str) -> axum::http::HeaderMap {
|
||||||
|
let mut h = axum::http::HeaderMap::new();
|
||||||
|
h.insert("authorization", format!("Bearer {token}").parse().unwrap());
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
// These pin down check_token's behavior directly: it's the single gate
|
||||||
|
// every mutating/sensitive handler (including the audit-log ones - see
|
||||||
|
// the missing-auth fix) relies on, so its logic must be independently
|
||||||
|
// verified rather than only exercised incidentally through handlers.
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_rejects_missing_header_when_configured() {
|
||||||
|
let state = make_test_state("panel");
|
||||||
|
assert!(!check_token(&state, &axum::http::HeaderMap::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_accepts_matching_api_token_as_bearer() {
|
||||||
|
let state = make_test_state("panel");
|
||||||
|
assert!(check_token(&state, &headers_with_bearer("test-token")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_accepts_matching_api_token_raw() {
|
||||||
|
let state = make_test_state("panel");
|
||||||
|
let mut h = axum::http::HeaderMap::new();
|
||||||
|
h.insert("authorization", "test-token".parse().unwrap());
|
||||||
|
assert!(check_token(&state, &h));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_rejects_wrong_token() {
|
||||||
|
let state = make_test_state("panel");
|
||||||
|
assert!(!check_token(&state, &headers_with_bearer("wrong-token")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_accepts_matching_session_token() {
|
||||||
|
let state = make_test_state("panel");
|
||||||
|
*state.session_token.write().unwrap() = Some("live-session".to_string());
|
||||||
|
assert!(check_token(&state, &headers_with_bearer("live-session")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check_token_open_when_no_credentials_configured() {
|
||||||
|
let mut state = make_test_state("panel");
|
||||||
|
state.api_token = None;
|
||||||
|
state.username.clear();
|
||||||
|
state.password_hash.clear();
|
||||||
|
// Documented "unsafe but possible" open-panel mode: no credentials
|
||||||
|
// configured at all means every request passes, including with no
|
||||||
|
// Authorization header.
|
||||||
|
assert!(check_token(&state, &axum::http::HeaderMap::new()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_get_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn handle_get_audit(
|
||||||
let logs = state.audit_logs.read().unwrap();
|
State(state): State<ApiState>,
|
||||||
ApiResponse::success(logs.clone())
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<Vec<AuditLogEntry>>();
|
||||||
|
}
|
||||||
|
let logs = state.audit_logs.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
(StatusCode::OK, ApiResponse::success(logs.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<CreateAuditLogRequest>) -> impl IntoResponse {
|
async fn handle_create_audit(
|
||||||
let mut logs = state.audit_logs.write().unwrap();
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(req): Json<CreateAuditLogRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<bool>();
|
||||||
|
}
|
||||||
|
let mut logs = state.audit_logs.write().unwrap_or_else(|e| e.into_inner());
|
||||||
let id = format!("{:x}", rand::random::<u64>());
|
let id = format!("{:x}", rand::random::<u64>());
|
||||||
let now = chrono::Local::now();
|
let now = chrono::Local::now();
|
||||||
let entry = AuditLogEntry {
|
let entry = AuditLogEntry {
|
||||||
|
|
@ -904,7 +992,7 @@ async fn handle_create_audit(State(state): State<ApiState>, Json(req): Json<Crea
|
||||||
logs.truncate(100);
|
logs.truncate(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse::success(true)
|
(StatusCode::OK, ApiResponse::success(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bulk keys & Router Rules ─────────────────────────────────────────────────
|
// ── Bulk keys & Router Rules ─────────────────────────────────────────────────
|
||||||
|
|
@ -1006,10 +1094,16 @@ async fn handle_put_rules(
|
||||||
(StatusCode::OK, ApiResponse::success(true))
|
(StatusCode::OK, ApiResponse::success(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_clear_audit(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn handle_clear_audit(
|
||||||
let mut logs = state.audit_logs.write().unwrap();
|
State(state): State<ApiState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !check_token(&state, &headers) {
|
||||||
|
return api_unauthorized::<()>();
|
||||||
|
}
|
||||||
|
let mut logs = state.audit_logs.write().unwrap_or_else(|e| e.into_inner());
|
||||||
logs.clear();
|
logs.clear();
|
||||||
ApiResponse::success(())
|
(StatusCode::OK, ApiResponse::success(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,41 @@ impl Dispatcher {
|
||||||
self.peer_machines.len()
|
self.peer_machines.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-session download-direction congestion headroom, in packets:
|
||||||
|
/// `(session_id, available)` where `available = clamped cwnd - in_flight`.
|
||||||
|
///
|
||||||
|
/// Consumed by the relay's per-target-connection reader tasks (see
|
||||||
|
/// `relay::handle_relay_message`'s Connect handler) to throttle how fast
|
||||||
|
/// they pull bytes from the upstream target and forward them to the
|
||||||
|
/// client's OSTP session. Without this, a fast target (e.g. a CDN) gets
|
||||||
|
/// read and forwarded as fast as the target can serve, completely
|
||||||
|
/// ignoring the client-facing session's real congestion window - on a
|
||||||
|
/// lossy/jittery client path that self-inflicts a loss burst, which
|
||||||
|
/// wrecks the RTT/RTO estimate and can stall the session hard enough to
|
||||||
|
/// trip the client's keepalive reconnect. Same clamp(16, 16384) the
|
||||||
|
/// client uses for its own analogous uplink gate, for symmetry.
|
||||||
|
pub fn snapshot_backpressure(&self) -> Vec<(u32, i64)> {
|
||||||
|
self.peer_machines
|
||||||
|
.iter()
|
||||||
|
.map(|(&sid, ps)| {
|
||||||
|
// Ceiling matches MAX_CWND_PACKETS in ostp-core. The old 16384
|
||||||
|
// allowed ~20 MB outstanding toward one client — on a mobile
|
||||||
|
// downlink that is standing queue, not throughput, and it is the
|
||||||
|
// download direction that carries video.
|
||||||
|
let cwnd = (ps.machine.cwnd_packets() as i64).clamp(16, 1024);
|
||||||
|
let in_flight = ps.machine.in_flight_count() as i64;
|
||||||
|
// Pacing gates the RATE, cwnd only the outstanding amount. With
|
||||||
|
// the pacing bucket empty, report no headroom so the relay
|
||||||
|
// reader pauses instead of handing over another chunk that would
|
||||||
|
// leave back-to-back.
|
||||||
|
if !ps.machine.can_pace_packet() {
|
||||||
|
return (sid, 0);
|
||||||
|
}
|
||||||
|
(sid, cwnd - in_flight)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result<DispatchOutcome> {
|
pub fn on_datagram(&mut self, peer: SocketAddr, packet: Bytes) -> Result<DispatchOutcome> {
|
||||||
if packet.len() < 4 {
|
if packet.len() < 4 {
|
||||||
return Ok(DispatchOutcome::Unauthorized);
|
return Ok(DispatchOutcome::Unauthorized);
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,18 @@ impl DnsServer {
|
||||||
///
|
///
|
||||||
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
|
/// Клиент может явно указать `<server_ip>:<local_port>` как DNS-сервер
|
||||||
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
|
/// в настройках — тогда все DNS-запросы туннелируются и резолвятся здесь.
|
||||||
|
///
|
||||||
|
/// SECURITY: this socket is bound on 0.0.0.0, reachable directly from the
|
||||||
|
/// public internet with no authentication (unlike the main OSTP port,
|
||||||
|
/// there is no Noise handshake gating it). Answering every UDP datagram
|
||||||
|
/// by resolving and replying to its (unverified, spoofable) source
|
||||||
|
/// address is a textbook DNS reflection/amplification primitive: an
|
||||||
|
/// attacker spoofing a victim's IP as the query source turns this server
|
||||||
|
/// into a free amplifier against that victim. There is currently no
|
||||||
|
/// caller for this function anywhere in the codebase, but the rate
|
||||||
|
/// limiter below exists so that connecting it later doesn't silently
|
||||||
|
/// reintroduce that risk - it bounds how much amplification bandwidth
|
||||||
|
/// this listener can ever contribute, regardless of query volume.
|
||||||
pub async fn run_local_udp_listener(self: Arc<Self>) {
|
pub async fn run_local_udp_listener(self: Arc<Self>) {
|
||||||
let port = self.config.read().await.local_port;
|
let port = self.config.read().await.local_port;
|
||||||
let bind_addr = format!("0.0.0.0:{port}");
|
let bind_addr = format!("0.0.0.0:{port}");
|
||||||
|
|
@ -289,10 +301,30 @@ impl DnsServer {
|
||||||
};
|
};
|
||||||
tracing::info!("Built-in DNS server listening on UDP {bind_addr}");
|
tracing::info!("Built-in DNS server listening on UDP {bind_addr}");
|
||||||
|
|
||||||
|
// Global token bucket capping total replies/sec this listener will
|
||||||
|
// ever send. Deliberately global (not per-source-IP): per-IP limiting
|
||||||
|
// does nothing against a reflection attack, since the attacker never
|
||||||
|
// sees the responses and can spread queries across arbitrarily many
|
||||||
|
// spoofed sources anyway. A global cap bounds this server's total
|
||||||
|
// contribution to any attack regardless of how the queries are
|
||||||
|
// distributed.
|
||||||
|
const MAX_REPLIES_PER_SEC: f64 = 100.0;
|
||||||
|
let mut tokens: f64 = MAX_REPLIES_PER_SEC;
|
||||||
|
let mut last_refill = tokio::time::Instant::now();
|
||||||
|
|
||||||
let mut buf = vec![0u8; 4096];
|
let mut buf = vec![0u8; 4096];
|
||||||
loop {
|
loop {
|
||||||
match socket.recv_from(&mut buf).await {
|
match socket.recv_from(&mut buf).await {
|
||||||
Ok((n, peer)) => {
|
Ok((n, peer)) => {
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
tokens = (tokens + now.duration_since(last_refill).as_secs_f64() * MAX_REPLIES_PER_SEC)
|
||||||
|
.min(MAX_REPLIES_PER_SEC);
|
||||||
|
last_refill = now;
|
||||||
|
if tokens < 1.0 {
|
||||||
|
continue; // over budget: drop silently, no reply sent
|
||||||
|
}
|
||||||
|
tokens -= 1.0;
|
||||||
|
|
||||||
let query = buf[..n].to_vec();
|
let query = buf[..n].to_vec();
|
||||||
let srv = self.clone();
|
let srv = self.clone();
|
||||||
let sock = socket.clone();
|
let sock = socket.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use portable_atomic::AtomicI64;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use dispatcher::{DispatchOutcome, Dispatcher};
|
use dispatcher::{DispatchOutcome, Dispatcher};
|
||||||
use ostp_core::relay::RelayMessage;
|
use ostp_core::relay::RelayMessage;
|
||||||
|
|
@ -10,6 +12,12 @@ use tokio::net::UdpSocket;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::{interval, Duration, Instant};
|
use tokio::time::{interval, Duration, Instant};
|
||||||
|
|
||||||
|
/// Shared per-session download-direction congestion headroom (packets),
|
||||||
|
/// published by `handle_tick` from `Dispatcher::snapshot_backpressure` and
|
||||||
|
/// read lock-free by relay reader tasks. See that method's doc comment for
|
||||||
|
/// why this exists.
|
||||||
|
pub(crate) type SessionBackpressure = Arc<RwLock<HashMap<u32, Arc<AtomicI64>>>>;
|
||||||
|
|
||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
pub mod outbound;
|
pub mod outbound;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
|
|
@ -467,6 +475,7 @@ async fn run_server_loop(
|
||||||
let mut last_empty_app_log = Instant::now() - Duration::from_secs(10);
|
let mut last_empty_app_log = Instant::now() - Duration::from_secs(10);
|
||||||
let mut peer_last_seen: HashMap<IpAddr, Instant> = HashMap::new();
|
let mut peer_last_seen: HashMap<IpAddr, Instant> = HashMap::new();
|
||||||
let mut peer_available: HashMap<IpAddr, bool> = HashMap::new();
|
let mut peer_available: HashMap<IpAddr, bool> = HashMap::new();
|
||||||
|
let session_backpressure: SessionBackpressure = Arc::new(RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
|
@ -489,7 +498,8 @@ async fn run_server_loop(
|
||||||
packet, peer, &mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx,
|
packet, peer, &mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx,
|
||||||
stream_tx.clone(), udp_reply_tx.clone(), connect_tx.clone(),
|
stream_tx.clone(), udp_reply_tx.clone(), connect_tx.clone(),
|
||||||
router.clone(),
|
router.clone(),
|
||||||
&mut peer_last_seen, &mut peer_available, &mut last_empty_app_log
|
&mut peer_last_seen, &mut peer_available, &mut last_empty_app_log,
|
||||||
|
&session_backpressure
|
||||||
).await {
|
).await {
|
||||||
tracing::error!("handle_udp_packet error: {}", e);
|
tracing::error!("handle_udp_packet error: {}", e);
|
||||||
}
|
}
|
||||||
|
|
@ -533,7 +543,7 @@ async fn run_server_loop(
|
||||||
_ = retransmit_tick.tick() => {
|
_ = retransmit_tick.tick() => {
|
||||||
if let Err(e) = handle_tick(
|
if let Err(e) = handle_tick(
|
||||||
&mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx,
|
&mut dispatcher, &tcp_map, &socket, &mut remotes, &ui_event_tx,
|
||||||
&mut peer_last_seen, &mut peer_available
|
&mut peer_last_seen, &mut peer_available, &session_backpressure
|
||||||
).await {
|
).await {
|
||||||
tracing::error!("handle_tick error: {}", e);
|
tracing::error!("handle_tick error: {}", e);
|
||||||
}
|
}
|
||||||
|
|
@ -559,6 +569,7 @@ async fn handle_udp_packet(
|
||||||
peer_last_seen: &mut HashMap<IpAddr, Instant>,
|
peer_last_seen: &mut HashMap<IpAddr, Instant>,
|
||||||
peer_available: &mut HashMap<IpAddr, bool>,
|
peer_available: &mut HashMap<IpAddr, bool>,
|
||||||
last_empty_app_log: &mut Instant,
|
last_empty_app_log: &mut Instant,
|
||||||
|
session_backpressure: &SessionBackpressure,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let size = packet.len();
|
let size = packet.len();
|
||||||
match dispatcher.on_datagram(peer, packet.clone()) {
|
match dispatcher.on_datagram(peer, packet.clone()) {
|
||||||
|
|
@ -621,6 +632,7 @@ async fn handle_udp_packet(
|
||||||
connect_tx.clone(),
|
connect_tx.clone(),
|
||||||
router.clone(),
|
router.clone(),
|
||||||
tcp_map,
|
tcp_map,
|
||||||
|
session_backpressure,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -639,6 +651,7 @@ async fn handle_tick(
|
||||||
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
ui_event_tx: &mpsc::UnboundedSender<UiEvent>,
|
||||||
peer_last_seen: &mut HashMap<IpAddr, Instant>,
|
peer_last_seen: &mut HashMap<IpAddr, Instant>,
|
||||||
peer_available: &mut HashMap<IpAddr, bool>,
|
peer_available: &mut HashMap<IpAddr, bool>,
|
||||||
|
session_backpressure: &SessionBackpressure,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let peer_timeout = Duration::from_secs(45);
|
let peer_timeout = Duration::from_secs(45);
|
||||||
|
|
@ -649,6 +662,22 @@ async fn handle_tick(
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Client {peer_ip} disconnected (timeout)")));
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Client {peer_ip} disconnected (timeout)")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Publish each active session's current download-direction headroom so
|
||||||
|
// relay reader tasks (running on other tasks, no access to `dispatcher`)
|
||||||
|
// can throttle without touching a lock on every read. New sessions get an
|
||||||
|
// entry created here on their first tick after the handshake; entries for
|
||||||
|
// sessions that no longer exist are pruned below alongside dropped_sessions.
|
||||||
|
{
|
||||||
|
let snapshot = dispatcher.snapshot_backpressure();
|
||||||
|
let mut map = session_backpressure.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
for (sid, available) in snapshot {
|
||||||
|
match map.get(&sid) {
|
||||||
|
Some(slot) => slot.store(available, std::sync::atomic::Ordering::Relaxed),
|
||||||
|
None => { map.insert(sid, Arc::new(AtomicI64::new(available))); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (frames, dropped_sessions) = dispatcher.on_tick();
|
let (frames, dropped_sessions) = dispatcher.on_tick();
|
||||||
for (frame, peer_addr) in frames {
|
for (frame, peer_addr) in frames {
|
||||||
let mut sent_tcp = false;
|
let mut sent_tcp = false;
|
||||||
|
|
@ -663,6 +692,12 @@ async fn handle_tick(
|
||||||
let _ = socket.send_to(&frame, peer_addr).await?;
|
let _ = socket.send_to(&frame, peer_addr).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !dropped_sessions.is_empty() {
|
||||||
|
let mut map = session_backpressure.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
for sid in &dropped_sessions {
|
||||||
|
map.remove(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
for sid in dropped_sessions {
|
for sid in dropped_sessions {
|
||||||
let _ = ui_event_tx.send(UiEvent::Log(format!("Session {sid} expired, releasing resources")));
|
let _ = ui_event_tx.send(UiEvent::Log(format!("Session {sid} expired, releasing resources")));
|
||||||
let mut streams_to_cancel = Vec::new();
|
let mut streams_to_cancel = Vec::new();
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,79 @@ pub async fn connect_target(
|
||||||
}
|
}
|
||||||
if action == OutboundAction::Proxy {
|
if action == OutboundAction::Proxy {
|
||||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||||
return match outbound.protocol.as_str() {
|
// Case-insensitive: a config saying "SOCKS5" means the same thing
|
||||||
|
// as "socks5", and silently treating it as unknown is a trap.
|
||||||
|
return match outbound.protocol.to_ascii_lowercase().as_str() {
|
||||||
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
"socks5" => connect_via_socks5(&proxy_addr, target).await,
|
||||||
"http" => connect_via_http(&proxy_addr, target).await,
|
"http" => connect_via_http(&proxy_addr, target).await,
|
||||||
_ => tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
// FAIL CLOSED. This used to fall through to a direct
|
||||||
.await
|
// connection, so any unrecognised protocol string — a typo,
|
||||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
// a case difference, an empty value — silently sent ALL TCP
|
||||||
.map_err(Into::into),
|
// straight out of the server while the operator believed it
|
||||||
|
// was proxied. Combined with the same bug on the UDP path,
|
||||||
|
// that is how one session ends up presenting two different
|
||||||
|
// exit addresses to the remote site.
|
||||||
|
other => Err(anyhow::anyhow!(
|
||||||
|
"outbound.protocol is \"{other}\", which is not a supported proxy type \
|
||||||
|
(expected \"socks5\" or \"http\"); refusing to connect to {target} \
|
||||||
|
directly, because the rules asked for the proxy"
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::timeout(connect_timeout, TcpStream::connect(target))
|
connect_direct(target, connect_timeout).await
|
||||||
.await
|
}
|
||||||
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
|
||||||
.map_err(Into::into)
|
/// Per-candidate-address connect attempt, tried in turn (see `connect_direct`
|
||||||
|
/// below). Short enough that a single dead-end address can't eat the whole
|
||||||
|
/// outer `connect_timeout` budget.
|
||||||
|
const PER_ADDR_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Resolve `target` ("host:port") and connect to it, trying candidate
|
||||||
|
/// addresses in turn rather than handing the raw string straight to
|
||||||
|
/// `TcpStream::connect` (which resolves and tries addresses internally but
|
||||||
|
/// shares ONE timeout across the whole attempt).
|
||||||
|
///
|
||||||
|
/// IPv4 candidates are tried first. Some VPS hosts (observed on a
|
||||||
|
/// DigitalOcean droplet) assign the machine an IPv6 address that the OS
|
||||||
|
/// prefers by RFC 6724 ordering but that has no actually-working outbound
|
||||||
|
/// route - the connect attempt doesn't get refused, it just hangs. With a
|
||||||
|
/// single shared timeout across all candidates, that one dead IPv6 address
|
||||||
|
/// eats the entire budget and the working IPv4 candidate is never even
|
||||||
|
/// attempted: every dual-stack destination (i.e. most popular sites) never
|
||||||
|
/// loads, while IPv4-only destinations work fine - exactly the "traffic
|
||||||
|
/// counter moves but sites don't open" symptom this fixes.
|
||||||
|
async fn connect_direct(target: &str, connect_timeout: Duration) -> Result<TcpStream> {
|
||||||
|
tokio::time::timeout(connect_timeout, async {
|
||||||
|
let mut addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(target)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("dns resolution failed for {}: {}", target, e))?
|
||||||
|
.collect();
|
||||||
|
if addrs.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("no addresses resolved for {}", target));
|
||||||
|
}
|
||||||
|
prefer_ipv4_first(&mut addrs);
|
||||||
|
|
||||||
|
let mut last_err = None;
|
||||||
|
for addr in addrs {
|
||||||
|
match tokio::time::timeout(PER_ADDR_CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
|
||||||
|
Ok(Ok(stream)) => return Ok(stream),
|
||||||
|
Ok(Err(e)) => last_err = Some(anyhow::anyhow!("{}: {}", addr, e)),
|
||||||
|
Err(_) => last_err = Some(anyhow::anyhow!("{}: connect timeout ({}s)", addr, PER_ADDR_CONNECT_TIMEOUT.as_secs())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all candidates failed for {}", target)))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("connect timeout ({}s): {}", connect_timeout.as_secs(), target))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable-sort so IPv4 candidates come before IPv6 ones, without otherwise
|
||||||
|
/// disturbing the resolver's original ordering within each family.
|
||||||
|
fn prefer_ipv4_first(addrs: &mut [std::net::SocketAddr]) {
|
||||||
|
addrs.sort_by_key(|a| a.is_ipv6());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rule matching ────────────────────────────────────────────────────────────
|
// ── Rule matching ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -326,10 +383,22 @@ pub async fn connect_udp_target(
|
||||||
}
|
}
|
||||||
if action == OutboundAction::Proxy {
|
if action == OutboundAction::Proxy {
|
||||||
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
let proxy_addr = format!("{}:{}", outbound.address, outbound.port);
|
||||||
if outbound.protocol == "socks5" {
|
if outbound.protocol.eq_ignore_ascii_case("socks5") {
|
||||||
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
|
return connect_udp_via_socks5(&proxy_addr, server_udp).await;
|
||||||
}
|
}
|
||||||
// HTTP CONNECT does not support UDP. Fallback to direct.
|
// FAIL CLOSED. HTTP CONNECT genuinely cannot carry UDP — but the
|
||||||
|
// answer to that is not to send the datagrams in the clear. The
|
||||||
|
// previous "fallback to direct" honoured a Proxy rule by
|
||||||
|
// egressing from the server's own address, so with an HTTP
|
||||||
|
// upstream every UDP flow (QUIC, DNS) leaked while TCP stayed
|
||||||
|
// proxied, presenting two exit IPs to the same remote site.
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"outbound rules route UDP to {target} through the proxy, but the upstream \
|
||||||
|
protocol is \"{}\", which cannot carry UDP. Refusing to send directly. \
|
||||||
|
Use a socks5 upstream, or add an explicit udp rule with action \"direct\" \
|
||||||
|
or \"block\" so the intent is recorded in the config.",
|
||||||
|
outbound.protocol
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -540,4 +609,49 @@ mod tests {
|
||||||
fn test_match_domain_rule_empty() {
|
fn test_match_domain_rule_empty() {
|
||||||
assert!(!match_domain_rule("example.com", &[]));
|
assert!(!match_domain_rule("example.com", &[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prefer_ipv4_first_reorders_mixed_list() {
|
||||||
|
let v6: std::net::SocketAddr = "[2001:db8::1]:443".parse().unwrap();
|
||||||
|
let v4: std::net::SocketAddr = "192.0.2.1:443".parse().unwrap();
|
||||||
|
let mut addrs = vec![v6, v4];
|
||||||
|
prefer_ipv4_first(&mut addrs);
|
||||||
|
assert_eq!(addrs, vec![v4, v6], "IPv4 candidate must sort before IPv6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prefer_ipv4_first_preserves_order_within_family() {
|
||||||
|
// Two IPv4 addresses: relative order should be untouched (stable sort).
|
||||||
|
let a: std::net::SocketAddr = "192.0.2.1:443".parse().unwrap();
|
||||||
|
let b: std::net::SocketAddr = "192.0.2.2:443".parse().unwrap();
|
||||||
|
let mut addrs = vec![a, b];
|
||||||
|
prefer_ipv4_first(&mut addrs);
|
||||||
|
assert_eq!(addrs, vec![a, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_connect_direct_succeeds_against_live_listener() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = listener.accept().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = connect_direct(&addr.to_string(), Duration::from_secs(2)).await;
|
||||||
|
assert!(result.is_ok(), "expected connect_direct to reach a live local listener: {:?}", result.err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_connect_direct_fails_fast_on_refused_port() {
|
||||||
|
// Bind and immediately drop to get a port nothing is listening on,
|
||||||
|
// so the OS sends RST and the attempt fails well under the timeout.
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let result = connect_direct(&addr.to_string(), Duration::from_secs(5)).await;
|
||||||
|
assert!(result.is_err(), "connecting to a closed port should fail");
|
||||||
|
assert!(start.elapsed() < Duration::from_secs(4), "a refused connection must not wait out the full timeout");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use portable_atomic::AtomicI64;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use ostp_core::relay::RelayMessage;
|
use ostp_core::relay::RelayMessage;
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
@ -8,7 +10,19 @@ use tokio::net::UdpSocket;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::dispatcher::Dispatcher;
|
use crate::dispatcher::Dispatcher;
|
||||||
use crate::{RemoteState, UiEvent};
|
use crate::{RemoteState, SessionBackpressure, UiEvent};
|
||||||
|
|
||||||
|
/// How long a target-connection reader task waits before rechecking the
|
||||||
|
/// client session's congestion headroom while throttled. Short enough that
|
||||||
|
/// a freed-up window (checked every server tick, 10ms) is noticed promptly;
|
||||||
|
/// long enough not to spin.
|
||||||
|
const BACKPRESSURE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5);
|
||||||
|
/// Upper bound on total time a single read is throttled before proceeding
|
||||||
|
/// anyway. Congestion state is a hint, not a hard guarantee - if the
|
||||||
|
/// session's headroom never frees up (e.g. a stuck/buggy state), a stream
|
||||||
|
/// must not be stalled forever; better to occasionally overshoot the window
|
||||||
|
/// than deadlock a connection.
|
||||||
|
const BACKPRESSURE_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
fn clean_ipv6_mapped_v4(addr: std::net::SocketAddr) -> std::net::SocketAddr {
|
fn clean_ipv6_mapped_v4(addr: std::net::SocketAddr) -> std::net::SocketAddr {
|
||||||
match addr {
|
match addr {
|
||||||
|
|
@ -38,6 +52,7 @@ pub async fn handle_relay_message(
|
||||||
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
|
connect_tx: mpsc::UnboundedSender<(u32, u16, String, Result<(tokio::net::tcp::OwnedWriteHalf, mpsc::Sender<()>), String>)>,
|
||||||
router: std::sync::Arc<crate::router::Router>,
|
router: std::sync::Arc<crate::router::Router>,
|
||||||
tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>,
|
tcp_map: &std::sync::Arc<tokio::sync::RwLock<HashMap<std::net::SocketAddr, tokio::sync::mpsc::Sender<Bytes>>>>,
|
||||||
|
session_backpressure: &SessionBackpressure,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match RelayMessage::decode(&payload)? {
|
match RelayMessage::decode(&payload)? {
|
||||||
RelayMessage::Connect(target) => {
|
RelayMessage::Connect(target) => {
|
||||||
|
|
@ -53,15 +68,41 @@ pub async fn handle_relay_message(
|
||||||
let connect_tx_clone = connect_tx.clone();
|
let connect_tx_clone = connect_tx.clone();
|
||||||
let stream_tx_clone = stream_tx.clone();
|
let stream_tx_clone = stream_tx.clone();
|
||||||
let router_clone = router.clone();
|
let router_clone = router.clone();
|
||||||
|
let backpressure_clone = session_backpressure.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let stream_res = router_clone.route_tcp(&target_clone).await;
|
let stream_res = router_clone.route_tcp(&target_clone).await;
|
||||||
match stream_res {
|
match stream_res {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
let (mut reader, writer) = stream.into_split();
|
let (mut reader, writer) = stream.into_split();
|
||||||
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1);
|
||||||
|
// Get-or-create this session's headroom handle. A brand
|
||||||
|
// new session may not have its first tick's snapshot
|
||||||
|
// yet (up to 10ms), so default it open (matches a fresh
|
||||||
|
// congestion window) rather than stalling the very
|
||||||
|
// first read while nothing has been published.
|
||||||
|
let headroom: Arc<AtomicI64> = {
|
||||||
|
let mut map = backpressure_clone.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
map.entry(session_id).or_insert_with(|| Arc::new(AtomicI64::new(32))).clone()
|
||||||
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = [0_u8; 4096];
|
let mut buf = [0_u8; 4096];
|
||||||
loop {
|
loop {
|
||||||
|
// Throttle to the client-facing OSTP session's
|
||||||
|
// real congestion window instead of reading from
|
||||||
|
// the target as fast as it'll send. Without this,
|
||||||
|
// a fast target blasts a lossy/jittery client
|
||||||
|
// path far beyond what it can sustain, which
|
||||||
|
// self-inflicts a loss burst, wrecks the RTT/RTO
|
||||||
|
// estimate, and can stall the session hard
|
||||||
|
// enough to trip the client's keepalive
|
||||||
|
// reconnect. See Dispatcher::snapshot_backpressure.
|
||||||
|
let mut waited = std::time::Duration::ZERO;
|
||||||
|
while headroom.load(std::sync::atomic::Ordering::Relaxed) <= 0
|
||||||
|
&& waited < BACKPRESSURE_MAX_WAIT
|
||||||
|
{
|
||||||
|
tokio::time::sleep(BACKPRESSURE_POLL_INTERVAL).await;
|
||||||
|
waited += BACKPRESSURE_POLL_INTERVAL;
|
||||||
|
}
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancel_rx.recv() => break,
|
_ = cancel_rx.recv() => break,
|
||||||
read_res = reader.read(&mut buf) => {
|
read_res = reader.read(&mut buf) => {
|
||||||
|
|
|
||||||
|
|
@ -1,403 +1,460 @@
|
||||||
//! Authenticated Relay Node
|
//! Transparent relay node.
|
||||||
//!
|
//!
|
||||||
//! Принимает входящие UDP/TCP (UoT) соединения от клиентов,
|
//! Forwards traffic to a fixed upstream OSTP server:
|
||||||
//! валидирует HMAC-подпись клиента, используя ключи синхронизированные с upstream-сервера,
|
|
||||||
//! и слепо пробрасывает авторизованный трафик к целевому upstream-серверу.
|
|
||||||
//!
|
//!
|
||||||
//! Архитектура цепочек:
|
//! Client -> [Relay] -> [Target server]
|
||||||
//! Клиент -> [Relay 1] -> [Relay 2] -> ... -> [Target Server]
|
//!
|
||||||
//! Каждый Relay скачивает access_keys напрямую с Target Server API.
|
//! ## Why this performs no authentication of its own
|
||||||
|
//!
|
||||||
|
//! The previous design had the relay authenticate clients itself, with an
|
||||||
|
//! HMAC handshake and a background job that pulled the access-key list from the
|
||||||
|
//! target server's management API. That was wrong on two counts.
|
||||||
|
//!
|
||||||
|
//! It did not work: no OSTP client has ever produced those credentials. The TCP
|
||||||
|
//! path expected an HTTP request (`GET /stream` with an `Authorization: Bearer`
|
||||||
|
//! header) and the UDP path expected a `timestamp || HMAC` preamble, while the
|
||||||
|
//! client sends junk frames followed by length-prefixed OSTP frames, and an
|
||||||
|
//! obfuscated Noise handshake, respectively. Every connection was rejected.
|
||||||
|
//!
|
||||||
|
//! It was also weak where it did apply: the HMAC covered only an 8-byte
|
||||||
|
//! timestamp, so a captured signature was a bearer token that anyone could
|
||||||
|
//! replay from any address for the length of the clock-skew window. And the
|
||||||
|
//! HTTP handshake was a plaintext `GET /stream` on the wire, a greppable
|
||||||
|
//! signature in a protocol whose entire premise is that no byte is
|
||||||
|
//! recognisable.
|
||||||
|
//!
|
||||||
|
//! Authentication belongs where it is cryptographically meaningful: the target
|
||||||
|
//! server already authenticates every session end-to-end via Noise with a PSK
|
||||||
|
//! derived from the access key, and silently drops anything that fails. A relay
|
||||||
|
//! that re-checks credentials adds a second, weaker gate and a copy of the key
|
||||||
|
//! list on a machine that has no need for it. So this relay makes no security
|
||||||
|
//! decisions at all — it is a pipe, and says so.
|
||||||
|
//!
|
||||||
|
//! What it does need is protection against being used as a resource sink, which
|
||||||
|
//! is what the session cap and admission rate limit below are for. It forwards
|
||||||
|
//! only to one fixed upstream and returns replies only to the sender, so it is
|
||||||
|
//! not a reflector: the amplification factor is one.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use bytes::Bytes;
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use sha2::Sha256;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
use tokio::net::{TcpListener, TcpStream, UdpSocket};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
/// Конфигурация Relay-узла.
|
/// Configuration for a relay node.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RelayConfig {
|
pub struct RelayConfig {
|
||||||
/// Адрес(а) для прослушивания входящих соединений (UDP + TCP).
|
/// Address(es) to accept client traffic on (UDP and TCP both bind here).
|
||||||
pub listen_addrs: Vec<String>,
|
pub listen_addrs: Vec<String>,
|
||||||
/// Адрес upstream TCP для пересылки (обычно тот же порт, что и у target-сервера).
|
/// Upstream target for TCP (UoT) traffic.
|
||||||
pub upstream_tcp: String,
|
pub upstream_tcp: String,
|
||||||
/// Адрес upstream UDP.
|
/// Upstream target for UDP traffic.
|
||||||
pub upstream_udp: String,
|
pub upstream_udp: String,
|
||||||
/// URL API target-сервера для получения access_keys.
|
|
||||||
/// Пример: "http://127.0.0.1:9090"
|
|
||||||
pub upstream_api_url: String,
|
|
||||||
/// Bearer-токен для аутентификации на API target-сервера.
|
|
||||||
pub upstream_api_token: String,
|
|
||||||
/// Интервал синхронизации ключей (секунды).
|
|
||||||
pub sync_interval_secs: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedKeys = Arc<RwLock<Vec<String>>>;
|
/// Maximum concurrent UDP client sessions. Each holds one upstream socket and
|
||||||
|
/// one reader task, so this bounds both file descriptors and tasks.
|
||||||
|
const MAX_UDP_SESSIONS: usize = 4096;
|
||||||
|
/// A UDP session with no traffic for this long is reclaimed. Mobile NAT
|
||||||
|
/// bindings are typically shorter-lived than this, so it is generous enough not
|
||||||
|
/// to break roaming clients.
|
||||||
|
const UDP_SESSION_IDLE: Duration = Duration::from_secs(120);
|
||||||
|
/// Maximum concurrent relayed TCP connections.
|
||||||
|
const MAX_TCP_CONNECTIONS: usize = 4096;
|
||||||
|
/// Sustained rate (and burst ceiling) for admitting NEW sessions, per second.
|
||||||
|
/// Established sessions are never rate limited; this only bounds how fast an
|
||||||
|
/// unknown source can cause state to be allocated.
|
||||||
|
const NEW_SESSION_RATE: f64 = 200.0;
|
||||||
|
/// How long to wait for the upstream TCP connection before giving up.
|
||||||
|
const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
/// Точка входа Relay-узла.
|
/// Token bucket bounding how fast new sessions may be created.
|
||||||
pub async fn run_relay_node(cfg: RelayConfig) -> Result<()> {
|
struct AdmissionLimiter {
|
||||||
let shared_keys: SharedKeys = Arc::new(RwLock::new(Vec::new()));
|
tokens: f64,
|
||||||
|
last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
// Первоначальная синхронизация ключей
|
impl AdmissionLimiter {
|
||||||
if let Err(e) = sync_keys(&cfg, &shared_keys).await {
|
fn new() -> Self {
|
||||||
tracing::warn!("Relay: initial key sync failed: {}. Will retry.", e);
|
Self { tokens: NEW_SESSION_RATE, last_refill: Instant::now() }
|
||||||
} else {
|
|
||||||
let count = shared_keys.read().unwrap_or_else(|e| e.into_inner()).len();
|
|
||||||
tracing::info!("Relay: synced {} access key(s) from upstream API", count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фоновый синхронизатор ключей
|
/// Consume one admission slot, or report that the caller should drop.
|
||||||
let cfg_clone = cfg.clone();
|
fn try_admit(&mut self) -> bool {
|
||||||
let keys_clone = shared_keys.clone();
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
|
||||||
|
self.last_refill = now;
|
||||||
|
self.tokens = (self.tokens + elapsed * NEW_SESSION_RATE).min(NEW_SESSION_RATE);
|
||||||
|
if self.tokens >= 1.0 {
|
||||||
|
self.tokens -= 1.0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point.
|
||||||
|
pub async fn run_relay_node(cfg: RelayConfig) -> Result<()> {
|
||||||
|
let udp_cfg = cfg.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
if let Err(e) = run_udp_relay(udp_cfg).await {
|
||||||
tokio::time::sleep(Duration::from_secs(cfg_clone.sync_interval_secs)).await;
|
tracing::error!("Relay UDP loop error: {e}");
|
||||||
match sync_keys(&cfg_clone, &keys_clone).await {
|
|
||||||
Ok(count) => tracing::debug!("Relay: refreshed {} access key(s)", count),
|
|
||||||
Err(e) => tracing::warn!("Relay: key sync error: {}", e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запуск UDP relay
|
run_tcp_relay(cfg).await
|
||||||
{
|
|
||||||
let cfg_udp = cfg.clone();
|
|
||||||
let keys_udp = shared_keys.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = run_udp_relay(cfg_udp, keys_udp).await {
|
|
||||||
tracing::error!("Relay UDP loop error: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запуск TCP (UoT) relay
|
|
||||||
run_tcp_relay(cfg, shared_keys).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Синхронизация access_keys с upstream API.
|
// ── UDP ──────────────────────────────────────────────────────────────────────
|
||||||
async fn sync_keys(cfg: &RelayConfig, shared_keys: &SharedKeys) -> Result<usize> {
|
|
||||||
let url = format!("{}/api/users", cfg.upstream_api_url.trim_end_matches('/'));
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
struct UdpSession {
|
||||||
.timeout(Duration::from_secs(10))
|
upstream: Arc<UdpSocket>,
|
||||||
.build()?;
|
last_seen: Instant,
|
||||||
|
|
||||||
let mut req = client.get(&url);
|
|
||||||
if !cfg.upstream_api_token.is_empty() {
|
|
||||||
req = req.header("Authorization", format!("Bearer {}", cfg.upstream_api_token));
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = req.send().await?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
anyhow::bail!("API returned HTTP {}", resp.status());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct UserStatsSnapshot {
|
|
||||||
access_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct ApiResponse {
|
|
||||||
ok: bool,
|
|
||||||
data: Option<Vec<UserStatsSnapshot>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: ApiResponse = resp.json().await?;
|
|
||||||
if !body.ok {
|
|
||||||
anyhow::bail!("API returned error ok=false");
|
|
||||||
}
|
|
||||||
|
|
||||||
let keys: Vec<String> = body.data.unwrap_or_default().into_iter().map(|u| u.access_key).collect();
|
|
||||||
let count = keys.len();
|
|
||||||
{
|
|
||||||
let mut lock = shared_keys.write().unwrap();
|
|
||||||
*lock = keys;
|
|
||||||
}
|
|
||||||
Ok(count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет HMAC-подпись клиента по набору ключей.
|
async fn run_udp_relay(cfg: RelayConfig) -> Result<()> {
|
||||||
/// Возвращает true если хотя бы один ключ подходит.
|
// client address -> the upstream socket carrying that client's flow
|
||||||
fn verify_hmac(ts_bytes: &[u8; 8], provided_mac: &[u8], keys: &[String]) -> bool {
|
let sessions: Arc<Mutex<HashMap<SocketAddr, UdpSession>>> =
|
||||||
let client_ts = u64::from_be_bytes(*ts_bytes);
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Защита от replay: ±60 секунд
|
|
||||||
if client_ts > now + 30 || client_ts < now.saturating_sub(60) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in keys {
|
|
||||||
if let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(key.as_bytes()) {
|
|
||||||
mac.update(ts_bytes);
|
|
||||||
if mac.verify_slice(provided_mac).is_ok() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UDP Relay ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn run_udp_relay(cfg: RelayConfig, shared_keys: SharedKeys) -> Result<()> {
|
|
||||||
// NAT-таблица: client_addr -> (upstream_socket, last_seen)
|
|
||||||
let nat_table: Arc<Mutex<HashMap<SocketAddr, (Arc<UdpSocket>, Instant)>>> =
|
|
||||||
Arc::new(Mutex::new(HashMap::new()));
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let limiter = Arc::new(Mutex::new(AdmissionLimiter::new()));
|
||||||
|
|
||||||
for bind_addr in &cfg.listen_addrs {
|
for bind_addr in &cfg.listen_addrs {
|
||||||
let sock = UdpSocket::bind(bind_addr).await?;
|
let sock = Arc::new(
|
||||||
tracing::info!("Relay UDP listening on {}", bind_addr);
|
UdpSocket::bind(bind_addr)
|
||||||
let sock = Arc::new(sock);
|
.await
|
||||||
let upstream_udp = cfg.upstream_udp.clone();
|
.with_context(|| format!("relay: failed to bind UDP on {bind_addr}"))?,
|
||||||
let keys = shared_keys.clone();
|
);
|
||||||
let nat = nat_table.clone();
|
tracing::info!("Relay UDP listening on {bind_addr} -> {}", cfg.upstream_udp);
|
||||||
|
|
||||||
|
let upstream_addr = cfg.upstream_udp.clone();
|
||||||
|
let sessions = sessions.clone();
|
||||||
|
let limiter = limiter.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
loop {
|
loop {
|
||||||
let (n, peer) = match sock.recv_from(&mut buf).await {
|
let (len, peer) = match sock.recv_from(&mut buf).await {
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let packet = Bytes::copy_from_slice(&buf[..n]);
|
|
||||||
|
|
||||||
// Быстрая проверка: первый UDP-пакет от нового клиента содержит Noise handshake.
|
|
||||||
// Мы берём из него первые 8 байт как timestamp + 32 байта MAC.
|
|
||||||
// Если пакет достаточно длинный, проверяем подпись.
|
|
||||||
// Для уже авторизованных клиентов (есть в NAT) — пропускаем проверку.
|
|
||||||
{
|
|
||||||
let nat_lock = nat.lock().await;
|
|
||||||
if !nat_lock.contains_key(&peer) {
|
|
||||||
drop(nat_lock);
|
|
||||||
|
|
||||||
// Пакет должен быть >= 40 байт (8 ts + 32 hmac) для первичной проверки
|
|
||||||
if packet.len() < 40 {
|
|
||||||
tracing::debug!("Relay UDP: dropping short packet from {}", peer);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ts_bytes: [u8; 8] = packet[0..8].try_into().unwrap();
|
|
||||||
let provided_mac = &packet[8..40];
|
|
||||||
let keys_guard = keys.read().unwrap_or_else(|e| e.into_inner());
|
|
||||||
|
|
||||||
if !verify_hmac(&ts_bytes, provided_mac, &keys_guard) {
|
|
||||||
tracing::debug!("Relay UDP: unauthorized probe from {}, dropped", peer);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tracing::debug!("Relay UDP: authorized new client {}", peer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Находим или создаём upstream socket для этого клиента
|
|
||||||
let upstream_sock = {
|
|
||||||
let mut nat_lock = nat.lock().await;
|
|
||||||
if let Some(entry) = nat_lock.get_mut(&peer) {
|
|
||||||
entry.1 = Instant::now();
|
|
||||||
entry.0.clone()
|
|
||||||
} else {
|
|
||||||
// Новый upstream socket для этого клиента
|
|
||||||
let usock = match UdpSocket::bind("0.0.0.0:0").await {
|
|
||||||
Ok(s) => Arc::new(s),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Relay UDP: failed to bind upstream socket: {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if usock.connect(&upstream_udp).await.is_err() {
|
|
||||||
tracing::warn!("Relay UDP: failed to connect to upstream {}", upstream_udp);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
nat_lock.insert(peer, (usock.clone(), Instant::now()));
|
|
||||||
|
|
||||||
// Задача: читаем ответы от upstream и отправляем клиенту
|
|
||||||
let usock_rx = usock.clone();
|
|
||||||
let client_sock = sock.clone();
|
|
||||||
let peer_addr = peer;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut rbuf = vec![0u8; 65535];
|
|
||||||
loop {
|
|
||||||
match usock_rx.recv(&mut rbuf).await {
|
|
||||||
Ok(n) => {
|
|
||||||
let _ = client_sock.send_to(&rbuf[..n], peer_addr).await;
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
usock
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Пересылаем пакет в upstream
|
|
||||||
let _ = upstream_sock.send(&packet).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Периодически чистим устаревшие NAT записи (timeout 120 сек)
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
|
||||||
let mut nat_lock = nat_table.lock().await;
|
|
||||||
let now = Instant::now();
|
|
||||||
nat_lock.retain(|_, (_, last)| now.duration_since(*last) < Duration::from_secs(120));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TCP (UoT) Relay ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn run_tcp_relay(cfg: RelayConfig, shared_keys: SharedKeys) -> Result<()> {
|
|
||||||
for bind_addr in &cfg.listen_addrs {
|
|
||||||
let listener = TcpListener::bind(bind_addr).await?;
|
|
||||||
tracing::info!("Relay TCP (UoT) listening on {}", bind_addr);
|
|
||||||
|
|
||||||
let upstream_tcp = cfg.upstream_tcp.clone();
|
|
||||||
let keys = shared_keys.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let (stream, peer_addr) = match listener.accept().await {
|
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Relay TCP accept error: {}", e);
|
tracing::warn!("Relay UDP recv error: {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let upstream = upstream_tcp.clone();
|
// Fast path: an established session just forwards.
|
||||||
let keys_clone = keys.clone();
|
{
|
||||||
|
let mut map = sessions.lock().await;
|
||||||
tokio::spawn(async move {
|
if let Some(session) = map.get_mut(&peer) {
|
||||||
if let Err(e) = handle_tcp_client(stream, peer_addr, upstream, keys_clone).await {
|
session.last_seen = Instant::now();
|
||||||
tracing::debug!("Relay TCP client {} closed: {}", peer_addr, e);
|
let upstream = session.upstream.clone();
|
||||||
|
drop(map);
|
||||||
|
let _ = upstream.send(&buf[..len]).await;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New client: bounded by both a hard cap and an admission rate,
|
||||||
|
// so a flood of spoofed sources cannot exhaust sockets or tasks.
|
||||||
|
{
|
||||||
|
let map = sessions.lock().await;
|
||||||
|
if map.len() >= MAX_UDP_SESSIONS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !limiter.lock().await.try_admit() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream = match new_upstream_socket(&upstream_addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Relay UDP: cannot reach upstream {upstream_addr}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions.lock().await.insert(
|
||||||
|
peer,
|
||||||
|
UdpSession { upstream: upstream.clone(), last_seen: Instant::now() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reverse direction for this client.
|
||||||
|
let back_sock = sock.clone();
|
||||||
|
let sessions_rx = sessions.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut rbuf = vec![0u8; 65535];
|
||||||
|
loop {
|
||||||
|
match upstream.recv(&mut rbuf).await {
|
||||||
|
Ok(n) => {
|
||||||
|
if back_sock.send_to(&rbuf[..n], peer).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(s) = sessions_rx.lock().await.get_mut(&peer) {
|
||||||
|
s.last_seen = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions_rx.lock().await.remove(&peer);
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = sessions
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&peer)
|
||||||
|
.map(|s| s.upstream.clone())
|
||||||
|
.unwrap()
|
||||||
|
.send(&buf[..len])
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reclaim idle sessions. Dropping the entry closes the upstream socket,
|
||||||
|
// which ends that session's reader task.
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = sessions.lock().await;
|
||||||
|
let before = map.len();
|
||||||
|
map.retain(|_, s| now.duration_since(s.last_seen) < UDP_SESSION_IDLE);
|
||||||
|
let reclaimed = before - map.len();
|
||||||
|
if reclaimed > 0 {
|
||||||
|
tracing::debug!("Relay UDP: reclaimed {reclaimed} idle session(s), {} active", map.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One upstream socket per client, `connect`ed so replies can be read with
|
||||||
|
/// `recv` and cannot come from anywhere else.
|
||||||
|
async fn new_upstream_socket(upstream: &str) -> Result<Arc<UdpSocket>> {
|
||||||
|
// Resolve first, then bind the SAME address family. Binding "[::]:0" and
|
||||||
|
// connecting to an IPv4 upstream fails anywhere IPV6_V6ONLY defaults on
|
||||||
|
// (Windows, and many Linux configurations) — which is every deployment with
|
||||||
|
// an IPv4 target server, i.e. the common case.
|
||||||
|
let addr: SocketAddr = tokio::net::lookup_host(upstream)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("resolve upstream {upstream}"))?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("upstream {upstream} resolved to no addresses"))?;
|
||||||
|
|
||||||
|
let bind: SocketAddr = if addr.is_ipv6() {
|
||||||
|
"[::]:0".parse().expect("valid literal")
|
||||||
|
} else {
|
||||||
|
"0.0.0.0:0".parse().expect("valid literal")
|
||||||
|
};
|
||||||
|
|
||||||
|
let sock = UdpSocket::bind(bind).await?;
|
||||||
|
sock.connect(addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connect to upstream {addr}"))?;
|
||||||
|
Ok(Arc::new(sock))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TCP (UoT) ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn run_tcp_relay(cfg: RelayConfig) -> Result<()> {
|
||||||
|
let live = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
|
||||||
|
for bind_addr in &cfg.listen_addrs {
|
||||||
|
let listener = TcpListener::bind(bind_addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("relay: failed to bind TCP on {bind_addr}"))?;
|
||||||
|
tracing::info!("Relay TCP (UoT) listening on {bind_addr} -> {}", cfg.upstream_tcp);
|
||||||
|
|
||||||
|
let upstream = cfg.upstream_tcp.clone();
|
||||||
|
let live = live.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (client, peer) = match listener.accept().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Relay TCP accept error: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
if live.load(Ordering::Relaxed) >= MAX_TCP_CONNECTIONS {
|
||||||
|
// Close immediately rather than queueing unbounded work.
|
||||||
|
drop(client);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
live.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let upstream = upstream.clone();
|
||||||
|
let live = live.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = splice_tcp(client, &upstream).await {
|
||||||
|
tracing::debug!("Relay TCP {peer} closed: {e}");
|
||||||
|
}
|
||||||
|
live.fetch_sub(1, Ordering::Relaxed);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Держим поток живым
|
|
||||||
futures_util::future::pending::<()>().await;
|
futures_util::future::pending::<()>().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка одного TCP (UoT) соединения.
|
/// Splice a client connection to the upstream, byte for byte.
|
||||||
///
|
///
|
||||||
/// Алгоритм:
|
/// Nothing is parsed or rewritten: the relay must stay agnostic to the payload,
|
||||||
/// 1. Читаем HTTP-заголовки (фейковый WebSocket upgrade).
|
/// both because the payload is an opaque encrypted stream and because any
|
||||||
/// 2. Извлекаем HMAC-подпись из Authorization: Bearer.
|
/// parsing would be a place for the relay to disagree with the endpoints.
|
||||||
/// 3. Проверяем подпись по синхронизированным ключам.
|
async fn splice_tcp(mut client: TcpStream, upstream_addr: &str) -> Result<()> {
|
||||||
/// 4. Если авторизован — открываем соединение к upstream и пайпим потоки.
|
let mut upstream = tokio::time::timeout(
|
||||||
async fn handle_tcp_client(
|
UPSTREAM_CONNECT_TIMEOUT,
|
||||||
mut client: TcpStream,
|
TcpStream::connect(upstream_addr),
|
||||||
peer_addr: SocketAddr,
|
|
||||||
upstream_addr: String,
|
|
||||||
shared_keys: SharedKeys,
|
|
||||||
) -> Result<()> {
|
|
||||||
// Читаем HTTP-заголовки (до \r\n\r\n)
|
|
||||||
let mut header_buf = vec![0u8; 4096];
|
|
||||||
let mut header_len = 0usize;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let n = client.read(&mut header_buf[header_len..]).await?;
|
|
||||||
if n == 0 {
|
|
||||||
anyhow::bail!("connection closed before handshake");
|
|
||||||
}
|
|
||||||
header_len += n;
|
|
||||||
if header_buf[..header_len].windows(4).any(|w| w == b"\r\n\r\n") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if header_len >= header_buf.len() {
|
|
||||||
anyhow::bail!("headers too large");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let headers_str = String::from_utf8_lossy(&header_buf[..header_len]);
|
|
||||||
|
|
||||||
// Быстрая проверка: должен быть GET /stream
|
|
||||||
if !headers_str.starts_with("GET /stream HTTP/1.1\r\n") {
|
|
||||||
// Возвращаем 404 как обычный сервер (anti-scan)
|
|
||||||
let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await;
|
|
||||||
anyhow::bail!("invalid request from {}", peer_addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекаем HMAC-подпись
|
|
||||||
let mut sig_b64 = None;
|
|
||||||
for line in headers_str.lines() {
|
|
||||||
let lower = line.to_ascii_lowercase();
|
|
||||||
if lower.starts_with("authorization: bearer ") {
|
|
||||||
sig_b64 = Some(line[22..].trim().to_string());
|
|
||||||
} else if lower.starts_with("cookie: ostp_token=") {
|
|
||||||
sig_b64 = Some(line[19..].trim().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sig_b64 = match sig_b64 {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await;
|
|
||||||
anyhow::bail!("missing authorization from {}", peer_addr);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let sig_bytes = base64::Engine::decode(
|
|
||||||
&base64::engine::general_purpose::STANDARD_NO_PAD,
|
|
||||||
&sig_b64,
|
|
||||||
)
|
)
|
||||||
.map_err(|_| anyhow::anyhow!("invalid base64 from {}", peer_addr))?;
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("upstream {upstream_addr} connect timed out"))?
|
||||||
|
.with_context(|| format!("connect to upstream {upstream_addr}"))?;
|
||||||
|
|
||||||
if sig_bytes.len() < 40 {
|
// Both sides carry latency-sensitive framed traffic; Nagle would add delay
|
||||||
let _ = client.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 12\r\nConnection: close\r\n\r\nUnauthorized").await;
|
// for no benefit on an already-batched stream.
|
||||||
anyhow::bail!("signature too short from {}", peer_addr);
|
let _ = client.set_nodelay(true);
|
||||||
}
|
let _ = upstream.set_nodelay(true);
|
||||||
|
|
||||||
let ts_bytes: [u8; 8] = sig_bytes[0..8].try_into().unwrap();
|
tokio::io::copy_bidirectional(&mut client, &mut upstream).await?;
|
||||||
let provided_mac = &sig_bytes[8..];
|
|
||||||
|
|
||||||
// Проверяем по синхронизированным ключам
|
|
||||||
let authorized = {
|
|
||||||
let keys = shared_keys.read().unwrap_or_else(|e| e.into_inner());
|
|
||||||
verify_hmac(&ts_bytes, provided_mac, &keys)
|
|
||||||
};
|
|
||||||
|
|
||||||
if !authorized {
|
|
||||||
let _ = client.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found").await;
|
|
||||||
anyhow::bail!("unauthorized client {}", peer_addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Relay TCP: authorized client {}, forwarding to {}", peer_addr, upstream_addr);
|
|
||||||
|
|
||||||
// Подключаемся к upstream
|
|
||||||
let mut upstream = TcpStream::connect(&upstream_addr).await
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to connect to upstream {}: {}", upstream_addr, e))?;
|
|
||||||
|
|
||||||
// Пересылаем upstream заголовки AS-IS (он сам проверит подпись)
|
|
||||||
upstream.write_all(&header_buf[..header_len]).await?;
|
|
||||||
|
|
||||||
// Пайпим оба потока: client <-> upstream
|
|
||||||
let (mut cr, mut cw) = client.into_split();
|
|
||||||
let (mut ur, mut uw) = upstream.into_split();
|
|
||||||
|
|
||||||
let c2u = tokio::spawn(async move {
|
|
||||||
let _ = tokio::io::copy(&mut cr, &mut uw).await;
|
|
||||||
});
|
|
||||||
let u2c = tokio::spawn(async move {
|
|
||||||
let _ = tokio::io::copy(&mut ur, &mut cw).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = tokio::join!(c2u, u2c);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The admission limiter is what replaced per-client authentication as the
|
||||||
|
/// defence against resource abuse, so it has to actually stop admitting.
|
||||||
|
#[test]
|
||||||
|
fn admission_limiter_stops_at_the_burst_ceiling() {
|
||||||
|
let mut limiter = AdmissionLimiter::new();
|
||||||
|
let mut admitted = 0usize;
|
||||||
|
// Ask for far more than one burst without letting time pass.
|
||||||
|
for _ in 0..(NEW_SESSION_RATE as usize * 3) {
|
||||||
|
if limiter.try_admit() {
|
||||||
|
admitted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
admitted <= NEW_SESSION_RATE as usize + 1,
|
||||||
|
"admitted {admitted} sessions in one instant, ceiling is {NEW_SESSION_RATE}"
|
||||||
|
);
|
||||||
|
assert!(admitted > 0, "limiter admitted nothing at all");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// It must also refill, or the relay would accept a burst once and then
|
||||||
|
/// refuse every client forever.
|
||||||
|
#[test]
|
||||||
|
fn admission_limiter_refills_over_time() {
|
||||||
|
let mut limiter = AdmissionLimiter::new();
|
||||||
|
while limiter.try_admit() {}
|
||||||
|
assert!(!limiter.try_admit(), "bucket should be empty");
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
assert!(
|
||||||
|
limiter.try_admit(),
|
||||||
|
"limiter never refilled; the relay would stop accepting new clients"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end through the real UDP path: a client datagram reaches the
|
||||||
|
/// upstream and the reply comes back to that same client. This is the whole
|
||||||
|
/// job of the relay, and it is what the previous implementation could not do
|
||||||
|
/// with a real client, because it demanded credentials no client sends.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn udp_relay_forwards_both_directions() {
|
||||||
|
// Stand-in upstream that echoes with a marker.
|
||||||
|
let upstream = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let upstream_addr = upstream.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 1500];
|
||||||
|
while let Ok((n, from)) = upstream.recv_from(&mut buf).await {
|
||||||
|
let mut reply = b"echo:".to_vec();
|
||||||
|
reply.extend_from_slice(&buf[..n]);
|
||||||
|
let _ = upstream.send_to(&reply, from).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let relay_listen = {
|
||||||
|
let probe = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let a = probe.local_addr().unwrap();
|
||||||
|
drop(probe);
|
||||||
|
a
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(run_udp_relay(RelayConfig {
|
||||||
|
listen_addrs: vec![relay_listen.to_string()],
|
||||||
|
upstream_tcp: upstream_addr.to_string(),
|
||||||
|
upstream_udp: upstream_addr.to_string(),
|
||||||
|
}));
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
|
||||||
|
// A plain OSTP-looking datagram: no credentials, no preamble.
|
||||||
|
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
client.send_to(b"opaque-payload", relay_listen).await.unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 1500];
|
||||||
|
let (n, _) = tokio::time::timeout(Duration::from_secs(3), client.recv_from(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect("relay did not deliver a reply within 3s")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&buf[..n],
|
||||||
|
b"echo:opaque-payload",
|
||||||
|
"relay did not forward the payload verbatim in both directions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same for TCP: bytes must cross unmodified in both directions, with no
|
||||||
|
/// handshake demanded of the client.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tcp_relay_splices_both_directions() {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let upstream_addr = upstream.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok((mut sock, _)) = upstream.accept().await {
|
||||||
|
let mut buf = [0u8; 128];
|
||||||
|
if let Ok(n) = sock.read(&mut buf).await {
|
||||||
|
let mut reply = b"echo:".to_vec();
|
||||||
|
reply.extend_from_slice(&buf[..n]);
|
||||||
|
let _ = sock.write_all(&reply).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let relay_listen = {
|
||||||
|
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let a = probe.local_addr().unwrap();
|
||||||
|
drop(probe);
|
||||||
|
a
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::spawn(run_tcp_relay(RelayConfig {
|
||||||
|
listen_addrs: vec![relay_listen.to_string()],
|
||||||
|
upstream_tcp: upstream_addr.to_string(),
|
||||||
|
upstream_udp: upstream_addr.to_string(),
|
||||||
|
}));
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
|
||||||
|
let mut client = TcpStream::connect(relay_listen).await.unwrap();
|
||||||
|
client.write_all(b"opaque-stream").await.unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 128];
|
||||||
|
let n = tokio::time::timeout(Duration::from_secs(3), client.read(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect("relay did not deliver a reply within 3s")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(&buf[..n], b"echo:opaque-stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,29 @@ impl Router {
|
||||||
|
|
||||||
let mut proxy = None;
|
let mut proxy = None;
|
||||||
if let Some(ref c) = cfg {
|
if let Some(ref c) = cfg {
|
||||||
if c.enabled && c.protocol == "socks5" {
|
if c.enabled {
|
||||||
let proxy_addr = format!("{}:{}", c.address, c.port);
|
if c.protocol == "socks5" {
|
||||||
if let Ok(p) = crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
|
let proxy_addr = format!("{}:{}", c.address, c.port);
|
||||||
proxy = Some(Arc::new(p));
|
match crate::outbound::connect_udp_via_socks5(&proxy_addr, server_udp.clone()).await {
|
||||||
} else if self.debug {
|
Ok(p) => proxy = Some(Arc::new(p)),
|
||||||
tracing::warn!("Failed to establish SOCKS5 UDP Associate");
|
// Warn unconditionally, not only under `debug`. Every UDP
|
||||||
|
// flow the rules want proxied is now dropped instead of
|
||||||
|
// sent, so an operator who cannot see this has a session
|
||||||
|
// where TCP works and UDP silently does not.
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"SOCKS5 UDP ASSOCIATE to {proxy_addr} failed: {e}. UDP that the \
|
||||||
|
outbound rules route through the proxy will be DROPPED (it is not \
|
||||||
|
sent directly, which would expose this server's address)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"Upstream proxy protocol is '{}', which cannot carry UDP. UDP matching \
|
||||||
|
a Proxy rule will be DROPPED. Use a socks5 upstream for UDP, or add an \
|
||||||
|
explicit udp rule with action \"direct\" or \"block\" to make the \
|
||||||
|
intent explicit.",
|
||||||
|
c.protocol
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,9 +104,28 @@ impl UdpSessionRouter {
|
||||||
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
return Err(anyhow::anyhow!("blocked by outbound udp rule: {}", target));
|
||||||
}
|
}
|
||||||
if action == crate::outbound::OutboundAction::Proxy {
|
if action == crate::outbound::OutboundAction::Proxy {
|
||||||
if let Some(p) = &self.proxy {
|
return match &self.proxy {
|
||||||
return p.send_to(data, target).await;
|
Some(p) => p.send_to(data, target).await,
|
||||||
}
|
// FAIL CLOSED. This used to fall through to the direct
|
||||||
|
// socket, so whenever the UDP proxy was unavailable —
|
||||||
|
// the SOCKS5 UDP ASSOCIATE failed, or the upstream is an
|
||||||
|
// HTTP proxy, which cannot carry UDP at all — every UDP
|
||||||
|
// datagram silently egressed from the server's own
|
||||||
|
// address while TCP still went through the proxy. The
|
||||||
|
// session then had two different exit IPs, which is what
|
||||||
|
// Google flags and why YouTube (QUIC, i.e. UDP/443)
|
||||||
|
// geolocated to the server instead of the proxy exit.
|
||||||
|
//
|
||||||
|
// A rule that says "proxy" must never be satisfied by
|
||||||
|
// sending in the clear: a dropped datagram is visible and
|
||||||
|
// debuggable, a deanonymising leak is neither.
|
||||||
|
None => Err(anyhow::anyhow!(
|
||||||
|
"outbound rule requires the proxy for UDP to {target}, but no UDP \
|
||||||
|
proxy is available (SOCKS5 UDP ASSOCIATE failed, or the upstream \
|
||||||
|
is an HTTP proxy, which cannot carry UDP) - dropping rather than \
|
||||||
|
leaking the server's own address"
|
||||||
|
)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
// or launched via ShellExecuteW("runas").
|
// or launched via ShellExecuteW("runas").
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// Key off the TARGET, not the host. In a build script `cfg(windows)`
|
||||||
|
// describes the machine doing the building, so cross-compiling the helper
|
||||||
|
// from Windows to Linux took this branch and failed with "Can only compile
|
||||||
|
// resource file when target_env is gnu or msvc". CARGO_CFG_TARGET_OS is the
|
||||||
|
// target being built for, which is what actually decides whether a Windows
|
||||||
|
// manifest belongs in the binary.
|
||||||
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
if target_os != "windows" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Second gate, on the HOST: winres is declared under
|
||||||
|
// [target.'cfg(windows)'.build-dependencies], and build-dependencies are
|
||||||
|
// resolved against the host triple, so the crate simply does not exist when
|
||||||
|
// building on Linux. Referencing it unconditionally would fail to compile
|
||||||
|
// there even though the target check above already passed.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let mut res = winres::WindowsResource::new();
|
let mut res = winres::WindowsResource::new();
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ fn log_to_file(msg: &str) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Launch parameters handed over in a file rather than on the command line.
|
||||||
|
/// See the `--args-file` handling in `main` for why.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct HelperArgs {
|
||||||
|
port: u16,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "cmd", rename_all = "lowercase")]
|
#[serde(tag = "cmd", rename_all = "lowercase")]
|
||||||
enum GuiCmd {
|
enum GuiCmd {
|
||||||
|
|
@ -76,6 +84,28 @@ async fn main() -> Result<()> {
|
||||||
let _ = std::fs::remove_file(path); // securely delete after reading
|
let _ = std::fs::remove_file(path); // securely delete after reading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Both port and token from one file. A Scheduled Task stores a FIXED
|
||||||
|
// command line, so anything that varies per launch cannot be passed as
|
||||||
|
// an argument — the GUI writes this file immediately before triggering
|
||||||
|
// the task instead. That indirection is what lets the task be created
|
||||||
|
// once (a single UAC prompt) and reused for every later connect without
|
||||||
|
// prompting again.
|
||||||
|
if args[i] == "--args-file" && i + 1 < args.len() {
|
||||||
|
let path = &args[i + 1];
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => {
|
||||||
|
let _ = std::fs::remove_file(path); // single use
|
||||||
|
match serde_json::from_str::<HelperArgs>(&content) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
port = parsed.port;
|
||||||
|
expected_token = parsed.token;
|
||||||
|
}
|
||||||
|
Err(e) => log_to_file(&format!("Failed to parse --args-file: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => log_to_file(&format!("Failed to read --args-file {path}: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log_to_file("Helper started (TCP mode)");
|
log_to_file("Helper started (TCP mode)");
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
ostp-core = { path = "../ostp-core" }
|
ostp-core = { path = "../ostp-core" }
|
||||||
colored = "2.1"
|
colored = "2.1"
|
||||||
rlimit = "0.11.0"
|
rlimit = "0.11.0"
|
||||||
|
sha2.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ enum Commands {
|
||||||
Init {
|
Init {
|
||||||
mode: String,
|
mode: String,
|
||||||
},
|
},
|
||||||
|
/// Hash a password for the web panel's `api.password_hash` config field
|
||||||
|
#[command(name = "hash-password", alias = "hp")]
|
||||||
|
HashPassword {
|
||||||
|
/// The password to hash. Omit to be prompted (keeps it out of shell history).
|
||||||
|
password: Option<String>,
|
||||||
|
},
|
||||||
/// Generate a new secure access key
|
/// Generate a new secure access key
|
||||||
#[command(name = "gk", alias = "generate-key")]
|
#[command(name = "gk", alias = "generate-key")]
|
||||||
GenerateKey {
|
GenerateKey {
|
||||||
|
|
@ -720,24 +726,18 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
}) as char
|
}) as char
|
||||||
}).collect();
|
}).collect();
|
||||||
let password = wizard_prompt("Admin password (blank for random)", &rand_pass);
|
let password = wizard_prompt("Admin password (blank for random)", &rand_pass);
|
||||||
let pass_hash = {
|
// Must match api.rs's handle_login exactly (format!("{:x}", Sha256::digest(..))) -
|
||||||
use std::fmt::Write as _;
|
// this used to be a DefaultHasher (SipHash) placeholder that produced a
|
||||||
let mut hash = String::new();
|
// differently-shaped digest, so a password set up through this wizard could
|
||||||
let digest: [u8; 32] = {
|
// never actually log into the panel it just configured.
|
||||||
use std::collections::hash_map::DefaultHasher;
|
// Trait-qualified so this compiles whether or not `sha2::Digest` happens
|
||||||
use std::hash::{Hash, Hasher};
|
// to be in scope: `digest` is a trait method, and relying on the import
|
||||||
// Panel password hashing. sha2 is not a direct dep of ostp/Cargo.toml,
|
// alone broke the CI build once (v0.4.2-beta.3) while resolving fine
|
||||||
// so we use std's hasher as a placeholder digest here.
|
// locally.
|
||||||
let mut h = DefaultHasher::new();
|
let pass_hash = format!(
|
||||||
password.hash(&mut h);
|
"{:x}",
|
||||||
let v = h.finish();
|
<sha2::Sha256 as sha2::Digest>::digest(password.as_bytes())
|
||||||
let mut out = [0u8; 32];
|
);
|
||||||
out[..8].copy_from_slice(&v.to_be_bytes());
|
|
||||||
out
|
|
||||||
};
|
|
||||||
for b in digest { let _ = write!(hash, "{:02x}", b); }
|
|
||||||
hash
|
|
||||||
};
|
|
||||||
|
|
||||||
wizard_step(4, TOTAL, "Saving configuration");
|
wizard_step(4, TOTAL, "Saving configuration");
|
||||||
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
let panel_bind = format!("0.0.0.0:{}", panel_port);
|
||||||
|
|
@ -799,18 +799,16 @@ fn run_setup_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
|
let listen = wizard_prompt("Listen address (host:port)", "0.0.0.0:50000");
|
||||||
let upstream = wizard_prompt("Upstream server address (host:port)", "");
|
let upstream = wizard_prompt("Upstream server address (host:port)", "");
|
||||||
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
|
if upstream.is_empty() { anyhow::bail!("Upstream address cannot be empty."); }
|
||||||
let api_url = wizard_prompt("Upstream server API URL (e.g. http://1.2.3.4:9090)", "");
|
|
||||||
let api_token = wizard_prompt("Upstream API token (leave blank if none)", "");
|
|
||||||
|
|
||||||
wizard_step(2, TOTAL, "Saving configuration");
|
wizard_step(2, TOTAL, "Saving configuration");
|
||||||
|
// No credentials are collected: the relay forwards transparently and
|
||||||
|
// authenticates nothing, so it needs neither the target's API nor a
|
||||||
|
// copy of the access keys.
|
||||||
let relay_json = serde_json::json!({
|
let relay_json = serde_json::json!({
|
||||||
"mode": "relay",
|
"mode": "relay",
|
||||||
"listen": listen,
|
"listen": listen,
|
||||||
"upstream_tcp": upstream,
|
"upstream_tcp": upstream,
|
||||||
"upstream_udp": upstream,
|
"upstream_udp": upstream,
|
||||||
"upstream_api_url": api_url,
|
|
||||||
"upstream_api_token": api_token,
|
|
||||||
"sync_interval_secs": 30,
|
|
||||||
"debug": false
|
"debug": false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -926,6 +924,38 @@ async fn run_app() -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Commands::Setup { init } => { args.setup = true; args.init = init; }
|
Commands::Setup { init } => { args.setup = true; args.init = init; }
|
||||||
Commands::Init { mode } => { args.init = Some(mode); }
|
Commands::Init { mode } => { args.init = Some(mode); }
|
||||||
|
Commands::HashPassword { password } => {
|
||||||
|
// The panel stores only a hash, and until now nothing in the CLI
|
||||||
|
// could produce one: `ostp init server` writes password_hash: ""
|
||||||
|
// and the only generator lived inside the Unix-only Server+Panel
|
||||||
|
// wizard branch, leaving no supported way to set up API auth on a
|
||||||
|
// plain server.
|
||||||
|
let password = match password {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
print!("Password: ");
|
||||||
|
use std::io::Write as _;
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
let mut buf = String::new();
|
||||||
|
std::io::stdin().read_line(&mut buf)?;
|
||||||
|
buf.trim_end_matches(['\r', '\n']).to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if password.is_empty() {
|
||||||
|
anyhow::bail!("password must not be empty");
|
||||||
|
}
|
||||||
|
// Must match api.rs's handle_login byte for byte.
|
||||||
|
let hash = format!(
|
||||||
|
"{:x}",
|
||||||
|
<sha2::Sha256 as sha2::Digest>::digest(password.as_bytes())
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!("Add this to the \"api\" section of your config:");
|
||||||
|
println!();
|
||||||
|
println!(" \"password_hash\": \"{hash}\"");
|
||||||
|
println!();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
Commands::GenerateKey { format, count } => { args.generate_key = true; args.format = format; args.count = count; }
|
Commands::GenerateKey { format, count } => { args.generate_key = true; args.format = format; args.count = count; }
|
||||||
Commands::Links => { args.links = true; }
|
Commands::Links => { args.links = true; }
|
||||||
Commands::Check => { args.check = true; }
|
Commands::Check => { args.check = true; }
|
||||||
|
|
@ -1105,7 +1135,9 @@ async fn run_app() -> Result<()> {
|
||||||
println!(" Listen: {:?}", r.listen.primary().cyan());
|
println!(" Listen: {:?}", r.listen.primary().cyan());
|
||||||
println!(" Upstream TCP: {}", r.upstream_tcp.cyan());
|
println!(" Upstream TCP: {}", r.upstream_tcp.cyan());
|
||||||
println!(" Upstream UDP: {}", r.upstream_udp.cyan());
|
println!(" Upstream UDP: {}", r.upstream_udp.cyan());
|
||||||
println!(" API sync: {}", r.upstream_api_url.yellow());
|
if !r.upstream_api_url.is_empty() {
|
||||||
|
println!(" {}", "upstream_api_url is set but no longer used - safe to remove".yellow());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1181,9 +1213,9 @@ async fn run_app() -> Result<()> {
|
||||||
"listen": "0.0.0.0:50000",
|
"listen": "0.0.0.0:50000",
|
||||||
"upstream_tcp": "TARGET_SERVER_IP:50000",
|
"upstream_tcp": "TARGET_SERVER_IP:50000",
|
||||||
"upstream_udp": "TARGET_SERVER_IP:50000",
|
"upstream_udp": "TARGET_SERVER_IP:50000",
|
||||||
"upstream_api_url": "http://TARGET_SERVER_IP:9090",
|
// The relay forwards transparently and holds no keys: sessions are
|
||||||
"upstream_api_token": "YOUR_API_TOKEN_HERE",
|
// authenticated end-to-end by the target server, which drops anything that
|
||||||
"sync_interval_secs": 30,
|
// fails. Nothing else needs configuring here.
|
||||||
"debug": false
|
"debug": false
|
||||||
}"#.to_string()
|
}"#.to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1382,14 +1414,18 @@ async fn run_app() -> Result<()> {
|
||||||
println!("{} Starting relay node on {:?}", "[ostp]".cyan().bold(), listen_addrs);
|
println!("{} Starting relay node on {:?}", "[ostp]".cyan().bold(), listen_addrs);
|
||||||
println!("{} Upstream TCP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_tcp);
|
println!("{} Upstream TCP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_tcp);
|
||||||
println!("{} Upstream UDP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_udp);
|
println!("{} Upstream UDP: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_udp);
|
||||||
println!("{} Key sync API: {}", "[ostp]".cyan().bold(), relay_cfg.upstream_api_url);
|
if !relay_cfg.upstream_api_url.is_empty() {
|
||||||
|
println!(
|
||||||
|
"{} Note: upstream_api_url is no longer used and can be removed. The relay \
|
||||||
|
forwards transparently; sessions are authenticated end-to-end by the target \
|
||||||
|
server.",
|
||||||
|
"[ostp]".yellow().bold()
|
||||||
|
);
|
||||||
|
}
|
||||||
let relay_config = ostp_server::RelayConfig {
|
let relay_config = ostp_server::RelayConfig {
|
||||||
listen_addrs,
|
listen_addrs,
|
||||||
upstream_tcp: relay_cfg.upstream_tcp,
|
upstream_tcp: relay_cfg.upstream_tcp,
|
||||||
upstream_udp: relay_cfg.upstream_udp,
|
upstream_udp: relay_cfg.upstream_udp,
|
||||||
upstream_api_url: relay_cfg.upstream_api_url,
|
|
||||||
upstream_api_token: relay_cfg.upstream_api_token,
|
|
||||||
sync_interval_secs: relay_cfg.sync_interval_secs,
|
|
||||||
};
|
};
|
||||||
ostp_server::relay_node::run_relay_node(relay_config).await?;
|
ostp_server::relay_node::run_relay_node(relay_config).await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,5 +138,5 @@ Write-Host "No configuration found. Launching setup wizard..."
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Push-Location $InstallDir
|
Push-Location $InstallDir
|
||||||
& .\ostp.exe --setup
|
& .\ostp.exe setup
|
||||||
Pop-Location
|
Pop-Location
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,18 @@ if [ -n "$TARGET_VERSION" ]; then
|
||||||
fi
|
fi
|
||||||
echo "Fetching requested release $LATEST_RELEASE..."
|
echo "Fetching requested release $LATEST_RELEASE..."
|
||||||
else
|
else
|
||||||
if [ "$TARGET_BRANCH" == "alpha" ]; then
|
if [ "$TARGET_BRANCH" == "alpha" ] || [ "$TARGET_BRANCH" == "beta" ]; then
|
||||||
echo "Fetching alpha release..."
|
# There is no floating "alpha"/"beta" GitHub Release - gha.ps1 cuts a
|
||||||
LATEST_RELEASE="alpha"
|
# fresh versioned tag every time (v0.4.2-beta.4, v0.4.2-alpha.7, ...).
|
||||||
elif [ "$TARGET_BRANCH" == "beta" ]; then
|
# /releases/latest only ever returns the newest NON-prerelease
|
||||||
echo "Fetching beta release..."
|
# (stable) tag, so it can't find these. Query the full releases list
|
||||||
LATEST_RELEASE="beta"
|
# (newest first) and take the first tag_name containing "-$TARGET_BRANCH".
|
||||||
|
echo "Fetching latest ${TARGET_BRANCH} release..."
|
||||||
|
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases" \
|
||||||
|
| grep '"tag_name":' \
|
||||||
|
| grep -- "-${TARGET_BRANCH}" \
|
||||||
|
| head -1 \
|
||||||
|
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
|
||||||
else
|
else
|
||||||
echo "Fetching latest stable release..."
|
echo "Fetching latest stable release..."
|
||||||
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
LATEST_RELEASE=$(curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
|
@ -238,4 +244,4 @@ echo "No configuration found. Launching setup wizard..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
exec ./ostp --setup --config "$CONFIG_FILE"
|
exec ./ostp setup --config "$CONFIG_FILE"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue