Compare commits

..

No commits in common. "365b4ccbf5dfad1eaf0b013523c3fb62ae66ea4f" and "2ede607027ffbddb0e0d87c13f7acdce55581e3d" have entirely different histories.

17 changed files with 51 additions and 569 deletions

View File

@ -378,15 +378,7 @@ 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
@ -468,10 +460,7 @@ 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
@ -533,10 +522,7 @@ 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
@ -601,107 +587,27 @@ jobs:
with: with:
ndk-version: r26b ndk-version: r26b
# The Android jobs had no Rust caching at all, so every release recompiled - name: Install cargo-ndk
# the whole ostp-jni dependency graph from scratch — the main reason these run: cargo install cargo-ndk
# 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: |
set -euo pipefail # 1. Compile JNI
# 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. Fail loudly if the APK somehow still came out debug-signed, rather # 4. Copy to output
# than shipping another un-updatable build. cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
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

8
.gitignore vendored
View File

@ -26,13 +26,6 @@ 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
@ -46,7 +39,6 @@ turn-harvesting-idea.md
# Private tooling (closed-source) # Private tooling (closed-source)
ostp-prober/ ostp-prober/
ostp-lab/
ostp-brain/ ostp-brain/

View File

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

12
Cargo.lock generated
View File

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

View File

@ -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.3" version = "0.4.2"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0" anyhow = "1.0"

View File

@ -137,16 +137,6 @@ 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 {
@ -183,8 +173,6 @@ 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(),
}) })
} }
@ -267,27 +255,7 @@ 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;
@ -304,20 +272,7 @@ 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| {
// Upper bound matches MAX_CWND_PACKETS in ostp-core's congestion s.iter().any(|ses| ses.machine.in_flight_count() < ses.machine.cwnd_packets().clamp(16, 16384))
// 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;
} }
@ -971,21 +926,7 @@ 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)),
}; };
// IPv4 first. Addresses are tried strictly in order, each burning its resolved_addrs.sort_by_key(|addr| if addr.is_ipv6() { 0 } else { 1 });
// 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);
@ -998,8 +939,7 @@ 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), false) = (target_ip, nat64_attempted) { if let std::net::IpAddr::V4(ipv4) = target_ip {
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 {
@ -1080,8 +1020,7 @@ 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), false) = (target_ip, nat64_attempted) { if let std::net::IpAddr::V4(ipv4) = target_ip {
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 {
@ -1325,19 +1264,8 @@ 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];
// Bound the discovery lookup. This runs on exactly the networks that are if let Ok(addrs) = tokio::net::lookup_host("ipv4only.arpa:80").await {
// 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();

View File

@ -39,9 +39,6 @@ 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
@ -68,20 +65,6 @@ 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);
@ -130,50 +113,9 @@ 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
@ -225,11 +167,6 @@ 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
@ -261,46 +198,9 @@ 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) {
// ── Delay-based congestion signal ──────────────────────────────────── // State machine
// 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 {
@ -313,21 +213,6 @@ 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.
@ -447,94 +332,6 @@ 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

View File

@ -4,11 +4,6 @@ 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};
@ -188,16 +183,6 @@ 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);
} }
@ -690,15 +675,8 @@ 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

View File

@ -1,6 +1,3 @@
import java.io.FileInputStream
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@ -8,37 +5,6 @@ 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
@ -68,43 +34,11 @@ android {
} }
} }
signingConfigs {
create("release") {
if (hasReleaseSigning) {
val store = signingSetting("storePassword", "OSTP_KEYSTORE_PASSWORD")
storeFile = file(releaseStorePath!!)
storePassword = store
keyAlias = signingSetting("keyAlias", "OSTP_KEY_ALIAS")
// PKCS12 (the keytool default since Java 9, and what our upload
// keystore is) cannot hold a key password that differs from the
// store password — the format simply has no place to put one. So
// treat a missing key password as "same as the store password"
// instead of demanding a secret that, for this keystore, can only
// ever be a duplicate. An explicit value still wins, for the older
// JKS format where the two genuinely can differ.
keyPassword = signingSetting("keyPassword", "OSTP_KEY_PASSWORD") ?: store
}
}
}
buildTypes { buildTypes {
release { release {
// Use the real upload key when one was supplied; otherwise fall back to // TODO: Add your own signing config for the release build.
// the debug keystore so a plain local `flutter build apk --release` // Signing with the debug keys for now, so `flutter run --release` works.
// still works for development. Anything PUBLISHED must take the first signingConfig = signingConfigs.getByName("debug")
// 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")
} }
} }

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 # 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.3+26 version: 0.4.2+24
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4

View File

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

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.4.3" version = "0.4.2"
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.3" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@ -2713,7 +2713,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-gui" name = "ostp-gui"
version = "0.4.3" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"json_comments", "json_comments",
@ -2733,7 +2733,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.4.3" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",

View File

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

View File

@ -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.3", "version": "0.4.2",
"identifier": "com.ospab.ostp", "identifier": "com.ospab.ostp",
"build": { "build": {
"frontendDist": "../src" "frontendDist": "../src"

View File

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

View File

@ -263,19 +263,8 @@ impl Dispatcher {
self.peer_machines self.peer_machines
.iter() .iter()
.map(|(&sid, ps)| { .map(|(&sid, ps)| {
// Ceiling matches MAX_CWND_PACKETS in ostp-core. The old 16384 let cwnd = (ps.machine.cwnd_packets() as i64).clamp(16, 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()

View File

@ -28,12 +28,6 @@ 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 {
@ -926,38 +920,6 @@ 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; }