Compare commits

...

12 Commits

Author SHA1 Message Date
ospab 365b4ccbf5 chore: release v0.4.3-beta.1 on beta 2026-07-31 19:47:26 +03:00
ospab 4a3fb8b944 fix(client): stop mobile connects from stalling for minutes on dead IPv6
Connecting over a mobile network took ~90s, and under worse conditions did
not complete at all. Three compounding causes, all in the address loop of
perform_handshake_with_id, which walks candidates strictly in order and burns
each one's full retry budget before touching the next:

  - IPv6 was tried FIRST. Carriers routinely hand out IPv6 with no working
    route and blackhole it rather than rejecting, so each AAAA record cost the
    entire 4x1.2s budget with nothing to show; with several of them the
    working IPv4 address was not reached for tens of seconds. The identical
    ordering bug was already fixed on the server's outbound path and in the
    UoT connect - the client's handshake was simply missed.

  - The NAT64 prefix discovery lookup had no timeout. It only ever runs on
    networks that are already misbehaving, exactly where a resolver can hang
    for tens of seconds. Now bounded at 2s, falling back to the well-known
    RFC 6052 prefix, which beats waiting.

  - NAT64 was retried per failing IPv4 address, each time re-running that
    lookup plus another four handshake attempts - for a path that either works
    for the whole network or for none of it. Now attempted once.

Ordering alone is the dominant fix; the other two bound the tail.
2026-07-31 19:26:30 +03:00
ospab f789167a22 feat(congestion): actually pace sends instead of releasing whole windows
pacing_rate had been computed on every ACK since the controller was written
and never read by anything: admission was decided purely by cwnd. But cwnd
bounds how much may be UNACKNOWLEDGED, not how fast it reaches the wire, so a
full window went out back-to-back. On a bottleneck with a deep buffer that
burst is absorbed rather than dropped, and it lands as standing queue — the
mechanism behind the multi-second RTT this protocol has been showing on
mobile. It is also why BBR could not simply be dropped in: BBR's whole model
is "send at the estimated bottleneck rate", which is meaningless without a
pacer underneath it.

Adds a token bucket to CongestionController, charged in on_send so every byte
that reaches the wire pays exactly once — retransmits included, since those
are precisely what must not bypass the limit and pile into a full queue.

Burst allowance is 10ms-at-rate rather than one packet. Pacing intervals here
are fractions of a millisecond, so strict per-packet release would need a
sub-millisecond timer per packet; sizing the burst to the loop's existing
~10ms wakeup lets the configured rate still be saturated. There is a floor of
4 MTU so a cold or collapsed rate estimate can never wedge sending entirely.

Wired into both directions. The client gates its proxy-event branch on it,
and the server's per-session backpressure snapshot reports zero headroom when
the bucket is empty. The download path matters most here — that is the one
carrying video — and it was also still clamped to the old 16384-packet
ceiling (~20 MB outstanding), now aligned with MAX_CWND_PACKETS.

Tested that the bucket denies once drained and refills over time; the second
is what keeps a stalled bucket from wedging sending permanently.
2026-07-31 19:18:42 +03:00
ospab 108bab6a90 fix(client): retry resume reconnects; add a way to hash the panel password
Two unrelated user-reported blockers.

Resume on desktop. The suspend/resume detector fired a single reconnect
attempt roughly half a second after waking — which is exactly when the NIC
has not reassociated yet, so it almost always failed. Failure then fell back
to the ordinary 25s stall heuristic, which keys off a monotonic clock that
does not advance while the machine is asleep, so it could take another 25s of
real uptime to fire, or never fire. The forced reconnect is now sticky:
retried every 3s until a session is actually established, verified via
last_valid_recv rather than by "an attempt was made".

Panel password. `api.password_hash` wants a hash, and nothing in the CLI
could produce one: `ostp init server` emits password_hash: "" and the only
generator was inline in the Server+Panel wizard branch, which is Unix-only —
so on a plain server there was no supported path to working API auth at all.
Adds `ostp hash-password [PASSWORD]`, prompting when the argument is omitted
so the password stays out of shell history. Output verified to match both
handle_login's comparison and a reference SHA-256.
2026-07-31 19:02:03 +03:00
ospab f7e9215331 fix(congestion): stop the bufferbloat spiral behind multi-second RTT stalls
Reported symptom: on mobile the reported RTT jumps to 15-20s (worst case
~150s), video stops loading, and it takes ~5 minutes to recover — or doesn't,
until the user reconnects.

Nothing on the network takes 150 seconds. That delay was our own queue. The
controller only ever treated LOSS as congestion, and mobile carrier buffers
are deep enough to absorb a burst rather than drop it, so the loss signal
never arrived and three things compounded:

  - slow start grew cwnd unbounded, with the client permitting up to 16384
    packets in flight (~20 MB — minutes of queue on a mobile uplink);
  - the resulting standing queue inflated RTT samples, which raised SRTT,
    which raised the adaptive RTO, so retransmits piled into the same queue;
  - backoff multiplied that already-inflated RTO by up to 64x. With RTO_MAX
    at 16s a frame could sit unretransmitted for ~17 minutes.

Reconnecting "fixed" it only because a fresh session resets cwnd to 32.

Three bounds, matching how delay-based controllers handle this:
  - Treat sustained RTT inflation as congestion: leave slow start at 2x the
    observed path floor, actively halve cwnd at 4x. This is the part that
    works where loss never comes.
  - Hard cwnd ceiling of 1024 packets (~1.2 MB), well above any real BDP here
    but far below a queue measured in seconds. The client's in-flight gate is
    lowered to match.
  - Cap the post-backoff retransmit timer at 8s.

Tested for the no-loss inflation case and the ceiling. Both encode the bug
directly, since neither is observable from loss-driven tests.

Not addressed here: pacing is still computed and unused, so sending remains
bursty. That is the next lever and wants real-link validation.
2026-07-31 18:47:14 +03:00
ospab ebfc751471 fix(android): treat a blank signing secret as absent, not as the password
The Android jobs failed with "Get Key failed: Given final block not properly
padded" once the store password was corrected - the keystore opened, but the
KEY could not be decrypted.

Cause: GitHub Actions substitutes an empty string, not an unset variable, for
a secret that does not exist. ANDROID_KEY_PASSWORD is deliberately not set (our
keystore is PKCS12, where the key password cannot differ from the store
password), so OSTP_KEY_PASSWORD arrived as "". Kotlin's elvis operator only
falls back on null, so `getenv(...) ?: storePassword` kept the empty string and
used it as the literal key password.

signingSetting() now maps blank to null, so the documented fallback actually
happens. Applies to every signing field, not just the key password - the same
trap would have hit any of them.
2026-07-30 20:56:33 +03:00
ospab 3cda1a9bd4 ci: cache the GUI/Android Rust builds, and diagnose signing failures early
Build-time work, plus a fix for the v0.4.2 Android signing failure.

Caching. The three Tauri GUI jobs were the slowest in the matrix (up to
9m17s vs 2-5m for the plain release targets) for two compounding reasons:

  - No restore-keys. The cache key ends in hashFiles('**/Cargo.lock'), and
    cutting a release rewrites every Cargo.lock (version bump), so the exact
    key missed on every single release. With no prefix fallback the cache
    restored nothing at all and each release rebuilt the full dependency
    graph from scratch. The plain release targets had restore-keys all along,
    which is exactly why they were multiples faster.
  - Wrong path. ostp-gui/src-tauri is excluded from the workspace, so its
    build output lands in ostp-gui/src-tauri/target/, not the cached target/.
    The bulk of each GUI job's Rust work was therefore never cached even when
    the key did hit - visible in the cache sizes (25-30 MiB for the macOS and
    Linux GUI entries, against 150-220 MiB for real target/ caches).

The Android jobs had no Rust cache whatsoever, and rebuilt cargo-ndk from
source every run; both now cache, the latter mirroring how `cross` is
already handled.

Signing diagnostics. v0.4.2's Android jobs failed after four minutes of
Gradle with "keystore password was incorrect". The keystore is now decoded
with stray CR/LF stripped (a single trailing \r corrupts the decode) and
validated with keytool up front, so a bad password or a missing alias fails
in seconds with a message that says which. The printed size and SHA-256
disclose nothing secret and let the operator tell a mangled transfer apart
from a genuinely wrong password.
2026-07-30 20:43:55 +03:00
ospab 77a45d7642 chore: release v0.4.2 on master 2026-07-30 20:31:26 +03:00
ospab 6abae68f35 fix(android): default the signing key password to the store password
Our upload keystore is PKCS12 (verified from its DER header, 0x30 0x82 —
JKS would start 0xFEEDFEED). That format has nowhere to store a key password
distinct from the store password, and keytool enforces the two being equal,
so requiring a separate OSTP_KEY_PASSWORD meant configuring a secret whose
only possible correct value was a copy of another one.

Falls back to the store password when unset; an explicit value still takes
precedence for the legacy JKS format, where the two can genuinely differ.
2026-07-30 20:28:07 +03:00
ospab cb57347d51 chore: never let an Android signing key be committed
ostp-upload.jks was sitting untracked but NOT ignored in the repo root, so a
single `git add -A` would have swept the private upload key into a public
repository. That key is unrecoverable-by-design: Android refuses to update an
app across a signing-key change, so leaking it (or losing it) means every
existing install is stranded and can only be fixed by a manual uninstall.

Ignores *.jks, *.keystore and key.properties at the root. The ostp-lab/ line
in this diff is not mine - it was already in the working tree and is carried
along because it shares the file.
2026-07-30 20:25:28 +03:00
ospab 32c36afc3b fix(android): sign releases with a stable upload key, not the debug keystore
Published APKs could never be updated over - users hit "App not installed" or
"unable to parse the package" and had to uninstall first. The cause was not the
version code (verified: local.properties carries flutter.versionCode=23 and
gha.ps1 bumps pubspec's build number every release, so it increments correctly).
It was the signing key: app/build.gradle.kts still had the stock Flutter
template TODO and pointed the release build type at signingConfigs["debug"].
Android identifies an app by applicationId + signing key and refuses to update
across a key change, and the debug keystore is generated per machine - on
ephemeral CI runners that means every single published build was signed with a
different random key.

Release builds now take their key from android/key.properties or the
OSTP_KEYSTORE_* environment variables, falling back to debug (with a loud
warning) only so local `flutter build apk --release` keeps working. CI
materialises the keystore from repository secrets, refuses to build at all if
the secret is absent, and re-verifies the finished APK is not debug-signed
rather than ever shipping an un-updatable build again.

NOTE: existing installs are signed with a now-unreproducible random key, so
users must uninstall once more for THIS release. Every update after it works.
2026-07-30 19:54:31 +03:00
ospab a8aba8f4b8 feat(gui): green aura on the connected state
The palette already declared --c-green/--c-green-glow/--c-green-dim with the
comment "Green only for connected state", but the values were near-white
(#e8e8e8) and nothing referenced them - so a successful connection looked
identical to every other state. Gave the tokens real green values (per theme,
deeper on light so it stays legible), added a --c-green-rgb triple so the
translucent layers can be expressed from one source, and wired them into the
three things that signal "connected": the power button's border and glow, the
orbit rings around it, and the brand status dot.
2026-07-30 19:54:11 +03:00
17 changed files with 569 additions and 51 deletions

View File

@ -378,7 +378,15 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
ostp-gui/src-tauri/target/
key: cargo-windows-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
# Without a prefix fallback this cache NEVER restored on a release:
# cutting a release rewrites every Cargo.lock (version bump), which
# changes hashFiles(), which misses the exact key — so each release
# rebuilt every dependency from scratch. That is why the GUI jobs ran
# 2-4x longer than the plain release targets, which had this all along.
restore-keys: |
cargo-windows-gui-${{ matrix.target }}-
- name: Download wintun
shell: pwsh
@ -460,7 +468,10 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
ostp-gui/src-tauri/target/
key: cargo-linux-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-linux-gui-${{ matrix.target }}-
- name: Build Tauri App
working-directory: ostp-gui
@ -522,7 +533,10 @@ jobs:
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
ostp-gui/src-tauri/target/
key: cargo-macos-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-macos-gui-${{ matrix.target }}-
- name: Build Tauri App
working-directory: ostp-gui
@ -587,27 +601,107 @@ jobs:
with:
ndk-version: r26b
- name: Install cargo-ndk
run: cargo install cargo-ndk
# The Android jobs had no Rust caching at all, so every release recompiled
# the whole ostp-jni dependency graph from scratch — the main reason these
# were among the slowest jobs in the matrix.
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: cargo-android-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-android-${{ matrix.arch }}-
# cargo-ndk was built from source on every run. Cache the binary the same
# way the cross-compilation jobs already cache `cross`.
- name: Restore cargo-ndk binary cache
id: cargo-ndk-cache
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-ndk
key: cargo-ndk-bin-${{ runner.os }}-v1
- name: Install cargo-ndk (if not cached)
if: steps.cargo-ndk-cache.outputs.cache-hit != 'true'
run: cargo install cargo-ndk --locked
- name: Build Android APK
shell: bash
working-directory: ostp-flutter
env:
OSTP_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
OSTP_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
OSTP_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
OSTP_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
# 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 }}
cd ../ostp-jni
cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
cd ../ostp-flutter
# 3. Build Flutter APK
flutter build apk --release --target-platform ${{ matrix.flutter_target }}
# 4. Copy to output
cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
# 4. Fail loudly if the APK somehow still came out debug-signed, rather
# than shipping another un-updatable build.
APK=build/app/outputs/flutter-apk/app-release.apk
if "$ANDROID_HOME"/build-tools/*/apksigner verify --print-certs "$APK" 2>/dev/null \
| grep -qi "CN=Android Debug"; then
echo "::error::APK is signed with the Android debug certificate - aborting."
exit 1
fi
# 5. Copy to output
cp "$APK" ostp-android-${{ matrix.arch }}.apk
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2

8
.gitignore vendored
View File

@ -26,6 +26,13 @@ test_route.ps1
config.json
wintun.dll
# Android signing keys. The upload keystore is the ONE key every published APK
# must be signed with (Android refuses to update an app across a key change),
# so losing or leaking it is unrecoverable — it can never be committed.
*.jks
*.keystore
key.properties
# Server runtime cache (public IP autodetect) — must never be committed,
# it's regenerated locally and leaks whatever host it ran on last.
.ostp_public_ip
@ -39,6 +46,7 @@ turn-harvesting-idea.md
# Private tooling (closed-source)
ostp-prober/
ostp-lab/
ostp-brain/

View File

@ -1,6 +1,6 @@
{
"target_version": "0.4.2",
"target_version": "0.4.3",
"branch": "beta",
"alpha_iteration": 0,
"beta_iteration": 5
"beta_iteration": 1
}

12
Cargo.lock generated
View File

@ -1386,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"base64",
@ -1409,7 +1409,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"base64",
@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"bytes",
@ -1474,7 +1474,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"axum",
@ -1507,7 +1507,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"libc",
@ -1519,7 +1519,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"chrono",

View File

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "AGPL-3.0"
version = "0.4.2"
version = "0.4.3"
[workspace.dependencies]
anyhow = "1.0"

View File

@ -137,6 +137,16 @@ pub struct Bridge {
last_rtt_ms: f64,
last_sample_at: Instant,
last_valid_recv: Instant,
/// Set when a suspend/resume is detected, cleared once a reconnect actually
/// succeeds. Waking is precisely when the network is least likely to be
/// ready — Wi-Fi has not reassociated yet — so a single attempt fired
/// milliseconds after resume usually fails, and a one-shot forced reconnect
/// then fell back to the ordinary 25s stall heuristic. That heuristic keys
/// off a monotonic clock which does not advance while the machine is
/// asleep, so it could take a further 25s of real uptime to fire, or not
/// fire at all. Retrying until success removes the dependency on either.
forced_reconnect_pending: bool,
last_forced_reconnect_try: Instant,
}
impl Bridge {
@ -173,6 +183,8 @@ impl Bridge {
last_rtt_ms: 0.0,
last_sample_at: Instant::now(),
last_valid_recv: Instant::now(),
forced_reconnect_pending: false,
last_forced_reconnect_try: Instant::now(),
})
}
@ -255,7 +267,27 @@ impl Bridge {
let _ = tx.send(UiEvent::Log(format!(
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
))).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;
// 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 {
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| {
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) => {
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(),
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);
@ -939,7 +998,8 @@ impl Bridge {
let socket = match self.try_connect_transport(target_ip, port).await {
Ok(sock) => sock,
Err(e) => {
if let std::net::IpAddr::V4(ipv4) = 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();
let nat64_ipv6 = synthesize_nat64(ipv4).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 {
(socket, size)
} 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();
let nat64_ipv6 = synthesize_nat64(ipv4).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 {
// 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];
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 {
if let std::net::SocketAddr::V6(v6) = addr {
let octets = v6.ip().octets();

View File

@ -39,6 +39,9 @@ pub struct CongestionController {
loss_count: u32,
/// Pacing rate: bytes per second
pacing_rate: u64,
/// Token-bucket allowance for pacing, in bytes.
pacing_tokens: f64,
pacing_last_refill: Instant,
/// MTU estimate (used for cwnd → packet count conversion)
mtu: u64,
/// Min RTT expiry: re-probe after 10 seconds
@ -65,6 +68,20 @@ const MIN_CWND_PACKETS: u64 = 2;
/// Min RTT expiry window (after which we re-probe)
const MIN_RTT_EXPIRY: Duration = Duration::from_secs(10);
/// Minimum RTO (RFC 6298: 1s in TCP; we use 50ms since we own the protocol)
/// Absolute ceiling on the congestion window, in packets. At a ~1200-byte MTU
/// this is roughly 1.2 MB in flight — already far above the bandwidth-delay
/// product of any link this protocol realistically runs over, so anything
/// beyond it is standing queue, not throughput. The client previously allowed
/// up to 16384 packets (~20 MB), which on a mobile uplink is minutes of buffer.
const MAX_CWND_PACKETS: u64 = 1024;
/// SRTT/min_rtt ratio at which slow start stops. Doubling is what fills a deep
/// buffer fastest, so growth must end when the queue starts building rather
/// than waiting for a loss that a deep buffer may never produce.
const RTT_INFLATION_EXIT_SLOW_START: f64 = 2.0;
/// SRTT/min_rtt ratio treated as a standing queue that must be actively drained.
const RTT_INFLATION_BACKOFF: f64 = 4.0;
/// How much pacing allowance may accumulate, expressed as time-at-rate.
const PACING_BURST: Duration = Duration::from_millis(10);
const RTO_MIN: Duration = Duration::from_millis(50);
/// Maximum RTO
const RTO_MAX: Duration = Duration::from_secs(16);
@ -113,9 +130,50 @@ impl CongestionController {
min_rtt_stamp: now,
slow_start_losses: 0,
slow_start_loss_window_start: now,
pacing_tokens: (INITIAL_CWND_PACKETS * mtu) as f64,
pacing_last_refill: now,
}
}
/// Bytes of pacing allowance available right now, without consuming any.
///
/// Read-only so the send path can use it as an admission check before it
/// commits to building a datagram.
pub fn pacing_available(&self) -> f64 {
let elapsed = self.pacing_last_refill.elapsed().as_secs_f64();
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
}
/// Whether at least one full-size packet may be released right now.
pub fn can_pace_packet(&self) -> bool {
self.pacing_available() >= self.mtu as f64
}
/// Ceiling on accumulated allowance.
///
/// Pacing intervals here are fractions of a millisecond, so releasing
/// strictly one packet at a time would need a sub-millisecond timer per
/// packet. Instead we allow a short burst — the same trade every real
/// pacing implementation makes — sized so the loop's existing ~10ms wakeups
/// can still saturate the configured rate, with a small floor so a
/// cold/low estimate can never wedge sending entirely.
fn pacing_burst(&self) -> f64 {
let by_rate = self.pacing_rate as f64 * PACING_BURST.as_secs_f64();
by_rate.max((self.mtu * 4) as f64)
}
/// Refill from elapsed time and deduct `bytes`. Called on the real send
/// path; allowance is permitted to go negative so an oversized packet still
/// pays for itself rather than being released for free.
fn consume_pacing(&mut self, bytes: u64) {
let now = Instant::now();
let elapsed = now.duration_since(self.pacing_last_refill).as_secs_f64();
self.pacing_last_refill = now;
self.pacing_tokens =
(self.pacing_tokens + elapsed * self.pacing_rate as f64).min(self.pacing_burst())
- bytes as f64;
}
/// Returns the current congestion window in bytes.
pub fn cwnd(&self) -> u64 {
self.cwnd
@ -167,6 +225,11 @@ impl CongestionController {
/// Record that we sent `bytes` of data.
pub fn on_send(&mut self, bytes: u64) {
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
// Charge the pacing bucket here rather than at the admission check, so
// every byte that actually reaches the wire is paid for exactly once —
// including retransmits, which are precisely what must not be allowed
// to bypass the rate limit and pile into an already-full queue.
self.consume_pacing(bytes);
}
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
@ -198,9 +261,46 @@ impl CongestionController {
/// Congestion-window growth shared by both ACK paths (slow start / probe).
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 {
Phase::SlowStart => {
// Exponential doubling is what fills a deep buffer fastest, so
// leave slow start as soon as the queue starts to build rather
// than waiting for the loss that may never come.
if inflation >= RTT_INFLATION_EXIT_SLOW_START {
self.ssthresh = self.cwnd;
self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, inflation, "congestion: RTT inflation ended slow start");
self.clamp_cwnd();
return;
}
// Exponential growth: increase cwnd by acked bytes (doubles per RTT)
self.cwnd = self.cwnd.saturating_add(bytes);
if self.cwnd >= self.ssthresh {
@ -213,6 +313,21 @@ impl CongestionController {
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.
@ -332,6 +447,94 @@ mod tests {
assert!(cc.cwnd() < initial);
}
/// The bufferbloat case: a deep buffer absorbs everything, so NOTHING is
/// ever lost, but the standing queue inflates RTT. A loss-only controller
/// grows cwnd forever here — which is how a session ends up reporting
/// multi-second RTT and stalling video.
#[test]
fn test_rtt_inflation_halts_growth_without_any_loss() {
let mut cc = CongestionController::new(1200);
// Establish a low path floor; this becomes min_rtt.
for _ in 0..4 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(20));
}
let cwnd_before = cc.cwnd();
// Queue builds: RTT climbs far above the floor, still zero loss.
for _ in 0..20 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(400));
}
assert!(
cc.cwnd() <= cwnd_before,
"cwnd kept growing while the queue was inflating RTT ({} -> {})",
cwnd_before,
cc.cwnd()
);
}
/// Pacing must actually bound the release rate: draining the bucket has to
/// deny the next packet. Without this the congestion window alone decides,
/// and a whole window leaves back-to-back.
#[test]
fn test_pacing_bucket_denies_once_drained() {
let mut cc = CongestionController::new(1200);
assert!(cc.can_pace_packet(), "a fresh controller must allow sending");
// Spend well beyond one burst allowance.
let burst_bytes = cc.pacing_available();
let mut spent = 0.0;
while spent <= burst_bytes + 1200.0 {
cc.on_send(1200);
spent += 1200.0;
}
assert!(
!cc.can_pace_packet(),
"pacing allowed unbounded sending: {} bytes still available after spending {}",
cc.pacing_available(),
spent
);
}
/// The allowance must refill over time, or sending would stall permanently
/// once the first burst is spent.
#[test]
fn test_pacing_bucket_refills_over_time() {
let mut cc = CongestionController::new(1200);
while cc.can_pace_packet() {
cc.on_send(1200);
}
assert!(!cc.can_pace_packet());
std::thread::sleep(Duration::from_millis(25));
assert!(
cc.can_pace_packet(),
"pacing bucket never refilled; sending would be stuck forever"
);
}
/// cwnd must never exceed the absolute ceiling, however long slow start
/// runs unopposed — above it the window is buffered queue, not throughput.
#[test]
fn test_cwnd_never_exceeds_absolute_ceiling() {
let mut cc = CongestionController::new(1200);
// Constant RTT: no inflation signal, so only the hard cap can stop this.
for _ in 0..5000 {
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(30));
}
assert!(
cc.cwnd() <= MAX_CWND_PACKETS * 1200,
"cwnd {} exceeded the {}-packet ceiling",
cc.cwnd(),
MAX_CWND_PACKETS
);
}
#[test]
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
// A single dropped packet (wireless noise, a brief handover blip) is

View File

@ -4,6 +4,11 @@ use thiserror::Error;
use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
/// Upper bound on a single frame's retransmit timer, after exponential backoff
/// is applied to the adaptive RTO. Past this the session is dead from the
/// user's point of view, and waiting longer only delays recovery.
const MAX_EFFECTIVE_RTO: Duration = Duration::from_secs(8);
use crate::congestion::CongestionController;
use crate::crypto::{NoiseRole, NoiseSession, SessionCipher};
use crate::framing::{AdaptivePadder, FrameHeader, FrameKind, FramedPacket, PaddingStrategy};
@ -183,6 +188,16 @@ impl ProtocolMachine {
self.cc.cwnd_packets() as usize
}
/// Whether the pacing bucket currently allows releasing another packet.
///
/// The congestion window bounds how much may be UNACKNOWLEDGED; it says
/// nothing about how fast that window is emptied onto the wire. Sending a
/// whole window back-to-back is what drives a deep buffer into standing
/// queue, so admission is gated on both.
pub fn can_pace_packet(&self) -> bool {
self.cc.can_pace_packet()
}
pub fn on_send(&mut self, bytes: u64) {
self.cc.on_send(bytes);
}
@ -675,8 +690,15 @@ impl ProtocolMachine {
break;
}
// Exponential backoff, but bounded in absolute terms. base_rto is
// itself adaptive and can reach RTO_MAX (16s) on a congested path;
// multiplying that by the 64x backoff cap yields a frame that sits
// unretransmitted for ~17 MINUTES, long past the point where the
// session is simply dead to the user. Cap the product so backoff
// stays a backoff rather than an outage.
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
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 {
// Only burn the retry counter and reset the RTO timer when the

View File

@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@ -5,6 +8,37 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// ── Release signing material ────────────────────────────────────────────────
// Supplied out-of-band and never committed: either an `android/key.properties`
// file (local release builds) or OSTP_KEYSTORE_* environment variables (CI).
//
// This exists because the release build used to be signed with the DEBUG
// keystore (the stock Flutter template TODO). Android identifies an app by
// applicationId + signing key, and refuses to update across a key change. The
// debug keystore is auto-generated per machine, and CI runners are ephemeral,
// so every published build carried a different random key — which is why
// updating on top of a previous install failed with "App not installed" /
// "unable to parse the package" and only a full uninstall+reinstall worked.
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("key.properties")
if (propsFile.exists()) {
FileInputStream(propsFile).use { load(it) }
}
}
// Blank counts as absent. GitHub Actions substitutes an EMPTY STRING (not an
// unset variable) for a secret that doesn't exist, so `getenv(...) ?: fallback`
// silently kept the empty value — the elvis operator only catches null. That is
// how an unset ANDROID_KEY_PASSWORD ended up being used as the literal key
// password instead of falling back to the store password, producing Gradle's
// "Get Key failed: Given final block not properly padded".
fun signingSetting(propKey: String, envKey: String): String? =
(keystoreProperties.getProperty(propKey) ?: System.getenv(envKey))
?.takeIf { it.isNotBlank() }
val releaseStorePath: String? = signingSetting("storeFile", "OSTP_KEYSTORE_PATH")
val hasReleaseSigning: Boolean = !releaseStorePath.isNullOrBlank()
android {
namespace = "com.ospab.ostp_client"
compileSdk = flutter.compileSdkVersion
@ -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 {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Use the real upload key when one was supplied; otherwise fall back to
// the debug keystore so a plain local `flutter build apk --release`
// still works for development. Anything PUBLISHED must take the first
// branch — a debug-signed build cannot be updated over, and its key is
// machine-local, so it also can't be reproduced later.
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn(
"OSTP: no release keystore configured (android/key.properties or " +
"OSTP_KEYSTORE_PATH) - falling back to the DEBUG keystore. This APK " +
"is for local use only: users cannot update over it, and the key is " +
"not reproducible on another machine."
)
signingConfig = signingConfigs.getByName("debug")
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.4.2+24
version: 0.4.3+26
environment:
sdk: ^3.11.4

View File

@ -1,7 +1,7 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.4.2",
"version": "0.4.3",
"type": "module",
"scripts": {
"tauri": "tauri",

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2696,7 +2696,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"bytes",
@ -2713,7 +2713,7 @@ dependencies = [
[[package]]
name = "ostp-gui"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"json_comments",
@ -2733,7 +2733,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.2"
version = "0.4.3"
dependencies = [
"anyhow",
"libc",

View File

@ -1,6 +1,6 @@
[package]
name = "ostp-gui"
version = "0.4.2"
version = "0.4.3"
description = "OSTP desktop GUI"
authors = ["ospab"]
edition = "2021"

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.4.2",
"version": "0.4.3",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"

View File

@ -17,10 +17,12 @@
--c-accent-dim: rgba(var(--c-fg-rgb),0.08);
--c-accent-glow: rgba(var(--c-fg-rgb),0.18);
/* Green only for "connected" state */
--c-green: #e8e8e8;
--c-green-glow: rgba(232,232,232,0.25);
--c-green-dim: rgba(232,232,232,0.07);
/* Green only for "connected" state the one deliberate break from the
monochrome palette, so a successful connection reads at a glance. */
--c-green-rgb: 46, 230, 109;
--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-amber: #f0b840;
@ -55,9 +57,11 @@
--c-accent: #18181b;
--c-accent-dim: rgba(0,0,0,0.08);
--c-accent-glow: rgba(0,0,0,0.14);
--c-green: #18181b;
--c-green-glow: rgba(0,0,0,0.16);
--c-green-dim: rgba(0,0,0,0.05);
/* Deeper green so it stays legible against the light background. */
--c-green-rgb: 22, 163, 74;
--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-amber: #d97706;
--c-txt-1: #18181b;
@ -155,7 +159,7 @@ a { text-decoration: none; }
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.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 {
0%,100% { opacity: 1; }
@ -233,11 +237,11 @@ a { text-decoration: none; }
.orbit-wrap.connected .orbit {
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;
}
.orbit-wrap.connected .orbit-2 { animation-duration: 6s; animation-direction: reverse; border-color: rgba(var(--c-fg-rgb),0.08); }
.orbit-wrap.connected .orbit-3 { animation-duration: 9s; border-color: rgba(var(--c-fg-rgb),0.04); }
.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-green-rgb),0.10); }
@keyframes orbit-spin {
from { transform: rotate(0deg); }
@ -270,9 +274,9 @@ a { text-decoration: none; }
animation: btn-breathe 2s infinite ease-in-out;
}
.power-btn.connected {
border-color: rgba(var(--c-fg-rgb),0.8);
color: var(--c-txt-1);
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);
border-color: var(--c-green);
color: var(--c-green);
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 {
border-color: var(--c-red);

View File

@ -263,8 +263,19 @@ impl Dispatcher {
self.peer_machines
.iter()
.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;
// 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()

View File

@ -28,6 +28,12 @@ enum Commands {
Init {
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
#[command(name = "gk", alias = "generate-key")]
GenerateKey {
@ -920,6 +926,38 @@ async fn run_app() -> Result<()> {
match cmd {
Commands::Setup { init } => { args.setup = true; args.init = init; }
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::Links => { args.links = true; }
Commands::Check => { args.check = true; }