Compare commits

...

53 Commits

Author SHA1 Message Date
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
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 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 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 1b536e9cf3 chore: release 0.4.6-beta on beta 2026-07-09 02:54:50 +03:00
ospab 5883f5105b chore: release 0.4.5-beta on beta 2026-07-09 02:51:47 +03:00
160 changed files with 1754 additions and 3906 deletions

View File

@ -4,7 +4,7 @@ name: CI/CD
# `run-name` is evaluated at workflow-start, BEFORE any job runs - it cannot
# see resolve-channel's computed tag_name (e.g. "0.4.3-alpha"), only the
# `github.*` context. The old "release version ${{ github.ref_name }}" showed
# the bare branch name ("alpha"/"pre-release") for every run, which reads
# the bare branch name ("alpha"/"beta") for every run, which reads
# exactly like a literal release tag and caused real confusion - the actual
# release tag has been correct (versioned) all along; only this label lied
# about it. Spell out "channel" so nobody mistakes one for the other again.
@ -53,7 +53,7 @@ jobs:
# Tag shape:
# - real "vX.Y.Z" / "vX.Y.Z-beta.N" tag push -> tag used as-is (stable promotion)
# - push to `alpha` -> "{version}-alpha" (rolling, same tag every push)
# - push to `pre-release` -> "{version}-beta" (rolling, same tag every push)
# - push to `beta` -> "{version}-beta" (rolling, same tag every push)
# - workflow_dispatch -> forced by the `channel` input (alpha|beta only)
resolve-channel:
name: Resolve release channel
@ -89,7 +89,7 @@ jobs:
# channel, then synthesize the rolling tag from Cargo.toml's version.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CHANNEL="${{ github.event.inputs.channel }}"
elif [ "${{ github.ref_name }}" = "pre-release" ]; then
elif [ "${{ github.ref_name }}" = "beta" ]; then
CHANNEL="beta"
else
CHANNEL="alpha"
@ -284,7 +284,15 @@ jobs:
- name: Install cross (if not cached)
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
# cross-rs's own source (not ours, not a dependency of ours) uses a
# macro-at-end-of-block pattern that trips rustc's
# semicolon_in_expressions_from_macros lint on current toolchains -
# harmless in cross's actual behavior, but `cargo install` compiles
# the installed package as the "local" crate, so dependency lint
# capping doesn't shield it. --cap-lints=warn is the standard escape
# hatch for building a third-party tool against a newer compiler than
# its own lint config assumed; it doesn't touch our own build.
run: RUSTFLAGS="--cap-lints=warn" cargo install cross --git https://github.com/cross-rs/cross.git --locked
- name: Build (cross)
if: ${{ matrix.use_cross }}
@ -370,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
@ -452,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
@ -514,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
@ -579,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

10
.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/
@ -47,3 +55,5 @@ ostp-control/
.agents/
netstack-smoltcp/
dnstt/
ostp-web/

View File

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

View File

@ -74,10 +74,10 @@ The repository runs three long-lived branches, in increasing order of stability:
| Branch | Role |
|---|---|
| `alpha` | Active development. All feature work and fixes land here first. |
| `pre-release` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
| `master` | Fast-forwarded from `pre-release` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
| `beta` | Periodically fast-forwarded from `alpha` once it's had some soak time. Ships as the `{version}-beta` release channel. |
| `master` | Fast-forwarded from `beta` when it's proven stable. Real, tagged releases (`vX.Y.Z`) are cut from here. |
`pre-release` and `master` are **never** committed to directly - they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `pre-release` or `master`.
`beta` and `master` are **never** committed to directly - they only ever move forward by fast-forwarding from the branch below them. This means promotion is always a plain `git merge` with zero conflicts by construction: don't `git merge`/rebase feature work directly onto `beta` or `master`.
**Contributor PRs target `alpha`**, not `master`.
@ -148,7 +148,7 @@ Multiple unrelated changes belong in separate commits, not one bundled commit -
```bash
git push origin feat/your-feature-name
```
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `pre-release`, never direct PRs).
2. Open a Pull Request (PR) targeting the `alpha` branch (see [Branch Strategy](#branch-strategy) - `master` only receives fast-forwards from `beta`, never direct PRs).
3. In your PR description, explain the rationale behind your changes, what was fixed/added, and how it was tested.
4. Verify that GitHub Actions CI runs successfully on your PR.

View File

@ -74,10 +74,10 @@
| Ветка | Роль |
|---|---|
| `alpha` | Активная разработка. Вся новая работа и фиксы попадают сюда первыми. |
| `pre-release` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
| `master` | Перематывается вперёд от `pre-release`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
| `beta` | Периодически перематывается вперёд (fast-forward) от `alpha`, когда та немного «отлежалась». Собирается в канал релиза `{версия}-beta`. |
| `master` | Перематывается вперёд от `beta`, когда та доказала стабильность. Настоящие тегированные релизы (`vX.Y.Z`) режутся отсюда. |
В `pre-release` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `pre-release` или `master`.
В `beta` и `master` **никогда** не коммитят напрямую - они только перематываются вперёд от ветки уровнем ниже. Это значит, что промоушен - всегда обычный `git merge` без единого конфликта по построению: не мержите/не ребейзьте свою фичу прямо в `beta` или `master`.
**PR от контрибьюторов нацелены на `alpha`**, не на `master`.
@ -149,7 +149,7 @@ obfuscation_key/psk), чтобы он был индивидуальным для
```bash
git push origin feat/имя-вашей-фичи
```
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `pre-release`, PR туда не принимаются напрямую).
2. Создайте Pull Request (PR) в ветку `alpha` основного репозитория (см. [Стратегия веток](#стратегия-веток) - `master` получает только fast-forward от `beta`, PR туда не принимаются напрямую).
3. Подробно опишите внесенные изменения: какая проблема решается, как проводилось тестирование и на каких платформах проверялась сборка.
4. Убедитесь, что автоматическое тестирование (GitHub Actions CI) завершилось успешно.

18
Cargo.lock generated
View File

@ -1316,7 +1316,9 @@ dependencies = [
[[package]]
name = "netstack-smoltcp"
version = "0.2.2"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c38f66cdd673ff0e760752f27c6d34a7e3a140f0b1eea9efae3c46d8867c83d"
dependencies = [
"etherparse",
"futures",
@ -1384,7 +1386,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ostp"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"base64",
@ -1398,6 +1400,7 @@ dependencies = [
"rlimit",
"serde",
"serde_json",
"sha2",
"tokio",
"tracing",
"tracing-subscriber",
@ -1406,7 +1409,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"base64",
@ -1437,7 +1440,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"bytes",
@ -1471,7 +1474,7 @@ dependencies = [
[[package]]
name = "ostp-server"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"axum",
@ -1494,6 +1497,7 @@ dependencies = [
"sha2",
"simple-dns",
"socket2",
"subtle",
"tokio",
"tower-http",
"tracing",
@ -1503,7 +1507,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"libc",
@ -1515,7 +1519,7 @@ dependencies = [
[[package]]
name = "ostp-tun-helper"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",

View File

@ -12,20 +12,17 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "AGPL-3.0"
version = "0.4.1"
version = "0.4.2"
[workspace.dependencies]
anyhow = "1.0"
bytes = "1.6"
chacha20poly1305 = "0.10"
rand = "0.8"
snow = "0.9"
snow = { version = "0.9", features = ["risky-raw-split"] }
thiserror = "1.0"
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] }
tracing = "0.1"
sha2 = "0.10"
hmac = "0.12"
portable-atomic = "1.10"
[patch.crates-io]
netstack-smoltcp = { path = "netstack-smoltcp" }

View File

@ -56,35 +56,42 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
## Architecture
```mermaid
graph TD
subgraph Client ["Client"]
A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
TUN[TUN Interface] -->|IP Packets| B
flowchart LR
%% Styles
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
subgraph OSTPCoreClient ["OSTP Core Protocol"]
B --> C{Protocol Machine}
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
D -->|Obfuscated UDP Payload| E((UDP Socket))
end
subgraph Local["💻 Client Device"]
Apps["Web Browser / Apps"]:::userApp
Socks["SOCKS5 / HTTP Proxy"]:::ostpCore
Tun["Global TUN (VPN)"]:::ostpCore
Client["OSTP Client Protocol Engine\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
Apps -->|TCP/UDP| Socks
Apps -->|IP Packets| Tun
Socks --> Client
Tun --> Client
end
E <==>|Encrypted & Obfuscated UDP Tunnel| F
subgraph Server ["Server"]
F((UDP Socket)) --> G{Dispatcher}
subgraph OSTPCoreServer ["OSTP Core Backend"]
G -->|Auth & Decrypt| H[Session & State Guard]
H -->|TCP Stream| I[Relay Loop]
subgraph Internet["🌐 Hostile Network (DPI/Firewall)"]
Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network
end
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
FB -->|Forward| NGINX[nginx / Caddy]
subgraph Remote["🖥️ Remote VPS (Server)"]
Server["OSTP Server Protocol Engine\n(Authentication & Decryption)"]:::ostpCore
Relay["Connection Multiplexer"]:::ostpCore
Fallback["Fake Website\n(Nginx/Caddy)"]:::fallback
Target["Open Internet\n(YouTube, Google, etc)"]:::external
H -->|Stats & Traffic| API[Management API]
I -->|Outbound| WWW((Internet))
Server -->|Decrypted Traffic| Relay
Server -->|Active Probe / Scanner| Fallback
Relay -->|Clear Traffic| Target
end
Client <==> Tunnel <==> Server
```
---
@ -185,7 +192,7 @@ Commands:
links Print client share links from the server config
import <URL> Import a share link into the config file
update Update OSTP to the latest release
-b, --branch <NAME> Release channel: stable, pre-release, alpha (default: stable)
-b, --branch <NAME> Release channel: stable, beta, alpha (default: stable)
-v, --version <VER> Update to an exact version instead of the channel's latest
migrate Force-migrate the configuration file to the current format
proxy-env Print shell export commands for the local SOCKS proxy

View File

@ -35,33 +35,42 @@
## Архитектура
```mermaid
graph TD
subgraph Client ["Клиент"]
A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer)
TUN[TUN Интерфейс] -->|IP Пакеты| B
flowchart LR
%% Styles
classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b
classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32
classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5
classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c
classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828
subgraph OSTPCoreClient ["OSTP Core Протокол"]
B --> C{Protocol Machine}
C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD]
D -->|Обфусцированный UDP| E((UDP Сокет))
end
subgraph Local["💻 Устройство клиента"]
Apps["Браузер / Приложения"]:::userApp
Socks["SOCKS5 / HTTP Прокси"]:::ostpCore
Tun["Global TUN (VPN)"]:::ostpCore
Client["OSTP Клиент\n(Noise + ChaCha20 + ARQ)"]:::ostpCore
Apps -->|TCP/UDP| Socks
Apps -->|IP Пакеты| Tun
Socks --> Client
Tun --> Client
end
E <==>|Зашифрованный UDP Туннель| F
subgraph Server ["Сервер"]
F((UDP Сокет)) --> G{Dispatcher}
subgraph OSTPCoreServer ["OSTP Core Backend"]
G -->|Auth & Decrypt| H[Session & State Guard]
H -->|TCP Поток| I[Relay Loop]
subgraph Internet["🌐 Сеть с цензурой (DPI)"]
Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network
end
G -->|Active Probing / Unauth| FB[TCP Fallback Proxy]
FB -->|Перенаправление| NGINX[nginx / Caddy]
subgraph Remote["🖥️ Удаленный сервер (VPS)"]
Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore
Relay["Мультиплексор соединений"]:::ostpCore
Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback
Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external
I -->|Outbound| WWW((Интернет))
Server -->|Расшифрованный трафик| Relay
Server -->|Сканеры цензоров| Fallback
Relay -->|Чистый трафик| Target
end
Client <==> Tunnel <==> Server
```
---
@ -172,7 +181,7 @@ ostp [--config <PATH>] [КОМАНДА]
links Вывести client-share-ссылки из серверного конфига
import <URL> Импортировать share-ссылку в конфиг
update Обновить OSTP до актуального релиза
-b, --branch <NAME> Канал релиза: stable, pre-release, alpha (по умолчанию stable)
-b, --branch <NAME> Канал релиза: stable, beta, alpha (по умолчанию stable)
-v, --version <VER> Обновиться на точную версию вместо последней в канале
migrate Принудительно мигрировать конфиг к текущему формату
proxy-env Вывести shell-команды для локального SOCKS-прокси

1
dnstt

@ -1 +0,0 @@
Subproject commit 0c5c52a57d899c05428c116898941761a2ed83c2

View File

@ -90,11 +90,22 @@ Because the `Nonce` is unique per packet, the mask is cryptographically independ
OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern.
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via SHA-256.
1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via HKDF-SHA-256.
2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram.
3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels.
3. Ephemeral Curve25519 key exchange (`ee`) is evaluated, and the two directional transport keys are taken from Noise's `Split()` over the final chaining key `ck`.
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window.
> **Forward secrecy.** The transport keys are derived from the chaining key
> `ck`, which absorbs the ephemeral `ee` Diffie-Hellman result. They are **not**
> derived from the Noise handshake hash `h``h` only ever absorbs public
> transcript data (ephemeral public keys and on-wire ciphertexts) and never the
> DH secret, so keys derived from it would give an access-key holder the ability
> to decrypt any recorded session. Deriving from `ck` binds each session to its
> ephemeral private keys, which are discarded after the handshake: an adversary
> who later compromises the PSK still cannot decrypt past traffic. This is a
> wire-breaking property gated by the internal protocol version (currently 5);
> peers on an older version derive different keys and cannot interoperate.
The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a ±300-second (5-minute) synchronization window and additionally records accepted handshakes in an anti-replay set for that window.
---
@ -126,4 +137,5 @@ The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to
* **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse.
* **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks.
* **Handshake-trial CPU DoS:** Because there is no cleartext key identifier on the wire (a deliberate stealth property), a datagram from an unknown source must be trial-decrypted against every registered key. Servers MUST bound this work: OSTP caches each key's derived secrets and time-windowed junk markers (so a trial is a cheap comparison plus one AEAD attempt per key, not a fresh HKDF/HMAC), and gates the trial path behind a global token bucket (default 100/s) so a spoofed-source flood cannot force unbounded per-packet crypto. The established-session fast path and IP-roaming path are not subject to this bucket.
* **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD).

View File

@ -90,11 +90,23 @@ OSTP поддерживает **внутреннее криптографиче
OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`.
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256.
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация).
3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных.
1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256.
2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия.
3. Выполняется эфемерный обмен ключами Curve25519 (`ee`), и два однонаправленных транспортных ключа берутся из `Split()` протокола Noise над финальным chaining key `ck`.
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд).
> **Прямая секретность (Forward Secrecy).** Транспортные ключи выводятся из
> chaining key `ck`, который вбирает результат эфемерного обмена Диффи-Хеллмана
> `ee`. Они **не** выводятся из handshake hash `h` протокола Noise: `h` вбирает
> только публичные данные транскрипта (эфемерные публичные ключи и шифртексты с
> провода) и никогда — сам DH-секрет, поэтому ключи, выведенные из `h`, дали бы
> держателю PSK возможность расшифровать любую записанную сессию. Вывод из `ck`
> привязывает каждую сессию к её эфемерным приватным ключам, которые
> уничтожаются после рукопожатия: злоумышленник, скомпрометировавший PSK позже,
> всё равно не сможет расшифровать прошлый трафик. Это свойство ломает
> совместимость и защищено внутренней версией протокола (сейчас 5): узлы более
> старой версии выводят другие ключи и не могут взаимодействовать.
Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд, 5 минут) и дополнительно фиксирует принятые рукопожатия в множестве защиты от повтора на время этого окна.
---
@ -119,4 +131,5 @@ OSTP обеспечивает надежную доставку поверх UDP
* **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра.
* **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти.
* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают.
* **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD).

View File

@ -1 +0,0 @@
{"v":1}

View File

@ -1,6 +0,0 @@
{
"git": {
"sha1": "702f6dfe124c5e4d343cfd3ca5a3efe0446cf6f0"
},
"path_in_vcs": ""
}

View File

@ -1,37 +0,0 @@
name: Setup Android NDK and Rust compiler ENV
description: Setup an Android_NDK_HOME environment by downloading and Rust compiler environment.
inputs:
rust-target:
description: Rust target to build
required: true
sdk-version:
description: Exact SDK version to use
default: "33"
ndk-version:
description: Exact NDK version to use
default: "25"
ndk-platform:
description: Which host platform to use
default: "linux"
runs:
using: "composite"
steps:
- name: Download Android NDK
run: curl --http1.1 -O https://dl.google.com/android/repository/android-ndk-r${{ inputs.ndk-version }}-${{ inputs.ndk-platform }}.zip
shell: bash
- name: Extract Android NDK
run: unzip -q android-ndk-r${{ inputs.ndk-version }}-${{ inputs.ndk-platform }}.zip
shell: bash
- name: Set Rust compiler ENV
run: |
ndk_home=${{ github.workspace }}/android-ndk-r${{ inputs.ndk-version }}
platform=$(ls ${ndk_home}/toolchains/llvm/prebuilt/ | head -1)
ndk_tool=${ndk_home}/toolchains/llvm/prebuilt/${platform}/bin
envvar_suffix=$(echo ${{ inputs.rust-target }} | sed "s/-/_/g")
upper_suffix=$(echo ${envvar_suffix} | tr '[:lower:]' '[:upper:]')
tool_prefix=${{ inputs.rust-target }}${{ inputs.sdk-version }}
echo "ANDROID_NDK_HOME=${ndk_home}" >> $GITHUB_ENV
echo "CC_${envvar_suffix}=${ndk_tool}/${tool_prefix}-clang" >> $GITHUB_ENV
echo "AR_${envvar_suffix}=${ndk_tool}/llvm-ar" >> $GITHUB_ENV
echo "CARGO_TARGET_${upper_suffix}_LINKER=${ndk_tool}/${tool_prefix}-clang" >> $GITHUB_ENV
shell: bash

View File

@ -1,80 +0,0 @@
name: CI
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
env:
CARGO_INCREMENTAL: 0
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- build: linux-amd64
os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- build: android-arm64
os: ubuntu-latest
target: aarch64-linux-android
no_run: --no-run
- build: android-amd64
os: ubuntu-latest
target: x86_64-linux-android
no_run: --no-run
- build: macos-amd64
os: macos-latest
target: x86_64-apple-darwin
- build: macos-arm64
os: macos-14
target: aarch64-apple-darwin
- build: ios-arm64
os: macos-latest
target: aarch64-apple-ios
no_run: --no-run
- build: windows-amd64
os: windows-latest
target: x86_64-pc-windows-msvc
- build: windows-arm64
os: windows-latest
target: aarch64-pc-windows-msvc
no_run: --no-run
steps:
- uses: actions/checkout@v4
- name: Install Rust (rustup)
run: |
set -euxo pipefail
rustup toolchain install stable --no-self-update --profile minimal --target ${{ matrix.target }}
rustup default stable
shell: bash
- uses: Swatinem/rust-cache@v2
- name: Setup android environment
if: contains(matrix.build, 'android')
uses: ./.github/actions/ndk-dev-rs
with:
rust-target: ${{ matrix.target }}
- run: cargo test ${{ matrix.no_run }} --workspace --target ${{ matrix.target }}
- run: cargo test ${{ matrix.no_run }} --workspace --target ${{ matrix.target }} --release
msrv_n_clippy:
name: MSRV & Clippy & Rustfmt
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo fmt -- --check
- run: cargo clippy --all-features -- -D warnings
- run: cargo check --lib -p netstack-smoltcp
- run: cargo check --lib -p netstack-smoltcp --all-features

View File

@ -1,15 +0,0 @@
on:
push:
tags:
- '*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to crates.io
run: |
cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

View File

@ -1,9 +0,0 @@
/target
/Cargo.lock
.idea
.VSCodeCounter/
.vscode
.DS_Store
*.iml
**/*.log

View File

@ -1,136 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.75.0"
name = "netstack-smoltcp"
version = "0.2.2"
authors = ["cavivie <cavivie@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = """
A netstack for the special purpose of turning packets from/to a TUN interface
into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
"""
homepage = "https://github.com/cavivie/netstack-smoltcp"
documentation = "https://docs.rs/netstack-smoltcp"
readme = "README.md"
keywords = [
"netstack",
"smoltcp",
"network",
"ip",
"tun",
]
categories = ["network-programming"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/cavivie/netstack-smoltcp"
[lib]
name = "netstack_smoltcp"
path = "src/lib.rs"
[[example]]
name = "forward"
path = "examples/forward.rs"
[[example]]
name = "forward-offload-linux"
path = "examples/forward-offload-linux.rs"
[[test]]
name = "regression"
path = "tests/regression.rs"
[dependencies.etherparse]
version = "0.16"
[dependencies.futures]
version = "0.3"
[dependencies.rand]
version = "0.8"
[dependencies.smoltcp]
version = "0.12"
features = [
"std",
"log",
"medium-ip",
"proto-ipv4",
"proto-ipv6",
"socket-icmp",
"socket-udp",
"socket-tcp",
]
default-features = false
[dependencies.spin]
version = "0.9"
[dependencies.tokio]
version = "1"
features = [
"sync",
"time",
"rt",
"macros",
]
[dependencies.tokio-util]
version = "0.7.10"
[dependencies.tracing]
version = "0.1"
features = ["std"]
default-features = false
[dev-dependencies.socket2]
version = "0.5.6"
[dev-dependencies.socket2-ext]
version = "0.1"
[dev-dependencies.structopt]
version = "0.3"
[dev-dependencies.tokio]
version = "1"
features = [
"rt",
"macros",
"rt-multi-thread",
"io-util",
]
[dev-dependencies.tracing]
version = "0.1"
features = ["std"]
default-features = false
[dev-dependencies.tracing-subscriber]
version = "0.3.18"
[dev-dependencies.tun-rs]
version = "2"
features = [
"async",
"async_framed",
]
[dev-dependencies.tun2]
version = "3"
features = ["async"]

View File

@ -1,51 +0,0 @@
[package]
name = "netstack-smoltcp"
version = "0.2.2"
edition = "2021"
authors = ["cavivie <cavivie@gmail.com>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/cavivie/netstack-smoltcp"
homepage = "https://github.com/cavivie/netstack-smoltcp"
documentation = "https://docs.rs/netstack-smoltcp"
keywords = ["netstack", "smoltcp", "network", "ip", "tun"]
categories = ["network-programming"]
description = """
A netstack for the special purpose of turning packets from/to a TUN interface
into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
"""
rust-version = "1.75.0"
[dependencies]
tracing = { version = "0.1", default-features = false, features = ["std"] }
tokio = { version = "1", features = ["sync", "time", "rt", "macros"] }
tokio-util = "0.7.10"
etherparse = "0.16"
futures = "0.3"
rand = "0.8"
spin = "0.9"
smoltcp = { version = "0.12", default-features = false, features = [
"std",
"log",
"medium-ip",
"proto-ipv4",
"proto-ipv6",
"socket-icmp",
"socket-udp",
"socket-tcp",
] }
[dev-dependencies]
tun2 = { version = "3", features = ["async"] }
# has better performance on linux than tun2
tun-rs = { version = "2", features = ["async", "async_framed"] }
tokio = { version = "1", features = [
"rt",
"macros",
"rt-multi-thread",
"io-util",
] }
tracing = { version = "0.1", default-features = false, features = ["std"] }
tracing-subscriber = "0.3.18"
structopt = "0.3"
socket2 = "0.5.6"
socket2-ext = { version = "0.1" }

View File

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,25 +0,0 @@
Copyright (c) 2024 cavivie and netstack-smoltcp Contributors
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@ -1,136 +0,0 @@
# Netstack Smoltcp
A netstack for the special purpose of turning packets from/to a TUN interface into TCP streams and UDP packets. It uses smoltcp-rs as the backend netstack.
[![Crates.io][crates-badge]][crates-url]
[![MIT licensed][mit-badge]][mit-url]
[![Apache licensed, Version 2.0][apache-badge]][apache-url]
[![Build Status][actions-badge]][actions-url]
[crates-badge]: https://img.shields.io/crates/v/netstack-smoltcp.svg
[crates-url]: https://crates.io/crates/netstack-smoltcp
[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
[mit-url]: https://github.com/automesh-network/netstack-smoltcp/blob/master/LICENSE-MIT
[apache-badge]: https://img.shields.io/badge/license-APACHE2.0-blue.svg
[apache-url]: https://github.com/automesh-network/netstack-smoltcp/blob/master/LICENSE-APACHE
[actions-badge]: https://github.com/automesh-network/netstack-smoltcp/workflows/CI/badge.svg
[actions-url]: https://github.com/automesh-network/netstack-smoltcp/actions?query=workflow%3ACI+branch%3Amain
## Features
- Supports Future Send and non-Send, mostly pepole use Send.
- Supports ICMP protocol drive by TCP runner to use ICMP ping.
- Supports filtering packets by source and destination IP addresses.
- Can read IP packets from netstack, write IP packets to netstack.
- Can receive TcpStream from TcpListener exposed from netstack.
- Can receive UDP datagram from UdpSocket exposed from netstack.
- Implements popular future streaming traits and asynchronous IO traits:
* TcpListener implements futures Stream/Sink trait
* TcpStream implements tokio AsyncRead/AsyncWrite trait
* UdpSocket(ReadHalf/WriteHalf) implements futures Stream/Sink trait.
## Platforms
This crate provides lightweight netstack support for Linux, iOS, macOS, Android and Windows.
Currently, it works on most targets, but mainly tested the popular platforms which includes:
- linux-amd64: x86_64-unknown-linux-gnu
- android-arm64: aarch64-linux-android
- android-amd64: x86_64-linux-android
- macos-amd64: x86_64-apple-darwin
- macos-arm64: aarch64-apple-darwin
- ios-arm64: aarch64-apple-ios
- windows-amd64: x86_64-pc-windows-msvc
- windows-arm64: aarch64-pc-windows-msvc
## Example
```rust
// let device = tun2::create_as_async(&cfg)?;
// let framed = device.into_framed();
let (stack, runner, udp_socket, tcp_listener) = netstack_smoltcp::StackBuilder::default()
.stack_buffer_size(512)
.tcp_buffer_size(4096)
.enable_udp(true)
.enable_tcp(true)
.enable_icmp(true)
.mtu(9000) // virtual device usually benefits from larger MTU
.build()
.unwrap();
let mut udp_socket = udp_socket.unwrap(); // udp enabled
let mut tcp_listener = tcp_listener.unwrap(); // tcp/icmp enabled
if let Some(runner) = runner {
tokio::spawn(runner);
}
let (mut stack_sink, mut stack_stream) = stack.split();
let (mut tun_sink, mut tun_stream) = framed.split();
// Reads packet from stack and sends to TUN.
tokio::spawn(async move {
while let Some(pkt) = stack_stream.next().await {
if let Ok(pkt) = pkt {
tun_sink.send(pkt).await.unwrap();
}
}
});
// Reads packet from TUN and sends to stack.
tokio::spawn(async move {
while let Some(pkt) = tun_stream.next().await {
if let Ok(pkt) = pkt {
stack_sink.send(pkt).await.unwrap();
}
}
});
// Extracts TCP connections from stack and sends them to the dispatcher.
tokio::spawn(async move {
handle_inbound_stream(tcp_listener).await;
});
// Receive and send UDP packets between netstack and NAT manager. The NAT
// manager would maintain UDP sessions and send them to the dispatcher.
tokio::spawn(async move {
handle_inbound_datagram(udp_socket).await;
});
```
## Performance
Typically, `netstack-smoltcp` will be used with an tun device, so a careful choice of TUN crate matters.
[tun-rs](https://github.com/tun-rs/tun-rs) have better performance on **Linux** than [rust-tun](https://github.com/meh/rust-tun/) due to GSO/GRO which allow you to process the packets in batches.
`bash scripts/bench-offload.sh` could tell that `tun-rs` boosts the performance by 4x. Try it out on your Linux machine!
The example for using `tun-rs` with `netstack-smoltcp` could be found at [forward-offload-linux.rs](examples/forward-offload-linux.rs)
For further tuning, refer to `tun-rs`'s detailed [README](https://github.com/tun-rs/tun-rs/blob/main/README.md)
## License
This project is licensed under either of
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
https://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or
https://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in netstack-smoltcp by you, as defined in the Apache-2.0 license,
shall be dual licensed as above, without any additional terms or conditions.
## Inspired By
Special thanks to these amazing projects that inspired netstack-smoltcp (in no particular order):
- [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust/)
- [netstack-lwip](https://github.com/eycorsican/netstack-lwip/)
- [rust-tun-active](https://github.com/tun2proxy/rust-tun)
- [rust-tun](https://github.com/meh/rust-tun/)
- [tun-rs](https://github.com/tun-rs/tun-rs)
- [smoltcp](https://github.com/smoltcp-rs/smoltcp)

View File

@ -1,239 +0,0 @@
#[cfg(target_os = "linux")]
mod inner {
use futures::{SinkExt, StreamExt};
use netstack_smoltcp::{StackBuilder, TcpListener, UdpSocket};
use std::{net::SocketAddr, sync::Arc};
use structopt::StructOpt;
use tokio::net::{TcpSocket, TcpStream};
use tracing::{error, info, warn};
use tun_rs::{DeviceBuilder, IDEAL_BATCH_SIZE, VIRTIO_NET_HDR_LEN};
// Patched forward example: tun2 → tun-rs with Linux GRO/GSO offload.
// For further reading, check out https://blog.cloudflare.com/virtual-networking-101-understanding-tap
//
// Key changes vs forward.rs:
// 1. Use tun-rs DeviceBuilder with .offload(true) on Linux (enables
// IFF_VNET_HDR + TUN_F_CSUM/TSO4/TSO6/USO4/USO6).
// 2. TX (stack → TUN): prepend 10-byte zero virtio_net_hdr (GSO_NONE)
// so the kernel accepts the write when IFF_VNET_HDR is set.
// 3. RX (TUN → stack): use recv_multiple() for batch GSO splitting;
// buffers sized to 1600 to fit smoltcp's 1504-byte MTU segments.
#[derive(Debug, StructOpt)]
#[structopt(name = "forward", about = "Simply forward tun tcp/udp traffic.")]
struct Opt {
/// Outbound interface to bind forwarded connections to.
#[structopt(short = "i", long = "interface")]
interface: String,
/// Name of the TUN device.
#[structopt(short = "n", long = "name", default_value = "utun8")]
name: String,
/// Tracing log level.
#[structopt(long = "log-level", default_value = "debug")]
log_level: tracing::Level,
/// Use current-thread Tokio runtime (default: multi-thread).
#[structopt(long = "current-thread")]
current_thread: bool,
/// Use spawn_local instead of spawn.
#[structopt(long = "local-task")]
local_task: bool,
}
pub(super) fn main() {
let opt = Opt::from_args();
let rt = if opt.current_thread {
tokio::runtime::Builder::new_current_thread()
} else {
tokio::runtime::Builder::new_multi_thread()
}
.enable_all()
.build()
.unwrap();
rt.block_on(main_exec(opt));
}
async fn main_exec(opt: Opt) {
macro_rules! tokio_spawn {
($fut:expr) => {
if opt.local_task {
tokio::task::spawn_local($fut)
} else {
tokio::task::spawn($fut)
}
};
}
tracing::subscriber::set_global_default(
tracing_subscriber::FmtSubscriber::builder()
.with_max_level(opt.log_level)
.finish(),
)
.unwrap();
// Build TUN device with GRO/GSO offload on Linux.
let builder = DeviceBuilder::new()
.name(opt.name)
.ipv4("10.10.10.2", 24, Some("10.10.10.1"))
.mtu(9000);
let builder = builder.offload(true);
let dev = Arc::new(builder.build_async().unwrap());
let (stack, runner, udp_socket, tcp_listener) = StackBuilder::default()
.enable_tcp(true)
.enable_udp(true)
.enable_icmp(true)
.build()
.unwrap();
let udp_socket = udp_socket.unwrap();
let tcp_listener = tcp_listener.unwrap();
if let Some(runner) = runner {
tokio_spawn!(runner);
}
let (mut stack_sink, mut stack_stream) = stack.split();
let mut futs = vec![];
// stack → TUN
// With IFF_VNET_HDR every write must start with a virtio_net_hdr.
// We use all-zero (gso_type = GSO_NONE, flags = 0): plain packet,
// checksum already valid (smoltcp always computes checksums itself).
let dev1 = dev.clone();
futs.push(tokio_spawn!(async move {
while let Some(pkt) = stack_stream.next().await {
if let Ok(pkt) = pkt {
let result = {
let mut buf = vec![0u8; VIRTIO_NET_HDR_LEN + pkt.len()];
buf[VIRTIO_NET_HDR_LEN..].copy_from_slice(&pkt);
dev1.send(&buf).await
};
if let Err(e) = result {
warn!("failed to send packet to TUN: {:?}", e);
}
}
}
}));
// TUN → stack
// recv_multiple() does one read() syscall and returns N individual IP
// packets after splitting any incoming GRO super-packet.
// Buffer size 1600 > smoltcp MTU (1504) to avoid an out-of-bounds panic
// when the kernel segments at MSS=1464 with 40-byte IP+TCP headers.
futs.push(tokio_spawn!(async move {
let mut orig = vec![0u8; VIRTIO_NET_HDR_LEN + 65535];
let mut bufs = vec![vec![0u8; 1600]; IDEAL_BATCH_SIZE];
let mut sizes = vec![0usize; IDEAL_BATCH_SIZE];
while let Ok(n) = dev.recv_multiple(&mut orig, &mut bufs, &mut sizes, 0).await {
for i in 0..n {
let pkt = &bufs[i][..sizes[i]];
if let Err(e) = stack_sink.send(pkt.to_vec()).await {
warn!("failed to send packet to stack: {:?}", e);
}
}
}
}));
futs.push(tokio_spawn!({
let iface = opt.interface.clone();
async move {
handle_inbound_stream(tcp_listener, iface).await;
}
}));
futs.push(tokio_spawn!(async move {
handle_inbound_datagram(udp_socket, opt.interface).await;
}));
futures::future::join_all(futs).await.iter().for_each(|r| {
if let Err(e) = r {
error!("{:?}", e);
}
});
}
async fn handle_inbound_stream(mut tcp_listener: TcpListener, interface: String) {
while let Some((mut stream, local, remote)) = tcp_listener.next().await {
let interface = interface.clone();
tokio::spawn(async move {
info!("tcp: {:?} => {:?}", local, remote);
match new_tcp_stream(remote, &interface).await {
Ok(mut r) => {
if let Err(e) = tokio::io::copy_bidirectional(&mut stream, &mut r).await {
warn!(
"failed to copy tcp stream {:?}=>{:?}: {:?}",
local, remote, e
);
}
}
Err(e) => warn!(
"failed to open tcp stream {:?}=>{:?}: {:?}",
local, remote, e
),
}
});
}
}
async fn handle_inbound_datagram(udp_socket: UdpSocket, interface: String) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (mut read_half, mut write_half) = udp_socket.split();
tokio::spawn(async move {
while let Some((data, local, remote)) = rx.recv().await {
let _ = write_half.send((data, remote, local)).await;
}
});
while let Some((data, local, remote)) = read_half.next().await {
let tx = tx.clone();
let interface = interface.clone();
tokio::spawn(async move {
match new_udp_packet(remote, &interface).await {
Ok(sock) => {
let _ = sock.send(&data).await;
loop {
let mut buf = vec![0; 1024];
match sock.recv_from(&mut buf).await {
Ok((n, _)) => {
let _ = tx.send((buf[..n].to_vec(), local, remote));
}
Err(e) => {
warn!("udp recv {:?}: {:?}", remote, e);
break;
}
}
}
}
Err(e) => warn!("failed to open udp socket {:?}: {:?}", remote, e),
}
});
}
}
async fn new_tcp_stream(addr: SocketAddr, iface: &str) -> std::io::Result<TcpStream> {
use socket2_ext::{AddressBinding, BindDeviceOption};
let s = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
s.bind_to_device(BindDeviceOption::v4(iface))?;
s.set_keepalive(true)?;
s.set_nodelay(true)?;
s.set_nonblocking(true)?;
Ok(TcpSocket::from_std_stream(s.into()).connect(addr).await?)
}
async fn new_udp_packet(
addr: SocketAddr,
iface: &str,
) -> std::io::Result<tokio::net::UdpSocket> {
use socket2_ext::{AddressBinding, BindDeviceOption};
let s = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?;
s.bind_to_device(BindDeviceOption::v4(iface))?;
s.set_nonblocking(true)?;
let sock = tokio::net::UdpSocket::from_std(s.into())?;
sock.connect(addr).await?;
Ok(sock)
}
}
#[cfg(not(target_os = "linux"))]
mod inner {
pub(super) fn main() {}
}
fn main() {
inner::main();
}

View File

@ -1,326 +0,0 @@
use std::net::{IpAddr, SocketAddr};
use futures::{SinkExt, StreamExt};
use netstack_smoltcp::{StackBuilder, TcpListener, UdpSocket};
use structopt::StructOpt;
use tokio::net::{TcpSocket, TcpStream};
use tracing::{error, info, warn};
// to run this example, you should set the policy routing **after the start of the main program**
//
// linux:
// with bind device:
// `curl 1.1.1.1 --interface utun8`
// with default route:
// `bash scripts/route-linux.sh add`
// `curl 1.1.1.1`
// with single route:
// `ip rule add to 1.1.1.1 table 200`
// `ip route add default dev utun8 table 200`
// `curl 1.1.1.1`
//
// macos:
// with default route:
// `bash scripts/route-macos.sh add`
// `curl 1.1.1.1`
//
// windows:
// with default route:
// tun2 set default route automatically, won't set agian
// # `powershell.exe scripts/route-windows.ps1 add`
// `curl 1.1.1.1`
//
// currently, the example only supports the TCP stream, and the UDP packet will be dropped.
#[derive(Debug, StructOpt)]
#[structopt(name = "forward", about = "Simply forward tun tcp/udp traffic.")]
struct Opt {
/// Default binding interface, default by guessed.
/// Specify but doesn't exist, no device is bound.
#[structopt(short = "i", long = "interface")]
interface: String,
/// name of the tun device, default to rtun8.
#[structopt(short = "n", long = "name", default_value = "utun8")]
name: String,
/// Tracing subscriber log level.
#[structopt(long = "log-level", default_value = "debug")]
log_level: tracing::Level,
/// Tokio current-thread runtime, default to multi-thread.
#[structopt(long = "current-thread")]
current_thread: bool,
/// Tokio task spawn_local, default to spwan.
#[structopt(long = "local-task")]
local_task: bool,
}
fn main() {
let opt = Opt::from_args();
let rt = if opt.current_thread {
tokio::runtime::Builder::new_current_thread()
} else {
tokio::runtime::Builder::new_multi_thread()
}
.enable_all()
.build()
.unwrap();
rt.block_on(main_exec(opt));
}
async fn main_exec(opt: Opt) {
macro_rules! tokio_spawn {
($fut: expr) => {
if opt.local_task {
tokio::task::spawn_local($fut)
} else {
tokio::task::spawn($fut)
}
};
}
tracing::subscriber::set_global_default(
tracing_subscriber::FmtSubscriber::builder()
.with_max_level(opt.log_level)
.finish(),
)
.unwrap();
let mut cfg = tun2::Configuration::default();
cfg.layer(tun2::Layer::L3);
let fd = -1;
if fd >= 0 {
cfg.raw_fd(fd);
} else {
cfg.tun_name(&opt.name)
.address("10.10.10.2")
.destination("10.10.10.1")
.mtu(tun2::DEFAULT_MTU);
#[cfg(not(any(target_arch = "mips", target_arch = "mips64",)))]
{
cfg.netmask("255.255.255.0");
}
cfg.up();
}
let device = tun2::create_as_async(&cfg).unwrap();
let mut builder = StackBuilder::default()
.enable_tcp(true)
.enable_udp(true)
.enable_icmp(true)
.mtu(9000);
if let Some(device_broadcast) = get_device_broadcast(&device) {
builder = builder
// .add_ip_filter(Box::new(move |src, dst| *src != device_broadcast && *dst != device_broadcast));
.add_ip_filter_fn(move |src, dst| *src != device_broadcast && *dst != device_broadcast);
}
let (stack, runner, udp_socket, tcp_listener) = builder.build().unwrap();
let udp_socket = udp_socket.unwrap(); // udp enabled
let tcp_listener = tcp_listener.unwrap(); // tcp enabled or icmp enabled
if let Some(runner) = runner {
tokio_spawn!(runner);
}
let framed = device.into_framed();
let (mut tun_sink, mut tun_stream) = framed.split();
let (mut stack_sink, mut stack_stream) = stack.split();
let mut futs = vec![];
// Reads packet from stack and sends to TUN.
futs.push(tokio_spawn!(async move {
while let Some(pkt) = stack_stream.next().await {
if let Ok(pkt) = pkt {
match tun_sink.send(pkt).await {
Ok(_) => {}
Err(e) => warn!("failed to send packet to TUN, err: {:?}", e),
}
}
}
}));
// Reads packet from TUN and sends to stack.
futs.push(tokio_spawn!(async move {
while let Some(pkt) = tun_stream.next().await {
if let Ok(pkt) = pkt {
match stack_sink.send(pkt).await {
Ok(_) => {}
Err(e) => warn!("failed to send packet to stack, err: {:?}", e),
};
}
}
}));
// Extracts TCP connections from stack and sends them to the dispatcher.
futs.push(tokio_spawn!({
let interface = opt.interface.clone();
async move {
handle_inbound_stream(tcp_listener, interface).await;
}
}));
// Receive and send UDP packets between netstack and NAT manager. The NAT
// manager would maintain UDP sessions and send them to the dispatcher.
futs.push(tokio_spawn!(async move {
handle_inbound_datagram(udp_socket, opt.interface).await;
}));
futures::future::join_all(futs)
.await
.iter()
.for_each(|res| {
if let Err(e) = res {
error!("error: {:?}", e);
}
});
}
/// simply forward tcp stream
async fn handle_inbound_stream(mut tcp_listener: TcpListener, interface: String) {
while let Some((mut stream, local, remote)) = tcp_listener.next().await {
let interface = interface.clone();
tokio::spawn(async move {
info!("new tcp connection: {:?} => {:?}", local, remote);
match new_tcp_stream(remote, &interface).await {
Ok(mut remote_stream) => {
// pipe between two tcp stream
match tokio::io::copy_bidirectional(&mut stream, &mut remote_stream).await {
Ok(_) => {}
Err(e) => warn!(
"failed to copy tcp stream {:?}=>{:?}, err: {:?}",
local, remote, e
),
}
}
Err(e) => warn!(
"failed to new tcp stream {:?}=>{:?}, err: {:?}",
local, remote, e
),
}
});
}
}
/// simply forward udp datagram
async fn handle_inbound_datagram(udp_socket: UdpSocket, interface: String) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (mut read_half, mut write_half) = udp_socket.split();
tokio::spawn(async move {
while let Some((data, local, remote)) = rx.recv().await {
let _ = write_half.send((data, remote, local)).await;
}
});
while let Some((data, local, remote)) = read_half.next().await {
let tx = tx.clone();
let interface = interface.clone();
tokio::spawn(async move {
info!("new udp datagram: {:?} => {:?}", local, remote);
match new_udp_packet(remote, &interface).await {
Ok(remote_socket) => {
// pipe between two udp sockets
let _ = remote_socket.send(&data).await;
loop {
let mut buf = vec![0; 1024];
match remote_socket.recv_from(&mut buf).await {
Ok((len, _)) => {
let _ = tx.send((buf[..len].to_vec(), local, remote));
}
Err(e) => {
warn!(
"failed to recv udp datagram {:?}<->{:?}: {:?}",
local, remote, e
);
break;
}
}
}
}
Err(e) => warn!(
"failed to new udp socket {:?}=>{:?}, err: {:?}",
local, remote, e
),
}
});
}
}
async fn new_tcp_stream<'a>(addr: SocketAddr, iface: &str) -> std::io::Result<TcpStream> {
use socket2_ext::{AddressBinding, BindDeviceOption};
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
socket.bind_to_device(BindDeviceOption::v4(iface))?;
socket.set_keepalive(true)?;
socket.set_nodelay(true)?;
socket.set_nonblocking(true)?;
let stream = TcpSocket::from_std_stream(socket.into())
.connect(addr)
.await?;
Ok(stream)
}
async fn new_udp_packet(addr: SocketAddr, iface: &str) -> std::io::Result<tokio::net::UdpSocket> {
use socket2_ext::{AddressBinding, BindDeviceOption};
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None)?;
socket.bind_to_device(BindDeviceOption::v4(iface))?;
socket.set_nonblocking(true)?;
let socket = tokio::net::UdpSocket::from_std(socket.into());
if let Ok(ref socket) = socket {
socket.connect(addr).await?;
}
socket
}
fn get_device_broadcast(device: &tun2::AsyncDevice) -> Option<std::net::Ipv4Addr> {
use tun2::AbstractDevice;
let mtu = device.mtu().unwrap_or(tun2::DEFAULT_MTU);
let address = match device.address() {
Ok(a) => match a {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => return None,
},
Err(_) => return None,
};
let netmask = match device.netmask() {
Ok(n) => match n {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => return None,
},
Err(_) => return None,
};
match smoltcp::wire::Ipv4Cidr::from_netmask(address, netmask) {
Ok(address_net) => match address_net.broadcast() {
Some(broadcast) => {
info!(
"tun device network: {} (address: {}, netmask: {}, broadcast: {}, mtu: {})",
address_net, address, netmask, broadcast, mtu,
);
Some(broadcast)
}
None => {
error!("invalid tun address {}, netmask {}", address, netmask);
None
}
},
Err(err) => {
error!(
"invalid tun address {}, netmask {}, error: {}",
address, netmask, err
);
None
}
}
}

View File

@ -1,174 +0,0 @@
#!/usr/bin/env bash
# bench-offload.sh
#
# Benchmarks netstack-smoltcp's forward examples with 2-stream iperf3.
# Compares:
# - examples/forward (tun2, no GRO/GSO offload)
# - examples/forward-offload-linux (tun-rs, Linux GRO/GSO offload via IFF_VNET_HDR)
#
# Setup: creates a veth pair + network namespace; iperf3 server runs inside
# the namespace, the forward proxy bridges traffic through a TUN device.
#
# Requirements: cargo, iperf3, ip (iproute2), root/CAP_NET_ADMIN
#
# Usage:
# sudo bash scripts/bench-offload.sh
#
# Run from the root of the netstack-smoltcp repository.
set -euo pipefail
# ── config ────────────────────────────────────────────────────────────────────
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NS=bench
VETH_HOST=veth-host
VETH_NS=veth-bench
HOST_IP=172.19.0.1
NS_IP=172.19.0.2
PREFIX=24
TUN_NAME=utun8
TUN_IP=10.10.10.2
IPERF_PORT=5201
DURATION=15
STREAMS=2
# ── helpers ───────────────────────────────────────────────────────────────────
die() { echo "ERROR: $*" >&2; exit 1; }
require() { command -v "$1" &>/dev/null || die "'$1' not found"; }
cleanup() {
pkill -f "forward-" 2>/dev/null || true
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
ip link del "$VETH_HOST" 2>/dev/null || true
ip netns del "$NS" 2>/dev/null || true
}
trap cleanup EXIT
# ── preflight ─────────────────────────────────────────────────────────────────
require cargo
require iperf3
require ip
[[ $EUID -eq 0 ]] || die "run as root (needs CAP_NET_ADMIN for TUN + netns)"
[[ -f "$REPO_DIR/Cargo.toml" ]] || die "run from the netstack-smoltcp repo root"
grep -q 'name = "netstack-smoltcp"' "$REPO_DIR/Cargo.toml" \
|| die "Cargo.toml does not look like netstack-smoltcp"
# ── network setup ─────────────────────────────────────────────────────────────
echo "[net] setting up namespace '$NS' and veth pair..."
cleanup 2>/dev/null || true
sleep 0.5
ip netns add "$NS"
ip link add "$VETH_HOST" type veth peer name "$VETH_NS"
ip link set "$VETH_NS" netns "$NS"
ip addr add "${HOST_IP}/${PREFIX}" dev "$VETH_HOST"
ip link set "$VETH_HOST" up
ip netns exec "$NS" ip addr add "${NS_IP}/${PREFIX}" dev "$VETH_NS"
ip netns exec "$NS" ip link set "$VETH_NS" up
ip netns exec "$NS" ip link set lo up
echo "[net] ${HOST_IP} <──veth──> ${NS_IP} (ns:${NS})"
# ── build: forward (tun2, no offload) ────────────────────────────────────────
echo ""
echo "[build] examples/forward (tun2, no GRO/GSO offload)..."
(
cd "$REPO_DIR"
cargo build --example forward --release --quiet
cp target/release/examples/forward /tmp/forward-tun2
)
echo "[build] done → /tmp/forward-tun2"
# ── build: forward-offload-linux (tun-rs, GRO/GSO offload) ───────────────────
echo ""
echo "[build] examples/forward-offload-linux (tun-rs, GRO/GSO offload)..."
(
cd "$REPO_DIR"
cargo build --example forward-offload-linux --release --quiet
cp target/release/examples/forward-offload-linux /tmp/forward-tun-rs
)
echo "[build] done → /tmp/forward-tun-rs"
# ── benchmark runner ──────────────────────────────────────────────────────────
run_bench() {
local label="$1" binary="$2"
# clean any leftover state
pkill -f "forward-" 2>/dev/null || true
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
sleep 0.8
# start iperf3 server inside namespace
ip netns exec "$NS" iperf3 -s -p "$IPERF_PORT" -D \
--logfile /tmp/iperf3-bench-server.log
# start proxy
"$binary" -i "$VETH_HOST" -n "$TUN_NAME" --log-level warn &
sleep 2
ip link show "$TUN_NAME" &>/dev/null \
|| { echo " [!] TUN not up, skipping"; return 1; }
# route iperf3 traffic through TUN (more-specific /32 overrides /24 via veth)
ip route add "${NS_IP}/32" dev "$TUN_NAME"
echo " running iperf3: ${STREAMS} streams × ${DURATION}s …"
local out
out=$(iperf3 -c "$NS_IP" -p "$IPERF_PORT" \
-t "$DURATION" -P "$STREAMS" 2>&1)
local sender receiver
sender=$(echo "$out" | grep "SUM.*sender" | awk '{print $6, $7}')
receiver=$(echo "$out" | grep "SUM.*receiver" | awk '{print $6, $7}')
if [[ -z "$sender" ]]; then
echo " result: FAILED"
echo "$out" | tail -5 | sed 's/^/ /'
else
printf " sender: %s\n" "$sender"
printf " receiver: %s\n" "$receiver"
fi
pkill -f "forward-" 2>/dev/null || true
ip netns exec "$NS" pkill iperf3 2>/dev/null || true
ip route del "${NS_IP}/32" dev "$TUN_NAME" 2>/dev/null || true
ip tuntap del dev "$TUN_NAME" mode tun 2>/dev/null || true
sleep 0.8
}
# ── direct baseline ───────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " BASELINE: direct veth (no TUN, no proxy)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ip netns exec "$NS" pkill iperf3 2>/dev/null || true; sleep 0.3
ip netns exec "$NS" iperf3 -s -p "$IPERF_PORT" -D \
--logfile /tmp/iperf3-bench-server.log; sleep 0.3
echo " running iperf3: ${STREAMS} streams × ${DURATION}s …"
baseline_out=$(iperf3 -c "$NS_IP" -p "$IPERF_PORT" \
-t "$DURATION" -P "$STREAMS" 2>&1)
echo "$baseline_out" | grep "SUM.*sender" | awk '{printf " sender: %s %s\n", $6, $7}'
echo "$baseline_out" | grep "SUM.*receiver" | awk '{printf " receiver: %s %s\n", $6, $7}'
ip netns exec "$NS" pkill iperf3 2>/dev/null || true; sleep 0.5
# ── tun2 ─────────────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " tun2 (main branch — no GRO/GSO offload)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
run_bench "tun2" /tmp/forward-tun2
# ── tun-rs + offload ──────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " tun-rs (patched — GRO/GSO offload via IFF_VNET_HDR)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
run_bench "tun-rs+offload" /tmp/forward-tun-rs
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " done."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

View File

@ -1,26 +0,0 @@
#!/bin/bash
#__author__: cavivie
DEFAULT_TUN_NAME="utun8"
function do_route() {
local route_op="${1}"
local tun_name="${2:-$DEFAULT_TUN_NAME}"
ip route ${route_op} 0.0.0.0/1 dev ${tun_name}
ip route ${route_op} 128.0.0.0/1 dev ${tun_name}
}
function usage(){
echo "Usage:
route add add tun routes to system route table
route del delete routes from system route table
route help display all usages of the shell script"
}
# START MAIN-OPTIONS
case $1 in
add) do_route add $2;;
del) do_route delete $2;;
*) usage ;;
esac
# END MAIN-OPTIONS

View File

@ -1,36 +0,0 @@
#!/bin/bash
#__author__: cavivie
DEFAULT_TUN_ADDR="10.10.10.2/24"
DEFAULT_TUN_DEST="10.10.10.1"
function do_route() {
local route_op="${1}"
local tun_addr="${2:-$DEFAULT_TUN_ADDR}"
local tun_dest="${3:-$DEFAULT_TUN_DEST}"
sudo route ${route_op} -net 1.0.0.0/8 ${tun_dest}
sudo route ${route_op} -net 2.0.0.0/7 ${tun_dest}
sudo route ${route_op} -net 4.0.0.0/6 ${tun_dest}
sudo route ${route_op} -net 8.0.0.0/5 ${tun_dest}
sudo route ${route_op} -net 16.0.0.0/4 ${tun_dest}
sudo route ${route_op} -net 32.0.0.0/3 ${tun_dest}
sudo route ${route_op} -net 64.0.0.0/2 ${tun_dest}
sudo route ${route_op} -net 128.0.0.0/1 ${tun_dest}
# tun2 do like this automatically
sudo route ${route_op} -net ${tun_addr} ${tun_dest}
}
function usage(){
echo "Usage:
route add add tun routes to system route table
route del delete routes from system route table
route help display all usages of the shell script"
}
# START MAIN-OPTIONS
case $1 in
add) do_route add $2 $3;;
del) do_route delete $2 $3;;
*) usage ;;
esac
# END MAIN-OPTIONS

View File

@ -1,30 +0,0 @@
#__author__: cavivie
param(
[string]$Cmd = "help",
[string]$TunName = "utun8",
[string]$TunGateway = "10.10.10.1"
)
$ErrorActionPreference = "Stop"
# START MAIN-OPTIONS
switch ($Cmd) {
"add" {
# tun2 do like this automatically
New-NetRoute -DestinationPrefix "0.0.0.0/1" -InterfaceAlias $TunName -NextHop "$TunGateway"
New-NetRoute -DestinationPrefix "128.0.0.0/1" -InterfaceAlias $TunName -NextHop "$TunGateway"
}
"del" {
# tun2 do like this automatically
Get-NetRoute -DestinationPrefix "0.0.0.0/1" -InterfaceAlias $TunName | Remove-NetRoute
Get-NetRoute -DestinationPrefix "128.0.0.0/1" -InterfaceAlias $TunName | Remove-NetRoute
}
default {
Write-Host "Usage:
route add add tun routes to system route table
route del delete routes from system route table
route help display all usages of the shell script"
}
}
# END MAIN-OPTIONS

View File

@ -1,109 +0,0 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use smoltcp::{
phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken},
time::Instant,
};
use tokio::sync::mpsc::{unbounded_channel, Permit, Sender, UnboundedReceiver, UnboundedSender};
use crate::packet::AnyIpPktFrame;
pub(super) struct VirtualDevice {
in_buf_avail: Arc<AtomicBool>,
in_buf: UnboundedReceiver<Vec<u8>>,
out_buf: Sender<AnyIpPktFrame>,
mtu: usize,
cached_packet: Option<Vec<u8>>,
}
impl VirtualDevice {
pub(super) fn new(
iface_egress_tx: Sender<AnyIpPktFrame>,
mtu: usize,
) -> (Self, UnboundedSender<Vec<u8>>, Arc<AtomicBool>) {
let iface_ingress_tx_avail = Arc::new(AtomicBool::new(false));
let (iface_ingress_tx, iface_ingress_rx) = unbounded_channel();
(
Self {
in_buf_avail: iface_ingress_tx_avail.clone(),
in_buf: iface_ingress_rx,
out_buf: iface_egress_tx,
mtu,
cached_packet: None,
},
iface_ingress_tx,
iface_ingress_tx_avail,
)
}
}
impl Device for VirtualDevice {
type RxToken<'a> = VirtualRxToken;
type TxToken<'a> = VirtualTxToken<'a>;
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
let buffer = if let Some(buf) = self.cached_packet.take() {
buf
} else {
let Ok(buf) = self.in_buf.try_recv() else {
self.in_buf_avail.store(false, Ordering::Release);
return None;
};
buf
};
let Ok(permit) = self.out_buf.try_reserve() else {
self.cached_packet = Some(buffer);
self.in_buf_avail.store(false, Ordering::Release);
return None;
};
Some((Self::RxToken { buffer }, Self::TxToken { permit }))
}
fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
match self.out_buf.try_reserve() {
Ok(permit) => Some(Self::TxToken { permit }),
Err(_) => None,
}
}
fn capabilities(&self) -> DeviceCapabilities {
let mut capabilities = DeviceCapabilities::default();
capabilities.medium = Medium::Ip;
capabilities.max_transmission_unit = self.mtu;
capabilities
}
}
pub(super) struct VirtualRxToken {
buffer: Vec<u8>,
}
impl RxToken for VirtualRxToken {
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&[u8]) -> R,
{
f(&self.buffer[..])
}
}
pub(super) struct VirtualTxToken<'a> {
permit: Permit<'a, Vec<u8>>,
}
impl<'a> TxToken for VirtualTxToken<'a> {
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
let mut buffer = vec![0u8; len];
let result = f(&mut buffer);
self.permit.send(buffer);
result
}
}

View File

@ -1,56 +0,0 @@
use std::net::IpAddr;
pub type IpFilter<'a> = Box<dyn Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'a>;
pub struct IpFilters<'a> {
filters: Vec<IpFilter<'a>>,
}
impl<'a> Default for IpFilters<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> IpFilters<'a> {
pub fn new() -> Self {
Self {
filters: Default::default(),
}
}
pub fn with_non_broadcast() -> Self {
macro_rules! non_broadcast {
($addr:ident) => {
match $addr {
IpAddr::V4(a) => !(a.is_broadcast() || a.is_multicast() || a.is_unspecified()),
IpAddr::V6(a) => !(a.is_multicast() || a.is_unspecified()),
}
};
}
Self {
filters: vec![Box::new(|src, dst| {
non_broadcast!(src) && non_broadcast!(dst)
})],
}
}
pub fn add(&mut self, filter: IpFilter<'a>) {
self.filters.push(filter);
}
pub fn add_fn<F>(&mut self, filter: F)
where
F: Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'a,
{
self.filters.push(Box::new(filter));
}
pub fn add_all<I: IntoIterator<Item = IpFilter<'a>>>(&mut self, filters: I) {
self.filters.extend(filters);
}
pub fn is_allowed(&self, src: &IpAddr, dst: &IpAddr) -> bool {
self.filters.iter().all(|filter| filter(src, dst))
}
}

View File

@ -1,22 +0,0 @@
mod device;
mod runner;
pub use runner::Runner;
mod packet;
pub use packet::AnyIpPktFrame;
mod filter;
pub use filter::{IpFilter, IpFilters};
pub mod udp;
pub use udp::UdpSocket;
pub mod tcp;
pub use tcp::{TcpListener, TcpStream};
pub mod stack;
pub use stack::{Stack, StackBuilder};
/// Re-export
pub use smoltcp;

View File

@ -1,53 +0,0 @@
use std::net::IpAddr;
use smoltcp::wire::{IpProtocol, IpVersion, Ipv4Packet, Ipv6Packet};
pub type AnyIpPktFrame = Vec<u8>;
#[derive(Debug)]
pub(super) enum IpPacket<T: AsRef<[u8]>> {
Ipv4(Ipv4Packet<T>),
Ipv6(Ipv6Packet<T>),
}
impl<T: AsRef<[u8]> + Copy> IpPacket<T> {
pub fn new_checked(packet: T) -> smoltcp::wire::Result<IpPacket<T>> {
let buffer = packet.as_ref();
match IpVersion::of_packet(buffer)? {
IpVersion::Ipv4 => Ok(IpPacket::Ipv4(Ipv4Packet::new_checked(packet)?)),
IpVersion::Ipv6 => Ok(IpPacket::Ipv6(Ipv6Packet::new_checked(packet)?)),
}
}
pub fn src_addr(&self) -> IpAddr {
match *self {
IpPacket::Ipv4(ref packet) => IpAddr::from(packet.src_addr()),
IpPacket::Ipv6(ref packet) => IpAddr::from(packet.src_addr()),
}
}
pub fn dst_addr(&self) -> IpAddr {
match *self {
IpPacket::Ipv4(ref packet) => IpAddr::from(packet.dst_addr()),
IpPacket::Ipv6(ref packet) => IpAddr::from(packet.dst_addr()),
}
}
pub fn protocol(&self) -> IpProtocol {
match *self {
IpPacket::Ipv4(ref packet) => packet.next_header(),
IpPacket::Ipv6(ref packet) => packet.next_header(),
}
}
}
impl<'a, T: AsRef<[u8]> + ?Sized> IpPacket<&'a T> {
/// Return a pointer to the payload.
#[inline]
pub fn payload(&self) -> &'a [u8] {
match *self {
IpPacket::Ipv4(ref packet) => packet.payload(),
IpPacket::Ipv6(ref packet) => packet.payload(),
}
}
}

View File

@ -1,42 +0,0 @@
use std::{
future::{Future, IntoFuture},
pin::Pin,
task::{Context, Poll},
};
/// BoxFuture acts the same as the [BoxFuture in crate futures utils],
/// which is an owned dynamically typed Future for use in cases where you
/// cant statically type your result or need to add some indirection.
/// But the difference of this structure is that it will conditionally
/// implement Send according to the properties of type T, which does not
/// require two sets of API interfaces in single-threaded and multi-threaded.
///
/// [BoxFuture in crate futures utils]: https://docs.rs/futures-util/latest/futures_util/future/type.BoxFuture.html
pub struct BoxFuture<'a, T>(Pin<Box<dyn Future<Output = T> + Send + 'a>>);
impl<'a, T> BoxFuture<'a, T> {
pub fn new<F>(f: F) -> BoxFuture<'a, T>
where
F: IntoFuture<Output = T> + Send + 'a,
F::IntoFuture: Send + 'a,
{
BoxFuture(Box::pin(f.into_future()))
}
#[allow(unused)]
pub fn wrap(f: Pin<Box<dyn Future<Output = T> + Send + 'a>>) -> BoxFuture<'a, T> {
BoxFuture(f)
}
}
impl<T> Future for BoxFuture<'_, T> {
type Output = T;
fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
self.0.as_mut().poll(context)
}
}
pub type Runner = BoxFuture<'static, std::io::Result<()>>;

View File

@ -1,279 +0,0 @@
use std::{
net::IpAddr,
pin::Pin,
task::{ready, Context, Poll},
};
use futures::{Sink, Stream};
use smoltcp::wire::IpProtocol;
use tokio::sync::mpsc::{channel, Receiver};
use tokio_util::sync::PollSender;
use tracing::{debug, trace};
use crate::{
filter::{IpFilter, IpFilters},
packet::{AnyIpPktFrame, IpPacket},
runner::Runner,
tcp::TcpListener,
udp::UdpSocket,
};
pub struct StackBuilder {
enable_udp: bool,
enable_tcp: bool,
enable_icmp: bool,
stack_buffer_size: usize,
udp_buffer_size: usize,
tcp_buffer_size: usize,
mtu: usize,
ip_filters: IpFilters<'static>,
}
impl Default for StackBuilder {
fn default() -> Self {
Self {
enable_udp: false,
enable_tcp: false,
enable_icmp: false,
stack_buffer_size: 1024,
udp_buffer_size: 512,
tcp_buffer_size: 512,
mtu: 1504, // 1500 for Ethernet + 4 for VLAN
ip_filters: IpFilters::with_non_broadcast(),
}
}
}
#[allow(unused)]
impl StackBuilder {
pub fn enable_udp(mut self, enable: bool) -> Self {
self.enable_udp = enable;
self
}
pub fn enable_tcp(mut self, enable: bool) -> Self {
self.enable_tcp = enable;
self
}
pub fn enable_icmp(mut self, enable: bool) -> Self {
self.enable_icmp = enable;
self
}
pub fn stack_buffer_size(mut self, size: usize) -> Self {
self.stack_buffer_size = size;
self
}
pub fn udp_buffer_size(mut self, size: usize) -> Self {
self.udp_buffer_size = size;
self
}
pub fn tcp_buffer_size(mut self, size: usize) -> Self {
self.tcp_buffer_size = size;
self
}
pub fn set_ip_filters(mut self, filters: IpFilters<'static>) -> Self {
self.ip_filters = filters;
self
}
pub fn add_ip_filter(mut self, filter: IpFilter<'static>) -> Self {
self.ip_filters.add(filter);
self
}
pub fn add_ip_filter_fn<F>(mut self, filter: F) -> Self
where
F: Fn(&IpAddr, &IpAddr) -> bool + Send + Sync + 'static,
{
self.ip_filters.add_fn(filter);
self
}
pub fn mtu(mut self, mtu: usize) -> Self {
self.mtu = mtu;
self
}
#[allow(clippy::type_complexity)]
pub fn build(
self,
) -> std::io::Result<(
Stack,
Option<Runner>,
Option<UdpSocket>,
Option<TcpListener>,
)> {
let (stack_tx, stack_rx) = channel(self.stack_buffer_size);
let (udp_tx, udp_rx) = if self.enable_udp {
let (udp_tx, udp_rx) = channel(self.udp_buffer_size);
(Some(PollSender::new(udp_tx)), Some(udp_rx))
} else {
(None, None)
};
let (tcp_tx, tcp_rx) = if self.enable_tcp {
let (tcp_tx, tcp_rx) = channel(self.tcp_buffer_size);
(Some(PollSender::new(tcp_tx)), Some(tcp_rx))
} else {
(None, None)
};
// ICMP is handled by TCP's Interface.
// smoltcp's interface will always send replies to EchoRequest
if self.enable_icmp && !self.enable_tcp {
use std::io::{Error, ErrorKind::InvalidInput};
return Err(Error::new(InvalidInput, "ICMP requires TCP"));
}
let icmp_tx = if self.enable_icmp {
tcp_tx.clone()
} else {
None
};
let udp_socket = udp_rx.map(|udp_rx| UdpSocket::new(udp_rx, stack_tx.clone()));
let (tcp_runner, tcp_listener) = if let Some(tcp_rx) = tcp_rx {
let (tcp_runner, tcp_listener) = TcpListener::new(tcp_rx, stack_tx, self.mtu)?;
(Some(tcp_runner), Some(tcp_listener))
} else {
(None, None)
};
let stack = Stack {
ip_filters: self.ip_filters,
stack_rx,
sink_buf: None,
udp_tx,
tcp_tx,
icmp_tx,
};
Ok((stack, tcp_runner, udp_socket, tcp_listener))
}
}
pub struct Stack {
ip_filters: IpFilters<'static>,
sink_buf: Option<(AnyIpPktFrame, IpProtocol)>,
udp_tx: Option<PollSender<AnyIpPktFrame>>,
tcp_tx: Option<PollSender<AnyIpPktFrame>>,
icmp_tx: Option<PollSender<AnyIpPktFrame>>,
stack_rx: Receiver<AnyIpPktFrame>,
}
impl Stack {
fn poll_send(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), std::io::Error>> {
let (item, proto) = match self.sink_buf.take() {
Some(val) => val,
None => return Poll::Ready(Ok(())),
};
let tx = match proto {
IpProtocol::Tcp => self.tcp_tx.as_mut(),
IpProtocol::Udp => self.udp_tx.as_mut(),
IpProtocol::Icmp | IpProtocol::Icmpv6 => self.icmp_tx.as_mut(),
_ => unreachable!(),
};
let Some(tx) = tx else {
return Poll::Ready(Ok(()));
};
match tx.poll_reserve(cx) {
Poll::Pending => {
self.sink_buf = Some((item, proto));
Poll::Pending
}
Poll::Ready(Err(_)) => Poll::Ready(Err(channel_closed_err("channel is closed"))),
Poll::Ready(Ok(_)) => match tx.send_item(item) {
Ok(()) => Poll::Ready(Ok(())),
Err(_) => Poll::Ready(Err(channel_closed_err("channel is closed"))),
},
}
}
}
// Recv from stack.
impl Stream for Stack {
type Item = std::io::Result<AnyIpPktFrame>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match self.stack_rx.poll_recv(cx) {
Poll::Ready(Some(pkt)) => Poll::Ready(Some(Ok(pkt))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
// Send to stack.
impl Sink<AnyIpPktFrame> for Stack {
type Error = std::io::Error;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
// If a buffered item exists, try to flush it first. This also properly
// registers the waker via poll_reserve so we get woken when the channel
// has capacity. Without this, returning Pending here with _cx unused
// means the task never gets rescheduled.
if self.sink_buf.is_some() {
ready!(self.poll_send(cx))?;
}
Poll::Ready(Ok(()))
}
fn start_send(mut self: Pin<&mut Self>, item: AnyIpPktFrame) -> Result<(), Self::Error> {
if item.is_empty() {
return Ok(());
}
use std::io::{Error, ErrorKind::InvalidInput};
let packet = IpPacket::new_checked(item.as_slice())
.map_err(|err| Error::new(InvalidInput, format!("invalid IP packet: {err}")))?;
let src_ip = packet.src_addr();
let dst_ip = packet.dst_addr();
let addr_allowed = self.ip_filters.is_allowed(&src_ip, &dst_ip);
if !addr_allowed {
trace!("IP packet {src_ip} -> {dst_ip} (allowed? {addr_allowed}) throwing away",);
return Ok(());
}
let protocol = packet.protocol();
if matches!(
protocol,
IpProtocol::Tcp | IpProtocol::Udp | IpProtocol::Icmp | IpProtocol::Icmpv6
) {
self.sink_buf.replace((item, protocol));
} else {
debug!("tun IP packet ignored (protocol: {:?})", protocol);
}
Ok(())
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.poll_send(cx)
}
fn poll_close(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<(), Self::Error>> {
self.stack_rx.close();
Poll::Ready(Ok(()))
}
}
fn channel_closed_err<E>(err: E) -> std::io::Error
where
E: Into<Box<dyn std::error::Error + Send + Sync>>,
{
std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)
}

View File

@ -1,564 +0,0 @@
use std::{
collections::HashMap,
net::SocketAddr,
pin::Pin,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
task::{Context, Poll, Waker},
};
use futures::Stream;
use smoltcp::{
iface::{Config as InterfaceConfig, Interface, SocketHandle, SocketSet},
phy::Device,
socket::tcp::{Socket as TcpSocket, SocketBuffer as TcpSocketBuffer, State as TcpState},
storage::RingBuffer,
time::{Duration, Instant},
wire::{HardwareAddress, IpAddress, IpCidr, IpProtocol, Ipv4Address, Ipv6Address, TcpPacket},
};
use spin::Mutex as SpinMutex;
use tokio::{
io::{AsyncRead, AsyncWrite, ReadBuf},
sync::{
mpsc::{channel, Receiver, Sender, UnboundedSender},
Notify,
},
};
use tracing::{error, trace};
use crate::{
device::VirtualDevice,
packet::{AnyIpPktFrame, IpPacket},
Runner,
};
// Reduced buffer sizes to 16KB to prevent excessive memory overhead (was 0x3FFF * 20 = 327KB per buffer)
const DEFAULT_TCP_SEND_BUFFER_SIZE: u32 = 16384;
const DEFAULT_TCP_RECV_BUFFER_SIZE: u32 = 16384;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum TcpSocketState {
Normal,
Close,
Closing,
Closed,
}
struct TcpSocketControl {
send_buffer: RingBuffer<'static, u8>,
send_waker: Option<Waker>,
recv_buffer: RingBuffer<'static, u8>,
recv_waker: Option<Waker>,
recv_state: TcpSocketState,
send_state: TcpSocketState,
}
struct TcpSocketCreation {
control: SharedControl,
socket: TcpSocket<'static>,
}
type SharedNotify = Arc<Notify>;
type SharedControl = Arc<SpinMutex<TcpSocketControl>>;
struct TcpListenerRunner;
impl TcpListenerRunner {
fn create(
device: VirtualDevice,
iface: Interface,
iface_ingress_tx: UnboundedSender<Vec<u8>>,
iface_ingress_tx_avail: Arc<AtomicBool>,
tcp_rx: Receiver<AnyIpPktFrame>,
stream_tx: Sender<TcpStream>,
sockets: HashMap<SocketHandle, SharedControl>,
) -> Runner {
Runner::new(async move {
let notify = Arc::new(Notify::new());
let (socket_tx, socket_rx) = channel::<TcpSocketCreation>(1024);
let res = tokio::select! {
v = Self::handle_packet(notify.clone(), iface_ingress_tx, iface_ingress_tx_avail.clone(), tcp_rx, stream_tx, socket_tx) => v,
v = Self::handle_socket(notify, device, iface, iface_ingress_tx_avail, sockets, socket_rx) => v,
};
res?;
trace!("VirtDevice::poll thread exited");
Ok(())
})
}
async fn handle_packet(
notify: SharedNotify,
iface_ingress_tx: UnboundedSender<Vec<u8>>,
iface_ingress_tx_avail: Arc<AtomicBool>,
mut tcp_rx: Receiver<AnyIpPktFrame>,
stream_tx: Sender<TcpStream>,
socket_tx: Sender<TcpSocketCreation>,
) -> std::io::Result<()> {
while let Some(frame) = tcp_rx.recv().await {
let packet = match IpPacket::new_checked(frame.as_slice()) {
Ok(p) => p,
Err(err) => {
error!("invalid TCP IP packet: {:?}", err,);
continue;
}
};
// Specially handle icmp packet by TCP interface.
if matches!(packet.protocol(), IpProtocol::Icmp | IpProtocol::Icmpv6) {
iface_ingress_tx
.send(frame)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
iface_ingress_tx_avail.store(true, Ordering::Release);
notify.notify_one();
continue;
}
let src_ip = packet.src_addr();
let dst_ip = packet.dst_addr();
let payload = packet.payload();
let packet = match TcpPacket::new_checked(payload) {
Ok(p) => p,
Err(err) => {
error!("invalid TCP err: {err}, src_ip: {src_ip}, dst_ip: {dst_ip}, payload: {payload:?}");
continue;
}
};
let src_port = packet.src_port();
let dst_port = packet.dst_port();
let src_addr = SocketAddr::new(src_ip, src_port);
let dst_addr = SocketAddr::new(dst_ip, dst_port);
// TCP first handshake packet, create a new Connection
if packet.syn() && !packet.ack() {
let mut socket = TcpSocket::new(
TcpSocketBuffer::new(vec![0u8; DEFAULT_TCP_RECV_BUFFER_SIZE as usize]),
TcpSocketBuffer::new(vec![0u8; DEFAULT_TCP_SEND_BUFFER_SIZE as usize]),
);
socket.set_keep_alive(Some(Duration::from_secs(28)));
// FIXME: It should follow system's setting. 7200 is Linux's default.
socket.set_timeout(Some(Duration::from_secs(7200)));
// NO ACK delay
// socket.set_ack_delay(None);
if let Err(err) = socket.listen(dst_addr) {
error!("listen error: {:?}", err);
continue;
}
trace!("created TCP connection for {} <-> {}", src_addr, dst_addr);
let control = Arc::new(SpinMutex::new(TcpSocketControl {
send_buffer: RingBuffer::new(vec![0u8; DEFAULT_TCP_SEND_BUFFER_SIZE as usize]),
send_waker: None,
recv_buffer: RingBuffer::new(vec![0u8; DEFAULT_TCP_RECV_BUFFER_SIZE as usize]),
recv_waker: None,
recv_state: TcpSocketState::Normal,
send_state: TcpSocketState::Normal,
}));
if let Err(_) = stream_tx.try_send(TcpStream {
src_addr,
dst_addr,
notify: notify.clone(),
control: control.clone(),
}) {
error!("stream_tx full or dropped, dropping SYN from {}", src_addr);
continue;
}
if let Err(_) = socket_tx.try_send(TcpSocketCreation { control, socket }) {
error!("socket_tx full or dropped, dropping SYN from {}", src_addr);
continue;
}
}
// Pipeline tcp stream packet
iface_ingress_tx
.send(frame)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))?;
iface_ingress_tx_avail.store(true, Ordering::Release);
notify.notify_one();
}
Ok(())
}
async fn handle_socket(
notify: SharedNotify,
mut device: VirtualDevice,
mut iface: Interface,
iface_ingress_tx_avail: Arc<AtomicBool>,
mut sockets: HashMap<SocketHandle, SharedControl>,
mut socket_rx: Receiver<TcpSocketCreation>,
) -> std::io::Result<()> {
let mut socket_set = SocketSet::new(vec![]);
loop {
while let Ok(TcpSocketCreation { control, socket }) = socket_rx.try_recv() {
let handle = socket_set.add(socket);
sockets.insert(handle, control);
}
let before_poll = Instant::now();
let updated_sockets = iface.poll(before_poll, &mut device, &mut socket_set);
if matches!(
updated_sockets,
smoltcp::iface::PollResult::SocketStateChanged
) {
trace!("VirtDevice::poll costed {}", Instant::now() - before_poll);
}
// Check all the sockets' status
let mut sockets_to_remove = Vec::new();
for (socket_handle, control) in sockets.iter() {
let socket_handle = *socket_handle;
let socket = socket_set.get_mut::<TcpSocket>(socket_handle);
let mut control = control.lock();
// Remove the socket only when it is in the closed state.
if socket.state() == TcpState::Closed {
sockets_to_remove.push(socket_handle);
control.send_state = TcpSocketState::Closed;
control.recv_state = TcpSocketState::Closed;
if let Some(waker) = control.send_waker.take() {
waker.wake();
}
if let Some(waker) = control.recv_waker.take() {
waker.wake();
}
trace!("closed TCP connection");
continue;
}
// SHUT_WR — only close once the send_buffer has been fully
// drained into the smoltcp socket. Closing earlier transitions
// the socket to FIN_WAIT_1, making can_send() return false, so
// the send loop below never runs and the remaining data is lost.
if matches!(control.send_state, TcpSocketState::Close)
&& control.send_buffer.is_empty()
{
trace!("closing TCP Write Half, {:?}", socket.state());
socket.close();
control.send_state = TcpSocketState::Closing;
}
// Check if readable
let mut wake_receiver = false;
while socket.can_recv() && !control.recv_buffer.is_full() {
let result = socket.recv(|buffer| {
let n = control.recv_buffer.enqueue_slice(buffer);
(n, ())
});
match result {
Ok(..) => wake_receiver = true,
Err(err) => {
error!("socket recv error: {:?}, {:?}", err, socket.state());
// Don't know why. Abort the connection.
socket.abort();
if matches!(control.recv_state, TcpSocketState::Normal) {
control.recv_state = TcpSocketState::Closed;
}
wake_receiver = true;
// The socket will be recycled in the next poll.
break;
}
}
}
// If socket is not in ESTABLISH, FIN-WAIT-1, FIN-WAIT-2,
// the local client have closed our receiver.
let states = [
TcpState::Listen,
TcpState::SynReceived,
TcpState::Established,
TcpState::FinWait1,
TcpState::FinWait2,
];
if matches!(control.recv_state, TcpSocketState::Normal)
&& !socket.may_recv()
&& !states.contains(&socket.state())
{
trace!("closed TCP Read Half, {:?}", socket.state());
// Let TcpStream::poll_read returns EOF.
control.recv_state = TcpSocketState::Closed;
wake_receiver = true;
}
if wake_receiver && control.recv_waker.is_some() {
if let Some(waker) = control.recv_waker.take() {
waker.wake();
}
}
// Check if writable
let mut wake_sender = false;
while socket.can_send() && !control.send_buffer.is_empty() {
let result = socket.send(|buffer| {
let n = control.send_buffer.dequeue_slice(buffer);
(n, ())
});
match result {
Ok(..) => wake_sender = true,
Err(err) => {
error!("socket send error: {:?}, {:?}", err, socket.state());
// Don't know why. Abort the connection.
socket.abort();
if matches!(control.send_state, TcpSocketState::Normal) {
control.send_state = TcpSocketState::Closed;
}
wake_sender = true;
// The socket will be recycled in the next poll.
break;
}
}
}
if wake_sender && control.send_waker.is_some() {
if let Some(waker) = control.send_waker.take() {
waker.wake();
}
}
}
for socket_handle in sockets_to_remove {
sockets.remove(&socket_handle);
socket_set.remove(socket_handle);
}
if !iface_ingress_tx_avail.load(Ordering::Acquire) {
let next_duration = iface
.poll_delay(before_poll, &socket_set)
.unwrap_or(Duration::from_millis(5));
if next_duration != Duration::ZERO {
let _ = tokio::time::timeout(
tokio::time::Duration::from(next_duration),
notify.notified(),
)
.await;
}
}
}
}
}
pub struct TcpListener {
stream_rx: Receiver<TcpStream>,
}
impl TcpListener {
pub(super) fn new(
tcp_rx: Receiver<AnyIpPktFrame>,
stack_tx: Sender<AnyIpPktFrame>,
mtu: usize,
) -> std::io::Result<(Runner, Self)> {
let (mut device, iface_ingress_tx, iface_ingress_tx_avail) =
VirtualDevice::new(stack_tx, mtu);
let iface = Self::create_interface(&mut device)?;
let (stream_tx, stream_rx) = channel(1024);
let runner = TcpListenerRunner::create(
device,
iface,
iface_ingress_tx,
iface_ingress_tx_avail,
tcp_rx,
stream_tx,
HashMap::new(),
);
Ok((runner, Self { stream_rx }))
}
fn create_interface<D>(device: &mut D) -> std::io::Result<Interface>
where
D: Device + ?Sized,
{
let mut iface_config = InterfaceConfig::new(HardwareAddress::Ip);
iface_config.random_seed = rand::random();
let mut iface = Interface::new(iface_config, device, Instant::now());
iface.update_ip_addrs(|ip_addrs| {
ip_addrs
.push(IpCidr::new(IpAddress::v4(0, 0, 0, 1), 0))
.expect("iface IPv4");
ip_addrs
.push(IpCidr::new(IpAddress::v6(0, 0, 0, 0, 0, 0, 0, 1), 0))
.expect("iface IPv6");
});
iface
.routes_mut()
.add_default_ipv4_route(Ipv4Address::new(0, 0, 0, 1))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, e))?;
iface
.routes_mut()
.add_default_ipv6_route(Ipv6Address::new(0, 0, 0, 0, 0, 0, 0, 1))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, e))?;
iface.set_any_ip(true);
Ok(iface)
}
}
impl Stream for TcpListener {
type Item = (TcpStream, SocketAddr, SocketAddr);
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.stream_rx.poll_recv(cx).map(|stream| {
stream.map(|stream| {
let local_addr = *stream.local_addr();
let remote_addr: SocketAddr = *stream.remote_addr();
(stream, local_addr, remote_addr)
})
})
}
}
pub struct TcpStream {
src_addr: SocketAddr,
dst_addr: SocketAddr,
notify: SharedNotify,
control: SharedControl,
}
impl Drop for TcpStream {
fn drop(&mut self) {
let mut control = self.control.lock();
if matches!(control.recv_state, TcpSocketState::Normal) {
control.recv_state = TcpSocketState::Close;
}
if matches!(control.send_state, TcpSocketState::Normal) {
control.send_state = TcpSocketState::Close;
}
self.notify.notify_one();
}
}
impl TcpStream {
pub fn local_addr(&self) -> &SocketAddr {
&self.src_addr
}
pub fn remote_addr(&self) -> &SocketAddr {
&self.dst_addr
}
}
impl AsyncRead for TcpStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let mut control = self.control.lock();
// Read from buffer
if control.recv_buffer.is_empty() {
// If socket is already closed / half closed, just return EOF directly.
if matches!(control.recv_state, TcpSocketState::Closed) {
return Ok(()).into();
}
// Nothing could be read. Wait for notify.
if let Some(old_waker) = control.recv_waker.replace(cx.waker().clone()) {
if !old_waker.will_wake(cx.waker()) {
old_waker.wake();
}
}
return Poll::Pending;
}
let recv_buf = buf.initialize_unfilled();
let n = control.recv_buffer.dequeue_slice(recv_buf);
buf.advance(n);
if n > 0 {
self.notify.notify_one();
}
Ok(()).into()
}
}
impl AsyncWrite for TcpStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
let mut control = self.control.lock();
// If state == Close | Closing | Closed, the TCP stream WR half is closed.
if !matches!(control.send_state, TcpSocketState::Normal) {
return Err(std::io::ErrorKind::BrokenPipe.into()).into();
}
// Write to buffer
if control.send_buffer.is_full() {
if let Some(old_waker) = control.send_waker.replace(cx.waker().clone()) {
if !old_waker.will_wake(cx.waker()) {
old_waker.wake();
}
}
return Poll::Pending;
}
let n = control.send_buffer.enqueue_slice(buf);
if n > 0 {
self.notify.notify_one();
}
Ok(n).into()
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Ok(()).into()
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
let mut control = self.control.lock();
if matches!(control.send_state, TcpSocketState::Closed | TcpSocketState::Closing) {
return Ok(()).into();
}
// SHUT_WR
if matches!(control.send_state, TcpSocketState::Normal) {
control.send_state = TcpSocketState::Close;
}
if let Some(old_waker) = control.send_waker.replace(cx.waker().clone()) {
if !old_waker.will_wake(cx.waker()) {
old_waker.wake();
}
}
self.notify.notify_one();
Poll::Pending
}
}

View File

@ -1,155 +0,0 @@
use std::{
net::SocketAddr,
pin::Pin,
task::{Context, Poll},
};
use etherparse::PacketBuilder;
use futures::{ready, Sink, SinkExt, Stream};
use smoltcp::wire::UdpPacket;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio_util::sync::PollSender;
use tracing::{error, trace};
use crate::packet::{AnyIpPktFrame, IpPacket};
pub type UdpMsg = (
Vec<u8>, /* payload */
SocketAddr, /* local */
SocketAddr, /* remote */
);
pub struct UdpSocket {
udp_rx: Receiver<AnyIpPktFrame>,
stack_tx: PollSender<AnyIpPktFrame>,
}
impl UdpSocket {
pub(super) fn new(udp_rx: Receiver<AnyIpPktFrame>, stack_tx: Sender<AnyIpPktFrame>) -> Self {
Self {
udp_rx,
stack_tx: PollSender::new(stack_tx),
}
}
pub fn split(self) -> (ReadHalf, WriteHalf) {
(
ReadHalf {
udp_rx: self.udp_rx,
},
WriteHalf {
stack_tx: self.stack_tx,
},
)
}
}
pub struct ReadHalf {
udp_rx: Receiver<AnyIpPktFrame>,
}
pub struct WriteHalf {
stack_tx: PollSender<AnyIpPktFrame>,
}
impl Stream for ReadHalf {
type Item = UdpMsg;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
loop {
match ready!(self.udp_rx.poll_recv(cx)) {
Some(frame) => {
let packet = match IpPacket::new_checked(frame.as_slice()) {
Ok(p) => p,
Err(err) => {
error!("invalid IP packet: {}", err);
continue;
}
};
let src_ip = packet.src_addr();
let dst_ip = packet.dst_addr();
let payload = packet.payload();
let packet = match UdpPacket::new_checked(payload) {
Ok(p) => p,
Err(err) => {
error!("invalid err: {err}, src_ip: {src_ip}, dst_ip: {dst_ip}, payload: {payload:?}");
continue;
}
};
let src_port = packet.src_port();
let dst_port = packet.dst_port();
let src_addr = SocketAddr::new(src_ip, src_port);
let dst_addr = SocketAddr::new(dst_ip, dst_port);
trace!("created UDP socket for {} <-> {}", src_addr, dst_addr);
return Poll::Ready(Some((packet.payload().to_vec(), src_addr, dst_addr)));
}
None => return Poll::Ready(None),
}
}
}
}
impl Sink<UdpMsg> for WriteHalf {
type Error = std::io::Error;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
match ready!(self.stack_tx.poll_ready_unpin(cx)) {
Ok(()) => Poll::Ready(Ok(())),
Err(err) => Poll::Ready(Err(std::io::Error::other(err))),
}
}
fn start_send(mut self: Pin<&mut Self>, item: UdpMsg) -> Result<(), Self::Error> {
use std::io::{Error, ErrorKind::InvalidData};
let (data, src_addr, dst_addr) = item;
if data.is_empty() {
return Ok(());
}
let builder = match (src_addr, dst_addr) {
(SocketAddr::V4(src), SocketAddr::V4(dst)) => {
PacketBuilder::ipv4(src.ip().octets(), dst.ip().octets(), 20)
.udp(src_addr.port(), dst_addr.port())
}
(SocketAddr::V6(src), SocketAddr::V6(dst)) => {
PacketBuilder::ipv6(src.ip().octets(), dst.ip().octets(), 20)
.udp(src_addr.port(), dst_addr.port())
}
_ => {
return Err(Error::new(InvalidData, "src or destination type unmatch"));
}
};
let mut ip_packet_writer = Vec::with_capacity(builder.size(data.len()));
builder
.write(&mut ip_packet_writer, &data)
.map_err(|err| Error::other(format!("PacketBuilder::write: {err}")))?;
match self.stack_tx.start_send_unpin(ip_packet_writer) {
Ok(()) => Ok(()),
Err(err) => Err(Error::other(format!("send error: {err}"))),
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
use std::io::Error;
match ready!(self.stack_tx.poll_flush_unpin(cx)) {
Ok(()) => Poll::Ready(Ok(())),
Err(err) => Poll::Ready(Err(Error::other(format!("flush error: {err}")))),
}
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
use std::io::Error;
match ready!(self.stack_tx.poll_close_unpin(cx)) {
Ok(()) => Poll::Ready(Ok(())),
Err(err) => Poll::Ready(Err(Error::other(format!("close error: {err}")))),
}
}
}

View File

@ -1,75 +0,0 @@
//! Regression tests that reproduce the bugs found in the static analysis.
use std::time::Duration;
use etherparse::{IpNumber, Ipv4Header, UdpHeader};
use futures::SinkExt;
use tokio::time::timeout;
use netstack_smoltcp::StackBuilder;
fn make_udp_ipv4(
src_ip: [u8; 4],
src_port: u16,
dst_ip: [u8; 4],
dst_port: u16,
payload: &[u8],
) -> Vec<u8> {
let udp_hdr = UdpHeader::with_ipv4_checksum(
src_port,
dst_port,
&Ipv4Header::new(
(UdpHeader::LEN + payload.len()) as u16,
64,
IpNumber::UDP,
src_ip,
dst_ip,
)
.unwrap(),
payload,
)
.unwrap();
let ip_hdr = Ipv4Header::new(
(UdpHeader::LEN + payload.len()) as u16,
64,
IpNumber::UDP,
src_ip,
dst_ip,
)
.unwrap();
let mut buf = Vec::with_capacity(Ipv4Header::MIN_LEN + UdpHeader::LEN + payload.len());
ip_hdr.write(&mut buf).unwrap();
udp_hdr.write(&mut buf).unwrap();
buf.extend_from_slice(payload);
buf
}
/// before(include) a15e0b72bfc72cb032e67138070da01e325d66f8
/// sink_buf is used in `Stack` to hold a slot for sending any pkt
///
/// the original assumption is that the `poll_ready` -> `start_send` -> `poll_flush`
/// are called sequentially so the slot could be reused and will never get blocked.
///
/// but once the user calls `send_all` on `Stack`, which will not immediate flush the pkt(call `poll_flush`),
/// then `sink_buf` is could be Some(pkt), then it will trigger `Poll::Pending` branch in `Stack::poll_ready`,
/// who did not register the waker correctly, so it will got hanged forever.
#[tokio::test(flavor = "current_thread")]
async fn bug1_poll_ready_waker_registered_via_send_all() {
let (mut stack, _runner, _udp_socket, _tcp) = StackBuilder::default()
.enable_udp(true)
.udp_buffer_size(64)
.stack_buffer_size(64)
.build()
.unwrap();
let pkt1 = make_udp_ipv4([1, 2, 3, 4], 1111, [5, 6, 7, 8], 9999, b"first");
let pkt2 = make_udp_ipv4([1, 2, 3, 4], 1111, [5, 6, 7, 8], 9999, b"second");
let mut stream = futures::stream::iter([Ok(pkt1), Ok(pkt2)]);
let result = timeout(Duration::from_secs(1), stack.send_all(&mut stream)).await;
// should be ok after the fix
assert!(result.is_ok());
}

View File

@ -10,12 +10,19 @@ use ostp_core::{NoiseRole, OstpEvent, PaddingStrategy, ProtocolAction, ProtocolC
use rand::Rng;
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, watch};
use tokio::time::{interval, timeout, Instant};
use tokio::time::{interval, timeout, Instant, MissedTickBehavior};
use crate::app::{BridgeCommand, ConnectionStatus, UiEvent};
use crate::config::ClientConfig;
use crate::tunnel::{ProxyEvent, ProxyToClientMsg};
/// Per-address ceiling on the UoT/TCP connect attempt. Long enough that a
/// genuinely slow mobile path still completes its handshake, short enough that
/// a blackholed address (typically IPv6 advertised without a working route)
/// costs seconds instead of the kernel's full SYN-retry budget before the next
/// candidate address is tried.
const UOT_CONNECT_TIMEOUT: Duration = Duration::from_secs(4);
static SOCKET_PROTECTOR: std::sync::OnceLock<Box<dyn Fn(i32) -> bool + Send + Sync>> = std::sync::OnceLock::new();
pub fn set_socket_protector<F>(f: F)
@ -46,6 +53,56 @@ async fn send_datagram(socket: &crate::transport::Transport, frame: &Bytes, _web
struct SessionState {
socket: crate::transport::Transport,
machine: ProtocolMachine,
/// Handle to this session's spawned receiver task. Held so the task is
/// aborted when the session is dropped (e.g. replaced on reconnect).
/// Otherwise, on a dead connection the task blocks forever in recv() while
/// keeping the old socket alive — leaking a task + socket on every
/// reconnect, which piles up across sleep/resume cycles.
rx_task: tokio::task::AbortHandle,
}
impl Drop for SessionState {
fn drop(&mut self) {
self.rx_task.abort();
}
}
/// Spawn the per-session receiver loop that reads inbound datagrams from the
/// transport and forwards them to the bridge, returning an AbortHandle so the
/// task is torn down when its `SessionState` is dropped. Consolidates the three
/// previously-duplicated inline copies (initial connect, network-change, and
/// keepalive reconnect).
fn spawn_session_receiver(
socket: crate::transport::Transport,
session_index: usize,
udp_tx: mpsc::Sender<(usize, Bytes)>,
) -> tokio::task::AbortHandle {
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
let is_uot = matches!(socket, crate::transport::Transport::Uot { .. });
loop {
match socket.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx.send((session_index, inbound)).await.is_err() {
break;
}
}
Err(e) => {
if is_uot {
// TCP transport is dead; exit so the bridge sees the
// channel close and reconnects.
tracing::debug!("UoT session {} disconnected: {}", session_index, e);
break;
} else {
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
}
})
.abort_handle()
}
pub struct Bridge {
@ -131,6 +188,21 @@ impl Bridge {
let mut metrics_tick = interval(Duration::from_millis(500));
let mut keepalive_tick = tokio::time::interval(Duration::from_secs(self.keepalive_interval_sec.max(1)));
let mut retransmit_tick = tokio::time::interval(Duration::from_millis(10));
// CRITICAL for suspend/resume: the default MissedTickBehavior is `Burst`,
// which after a laptop sleep or a phone backgrounding the app fires ALL
// the ticks that "should" have happened during the gap back-to-back. For
// the 10ms retransmit tick that is tens of thousands of instant ticks on
// resume — a CPU storm that hangs the bridge and manifests as the app
// freezing or getting stuck "Connecting". Skip missed ticks instead.
metrics_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
keepalive_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
retransmit_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
// Wall-clock anchor for suspend/resume detection. tokio's timers run on a
// monotonic clock; comparing it against wall-clock lets us notice that
// the machine slept (or the app was frozen in the background) and force
// one clean reconnect instead of trying to resume a long-dead session.
let mut last_wall_check = SystemTime::now();
let init_msg = if self.mode == "tun" {
"Bridge initialized (TUN mode)".to_string()
} else {
@ -166,18 +238,32 @@ impl Bridge {
self.handle_inbound_udp(udp_msg, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await;
}
cmd = bridge_rx.recv() => {
if !self.handle_bridge_cmd(cmd, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
if !self.handle_bridge_cmd(cmd, &mut bridge_rx, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx).await {
break;
}
}
_ = metrics_tick.tick() => {
// Suspend/resume detection: the wall clock jumps forward on
// wake even when the monotonic timer clock does not, so a
// large gap here means the machine slept / the app was frozen.
// The session is almost certainly dead (the server evicts
// idle sessions after 10 min), so force one clean reconnect
// rather than waiting on stale-session heuristics.
let wall_gap = last_wall_check.elapsed().unwrap_or_default();
last_wall_check = SystemTime::now();
if self.running && wall_gap > Duration::from_secs(15) {
let _ = tx.send(UiEvent::Log(format!(
"Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs()
))).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;
}
if self.running {
self.emit_metrics(&tx).await;
}
}
_ = keepalive_tick.tick() => {
if self.running {
self.handle_keepalive(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
self.handle_keepalive(false, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await;
}
}
_ = retransmit_tick.tick() => {
@ -209,8 +295,8 @@ impl Bridge {
) {
match udp_msg {
Some((session_index, inbound)) => {
// Raw byte counter — every datagram that reached the socket counts.
self.metrics.bytes_recv.fetch_add(inbound.len() as u64, Ordering::Relaxed);
self.last_valid_recv = Instant::now();
if let Some(sessions) = sessions_opt.as_mut() {
if session_index < sessions.len() {
let session = &mut sessions[session_index];
@ -223,6 +309,22 @@ impl Bridge {
}
};
// Only NOW, after the datagram actually authenticated and
// decrypted, does it count as a sign of life. This used to
// be set above, before any validation — so a datagram that
// failed to decrypt still reset the stall detector on its
// way to the `return` above. Anything arriving at this port
// (frames from a session the server already evicted, stale
// retransmits, or plain garbage from an off-path source that
// knows the ip:port) kept the client convinced the tunnel
// was healthy: the 25s background reconnect in
// handle_keepalive never fired and the tunnel sat dead at
// 0 b/s until the user reconnected by hand. It also made
// `is_healthy` (see emit_metrics) lie in the UI, and handed
// any off-path sender a trivial way to pin a client in a
// dead session indefinitely.
self.last_valid_recv = Instant::now();
let mut actions_queue = std::collections::VecDeque::new();
actions_queue.push_back(initial_action);
@ -295,6 +397,7 @@ impl Bridge {
async fn handle_bridge_cmd(
&mut self,
cmd: Option<BridgeCommand>,
bridge_rx: &mut mpsc::Receiver<BridgeCommand>,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
@ -331,35 +434,9 @@ impl Bridge {
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = sessions.len();
let socket_clone = sock.clone();
let udp_tx_clone = udp_tx.clone();
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx_clone.send((session_index, inbound)).await.is_err() {
break;
}
}
Err(e) => {
if is_uot {
// TCP is dead — drop sender to signal bridge via channel close
tracing::debug!("UoT session {} disconnected: {}", session_index, e);
break;
} else {
tracing::warn!("UDP socket recv error (session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
}
});
sessions.push(SessionState { socket: sock, machine: mach });
sessions.push(SessionState { socket: sock, machine: mach, rx_task });
rtt_sum += rtt;
successful_sessions += 1;
}
@ -412,6 +489,32 @@ impl Bridge {
tx.send(UiEvent::Log(format!("Obfuscation profile switched to {:?}", self.profile))).await.ok();
}
Some(BridgeCommand::NetworkChanged) => {
// A real network handoff (Wi-Fi <-> cellular) commonly fires
// onLost + onAvailable within milliseconds of each other on
// Android, queuing several NetworkChanged commands back to
// back. Each reconnect below is a full sequential handshake
// (up to ~1.2s x 4 attempts x mux_sessions) run synchronously
// in this select-loop iteration, so without coalescing, the
// first attempt often races the OS's own network switch and
// fails on the now-dead interface, then the SECOND queued
// NetworkChanged only starts its own full reconnect after
// that first one finishes - multiplying a sub-second handoff
// into many seconds of extra outage. Drain same-kind repeats
// so a burst collapses into one reconnect on the freshest
// signal; a different command found while draining is
// handled immediately rather than dropped.
while let Ok(next) = bridge_rx.try_recv() {
if !matches!(next, BridgeCommand::NetworkChanged) {
let more = Box::pin(self.handle_bridge_cmd(
Some(next), bridge_rx, sessions_opt, udp_rx_opt, proxy_guard, stream_map, tx, proxy_tx,
)).await;
if !more {
return false;
}
break;
}
}
if self.running {
let _ = tx.send(UiEvent::Log("Network changed — starting immediate reconnect".to_string())).await;
self.metrics.connection_state.store(1, Ordering::Relaxed);
@ -428,31 +531,8 @@ impl Bridge {
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = new_sessions.len();
let socket_clone = sock.clone();
let udp_tx_clone = udp_tx.clone();
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx_clone.send((session_index, inbound)).await.is_err() { break; }
}
Err(e) => {
if is_uot {
tracing::debug!("UoT network-change session {} disconnected: {}", session_index, e);
break;
} else {
tracing::warn!("UDP recv error (network-change session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
}
});
new_sessions.push(SessionState { socket: sock, machine: mach });
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
rtt_sum += rtt;
successful_sessions += 1;
}
@ -523,6 +603,7 @@ impl Bridge {
async fn handle_keepalive(
&mut self,
force: bool,
sessions_opt: &mut Option<Vec<SessionState>>,
udp_rx_opt: &mut Option<mpsc::Receiver<(usize, Bytes)>>,
proxy_guard: &mut Option<crate::sysproxy::SystemProxyGuard>,
@ -531,9 +612,12 @@ impl Bridge {
proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>,
proxy_rx: &mut mpsc::Receiver<ProxyEvent>,
) {
if self.last_valid_recv.elapsed().as_secs() > 25 {
if force || self.last_valid_recv.elapsed().as_secs() > 25 {
let elapsed = self.last_valid_recv.elapsed().as_secs();
if elapsed > 180 {
// On a forced (post-resume) reconnect the monotonic clock may not
// have advanced, so `elapsed` can be small — never treat a forced
// reconnect as a hard timeout; we specifically want to re-establish.
if !force && elapsed > 180 {
if self.kill_switch {
let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await;
} else {
@ -564,34 +648,9 @@ impl Bridge {
match self.perform_handshake_with_id(&tx, session_id).await {
Ok((sock, mach, rtt)) => {
let session_index = new_sessions.len();
let socket_clone = sock.clone();
let udp_tx_clone = udp_tx.clone();
let rx_task = spawn_session_receiver(sock.clone(), session_index, udp_tx.clone());
tokio::spawn(async move {
let mut buf = vec![0_u8; 65535];
let is_uot = matches!(socket_clone, crate::transport::Transport::Uot { .. });
loop {
match socket_clone.recv(&mut buf).await {
Ok(n) => {
let inbound = Bytes::copy_from_slice(&buf[..n]);
if udp_tx_clone.send((session_index, inbound)).await.is_err() {
break;
}
}
Err(e) => {
if is_uot {
tracing::debug!("UoT reconnect session {} disconnected: {}", session_index, e);
break;
} else {
tracing::warn!("UDP socket recv error (reconnect session {}): {}", session_index, e);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
}
});
new_sessions.push(SessionState { socket: sock, machine: mach });
new_sessions.push(SessionState { socket: sock, machine: mach, rx_task });
rtt_sum += rtt;
successful_sessions += 1;
}
@ -1048,7 +1107,27 @@ impl Bridge {
) -> Result<crate::transport::Transport> {
let mode = self.transport_mode.to_lowercase();
if mode == "uot" || mode == "tcp" {
let stream = tokio::net::TcpStream::connect((target_ip, port)).await?;
// Bound the TCP connect. Without this it inherits the kernel's SYN
// retry budget, which is tens of seconds (and can reach ~2 minutes).
// That is exactly what made UoT appear to hang on mobile: callers
// resolve every address for the server and try IPv6 first (see the
// sort in perform_handshake_with_id), and a mobile network that
// advertises IPv6 without a working route blackholes the SYN rather
// than rejecting it — so the client sat through the full retry
// budget before it ever reached the IPv4 address that would have
// connected immediately. UDP never showed this because connect() on
// a UDP socket only sets the default peer and returns at once.
let stream = tokio::time::timeout(
UOT_CONNECT_TIMEOUT,
tokio::net::TcpStream::connect((target_ip, port)),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"TCP connect to {target_ip}:{port} timed out after {:?}",
UOT_CONNECT_TIMEOUT
)
})??;
let _ = stream.set_nodelay(true);
let (mut read_half, mut write_half) = stream.into_split();

View File

@ -361,6 +361,10 @@ async fn handle_udp_associate(
let mut direct_udp_v4: Option<Arc<UdpSocket>> = None;
let mut direct_udp_v6: Option<Arc<UdpSocket>> = None;
// Held only to keep the direct-UDP readers' cancellation senders alive;
// dropping this (on every return path from this function) is what tells
// spawn_direct_udp_reader's tasks to stop. See its doc comment.
let mut direct_udp_cancel_txs: Vec<tokio::sync::oneshot::Sender<()>> = Vec::new();
let mut tcp_buf = [0u8; 1];
loop {
@ -432,7 +436,9 @@ async fn handle_udp_associate(
match create_udp_socket_bypassing_tun(true, matcher.physical_if_index, &matcher.physical_if_name).await {
Ok(s) => {
let s_arc = Arc::new(s);
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
direct_udp_cancel_txs.push(cancel_tx);
direct_udp_v6 = Some(s_arc);
}
Err(e) => {
@ -446,7 +452,9 @@ async fn handle_udp_associate(
match create_udp_socket_bypassing_tun(false, matcher.physical_if_index, &matcher.physical_if_name).await {
Ok(s) => {
let s_arc = Arc::new(s);
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug);
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
spawn_direct_udp_reader(s_arc.clone(), sock_tx.clone(), client_udp_addr.clone(), debug, cancel_rx);
direct_udp_cancel_txs.push(cancel_tx);
direct_udp_v4 = Some(s_arc);
}
Err(e) => {
@ -520,11 +528,24 @@ fn spawn_direct_udp_reader(
sock_tx: Arc<UdpSocket>,
client_udp_addr: Arc<std::sync::Mutex<Option<std::net::SocketAddr>>>,
_debug: bool,
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
) {
tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
match direct_socket.recv_from(&mut buf).await {
let recv_result = tokio::select! {
// Fires as soon as the sender half (held by handle_udp_associate
// for exactly this reason) is dropped - which happens the
// instant that function returns, on every exit path, with no
// explicit signaling needed. Without this, a UDP-associate
// session that ever bypassed traffic direct (excluded IP/
// domain) leaked this socket + task for the rest of the
// process's life once the session ended: nothing else ever
// stopped this loop.
_ = &mut cancel_rx => break,
res = direct_socket.recv_from(&mut buf) => res,
};
match recv_result {
Ok((len, target_addr)) => {
let client_addr = {
let guard = client_udp_addr.lock().unwrap();

View File

@ -138,27 +138,34 @@ async fn start_udp_bypass_session(
let _ = crate::tunnel::proxy::bind_socket_to_interface(&socket, name);
}
let socket = Arc::new(socket);
let socket_rx = socket.clone();
// Spawn a task to read from physical socket and send back to smoltcp
let tx_clone = smoltcp_tx.clone();
tokio::spawn(async move {
// A single select! loop over both directions, rather than spawning a
// separate task for the read side, so the whole session - physical
// socket included - is torn down the moment this function returns
// (e.g. when session_rx closes). The previous spawned-task version left
// that task (and its Arc<UdpSocket> clone, keeping the OS socket fd
// alive) running forever after this function returned: nothing ever
// cancelled it, so every bypassed UDP flow (any excluded app/IP in TUN
// mode) leaked one socket + one task for the lifetime of the process.
use futures::SinkExt;
let mut buf = [0u8; 65536];
loop {
match socket_rx.recv_from(&mut buf).await {
tokio::select! {
outbound = session_rx.recv() => {
match outbound {
Some((payload, dst)) => { socket.send_to(&payload, dst).await?; }
None => break,
}
}
inbound = socket.recv_from(&mut buf) => {
match inbound {
Ok((n, peer)) => {
let mut lock = tx_clone.lock().await;
let mut lock = smoltcp_tx.lock().await;
let _ = lock.send((buf[..n].to_vec(), peer, client_src)).await;
}
Err(_) => break,
}
}
});
while let Some((payload, dst)) = session_rx.recv().await {
socket.send_to(&payload, dst).await?;
}
}
Ok(())

View File

@ -43,6 +43,11 @@ pub struct CongestionController {
mtu: u64,
/// Min RTT expiry: re-probe after 10 seconds
min_rtt_stamp: Instant,
/// Loss events counted toward SLOW_START_LOSS_TOLERANCE within the
/// current SLOW_START_LOSS_WINDOW (see on_loss's SlowStart arm).
slow_start_losses: u32,
/// Start of the current loss-tolerance window.
slow_start_loss_window_start: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -67,6 +72,24 @@ const RTO_MAX: Duration = Duration::from_secs(16);
/// Will be replaced by first real measurement within milliseconds.
const INITIAL_RTT: Duration = Duration::from_millis(30);
/// Isolated packet loss during slow start (a single dropped frame from
/// wireless noise, a brief LTE handover blip, etc.) is normal on real
/// mobile/Wi-Fi links and does NOT mean the link is congested. The previous
/// behavior exited slow start and halved cwnd on the very FIRST loss, which
/// on any link with a non-zero background loss rate permanently downgrades
/// the session from exponential growth to linear (+1 MTU/RTT) ProbeBandwidth
/// growth within the first few RTTs - turning what should be a sub-second
/// ramp-up into tens of seconds to minutes before throughput opens up
/// (observed as: a trickle of KB/s, then a sudden jump once cwnd finally
/// claws back up). Only treat loss as a real congestion signal - and pay
/// the full slow-start-exit + halving cost - once this many losses land
/// within SLOW_START_LOSS_WINDOW.
const SLOW_START_LOSS_TOLERANCE: u32 = 3;
/// Window within which SLOW_START_LOSS_TOLERANCE losses must land to count
/// as sustained (rather than isolated) loss. Roughly a few RTTs on a
/// well-connected link, generous on a slow one.
const SLOW_START_LOSS_WINDOW: Duration = Duration::from_millis(500);
impl CongestionController {
pub fn new(mtu: u64) -> Self {
let now = Instant::now();
@ -88,6 +111,8 @@ impl CongestionController {
pacing_rate: initial_pacing,
mtu,
min_rtt_stamp: now,
slow_start_losses: 0,
slow_start_loss_window_start: now,
}
}
@ -144,6 +169,19 @@ impl CongestionController {
self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes);
}
/// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample
/// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids
/// measuring RTT from it). The window still advances; only the RTT estimator
/// is left untouched.
pub fn on_ack_no_rtt(&mut self, bytes: u64) {
let now = Instant::now();
self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes);
self.total_acked = self.total_acked.saturating_add(bytes);
self.grow_window(bytes);
self.update_pacing_rate();
self.last_ack_time = now;
}
/// Record that `bytes` were acknowledged with the given RTT sample.
pub fn on_ack(&mut self, bytes: u64, rtt: Duration) {
let now = Instant::now();
@ -153,6 +191,13 @@ impl CongestionController {
// Update RTT measurements
self.update_rtt(rtt, now);
self.grow_window(bytes);
self.update_pacing_rate();
self.last_ack_time = now;
}
/// Congestion-window growth shared by both ACK paths (slow start / probe).
fn grow_window(&mut self, bytes: u64) {
// State machine
match self.phase {
Phase::SlowStart => {
@ -168,9 +213,6 @@ impl CongestionController {
self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1));
}
}
self.update_pacing_rate();
self.last_ack_time = now;
}
/// Record a loss event.
@ -180,11 +222,28 @@ impl CongestionController {
match self.phase {
Phase::SlowStart => {
// Exit slow start, set ssthresh to half of cwnd
let now = Instant::now();
if now.duration_since(self.slow_start_loss_window_start) > SLOW_START_LOSS_WINDOW {
// Previous window's losses have aged out - this loss starts a fresh count.
self.slow_start_losses = 0;
self.slow_start_loss_window_start = now;
}
self.slow_start_losses += 1;
if self.slow_start_losses >= SLOW_START_LOSS_TOLERANCE {
// Sustained loss within the window: treat as real congestion.
// Exit slow start, set ssthresh to half of cwnd.
self.ssthresh = self.cwnd / 2;
self.cwnd = self.ssthresh.max(MIN_CWND_PACKETS * self.mtu);
self.phase = Phase::ProbeBandwidth;
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: loss during slow start");
tracing::debug!(cwnd = self.cwnd, ssthresh = self.ssthresh, "congestion: sustained loss during slow start, exiting");
} else {
// Isolated loss: likely non-congestive noise. Take a mild,
// temporary haircut but keep exponential growth going -
// don't throw away slow start over a single dropped frame.
self.cwnd = (self.cwnd * 8 / 10).max(MIN_CWND_PACKETS * self.mtu);
tracing::debug!(cwnd = self.cwnd, count = self.slow_start_losses, "congestion: isolated loss during slow start, staying in slow start");
}
}
Phase::ProbeBandwidth => {
// Multiplicative decrease: cwnd *= 0.7 (BBR-style, less aggressive than Cubic's 0.5)
@ -273,6 +332,50 @@ mod tests {
assert!(cc.cwnd() < initial);
}
#[test]
fn test_isolated_slow_start_loss_does_not_exit_slow_start() {
// A single dropped packet (wireless noise, a brief handover blip) is
// normal on real links and must not permanently downgrade the
// session from exponential to linear growth.
let mut cc = CongestionController::new(1200);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart, "one isolated loss must not exit slow start");
// It should still shrink the window somewhat (not ignored entirely),
// just far less punishing than the sustained-congestion case.
let after_one = cc.cwnd();
assert!(after_one < INITIAL_CWND_PACKETS * 1200);
}
#[test]
fn test_sustained_slow_start_loss_exits_slow_start() {
// Losses landing close together (within SLOW_START_LOSS_WINDOW) are
// a real congestion signal and must still trigger the harsher
// exit-slow-start + halve response.
let mut cc = CongestionController::new(1200);
for _ in 0..SLOW_START_LOSS_TOLERANCE {
cc.on_loss(1200);
}
assert_eq!(cc.phase, Phase::ProbeBandwidth, "sustained loss must exit slow start");
}
#[test]
fn test_slow_start_loss_window_resets_after_expiry() {
// Two losses far enough apart (window expired between them) must
// each be treated as isolated, not accumulated toward the sustained-
// loss threshold.
let mut cc = CongestionController::new(1200);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart);
// Simulate the window having expired by resetting its start
// directly (std::thread::sleep in a unit test would be flaky/slow).
cc.slow_start_loss_window_start = Instant::now() - SLOW_START_LOSS_WINDOW - Duration::from_millis(1);
cc.on_loss(1200);
assert_eq!(cc.phase, Phase::SlowStart, "a loss after the window expired must restart the count, not accumulate");
assert_eq!(cc.slow_start_losses, 1);
}
#[test]
fn test_can_send_limits() {
let mut cc = CongestionController::new(1200);
@ -313,6 +416,23 @@ mod tests {
assert_eq!(rto, Duration::from_millis(150));
}
#[test]
fn test_on_ack_no_rtt_grows_window_without_touching_srtt() {
let mut cc = CongestionController::new(1200);
// Establish a known SRTT with a real sample.
cc.on_send(1200);
cc.on_ack(1200, Duration::from_millis(40));
let srtt_before = cc.smoothed_rtt();
let cwnd_before = cc.cwnd();
// A Karn's-algorithm ACK (all acked frames were retransmitted): window
// must advance, RTT estimate must be untouched.
cc.on_send(1200);
cc.on_ack_no_rtt(1200);
assert!(cc.cwnd() > cwnd_before, "cwnd should still grow on a no-RTT ack");
assert_eq!(cc.smoothed_rtt(), srtt_before, "SRTT must not move on a no-RTT ack");
}
#[test]
fn test_rto_clamp_min() {
let cc = CongestionController::new(1200);

View File

@ -1,4 +1,4 @@
use snow::{Builder, HandshakeState, TransportState};
use snow::{Builder, HandshakeState};
use crate::protocol::ProtocolError;
@ -10,9 +10,15 @@ pub enum NoiseRole {
Responder,
}
pub enum NoiseSession {
Handshake(Box<HandshakeState>),
Transport(TransportState),
/// A Noise handshake in progress. OSTP does not use snow's transport mode: once
/// the handshake finishes we extract the raw Split() keys (see [`raw_split`])
/// and drive our own out-of-order AEAD (see `crypto::aead`), because the wire
/// protocol needs explicit per-frame nonces for reordering that snow's internal
/// nonce counter can't express.
///
/// [`raw_split`]: NoiseSession::raw_split
pub struct NoiseSession {
handshake: Box<HandshakeState>,
}
impl NoiseSession {
@ -36,50 +42,92 @@ impl NoiseSession {
.map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?,
};
Ok(Self::Handshake(Box::new(handshake)))
Ok(Self { handshake: Box::new(handshake) })
}
pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
match self {
NoiseSession::Handshake(hs) => hs
self.handshake
.write_message(payload, out)
.map_err(|_| ProtocolError::Crypto("noise-write".to_string())),
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
}
.map_err(|_| ProtocolError::Crypto("noise-write".to_string()))
}
pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result<usize, ProtocolError> {
match self {
NoiseSession::Handshake(hs) => hs
self.handshake
.read_message(input, out)
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))),
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
}
.map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e)))
}
pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> {
match self {
NoiseSession::Handshake(hs) => {
let hash = hs.get_handshake_hash();
if out.len() != hash.len() {
return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string()));
}
out.copy_from_slice(hash);
Ok(())
}
NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())),
}
}
pub fn into_transport(self) -> Result<Self, ProtocolError> {
match self {
NoiseSession::Handshake(hs) => {
let transport = hs
.into_transport_mode()
.map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?;
Ok(NoiseSession::Transport(transport))
}
NoiseSession::Transport(_) => Ok(self),
/// Derive the two directional transport keys via Noise's Split().
///
/// SECURITY: keys are taken from the final chaining key `ck` (which absorbs
/// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h`
/// (which only absorbs public transcript data — ephemeral pubkeys and
/// ciphertexts — and never the DH secret). Deriving from `ck` is what gives
/// the session forward secrecy: an adversary who later learns the PSK still
/// cannot recompute these keys without the ephemeral private keys, which are
/// discarded after the handshake.
///
/// Must only be called once the handshake is finished (both messages of the
/// NNpsk0 exchange processed); at that point `ck` is final. Returns
/// `(send_key, recv_key)` for the given role, matching snow's TransportState
/// direction mapping: split output `.0` is initiator→responder, `.1` is
/// responder→initiator.
pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> {
if !self.handshake.is_handshake_finished() {
return Err(ProtocolError::State("handshake not finished at key split".to_string()));
}
let (k0, k1) = self.handshake.dangerously_get_raw_split();
Ok(match role {
// Initiator sends on .0 (i→r), receives on .1 (r→i).
NoiseRole::Initiator => (k0, k1),
// Responder is the mirror image.
NoiseRole::Responder => (k1, k0),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Drive a full NNpsk0 handshake and confirm both sides derive matching
/// directional keys. This guards the .0/.1 → send/recv role mapping in
/// `raw_split`: if it were wrong, the two sides' send/recv keys wouldn't
/// cross-match and the transport channel would silently fail to decrypt.
#[test]
fn raw_split_keys_agree_across_roles() {
let psk = [7u8; 32];
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
let mut responder = NoiseSession::new(NoiseRole::Responder, &psk).unwrap();
// msg1: initiator -> responder
let mut buf1 = [0u8; 1024];
let n1 = initiator.write_handshake(&[], &mut buf1).unwrap();
let mut tmp = [0u8; 1024];
responder.read_handshake(&buf1[..n1], &mut tmp).unwrap();
// msg2: responder -> initiator
let mut buf2 = [0u8; 1024];
let n2 = responder.write_handshake(&[], &mut buf2).unwrap();
initiator.read_handshake(&buf2[..n2], &mut tmp).unwrap();
let (i_send, i_recv) = initiator.raw_split(NoiseRole::Initiator).unwrap();
let (r_send, r_recv) = responder.raw_split(NoiseRole::Responder).unwrap();
// What the initiator sends with, the responder must receive with.
assert_eq!(i_send, r_recv, "initiator send key must equal responder recv key");
assert_eq!(r_send, i_recv, "responder send key must equal initiator recv key");
// The two directions use distinct keys.
assert_ne!(i_send, i_recv, "the two directions must not share a key");
}
/// raw_split must refuse to hand out keys before the handshake is complete —
/// keys taken from a half-mixed chaining key would be wrong and insecure.
#[test]
fn raw_split_rejected_before_handshake_finishes() {
let psk = [9u8; 32];
let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap();
// No messages exchanged yet: handshake not finished.
assert!(initiator.raw_split(NoiseRole::Initiator).is_err());
}
}

View File

@ -54,6 +54,7 @@ fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec<u8> {
/// The derivation uses the access key as both IKM and salt material,
/// split into two halves. No fixed strings are used — the access key
/// alone determines all derived values.
#[derive(Clone)]
pub struct DerivedSecrets {
pub obfuscation_key: [u8; 8],
pub psk: [u8; 32],
@ -74,8 +75,11 @@ pub struct DerivedSecrets {
/// without a version) produces a different obfuscation key, so a 0.4.0 server
/// cannot recover its handshake header and rejects it as an unauthorized probe.
///
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4.
pub const PROTOCOL_VERSION: u8 = 4;
/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4;
/// version 5 (0.4.x hardening) moved transport keys from the handshake hash to
/// Noise's Split() output — a wire-breaking crypto change, so old peers must not
/// interop (they would derive different session keys and fail decryption).
pub const PROTOCOL_VERSION: u8 = 5;
pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets {
derive_all_secrets_versioned(access_key, PROTOCOL_VERSION)

View File

@ -13,8 +13,6 @@ pub enum FrameKind {
KeepAlive = 4,
Nack = 5,
Ack = 6,
/// 0-RTT session resumption: client sends ticket + early data
Resume = 7,
}
impl TryFrom<u8> for FrameKind {
@ -28,7 +26,6 @@ impl TryFrom<u8> for FrameKind {
4 => Ok(Self::KeepAlive),
5 => Ok(Self::Nack),
6 => Ok(Self::Ack),
7 => Ok(Self::Resume),
_ => Err(ProtocolError::Framing("unknown frame kind".to_string())),
}
}
@ -104,7 +101,15 @@ impl FramedPacket {
let payload_len = header.payload_len as usize;
let pad_len = header.pad_len as usize;
let expected = FRAME_HEADER_LEN + payload_len + pad_len;
// Use checked arithmetic: payload_len is a u32 from the (decrypted, but
// still to-be-trusted) header, and on 32-bit targets — MIPS/ARMv7
// routers are supported build targets — header+payload+pad can overflow
// usize and wrap to a small value that spuriously passes the length
// check, causing an out-of-range slice below.
let expected = FRAME_HEADER_LEN
.checked_add(payload_len)
.and_then(|v| v.checked_add(pad_len))
.ok_or_else(|| ProtocolError::Framing("frame length overflow".to_string()))?;
if buf.len() < expected {
return Err(ProtocolError::Framing("frame body truncated".to_string()));
}

View File

@ -3,7 +3,6 @@ pub mod crypto;
pub mod framing;
pub mod protocol;
pub mod relay;
pub mod resumption;
pub use crypto::NoiseRole;
pub use framing::{TrafficProfile, PaddingStrategy};

View File

@ -1,6 +1,5 @@
use bytes::Bytes;
use rand::Rng;
use sha2::{Digest, Sha256};
use thiserror::Error;
use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant};
@ -103,6 +102,17 @@ pub struct ProtocolMachine {
_mtu: usize,
}
// ── Gap recovery (see `ProtocolMachine::recover_stalled_gap`) ────────────────
// How long the receive sequence may sit stuck behind a missing frame, with
// later frames already buffered, before that frame is declared unrecoverable
// and skipped. Derived from the live RTO so it scales with the path instead of
// guessing, then clamped: the floor keeps a fast link from discarding a frame
// that is merely late, the ceiling bounds how long a stall can be visible to
// the user before the tunnel unblocks itself.
const GAP_RECOVERY_RTO_MULTIPLIER: u32 = 8;
const GAP_RECOVERY_MIN: Duration = Duration::from_secs(2);
const GAP_RECOVERY_MAX: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
struct SentFrame {
nonce: u64,
@ -156,6 +166,19 @@ impl ProtocolMachine {
self.sent_history.iter().filter(|f| f.is_retransmittable).count()
}
/// Sum of retry counters across in-flight frames. Test-only: lets a test
/// assert the core retransmit invariant (a retry is only ever charged to a
/// frame that was actually put on the wire) without needing to advance the
/// clock through several seconds of exponential backoff.
#[cfg(test)]
fn total_retries(&self) -> usize {
self.sent_history
.iter()
.filter(|f| f.is_retransmittable)
.map(|f| f.retries as usize)
.sum()
}
pub fn cwnd_packets(&self) -> usize {
self.cc.cwnd_packets() as usize
}
@ -237,7 +260,9 @@ impl ProtocolMachine {
let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]);
if session_id != self.session_id {
tracing::error!("session id mismatch! expected={:#010x}, got={:#010x}, is_handshake={}, raw_len={}", self.session_id, session_id, is_handshake, raw_vec.len());
// Per-packet, attacker-triggerable event: keep at debug and don't
// dump internal session ids (log-flood + info-leak surface).
tracing::debug!("session id mismatch (is_handshake={})", is_handshake);
return Err(ProtocolError::State("session id mismatch".to_string()));
}
@ -263,7 +288,6 @@ impl ProtocolMachine {
noise_len, raw_vec.len() - 6
)));
}
tracing::info!("handle_inbound: raw_vec.len()={}, noise_len={}, raw_vec[0..6]={:?}", raw_vec.len(), noise_len, &raw_vec[0..6]);
let mut read_out = vec![0_u8; 1024];
let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| {
@ -281,9 +305,12 @@ impl ProtocolMachine {
NoiseRole::Initiator => None,
};
let mut key = [0_u8; 32];
self.noise.handshake_hash(&mut key)?;
let (send_key, recv_key) = derive_split_keys(&key, self.role);
// Transport keys come from Noise's Split() over the final chaining key,
// so they depend on the ephemeral `ee` DH secret and give the session
// forward secrecy. (Previously these were derived from the handshake
// hash, which never absorbs the DH result — see raw_split's SECURITY
// note. That is the wire-breaking change gated by PROTOCOL_VERSION.)
let (send_key, recv_key) = self.noise.raw_split(self.role)?;
self.send_cipher = Some(SessionCipher::new(&send_key));
self.recv_cipher = Some(SessionCipher::new(&recv_key));
self.state = OstpState::Established;
@ -293,7 +320,107 @@ impl ProtocolMachine {
Ok(ProtocolAction::HandshakePayload(Bytes::from(extracted_payload), response))
}
/// Restores liveness when the receive sequence is stuck behind a frame that
/// can never arrive.
///
/// Delivery is gated on `expected_recv_nonce`, so a single missing frame
/// holds back every later frame. That is correct *while the sender can still
/// retransmit* — but the sender drops a frame from `sent_history` once it
/// exceeds `max_retries + 2` attempts (see the zombie eviction in
/// `handle_tick`). After that the frame is gone for good and the two sides
/// deadlock: the receiver buffers forever and NACKs a nonce nobody can
/// resend.
///
/// That deadlock is invisible to the keepalive watchdog, which is why it
/// presented as a hard freeze rather than a reconnect: retransmits, ACKs and
/// NACKs keep flowing, so the client's `last_valid_recv` keeps refreshing and
/// its stall detector never fires. The RTT readout freezes at its last value
/// for the same reason — Pong rides in a Data frame stuck behind the gap.
///
/// So: once we have been stuck long enough that retransmission has provably
/// given up, skip to the lowest buffered nonce and drain. This drops the
/// missing frame's payload (one RelayMessage — a chunk of one stream), which
/// is a real cost, but the alternative is a permanently dead tunnel.
fn recover_stalled_gap(&mut self) -> Vec<ProtocolAction> {
let mut recovered = Vec::new();
if self.reorder_buffer.is_empty() {
return recovered;
}
// Wait out the sender's full retransmit budget before giving up, so a
// frame that is merely late is never discarded. The sender backs off
// exponentially, so key this off the live RTO estimate rather than a
// flat constant, with a floor that keeps low-RTT links from skipping
// too eagerly and a ceiling that bounds the visible freeze.
let timeout = self
.cc
.rto()
.saturating_mul(GAP_RECOVERY_RTO_MULTIPLIER)
.clamp(GAP_RECOVERY_MIN, GAP_RECOVERY_MAX);
if self.last_recv_advance.elapsed() < timeout {
return recovered;
}
let Some(&resume_at) = self.reorder_buffer.keys().next() else {
return recovered;
};
let skipped = resume_at.saturating_sub(self.expected_recv_nonce);
tracing::warn!(
"Gap recovery: no progress for {:?}; skipping {} unrecoverable frame(s) \
(nonce {} -> {}) to unblock the session",
self.last_recv_advance.elapsed(),
skipped,
self.expected_recv_nonce,
resume_at
);
self.expected_recv_nonce = resume_at;
while let Some(buffered) = self.reorder_buffer.remove(&self.expected_recv_nonce) {
recovered.push(buffered);
match self.expected_recv_nonce.checked_add(1) {
Some(next) => self.expected_recv_nonce = next,
// u64 nonce space exhausted: stop draining rather than wrap.
// The session is finished either way; the caller's next decrypt
// will fail and tear it down.
None => break,
}
}
self.last_recv_advance = Instant::now();
// The peer must learn the sequence moved on, or it will keep
// retransmitting into the void.
self.ack_pending = true;
recovered
}
fn handle_data_inbound(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
// Check for a stalled gap before classifying this frame, so the rest of
// the function sees an already-advanced `expected_recv_nonce`. Runs here
// rather than on Tick because both tick handlers discard DeliverApp
// actions, and because inbound frames keep arriving throughout the stall
// (retransmits/ACKs/NACKs/keepalives) — so this path is reliably reached.
let recovered = self.recover_stalled_gap();
let result = self.handle_data_inbound_frame(raw_vec)?;
if recovered.is_empty() {
return Ok(result);
}
// Recovered payloads are older than anything this frame produces, so
// they go first to preserve delivery order.
let mut all = recovered;
match result {
ProtocolAction::Noop => {}
ProtocolAction::Multiple(list) => all.extend(list),
single => all.push(single),
}
Ok(if all.len() == 1 {
all.pop().unwrap()
} else {
ProtocolAction::Multiple(all)
})
}
fn handle_data_inbound_frame(&mut self, raw_vec: &[u8]) -> Result<ProtocolAction, ProtocolError> {
if raw_vec.len() < 12 {
return Err(ProtocolError::Framing("data datagram too short".to_string()));
}
@ -358,13 +485,8 @@ impl ProtocolMachine {
FrameKind::Data => {
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
}
FrameKind::Resume => {
// 0-RTT: treat early data as application data
tracing::info!("0-RTT Resume frame received, processing early data");
ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload)
}
FrameKind::Close => {
tracing::info!("Received Close frame, terminating session");
tracing::debug!("Received Close frame, terminating session");
self.state = OstpState::Closed;
ProtocolAction::Noop
}
@ -545,20 +667,34 @@ impl ProtocolMachine {
if !frame.is_retransmittable {
continue;
}
// Out of budget for this tick — stop scanning rather than walking the
// rest of the queue. sent_history is in send order, so everything we
// skip is strictly newer than what we already handled; deferring it to
// the next tick preserves oldest-first retransmit priority.
if retransmit_budget == 0 {
break;
}
let backoff_factor = 1u64 << (frame.retries as u64).min(6);
let effective_rto = Duration::from_millis(base_rto_ms.saturating_mul(backoff_factor));
if now.duration_since(frame.last_sent) >= effective_rto {
// Only burn the retry counter and reset the RTO timer when the
// frame is ACTUALLY put on the wire. Doing it unconditionally
// meant that whenever the per-tick budget ran out — which is
// exactly when loss is heavy and retransmits matter most —
// frames accumulated "phantom retries" they never actually got,
// and the zombie eviction above then silently dropped them after
// `grace` such rounds. The peer never received that data and
// never would: that stream stalls forever while the session
// itself stays healthy, which is precisely the reported "tunnel
// frozen at 0 b/s but the session still up" symptom.
frame.last_sent = now;
frame.retries = frame.retries.saturating_add(1);
if retransmit_budget > 0 {
actions.push(ProtocolAction::SendDatagram(frame.bytes.clone()));
retransmit_budget -= 1;
}
}
}
if actions.is_empty() {
Ok(ProtocolAction::Noop)
@ -685,24 +821,34 @@ impl ProtocolMachine {
fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) {
let now = Instant::now();
let mut acked_bytes = 0u64;
let mut min_rtt = Duration::from_secs(60);
let mut min_rtt: Option<Duration> = None;
// Compute RTT from the oldest acked frame's send timestamp
for frame in self.sent_history.iter() {
if nonce_in_ranges(frame.nonce, ranges) {
acked_bytes += frame.bytes.len() as u64;
// Karn's algorithm: never take an RTT sample from a frame that
// was retransmitted. `last_sent` is bumped on every retransmit,
// so an ACK for the ORIGINAL transmission would be measured
// against the retransmit time, yielding a spuriously small RTT
// that drags SRTT/RTO down and triggers more spurious
// retransmits. Only unambiguous (never-retried) frames qualify.
if frame.retries == 0 {
let rtt = now.duration_since(frame.last_sent);
if rtt < min_rtt {
min_rtt = rtt;
min_rtt = Some(min_rtt.map_or(rtt, |m| m.min(rtt)));
}
}
}
self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges));
// Notify congestion controller
// Notify congestion controller. Feed an RTT sample only when we had at
// least one unambiguous ACK; otherwise update the window without
// polluting the RTT estimator.
if acked_bytes > 0 {
self.cc.on_ack(acked_bytes, min_rtt);
match min_rtt {
Some(rtt) => self.cc.on_ack(acked_bytes, rtt),
None => self.cc.on_ack_no_rtt(acked_bytes),
}
}
}
}
@ -732,26 +878,6 @@ fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool {
ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end)
}
fn derive_split_keys(base_key: &[u8; 32], role: NoiseRole) -> ([u8; 32], [u8; 32]) {
let mut initiator_key = [0u8; 32];
let mut responder_key = [0u8; 32];
let mut h1 = Sha256::new();
h1.update(base_key);
h1.update(b"ostp-initiator");
initiator_key.copy_from_slice(&h1.finalize());
let mut h2 = Sha256::new();
h2.update(base_key);
h2.update(b"ostp-responder");
responder_key.copy_from_slice(&h2.finalize());
match role {
NoiseRole::Initiator => (initiator_key, responder_key),
NoiseRole::Responder => (responder_key, initiator_key),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -983,4 +1109,154 @@ mod tests {
let _ = client.on_event(OstpEvent::Tick).unwrap();
let _ = server.on_event(OstpEvent::Tick).unwrap();
}
/// A retry may only be charged to a frame that was actually retransmitted.
///
/// The retransmit loop is budget-limited per tick. It used to bump
/// `retries` and reset `last_sent` for every due frame regardless of
/// whether the budget allowed it to actually send — so under heavy loss
/// (exactly when the budget runs out) frames racked up retries they never
/// received, and the zombie eviction dropped them after `max_retries + 2`
/// such rounds. That data was never delivered and never would be: the
/// stream stalls permanently while the session itself stays up.
#[test]
fn test_retransmit_budget_charges_retries_only_for_frames_actually_sent() {
let (mut client, _server) = do_handshake();
// Queue far more in-flight frames than a single tick's budget allows.
const FRAMES: usize = 40;
for i in 0..FRAMES {
let payload = Bytes::from(vec![i as u8; 200]);
client.on_event(OstpEvent::Outbound(1, payload)).unwrap();
}
assert_eq!(client.in_flight_count(), FRAMES);
assert_eq!(client.total_retries(), 0, "nothing retransmitted yet");
// Let every frame's RTO lapse so that on the next tick all FRAMES frames
// are due at once and the per-tick budget is guaranteed to run out. The
// effective RTO here is max(cc.rto(), config rto_ms) = 100ms at retries=0.
std::thread::sleep(Duration::from_millis(150));
let sent = count_datagrams(&client.on_event(OstpEvent::Tick).unwrap());
assert!(sent > 0, "expected some retransmits after the RTO lapsed");
assert!(
sent < FRAMES,
"budget should have capped this tick below the {FRAMES} due frames, got {sent}"
);
assert_eq!(
client.total_retries(),
sent,
"charged {} retries but only put {} frames on the wire — the \
difference is phantom retries that will silently evict live data",
client.total_retries(),
sent
);
assert_eq!(
client.in_flight_count(),
FRAMES,
"nothing was acked, so no frame may be evicted yet"
);
}
/// Count how many datagrams an action tree actually puts on the wire.
fn count_datagrams(action: &ProtocolAction) -> usize {
match action {
ProtocolAction::SendDatagram(_) => 1,
ProtocolAction::Multiple(list) => list.iter().map(count_datagrams).sum(),
_ => 0,
}
}
/// Count how many application payloads an action tree actually delivers.
fn delivered_payloads(action: &ProtocolAction) -> Vec<Bytes> {
match action {
ProtocolAction::DeliverApp(_, data) => vec![data.clone()],
ProtocolAction::Multiple(list) => list.iter().flat_map(delivered_payloads).collect(),
_ => Vec::new(),
}
}
/// Build `count` data frames on `client`, returning them without delivering
/// any — lets a test choose which ones to "lose" in transit.
fn make_data_frames(client: &mut ProtocolMachine, count: u8) -> Vec<Bytes> {
(0..count)
.map(|i| {
let payload = Bytes::from(vec![i; 32]);
match client.on_event(OstpEvent::Outbound(1, payload)).unwrap() {
ProtocolAction::SendDatagram(d) => d,
_ => panic!("expected SendDatagram for frame {i}"),
}
})
.collect()
}
/// The freeze this fixes: a frame is lost, the sender eventually stops
/// retransmitting it, and the receiver — which gates delivery on
/// `expected_recv_nonce` — waits for it forever. Every later frame piles up
/// undelivered while the transport itself stays healthy, so nothing upstream
/// notices. Recovery must eventually skip the hole and release the backlog.
#[test]
fn test_gap_recovery_releases_permanently_stalled_frames() {
let (mut client, mut server) = do_handshake();
let frames = make_data_frames(&mut client, 4);
// Frame 0 arrives in order and is delivered straight through.
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
assert_eq!(delivered_payloads(&action).len(), 1, "in-order frame should deliver");
// Frame 1 is lost. 2 and 3 arrive but must be held back — delivering them
// now would reorder the stream.
for idx in [2usize, 3] {
let action = server.on_event(OstpEvent::Inbound(frames[idx].clone())).unwrap();
assert!(
delivered_payloads(&action).is_empty(),
"frame {idx} must stay buffered behind the missing frame"
);
}
// Stand in for "the sender exhausted its retries and dropped frame 1":
// the sequence has not advanced for longer than the recovery timeout.
server.last_recv_advance = Instant::now() - GAP_RECOVERY_MAX - Duration::from_secs(1);
// The next inbound frame (a retransmitted duplicate, which is exactly what
// a real stalled session keeps receiving) must unblock the backlog.
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
let delivered = delivered_payloads(&action);
assert_eq!(
delivered.len(),
2,
"both buffered frames must be released once the gap is declared unrecoverable"
);
// ...and in order: frame 2 before frame 3.
assert_eq!(delivered[0][0], 2);
assert_eq!(delivered[1][0], 3);
}
/// Recovery must not be trigger-happy: a frame that is merely late still has
/// to be waited for, or we would discard data the sender is about to resend.
#[test]
fn test_gap_recovery_does_not_fire_before_timeout() {
let (mut client, mut server) = do_handshake();
let frames = make_data_frames(&mut client, 3);
server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
let action = server.on_event(OstpEvent::Inbound(frames[2].clone())).unwrap();
assert!(delivered_payloads(&action).is_empty());
// Well inside the timeout — the gap must still be respected.
let action = server.on_event(OstpEvent::Inbound(frames[0].clone())).unwrap();
assert!(
delivered_payloads(&action).is_empty(),
"must keep waiting while retransmission is still plausible"
);
// And once the genuinely-late frame shows up, normal in-order delivery
// resumes with nothing dropped.
let action = server.on_event(OstpEvent::Inbound(frames[1].clone())).unwrap();
let delivered = delivered_payloads(&action);
assert_eq!(delivered.len(), 2, "late frame plus the buffered one");
assert_eq!(delivered[0][0], 1);
assert_eq!(delivered[1][0], 2);
}
}

View File

@ -1,307 +0,0 @@
//! 0-RTT Session Resumption for OSTP.
//!
//! When a client has previously connected to a server, it can cache
//! a "session ticket" that allows it to send encrypted data in the
//! very first packet — eliminating the handshake round-trip entirely.
//!
//! How it works:
//! 1. After a successful handshake, the server issues a SessionTicket
//! containing enough state to resume the session.
//! 2. The client stores the ticket locally (encrypted with the PSK).
//! 3. On reconnection, the client sends a ResumptionRequest with the
//! ticket + early data in the first packet.
//! 4. The server validates the ticket and immediately begins processing
//! data, achieving 0-RTT.
//!
//! Security considerations:
//! - Tickets have a TTL (default 3600s) to limit replay window.
//! - The server maintains a ticket nonce set to prevent replay.
//! - Early data is idempotent by protocol design (relay CONNECT is safe
//! because duplicate CONNECTs to the same target are no-ops).
use std::collections::HashSet;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
/// A session ticket that allows 0-RTT resumption.
#[derive(Debug, Clone)]
pub struct SessionTicket {
/// Unique ticket identifier (prevents replay)
pub ticket_id: [u8; 16],
/// Server session ID to resume
pub session_id: u32,
/// Derived cipher key for early data
pub cipher_key: [u8; 32],
/// Timestamp of issuance (seconds since epoch)
pub issued_at: u64,
/// Time-to-live in seconds
pub ttl: u64,
}
/// Maximum ticket age (1 hour default)
const DEFAULT_TICKET_TTL: u64 = 3600;
/// Maximum tickets in the anti-replay set
const MAX_REPLAY_SET: usize = 10000;
impl SessionTicket {
/// Create a new session ticket from the transport key material.
pub fn new(session_id: u32, transport_key: &[u8; 32], psk: &[u8; 32]) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Derive ticket ID from key material + timestamp
let mut hasher = Sha256::new();
hasher.update(transport_key);
hasher.update(now.to_be_bytes());
hasher.update(b"ostp-ticket-id");
let hash = hasher.finalize();
let mut ticket_id = [0u8; 16];
ticket_id.copy_from_slice(&hash[..16]);
// Derive cipher key for early data from PSK + ticket
let mut key_hasher = Sha256::new();
key_hasher.update(psk);
key_hasher.update(ticket_id);
key_hasher.update(b"ostp-early-data-key");
let cipher_key_hash = key_hasher.finalize();
let mut cipher_key = [0u8; 32];
cipher_key.copy_from_slice(&cipher_key_hash);
Self {
ticket_id,
session_id,
cipher_key,
issued_at: now,
ttl: DEFAULT_TICKET_TTL,
}
}
/// Check if the ticket has expired.
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now > self.issued_at + self.ttl
}
/// Serialize the ticket to bytes for storage/transmission.
/// Wire format: [ticket_id:16][session_id:4][cipher_key:32][issued_at:8][ttl:8]
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(68);
out.extend_from_slice(&self.ticket_id);
out.extend_from_slice(&self.session_id.to_be_bytes());
out.extend_from_slice(&self.cipher_key);
out.extend_from_slice(&self.issued_at.to_be_bytes());
out.extend_from_slice(&self.ttl.to_be_bytes());
out
}
/// Deserialize a ticket from bytes.
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < 68 {
return None;
}
let mut ticket_id = [0u8; 16];
ticket_id.copy_from_slice(&data[0..16]);
let session_id = u32::from_be_bytes(data[16..20].try_into().ok()?);
let mut cipher_key = [0u8; 32];
cipher_key.copy_from_slice(&data[20..52]);
let issued_at = u64::from_be_bytes(data[52..60].try_into().ok()?);
let ttl = u64::from_be_bytes(data[60..68].try_into().ok()?);
Some(Self {
ticket_id,
session_id,
cipher_key,
issued_at,
ttl,
})
}
/// Encrypt the ticket with a PSK for client-side storage.
/// Uses a simple XOR cipher with HMAC-SHA256 derived key.
pub fn encrypt(&self, psk: &[u8; 32]) -> Vec<u8> {
let raw = self.to_bytes();
let mut enc_key_hasher = Sha256::new();
enc_key_hasher.update(psk);
enc_key_hasher.update(b"ostp-ticket-encryption");
let enc_key = enc_key_hasher.finalize();
let mut encrypted = raw.clone();
for (i, byte) in encrypted.iter_mut().enumerate() {
*byte ^= enc_key[i % 32];
}
encrypted
}
/// Decrypt a ticket from encrypted bytes.
pub fn decrypt(encrypted: &[u8], psk: &[u8; 32]) -> Option<Self> {
let mut enc_key_hasher = Sha256::new();
enc_key_hasher.update(psk);
enc_key_hasher.update(b"ostp-ticket-encryption");
let enc_key = enc_key_hasher.finalize();
let mut decrypted = encrypted.to_vec();
for (i, byte) in decrypted.iter_mut().enumerate() {
*byte ^= enc_key[i % 32];
}
Self::from_bytes(&decrypted)
}
}
/// Server-side anti-replay guard for session tickets.
#[allow(dead_code)]
pub struct TicketValidator {
/// Set of consumed ticket IDs (prevents replay)
consumed: HashSet<[u8; 16]>,
/// PSK for ticket validation
psk: [u8; 32],
/// Maximum age for tickets
max_age: Duration,
}
impl TicketValidator {
pub fn new(psk: [u8; 32]) -> Self {
Self {
consumed: HashSet::new(),
psk,
max_age: Duration::from_secs(DEFAULT_TICKET_TTL),
}
}
/// Validate a ticket from the client. Returns the ticket if valid,
/// or None if expired, replayed, or invalid.
pub fn validate(&mut self, encrypted_ticket: &[u8]) -> Option<SessionTicket> {
let ticket = SessionTicket::decrypt(encrypted_ticket, &self.psk)?;
// Check expiry
if ticket.is_expired() {
tracing::debug!("0-RTT ticket rejected: expired");
return None;
}
// Check replay
if self.consumed.contains(&ticket.ticket_id) {
tracing::warn!("0-RTT ticket rejected: replay detected");
return None;
}
// Accept and mark as consumed
self.consumed.insert(ticket.ticket_id);
// Garbage collection: remove old entries when set grows too large
if self.consumed.len() > MAX_REPLAY_SET {
// Simple strategy: clear the entire set. This is safe because
// expired tickets would fail the expiry check anyway.
self.consumed.clear();
self.consumed.insert(ticket.ticket_id);
tracing::debug!("0-RTT replay set cleared (overflow)");
}
tracing::debug!("0-RTT ticket accepted: session_id={}", ticket.session_id);
Some(ticket)
}
/// Issue a new ticket for a completed session.
pub fn issue_ticket(&self, session_id: u32, transport_key: &[u8; 32]) -> SessionTicket {
SessionTicket::new(session_id, transport_key, &self.psk)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ticket_serialize_roundtrip() {
let psk = [42u8; 32];
let key = [1u8; 32];
let ticket = SessionTicket::new(12345, &key, &psk);
let bytes = ticket.to_bytes();
let restored = SessionTicket::from_bytes(&bytes).unwrap();
assert_eq!(ticket.ticket_id, restored.ticket_id);
assert_eq!(ticket.session_id, restored.session_id);
assert_eq!(ticket.cipher_key, restored.cipher_key);
assert_eq!(ticket.issued_at, restored.issued_at);
}
#[test]
fn test_ticket_encrypt_decrypt() {
let psk = [42u8; 32];
let key = [1u8; 32];
let ticket = SessionTicket::new(99, &key, &psk);
let encrypted = ticket.encrypt(&psk);
let decrypted = SessionTicket::decrypt(&encrypted, &psk).unwrap();
assert_eq!(ticket.ticket_id, decrypted.ticket_id);
assert_eq!(ticket.session_id, decrypted.session_id);
}
#[test]
fn test_ticket_wrong_psk_fails() {
let psk = [42u8; 32];
let wrong_psk = [99u8; 32];
let key = [1u8; 32];
let ticket = SessionTicket::new(1, &key, &psk);
let encrypted = ticket.encrypt(&psk);
// Decrypting with wrong PSK produces garbage, from_bytes should
// still return Some but ticket_id won't match
let decrypted = SessionTicket::decrypt(&encrypted, &wrong_psk);
// It may parse but the data will be wrong
if let Some(d) = decrypted {
assert_ne!(d.ticket_id, ticket.ticket_id);
}
}
#[test]
fn test_ticket_not_expired() {
let psk = [42u8; 32];
let key = [1u8; 32];
let ticket = SessionTicket::new(1, &key, &psk);
assert!(!ticket.is_expired());
}
#[test]
fn test_validator_replay_protection() {
let psk = [42u8; 32];
let key = [1u8; 32];
let mut validator = TicketValidator::new(psk);
let ticket = validator.issue_ticket(1, &key);
let encrypted = ticket.encrypt(&psk);
// First use should succeed
assert!(validator.validate(&encrypted).is_some());
// Replay should fail
assert!(validator.validate(&encrypted).is_none());
}
#[test]
fn test_validator_different_tickets() {
let psk = [42u8; 32];
let mut validator = TicketValidator::new(psk);
let ticket1 = validator.issue_ticket(1, &[1u8; 32]);
let ticket2 = validator.issue_ticket(2, &[2u8; 32]);
assert!(validator.validate(&ticket1.encrypt(&psk)).is_some());
assert!(validator.validate(&ticket2.encrypt(&psk)).is_some());
}
#[test]
fn test_truncated_ticket_fails() {
assert!(SessionTicket::from_bytes(&[0u8; 10]).is_none());
}
}

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.
// 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")
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 769 KiB

View File

@ -8,6 +8,13 @@ import '../models/connection_state_enum.dart';
import '../models/ostp_profile.dart';
import 'settings_screen.dart';
/// Success green for the "connected" state the button aura/border/icon and
/// the top-bar status dot. The theme's `secondary` (#AAAAAA) reads as plain
/// white here, which gave no visual confirmation that the tunnel actually came
/// up. Reuses the same green already used for a healthy ping value, so
/// "green = good" stays consistent across the UI.
const Color kConnectedGreen = Color(0xFF22D3A5);
class HomeScreen extends StatefulWidget {
final SharedPreferences prefs;
const HomeScreen({super.key, required this.prefs});
@ -45,8 +52,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
late AnimationController _pulseController;
late AnimationController _spinController;
bool _isCheckingPing = false;
String _pingText = 'Target Ping: -- ms';
String _pingText = '-- ms';
Color _pingColor = Colors.white54;
@override
@ -420,8 +426,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_prevBytesSent = bytesSent;
_downSpeed = '${_formatBytes(dRecv)}/s';
_upSpeed = '${_formatBytes(dSent)}/s';
if (rttMs > 0 && !_isCheckingPing) {
_pingText = 'Server Ping: $rttMs ms';
if (rttMs > 0) {
_pingText = '$rttMs ms';
if (rttMs < 100) {
_pingColor = const Color(0xFF22D3A5);
} else if (rttMs < 250) {
@ -447,47 +453,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
Future<void> _checkConnectionLatency() async {
if (_state != ConnectionStateEnum.connected) return;
setState(() {
_isCheckingPing = true;
_pingText = 'Updating...';
_pingColor = Colors.white70;
});
try {
final metricsJson = await platform.invokeMethod('getMetrics');
if (metricsJson != null && metricsJson.isNotEmpty) {
final Map<String, dynamic> parsed = jsonDecode(metricsJson);
final rttMs = parsed['rtt_ms'] as int? ?? 0;
if (mounted) {
setState(() {
if (rttMs > 0) {
_pingText = 'Server Ping: $rttMs ms';
_pingColor = rttMs < 100
? const Color(0xFF22D3A5)
: rttMs < 250
? Colors.amberAccent
: Colors.redAccent;
} else {
_pingText = 'Server Ping: -- ms';
_pingColor = Colors.white54;
}
});
}
}
} catch (e) {
debugPrint("Failed to check latency: $e");
}
if (mounted) {
setState(() {
_isCheckingPing = false;
});
}
}
void _setDisconnected() {
if (!mounted) return;
setState(() {
@ -498,9 +463,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_upSpeed = '0 B/s';
_prevBytesRecv = 0;
_prevBytesSent = 0;
_pingText = 'Target Ping: -- ms';
_pingText = '-- ms';
_pingColor = Colors.white54;
_isCheckingPing = false;
});
_pulseController.stop();
_pulseController.value = 0.0;
@ -578,12 +542,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: _state == ConnectionStateEnum.connected
? theme.colorScheme.secondary
? kConnectedGreen
: theme.colorScheme.primary,
boxShadow: [
BoxShadow(
color: _state == ConnectionStateEnum.connected
? theme.colorScheme.secondary.withOpacity(0.5)
? kConnectedGreen.withOpacity(0.5)
: theme.colorScheme.primary.withOpacity(0.5),
blurRadius: 10,
)
@ -637,7 +601,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Widget _buildStage(ThemeData theme) {
Color getAccentColor() {
if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
if (_state == ConnectionStateEnum.connected) return kConnectedGreen;
return theme.colorScheme.primary;
}
@ -775,37 +739,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Padding(
padding: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.only(top: 10),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.06)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'CONNECTION TEST',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white38,
letterSpacing: 0.8,
),
),
const SizedBox(height: 4),
Icon(Icons.speed_rounded, size: 13, color: _pingColor),
const SizedBox(width: 6),
Text(
_pingText,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontSize: 13,
fontWeight: FontWeight.bold,
color: _pingColor,
),
@ -813,32 +763,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
const SizedBox(width: 8),
_isCheckingPing
? const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
)
: TextButton.icon(
onPressed: _checkConnectionLatency,
icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
label: Text(
'Test Ping',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: theme.colorScheme.primary,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
),
),
)
],

View File

@ -265,11 +265,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (v != null) setDialogState(() => transportMode = v);
},
),
// Junk packets and TCP fragmentation only take effect on the
// UoT (TCP) transport the UDP path applies neither so the
// whole section is hidden under UDP instead of shown with a
// "UoT only" caveat. Reactive: switching Transport above calls
// setDialogState, which rebuilds this and shows/hides it.
if (transportMode == 'uot') ...[
const Divider(height: 32),
// Junk packets + TCP fragmentation moved into their own
// modals (tap to configure) this dialog was carrying too
// many fields at once; these two are advanced/occasional
// settings, not something every profile edit needs to see.
const Text('DPI OBFUSCATION', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white54, letterSpacing: 1.0)),
const SizedBox(height: 12),
Row(
@ -299,6 +301,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
],
],
),
),
actions: [

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.1+19
version: 0.4.2+25
environment:
sdk: ^3.11.4
@ -58,7 +58,7 @@ dev_dependencies:
flutter_launcher_icons:
android: "launcher_icon"
ios: false
image_path: "../icons/sqare.png"
image_path: "../icons/logo_new.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Some files were not shown because too many files have changed in this diff Show More