Commit Graph

544 Commits

Author SHA1 Message Date
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
ospab 2ede607027 chore: release v0.4.2-beta.5 on beta 2026-07-30 19:02:48 +03:00
ospab 0c69617725 fix(cli): trait-qualify Sha256::digest so it builds with or without the import
Follow-up to the v0.4.2-beta.3 CI break. Importing sha2::Digest fixed the
build there but the import reads as unused locally (different dependency
resolution), leaving a permanent warning in every build. Calling through
<sha2::Sha256 as sha2::Digest>::digest resolves the trait method
explicitly, so it compiles in both environments with no import and no
warning. Workspace now builds clean.
2026-07-30 14:41:16 +03:00
ospab 88e0634f09 fix: two independent causes of the tunnel freezing at 0 b/s
Both produce the same reported symptom - traffic stops dead, the session
itself looks fine, and only a manual reconnect recovers it.

1. protocol.rs: a retry could be charged to a frame that was never sent.
   The retransmit loop is budget-limited per tick, but it bumped `retries`
   and reset `last_sent` for every due frame regardless of whether the
   budget actually allowed a send. The budget is smallest exactly when loss
   is heaviest (it is derived from cwnd, which collapses under loss), so
   under real packet loss frames accumulated "phantom retries" they never
   received - measured at 40 retries charged for 8 frames actually sent in
   one tick. After max_retries+2 such rounds the zombie eviction dropped
   them as dead. That data was never delivered and never would be: the
   stream stalls permanently while pings keep flowing, so nothing upstream
   notices anything is wrong. Retries/timers are now only charged on an
   actual transmit, and the loop stops scanning once the budget is spent
   (sent_history is in send order, so this also keeps retransmit priority
   oldest-first). Covered by a new test that asserts retries charged ==
   datagrams emitted; verified it fails against the old code.

2. bridge.rs: the stall detector was reset by datagrams that never
   validated. `last_valid_recv` - "last VALID recv" - was assigned before
   decryption, so a datagram that failed to decrypt still refreshed it on
   its way to the error return. Anything landing on that port kept the
   client convinced the tunnel was healthy: frames from a session the
   server had already evicted, stale retransmits, or plain garbage from an
   off-path source that knows the ip:port. The 25s background reconnect in
   handle_keepalive therefore never fired. It also made the UI health
   indicator report a dead tunnel as fine, and gave any off-path sender a
   trivial way to pin a client in a dead session indefinitely. Now set only
   after the datagram authenticates and decrypts.
2026-07-30 00:36:17 +03:00
ospab 7473278cc2 fix(client): bound the UoT connect; green aura + self-updating ping on mobile
UoT took 20-30s (sometimes 1-2 min) to come up on mobile. The TCP connect
had no timeout, so it inherited the kernel's SYN retry budget. Callers
resolve every address for the server and deliberately try IPv6 first
(perform_handshake_with_id sorts is_ipv6 to the front); a mobile network
that advertises IPv6 without a working route blackholes the SYN instead of
rejecting it, so the client sat through that entire budget before reaching
the IPv4 address that would have connected immediately. UDP never showed
this because connect() on a UDP socket just sets the default peer and
returns.

Capped at 4s per address, so a blackholed candidate costs seconds and the
next one is tried. Left the IPv6-first ordering alone: it is what makes
IPv6-only and NAT64 networks work, and with the cap its worst case is now
bounded. (A further win would be remembering which family last succeeded
and trying that first, removing even those 4s — not done here.)

Also, per the earlier UI requests:
- The connected state drew its aura, ring, icon and status dot from the
  theme's `secondary`, which is #AAAAAA and reads as plain white, giving no
  confirmation the tunnel was actually up. Now green, reusing the green
  already used for a healthy ping so "green = good" stays consistent.
  Applied at the call sites rather than to the theme, since `secondary`
  also paints routing toggles, the download metric and settings switches.
- Ping now updates itself from the metrics stream that was already
  arriving, instead of needing the "Test Ping" button, and is rendered as a
  compact icon + value.
2026-07-29 20:06:07 +03:00
ospab 77e42b77f7 fix(protocol): recover from an unrecoverable gap instead of freezing forever
The freeze users hit every few minutes: traffic drops to 0 B/s, the RTT
readout sticks at its last value, and only a manual reconnect clears it.

Delivery is gated on expected_recv_nonce, so one missing frame holds back
every frame behind it. That is correct only while the sender can still
retransmit — but the sender drops a frame from sent_history once it passes
max_retries + 2 attempts (zombie eviction in handle_tick). Past that point
the frame no longer exists anywhere and both sides deadlock: the receiver
buffers indefinitely and NACKs a nonce nobody can resend.

The watchdog could not save it, which is why it froze rather than
reconnecting. Retransmits, ACKs and NACKs keep arriving throughout, so the
client's last_valid_recv keeps refreshing and its 25s stall detector never
fires. The frozen RTT has the same cause: Pong travels in a Data frame,
stuck behind the very gap it would have reported.

The machinery for this was half-built: last_recv_advance was declared,
initialised and written on every advance, and its doc comment describes
exactly this recovery — but nothing ever read it, and a warning elsewhere
already referred to "gap recovery" that did not exist.

So implement it. Once the sequence has been stuck longer than the sender's
retransmit budget could plausibly last (8x the live RTO, clamped to 2..10s
so fast links do not discard merely-late frames and slow ones still
unblock), skip to the lowest buffered nonce, drain, and mark an ACK
pending so the peer stops retransmitting into a void.

This runs on the inbound path, not on Tick, for two reasons: both tick
handlers discard DeliverApp actions (client bridge.rs and server
dispatcher.rs match only SendDatagram/Multiple), and inbound frames keep
flowing all through the stall, so the path is reliably reached.

Skipping the hole drops one frame's payload — one RelayMessage, a chunk of
a single stream. That is a real cost, paid only when the data was already
lost for good, against a tunnel that otherwise stays dead until the user
intervenes.

Both tests were confirmed to fail without the fix (0 frames released
instead of 2), so they pin the deadlock rather than just the happy path.
2026-07-29 19:56:09 +03:00
ospab e7a4f2b4a4 merge master: reconcile the two direct install.sh hotfixes
Both were emergency live-fixes to master (since install.sh/install.ps1 are
curl'd straight from that branch's raw URL, bypassing the normal release
promotion): the `ostp setup` subcommand fix and the alpha/beta self-update
mechanism fix. alpha already has equivalent content for both via its own
separate commits, so this is a pure reconciliation.
2026-07-21 18:42:00 +03:00
ospab 6bc646c8a5 fix(install): alpha/beta self-update actually finds a real release now
Direct hotfix to master (like the earlier `ostp setup` wizard fix) - users
curl install.sh live from this branch's raw URL for every self-update, so
this can't wait for the normal alpha->beta->master promotion.

Master's install.sh still had the pre-rename "pre-release" branch check
(the alpha->beta rename landed on alpha/beta after this file's last direct
hotfix, never reaching master) AND the deeper bug: ostp update -b alpha/-b
beta tried to download a GitHub Release literally tagged "alpha"/"beta".
No such tag has ever existed - gha.ps1 cuts a fresh VERSIONED tag every
release (v0.4.2-beta.4, v0.4.3-alpha.2, ...) - so -b beta fell through to
the stable-release path entirely unnoticed (silently "succeeding" with the
wrong, older version) while -b alpha 404'd outright.

Now queries the full /releases list (newest first, unlike /releases/latest
which only ever returns the newest non-prerelease) and takes the first
tag_name containing "-alpha"/"-beta". Brings master to parity with alpha's
same fix.
2026-07-21 18:41:26 +03:00
ospab d9fe749cd4 fix(install): alpha/beta self-update actually finds a real release now
ostp update -b alpha/-b beta (and install.sh --branch alpha/beta directly)
tried to download a GitHub Release literally tagged "alpha" or "beta".
No such tag has ever existed - gha.ps1 cuts a fresh VERSIONED tag on every
release (v0.4.2-beta.4, v0.4.3-alpha.2, ...) - so this always 404'd.

/releases/latest can't help either: it only ever returns the newest
non-prerelease (stable) tag, by GitHub's own definition, so it can never
surface an alpha/beta release even in principle.

Fix: for alpha/beta, query the full /releases list (returned newest-first)
and take the first tag_name containing "-alpha"/"-beta". Verified the
grep/sed extraction against a mock releases-list payload for both channels.
2026-07-21 18:39:48 +03:00
ospab cdfd2babc0 chore: release v0.4.2-beta.4 on beta 2026-07-21 18:18:01 +03:00
ospab 2092e22a7c fix(cli): missing sha2::Digest import broke the CI build (v0.4.2-beta.3)
Sha256::digest() is a trait method (from digest::Digest, re-exported as
sha2::Digest), not an inherent one - fully-qualifying the call
(sha2::Sha256::digest(...)) doesn't exempt it from Rust's requirement that
the trait itself be in scope for method resolution. Whatever made this
resolve locally without the explicit import didn't reproduce on the CI
runner's dependency resolution, breaking `cargo check` and killing the
whole v0.4.2-beta.3 matrix before a single platform job even started.
Added the import; harmless even where it isn't strictly needed.
2026-07-21 18:17:49 +03:00
ospab 5278f58903 chore: release v0.4.2-beta.3 on beta 2026-07-21 18:12:30 +03:00
ospab 340819745a chore: update Cargo.lock for ostp's new direct sha2 dependency 2026-07-21 18:10:23 +03:00
ospab e31c4b2268 fix(protocol): don't abandon slow start over a single isolated packet loss
Root cause of "connection takes 20-30s, sometimes 1-2 minutes, to reach
stable throughput" (trickle of KB for a while, then a sudden jump to full
speed): on_loss during SlowStart unconditionally halved cwnd AND
permanently switched to ProbeBandwidth's linear (+1 MTU/RTT) growth on the
very FIRST loss. Real mobile/Wi-Fi links have a non-zero background loss
rate from ordinary wireless noise and handover blips that has nothing to
do with congestion; on such a link the first RTT or two of slow start would
hit a loss, get knocked into linear growth from a still-small window, and
take an enormous number of RTTs to claw back up to full speed - directly
contradicting the module's own stated BBR-inspired design intent, since
real BBR is deliberately loss-tolerant during startup instead of treating
any loss as a hard congestion signal.

Fix: track losses within a short (500ms) window and only pay the full
exit-slow-start-and-halve cost once SLOW_START_LOSS_TOLERANCE (3) losses
land within it - sustained loss is still treated as real congestion.  A
single isolated loss now takes a mild, temporary haircut (cwnd *= 0.8) but
stays in slow start, so exponential growth continues instead of being
abandoned over a one-off dropped packet.
2026-07-21 18:08:04 +03:00
ospab e46c863ef0 fix(client): stop leaking a socket+task per direct-bypassed SOCKS5 UDP flow
Same class of bug as dbf923f (which fixed the TUN-mode UDP NAT path):
handle_udp_associate's direct-bypass branch spawned spawn_direct_udp_reader
holding its own Arc<UdpSocket> clone with no way to know when the
UDP-associate session it belonged to had ended. Every SOCKS5 UDP session
that ever bypassed traffic direct (an excluded IP/domain) leaked one
socket + one reader task for the rest of the process's life.

Wired a oneshot cancellation channel per spawned reader, held by
handle_udp_associate itself: the channel closes automatically (no explicit
signal needed) the instant that function returns, on every exit path,
telling the reader loop to stop via tokio::select! against the cancel
future.
2026-07-21 18:07:49 +03:00
ospab cddd623ad0 fix(client): coalesce bursty NetworkChanged events on mobile handoff
Root cause of "constantly disconnects on mobile, have to reconnect
manually": Android's ConnectivityManager fires onLost(old) + onAvailable(new)
within milliseconds of each other during a real Wi-Fi<->cellular handoff,
and each one queues its own BridgeCommand::NetworkChanged. Each reconnect is
a full sequential handshake (up to ~1.2s x 4 attempts x mux_sessions) run
synchronously inside the bridge's select-loop iteration - so without
coalescing, the FIRST queued NetworkChanged often starts reconnecting before
the OS has actually finished switching networks, races the dying interface,
and only fails after burning its full attempt budget. Only THEN does the
SECOND (correct) NetworkChanged get to run its own reconnect. A sub-second
handoff was turning into several extra seconds of outage on every
occurrence, and multiple back-to-back handoffs (common walking in/out of
Wi-Fi range) compounded this every time.

Fix: on NetworkChanged, drain any additional same-kind events already
queued before starting the reconnect, so a burst collapses into one attempt
using the freshest signal. A different command found while draining isn't
dropped - it's dispatched immediately (recursing into handle_bridge_cmd)
so nothing queued behind the burst gets lost or reordered incorrectly.
2026-07-21 17:57:39 +03:00
ospab 9a891310f9 fix(cli): setup wizard used a fake password hash, locking admins out of their own panel
The Server+Panel setup wizard's panel-password hashing was a placeholder:
std::collections::hash_map::DefaultHasher (SipHash, not cryptographic, and
not even a 256-bit output - only the first 8 of 32 bytes were real, the
rest zero-padded), left in by the comment "sha2 is not a direct dep of
ostp/Cargo.toml, so we use std's hasher as a placeholder digest here."

api.rs's handle_login computes the REAL SHA256 hex digest of the submitted
password and compares it against config.json's stored password_hash. Since
the wizard's placeholder never produces the same value as real SHA256 of
the same password, anyone who set up a panel through this wizard could
never actually log into it with the password it just showed them - a
complete functional break of the wizard-driven admin flow, not a corner
case.

Added sha2 as a direct ostp dependency and replaced the placeholder with
the exact same format!("{:x}", Sha256::digest(..)) api.rs's login check
uses.
2026-07-18 18:14:19 +03:00
ospab d9686c9344 fix(ci): cap lints when installing cross, so its own code can't fail our build
The mipsel-unknown-linux-musl job in v0.4.2-beta.2 failed at "Install cross":
cross-rs's own source uses a macro-at-end-of-block pattern (eyre::bail!())
that trips rustc's semicolon_in_expressions_from_macros lint on current
toolchains. `cargo install` compiles the installed package as the "local"
crate, so Cargo's usual automatic lint-capping for dependencies doesn't
apply to cross's own code - and other cross-built targets in the same run
(armv7, aarch64-linux, i686-linux) succeeded, so this reads as a race
against cross-rs's unpinned `main` branch history (no --rev/--tag) rather
than a deterministic break.

RUSTFLAGS="--cap-lints=warn" is the standard mechanism for exactly this
situation - building a third-party tool against a newer compiler than its
own lint config assumed - without touching our own build's lint levels.
More robust than pinning to one historical commit, which just relocates
the same risk to whenever that pin is next updated.
2026-07-18 17:59:02 +03:00
ospab dbf923fb16 fix(client): stop leaking a socket+task per bypassed UDP flow
start_udp_bypass_session (the TUN-mode path for UDP from apps/IPs the user
has excluded from the tunnel) spawned a separate task to read from the
physical-interface-bound socket, holding its own Arc<UdpSocket> clone.
Nothing ever cancelled that task when the outer function returned (e.g.
once session_rx closed) - it just kept running, and its socket clone kept
the OS fd alive, for the lifetime of the process. Every distinct bypassed
UDP flow (any excluded app's DNS query, game session, etc.) leaked one
socket and one task permanently.

The sibling function right below it, start_udp_session, already does this
correctly: one tokio::select! loop combining both directions in a single
task that exits (and drops the socket) as soon as either side closes.
Rewrote start_udp_bypass_session to match that pattern instead of
spawning a detached reader task.
2026-07-18 17:46:14 +03:00
ospab 51b947e6ff fix(server): rate-limit the (currently unwired) open UDP DNS listener
DnsServer::run_local_udp_listener binds 0.0.0.0 and answers every UDP
datagram by resolving it and replying to the packet's (unverified,
spoofable) source address - a textbook DNS reflection/amplification
primitive. An attacker spoofing a victim's IP as the query source turns
any server with this listener running into a free amplifier against that
victim, with zero authentication gating it (unlike the main OSTP port,
there's no Noise handshake here).

Nothing in the codebase currently calls this function - the live DNS path
is router.route_dns(), reached only through the authenticated OSTP tunnel
relay (relay.rs). But the doc comment describes this as an intended,
not-yet-wired entry point for clients that point their OS resolver
directly at the server, so it's a real latent risk for whoever connects it
without realizing the implication. Added a global (not per-source-IP -
per-IP limiting doesn't help against a reflection attack, since the
attacker never sees the replies and can spread queries across arbitrary
spoofed sources) token bucket capping total replies/sec, so connecting
this later can't silently reintroduce unbounded amplification.
2026-07-18 17:42:49 +03:00
ospab f01ed4ec25 fix(server): constant-time comparison for Management API secrets
check_token() and handle_login() compared bearer tokens, session tokens,
and the password hash with plain ==, which short-circuits on the first
differing byte - a textbook remote timing side-channel against exactly
the long-lived secrets these gates exist to protect. Added subtle (already
in the dependency tree transitively via chacha20poly1305) as a direct
dependency and route every secret comparison through a small secure_eq()
wrapper over ConstantTimeEq. Username comparison in handle_login is left
as-is: it isn't treated as a secret in this threat model (one fixed admin
username), matching standard practice of only constant-timing the
password/token side of an auth check.

Added tests for secure_eq() itself (equal, different, different-length,
empty) alongside the existing check_token coverage.
2026-07-18 17:38:09 +03:00
ospab c2a1a53b4d fix(server): audit-log API endpoints had no auth check at all
GET/POST/DELETE /api/audit were the only three handlers in the whole
Management API that never called check_token() - every other endpoint
(status, users, rules, config) does. Concretely, with the panel's
credentials configured, an unauthenticated request could still:
  - read the full audit log (GET)
  - inject arbitrary forged entries, e.g. fake "success" events to cover
    tracks (POST)
  - wipe the entire audit log (DELETE) - the exact mechanism meant to
    detect and investigate unauthorized actions, erasable with zero auth

Added the same check_token() gate the rest of the file uses, and fixed
these three handlers' raw .unwrap() on the audit_logs lock to the
poison-recovery pattern (unwrap_or_else(|e| e.into_inner())) used
everywhere else, for consistency.

Added focused unit tests on check_token() itself (missing header, correct/
wrong bearer, raw token, session token, and the documented open-panel
mode when no credentials are configured) - it's the single gate every
sensitive handler depends on, worth pinning down independently of any one
handler.
2026-07-18 17:27:58 +03:00
ospab cd12b01bc3 chore: release v0.4.2-beta.2 on beta 2026-07-18 17:10:47 +03:00
ospab de5cee103b fix(install): setup wizard is a subcommand now, not a --setup flag
Both installers still invoked `ostp --setup` / `ostp.exe --setup` to launch
the first-run wizard on a fresh install. The CLI's subcommand refactor
(2026-07-08, "Refactor CLI to subcommands") turned `setup` into
`Commands::Setup { .. }` with no top-level `--setup` flag left in Args at
all, so every fresh install has hit "error: unexpected argument '--setup'
found" and dropped the user out of the installer instead of the wizard.
Verified `ostp setup --help` parses correctly with the fix.
2026-07-18 16:49:08 +03:00
ospab c523b083cb fix(server): outbound connect no longer lets a dead IPv6 candidate eat the whole timeout
Matches a real report: traffic counters move (the OSTP tunnel handshakes
fine) but sites don't open, or take very long - on a freshly deployed
DigitalOcean droplet in Amsterdam.

connect_target's fallback path handed the raw "host:port" string straight
to TcpStream::connect, which resolves and tries addresses internally but
shares ONE 10s timeout across the WHOLE attempt (all resolved addresses,
not per-address). Some VPS hosts assign the machine an IPv6 address that
the OS prefers by RFC 6724 ordering but that has no actually-working
outbound route - the connect doesn't get refused, it just hangs. With a
single shared budget, that one dead IPv6 candidate eats the entire 10s and
the working IPv4 candidate is never even attempted: every dual-stack
destination (i.e. most popular sites) times out, while IPv4-only
destinations work fine.

New connect_direct() resolves target itself via lookup_host, sorts IPv4
candidates first, and tries each with its own 3s budget (still bounded
overall by the original 10s outer timeout as a backstop) so a hung IPv6
attempt can't starve the IPv4 fallback of a chance.

Added tests: IPv4-first sort ordering (and stability within a family), a
successful connect against a live local listener, and a refused-port
connect failing well under the timeout (proving failures aren't
needlessly slow). Scoped to connect_target's direct-connect paths; the
SOCKS5/HTTP outbound-proxy paths and the fallback/camouflage TCP proxy
(which targets a fixed admin-configured local address, not arbitrary
dual-stack hostnames) are unaffected.
2026-07-18 16:40:57 +03:00
ospab c6a130673d fix(gui): remove duplicate junk/tcp-frag fields from the profile editor modal
These were editable in two disconnected places: the profile editor modal
(pm-* fields, written into each saved profile's own tcp_fragmentation/
frag_chunk/frag_sleep/junk_pc/junk_ps) and the simple settings page (cs-*
fields, a global override applied at connect time via buildConfig()'s
merge: `s.tcpFrag || active.tcp_fragmentation`, etc). The simple settings
page already covers the same knobs, so the modal copy was pure duplication
and a source of confusion about which one actually took effect.

Removed the pm-tcp-settings panel and its fields from index.html, and all
now-dead JS: the variable lookups, the open-editor populate/reset logic,
the save-profile field writes (existing profiles keep their previously-
saved values via the {...profiles[idx], ...} merge - only new edits
through this modal no longer touch these fields), and the two change
listeners whose sole job was showing/hiding the removed panel. The
Transport (UDP/UoT) dropdown itself is untouched.
2026-07-18 16:30:44 +03:00
ospab c756e02b63 fix(server): throttle relay reads to the client session's congestion window
Matches a real user report after 2 weeks on this version: bandwidth is low,
the client reconnects every 10-20 minutes, sites randomly stop loading or
crawl, and ping visibly jitters. Root cause: the per-target-connection
reader task (handle_relay_message's Connect handler) read from the
upstream target as fast as it would send and forwarded every chunk
straight to send_relay_to_stream -> an immediate UDP datagram, with ZERO
awareness of the client-facing OSTP session's actual congestion window.
The client already gates its own uplink on cwnd (bridge.rs's proxy_ev
select arm); the server's download direction had no equivalent.

On a real (lossy/jittery mobile or Wi-Fi) client path, a fast target (a
CDN, say) gets blasted at the client far beyond what the path can sustain.
That's a self-inflicted loss burst: it wrecks the RTT/RTO estimate (the
"ping jitters" symptom), can push the session into a stall bad enough that
the client's 25s/180s keepalive stall-detection gives up and reconnects
(the "every 10-20 minutes" symptom), and produces exactly the "randomly
stops loading or crawls" experience while it's happening.

Fix: Dispatcher::snapshot_backpressure() computes each session's headroom
(clamped cwnd - in_flight, same clamp(16,16384) the client uses) from the
existing 10ms retransmit tick - no new polling loop. Published to a
lock-free-reader Arc<AtomicI64> per session in a shared map so relay reader
tasks (which don't have access to Dispatcher; it lives on the main loop
task) can check it without touching a lock/mutex on every read. Before
each read, a reader task waits (capped at 2s, so a wedged read can't stall
forever) while headroom is <= 0. New sessions default to a healthy 32
packets until their first snapshot lands (worst case 10ms), so this can't
stall the very first bytes of a fresh connection.

Scoped to the primary TCP CONNECT/proxy path (the one actual web browsing
uses); UDP relay/TURN is unaffected. No load test against real network
jitter was possible in this environment - the mechanism directly targets
the identified cause, but real-world confirmation is still needed.
2026-07-18 15:40:12 +03:00
ospab 70a669d3c6 fix(install): setup wizard is a subcommand now, not a --setup flag
Both installers still invoked `ostp --setup` / `ostp.exe --setup` to launch
the first-run wizard on a fresh install. The CLI's subcommand refactor
(2026-07-08, "Refactor CLI to subcommands") turned `setup` into
`Commands::Setup { .. }` with no top-level `--setup` flag left in Args at
all, so every fresh install has hit "error: unexpected argument '--setup'
found" and dropped the user out of the installer instead of the wizard.
Verified `ostp setup --help` parses correctly with the fix.
2026-07-18 15:10:57 +03:00
ospab 66a1e97840 chore: release v0.4.2-beta.1 on beta 2026-07-12 02:18:47 +03:00
ospab b6bdd53066 merge beta: reconcile 2 obsolete version-bump commits from the old lineage
beta had two "chore: release 0.4.5-beta"/"0.4.6-beta" commits (pure
Cargo.toml/tauri.conf.json/package.json/pubspec.yaml/.release-state.json
version bumps, no code) from the pre-gha.ps1-rewrite versioning scheme
that was abandoned when the project reset to 0.4.1 as the new baseline
(see the earlier master reconciliation). alpha's manifests are about to
be bumped to the new 0.4.2 target anyway, so alpha's side wins on the
conflicting version files - this is a pure reconciliation, not a content
decision.
2026-07-12 02:16:53 +03:00
ospab 1c291d9c88 fix(release): the second branch is 'beta', not 'pre-release' — was never checkoutable
scripts/gha.ps1's -Branch ValidateSet accepted 'pre-release' and would
`git checkout pre-release` to promote alpha, but no such branch has ever
existed in this repo — only `beta` does (confirmed: `git branch -a`, and
the existing 0.4.6-beta/0.4.7-beta release history was cut from `beta`).
The very first beta release under the new gha.ps1 versioning scheme would
have failed outright on the checkout step.

This naming mismatch had spread through the whole release surface:
  - scripts/gha.ps1: -Branch ValidateSet + all internal checks
  - .github/workflows/release.yml: a dead branch-name check (harmless only
    because the workflow currently triggers on tag-push, not branch-push)
    plus two comments
  - CONTRIBUTING.md / .ru.md: branch-strategy table documented a
    `pre-release` branch that doesn't exist
  - README.md / .ru.md and ostp/src/main.rs: the `ostp update -b <name>`
    CLI help text/docs
  - scripts/install.sh: the channel match the CLI flag feeds into

Renamed all of it to `beta` to match the branch that actually exists.
Left scripts/gha.ps1:21's "semver pre-release identifier" alone — that's
the generic semver spec term, unrelated to the branch name, and got
reverted after a blanket replace briefly clobbered it.

Note: install.sh's alpha/beta self-update paths still assume a rolling
GitHub release tagged literally "alpha"/"beta" exists, which no gha.ps1
release ever publishes (only versioned tags like v0.4.7-beta.3) - that's
a separate, real bug, tracked apart from this rename since fixing it needs
either a floating tag from gha.ps1 or an API-query rewrite of install.sh.
2026-07-12 02:12:57 +03:00
ospab 2660a37249 merge protocol-hardening: forward secrecy + DoS/logging/dead-code hardening
Brings the crypto security audit into alpha alongside the already-merged
reconnection/UoT/Flutter fixes:

  - CRITICAL: transport keys now come from Noise Split() over ck (DH-
    inclusive), not the handshake hash — restores forward secrecy.
    Wire-breaking, PROTOCOL_VERSION 4->5.
  - Rate-limit + cache the O(N_keys) handshake trial path (CPU DoS).
  - Quiet hot-path logging; access keys no longer logged verbatim.
  - Removed dead 0-RTT resumption module (unsafe XOR ticket crypto).
  - Karn's algorithm RTT fix, 32-bit frame-length overflow guard,
    replay-cache eviction instead of global reject.
  - Updated EN/RU specification docs to match.

No conflicts: protocol-hardening's bridge.rs commit was an independent
duplicate of the same reconnection fix already on alpha, so git merged it
as a no-op on that file.
2026-07-12 02:06:42 +03:00
ospab 108ab8468b fix(flutter): show Junk/TCP-Frag controls only when transport is UoT
Junk packets and TCP fragmentation only take effect on the UoT (TCP)
transport — the UDP path applies neither — so showing them (with an
implicit "UoT only" caveat) while UDP is selected was misleading. The
whole DPI OBFUSCATION section is now gated on transportMode == 'uot' and
appears/disappears reactively when the Transport dropdown changes
(setDialogState already rebuilds the dialog).
2026-07-12 02:04:09 +03:00
ospab 5fcc0ba7f4 fix(server): UoT — set TCP_NODELAY + tear down half-open connections
Two UoT (UDP-over-TCP) correctness issues:

- The accepted UoT stream never had TCP_NODELAY set (the client sets it on
  its end, the server didn't). Nagle's algorithm then batched server->client
  writes and interacted with the client's delayed ACKs, adding tens-to-
  hundreds of ms of stall per burst — throttling the download direction
  badly for streaming/video. Every TCP-tunnel proxy disables Nagle; now the
  server matches the client.

- handle_tcp_connection join!ed the reader and writer tasks, so a half-open
  connection (client's read side gone, no outbound data pending) parked the
  writer on rx.recv() forever, leaking the task and a stale tcp_map entry.
  Rewrote it with select! so either half closing cancels the other and the
  tcp_map entry is always removed. Added duplex-stream tests covering
  inbound reassembly across segment boundaries, outbound framing, and
  teardown-on-close.
2026-07-12 01:25:34 +03:00
ospab 7e3ada8d4d fix(client): robust reconnection across sleep/resume + no zombie tasks
Addresses the PC-after-sleep failure (app either fully disconnects or gets
stuck "Connecting") and the mobile "must reconnect manually" symptom.

- Zombie receiver tasks: each session spawns a task that loops on recv().
  On a dead connection recv() never returns, so the task (and the socket it
  holds) leaked on every reconnect, piling up across sleep/resume cycles.
  SessionState now owns the task's AbortHandle and aborts it on Drop, so
  replacing sessions tears the old task down. The three duplicated inline
  receiver loops are consolidated into spawn_session_receiver().

(Builds on the tick-storm and resume-detection changes already in tree:
MissedTickBehavior::Skip on all intervals so a post-sleep wake doesn't fire
tens of thousands of catch-up ticks — the 10ms retransmit tick was the
worst — and a wall-clock-gap check that forces one clean reconnect on wake
via handle_keepalive(force=true).)
2026-07-12 01:21:58 +03:00
ospab 4fd4f7d435 build: drop stale [patch.crates-io] for removed vendored netstack-smoltcp
The vendored netstack-smoltcp directory was removed, but the workspace
[patch.crates-io] entry still pointed at the now-missing path, breaking the
whole build. ostp-client already declares netstack-smoltcp = "0.2.2", so
dropping the patch simply builds against the published crate (0.2.4).
2026-07-12 01:21:58 +03:00
ospab b166f13d59 chore: remove dnstt, netstack-smoltcp, and ostp-web and add to gitignore 2026-07-12 00:36:38 +03:00
ospab a69ffae750 docs: update architecture diagram to be more understandable 2026-07-12 00:34:32 +03:00
ospab 90a919df59 docs: update architecture diagram to be more understandable 2026-07-12 00:34:10 +03:00
ospab 4ac2e79e14 docs: document DH-inclusive transport keys / forward secrecy + trial rate-limit
Reflect the crypto hardening in the EN/RU specification:
  - Section 6: transport keys now come from Noise Split() over the chaining
    key ck (includes the ee DH secret), giving forward secrecy; added the
    rationale for why keys must NOT come from the handshake hash h, and the
    wire-version-5 gate.
  - Section 8: documented the handshake-trial CPU-DoS defense (per-key
    secret/marker caching + trial-path token bucket).
  - Corrected the handshake replay window (±300s / 5min, was mis-stated as
    ±30s) and PSK derivation (HKDF-SHA256).
2026-07-11 22:01:40 +03:00
ospab a9509a235d fix: low-severity hardening (Karn RTT, 32-bit frame overflow, replay-cache DoS)
- Karn's algorithm: drop_acked_frames no longer samples RTT from frames
  that were retransmitted (last_sent is bumped on each retransmit, so an
  ACK for the original transmission would measure a spuriously small RTT
  and drag SRTT/RTO down). Added CongestionController::on_ack_no_rtt for
  the case where every acked frame was ambiguous, so the window still
  advances without polluting the RTT estimator. Refactored the shared
  window-growth into grow_window.
- Frame decode: header+payload+pad length now uses checked_add. payload_len
  is a u32 from the header and on 32-bit targets (MIPS/ARMv7 routers are
  supported) the sum could wrap usize and slip past the truncation check.
- Replay cache: a full cache used to reject ALL new handshakes globally
  until the next tick, letting one flooding key-holder deny service to
  everyone. Now it reclaims expired entries and, if still full, evicts the
  single oldest — new handshakes always get in. Fixed the mislabelled
  "100000" log (cap is 50000) and named it REPLAY_CACHE_MAX.
2026-07-11 21:25:36 +03:00
ospab 5754689e09 refactor: remove dead 0-RTT resumption module (unsafe XOR ticket crypto)
The resumption module (SessionTicket/TicketValidator) was never wired into
the client or server — nothing issued or validated tickets, and no Resume
frame was ever sent. But it "encrypted" tickets by XOR-ing them with a
single static keystream SHA256(psk || const) and had no MAC (despite a doc
comment claiming HMAC): a textbook many-time-pad, trivially broken from a
couple of captured tickets, and malleable. Leaving it in-tree invited
someone to wire up a broken 0-RTT path later.

Removed the module, its FrameKind::Resume wire variant, and the protocol
handler for it. 0-RTT can be reintroduced later on a real AEAD-sealed
ticket if desired.
2026-07-11 21:22:45 +03:00
ospab b5735fe8c2 fix: quiet hot-path logging and stop logging access keys verbatim
Two classes of issue:
  - Hot-path/attacker-triggerable events logged at info/error with internal
    detail: a per-handshake info! byte dump (raw_vec[0..6]) and a per-packet
    error! on session-id mismatch that dumped expected/got session ids.
    Both are log-flood + info-leak surfaces; downgraded to debug and
    stripped of the sensitive detail. Close/Resume frame handling likewise
    moved from info to debug.
  - The access key (a shared secret) was written to logs verbatim in three
    places (session drop, key-created UI event, API create-user) and as an
    8-char prefix in one. Added key_fp() — a short SHA-256 fingerprint — and
    routed all key logging through it so operators can still correlate
    events without the secret ever hitting the log.
2026-07-11 21:21:01 +03:00
ospab f904695760 fix(server): rate-limit + cache the O(N_keys) handshake trial path (CPU DoS)
Every datagram from an unrecognized source ran the full key-trial loop:
for each registered access key, an HKDF (derive_all_secrets) plus two
HMACs (junk markers) plus a Noise read. A garbage flood from spoofed
sources could therefore force unbounded O(N_keys) crypto per packet — a
CPU-amplification DoS with no throttle (the existing token bucket only
guarded the roaming path, not this one).

Two mitigations:
  - Memoize the per-key derived secrets (pure function of key+version) and
    the per-window junk markers, so the trial loop is now cheap comparisons
    plus one Noise read per key instead of HKDF+2*HMAC per key per packet.
    Also speeds up every legitimate new connection. Caches are pruned in
    on_tick when keys are deleted.
  - Gate the trial path behind a global token bucket (TRIAL_RATE=100/s,
    same burst). The established-session fast path and roaming are not
    gated, so live sessions are unaffected; only unknown-datagram trials
    are bounded. Over-budget datagrams are dropped silently.
2026-07-11 21:18:05 +03:00
ospab 29554a71f1 fix(crypto)!: derive transport keys from DH-inclusive Noise Split, not the handshake hash
CRITICAL forward-secrecy fix. Session transport keys were derived as
SHA256(get_handshake_hash() || label). The Noise handshake hash `h` only
ever absorbs PUBLIC transcript data (ephemeral pubkeys + on-wire
ciphertexts, via MixHash); the ephemeral ee DH result is mixed via MixKey
into the chaining key `ck` ONLY, never into `h` (confirmed in snow 0.9.6
symmetricstate.rs). So the data-transport keys depended on the PSK and the
public transcript but NOT on the DH secret, meaning:

  - zero forward secrecy: anyone who later learns the access-key PSK can
    decrypt all recorded past sessions from the observed handshake alone;
  - any PSK holder can passively decrypt any other session on that key;
  - the ephemeral Diffie-Hellman was cryptographically wasted.

Fix: take the two directional keys from Noise's Split() over the final `ck`
via snow's dangerously_get_raw_split (risky-raw-split feature). These keys
depend on ee, restoring forward secrecy. The custom out-of-order AEAD,
explicit nonces, session_id AAD, framing and reordering are all unchanged
- only the key SOURCE moved. The dead into_transport()/handshake_hash()
paths and the unreachable NoiseSession::Transport variant are removed.

Wire-breaking: PROTOCOL_VERSION 4 -> 5 so pre-fix peers derive different
keys and cannot interop (version gate is invisible on the wire).

Added noise unit tests for the .0/.1 -> send/recv role mapping and the
not-finished guard.
2026-07-11 21:14:15 +03:00
ospab 271a39c664 fix(icons): forcefully overwrite all android legacy and adaptive icons with logo_new.png 2026-07-11 20:59:45 +03:00
ospab 44f9067222 feat(icons): apply logo_new.png to all apps and watermarks 2026-07-11 00:58:44 +03:00
ospab dee0288f2a fix(release): rename -Switch param to -NewVersion (silently broke channel resolution)
A script parameter named exactly $Switch collides with PowerShell's `switch`
statement keyword - confirmed by bisection - and made every `$X = switch (...)
{...}` in the script silently evaluate to empty instead of erroring. This is
what produced the malformed "v0.4.1-.0" tag on the last release attempt
(Channel resolved to "" instead of "stable", Iteration to 0). Renaming the
parameter is the only fix; nothing else about the switch statement itself
was wrong.
2026-07-10 03:25:26 +03:00