mirror of https://github.com/ospab/ostp.git
Compare commits
12 Commits
2ede607027
...
365b4ccbf5
| Author | SHA1 | Date |
|---|---|---|
|
|
365b4ccbf5 | |
|
|
4a3fb8b944 | |
|
|
f789167a22 | |
|
|
108bab6a90 | |
|
|
f7e9215331 | |
|
|
ebfc751471 | |
|
|
3cda1a9bd4 | |
|
|
77a45d7642 | |
|
|
6abae68f35 | |
|
|
cb57347d51 | |
|
|
32c36afc3b | |
|
|
a8aba8f4b8 |
|
|
@ -378,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
|
||||||
|
|
@ -460,7 +468,10 @@ 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
|
||||||
|
|
@ -522,7 +533,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
|
||||||
|
|
@ -587,27 +601,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.3",
|
||||||
"branch": "beta",
|
"branch": "beta",
|
||||||
"alpha_iteration": 0,
|
"alpha_iteration": 0,
|
||||||
"beta_iteration": 5
|
"beta_iteration": 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp"
|
name = "ostp"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1409,7 +1409,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-client"
|
name = "ostp-client"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|
@ -1440,7 +1440,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-core"
|
name = "ostp-core"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -1474,7 +1474,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-server"
|
name = "ostp-server"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1507,7 +1507,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun"
|
name = "ostp-tun"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -1519,7 +1519,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostp-tun-helper"
|
name = "ostp-tun-helper"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
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.3"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,16 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bridge {
|
impl Bridge {
|
||||||
|
|
@ -173,6 +183,8 @@ 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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,7 +267,27 @@ 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.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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;
|
||||||
|
|
@ -272,7 +304,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;
|
||||||
}
|
}
|
||||||
|
|
@ -926,7 +971,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);
|
||||||
|
|
||||||
|
|
@ -939,7 +998,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 {
|
||||||
|
|
@ -1020,7 +1080,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 {
|
||||||
|
|
@ -1264,8 +1325,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();
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ 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
|
||||||
|
|
@ -65,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);
|
||||||
|
|
@ -113,9 +130,50 @@ impl CongestionController {
|
||||||
min_rtt_stamp: now,
|
min_rtt_stamp: now,
|
||||||
slow_start_losses: 0,
|
slow_start_losses: 0,
|
||||||
slow_start_loss_window_start: now,
|
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
|
||||||
|
|
@ -167,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
|
||||||
|
|
@ -198,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 {
|
||||||
|
|
@ -213,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.
|
||||||
|
|
@ -332,6 +447,94 @@ 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]
|
#[test]
|
||||||
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
|
||||||
// A single dropped packet (wireless noise, a brief handover blip) is
|
// A single dropped packet (wireless noise, a brief handover blip) is
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
@ -183,6 +188,16 @@ impl ProtocolMachine {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -675,8 +690,15 @@ impl ProtocolMachine {
|
||||||
break;
|
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
|
// Only burn the retry counter and reset the RTO timer when the
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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+24
|
version: 0.4.3+26
|
||||||
|
|
||||||
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.3",
|
||||||
"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.3"
|
||||||
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.3"
|
||||||
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.3"
|
||||||
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.3"
|
||||||
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.3"
|
||||||
description = "OSTP desktop GUI"
|
description = "OSTP desktop GUI"
|
||||||
authors = ["ospab"]
|
authors = ["ospab"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -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.3",
|
||||||
"identifier": "com.ospab.ostp",
|
"identifier": "com.ospab.ostp",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -155,7 +159,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 +237,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 +274,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);
|
||||||
|
|
|
||||||
|
|
@ -263,8 +263,19 @@ impl Dispatcher {
|
||||||
self.peer_machines
|
self.peer_machines
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(&sid, ps)| {
|
.map(|(&sid, ps)| {
|
||||||
let cwnd = (ps.machine.cwnd_packets() as i64).clamp(16, 16384);
|
// 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;
|
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)
|
(sid, cwnd - in_flight)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -920,6 +926,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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue